feat(lesereisen): data model + Flyway migration — GeschichteType, JourneyItem, migrate geschichten_documents #787
25
CLAUDE.md
25
CLAUDE.md
@@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ FileService (S3/MinIO)
|
├── 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
|
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||||
├── notification/ Notification domain + SseEmitterRegistry
|
├── notification/ Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||||
@@ -105,13 +106,15 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
|
|
||||||
### Domain Model
|
### Domain Model
|
||||||
|
|
||||||
| Entity | Table | Key relationships |
|
| Entity | Table | Key relationships |
|
||||||
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
|
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
|
||||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||||
|
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
||||||
|
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
|
||||||
|
|
||||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||||
|
|
||||||
@@ -152,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
|||||||
|
|
||||||
### DTOs
|
### DTOs
|
||||||
|
|
||||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
|
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
|
||||||
|
|
||||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -268,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ # FileService (S3/MinIO)
|
├── 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
|
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||||
├── notification/ # Notification domain + SseEmitterRegistry
|
├── notification/ # Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||||
|
|||||||
@@ -50,10 +50,30 @@ public enum AuditKind {
|
|||||||
ADMIN_FORCE_LOGOUT,
|
ADMIN_FORCE_LOGOUT,
|
||||||
|
|
||||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||||
LOGIN_RATE_LIMITED;
|
LOGIN_RATE_LIMITED,
|
||||||
|
|
||||||
|
// --- Documents ---
|
||||||
|
|
||||||
|
/** Payload: none — the deleted document's id is carried in the documentId column */
|
||||||
|
DOCUMENT_DELETED,
|
||||||
|
|
||||||
|
// --- Reading Journeys (Lesereisen) ---
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||||
|
JOURNEY_ITEM_ADDED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||||
|
JOURNEY_ITEM_REMOVED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||||
|
JOURNEY_ITEM_NOTE_UPDATED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||||
|
JOURNEY_ITEMS_REORDERED;
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||||
|
JOURNEY_ITEMS_REORDERED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ public class DocumentController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
|
||||||
documentService.deleteDocument(id);
|
documentService.deleteDocument(id, requireUserId(authentication));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
|
||||||
|
* before documentRepository.deleteById fires. Listeners run synchronously in the
|
||||||
|
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
|
||||||
|
* see ADR-038.
|
||||||
|
*/
|
||||||
|
public record DocumentDeletingEvent(UUID documentId) {}
|
||||||
@@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@EntityGraph("Document.list")
|
@EntityGraph("Document.list")
|
||||||
Page<Document> findAll(Pageable pageable);
|
Page<Document> findAll(Pageable pageable);
|
||||||
|
|
||||||
|
// Loader for the relevance fast path: list-item enrichment reads tags after the
|
||||||
|
// repository call returns, so the fetch shape must match the spec-based findAll
|
||||||
|
// overloads above. Plain findAllById carries no entity graph and must not feed
|
||||||
|
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
List<Document> findByIdIn(Collection<UUID> ids);
|
||||||
|
|
||||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
|||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -80,6 +81,7 @@ public class DocumentService {
|
|||||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
private final AuditLogQueryService auditLogQueryService;
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
@@ -851,14 +853,14 @@ public class DocumentService {
|
|||||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
|
|
||||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
|
||||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||||
List<UUID> pageIds = new ArrayList<>();
|
List<UUID> pageIds = new ArrayList<>();
|
||||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||||
pageIds.add(ftsPage.hits().get(i).id());
|
pageIds.add(ftsPage.hits().get(i).id());
|
||||||
}
|
}
|
||||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||||
@@ -1006,6 +1008,28 @@ public class DocumentService {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||||
|
*
|
||||||
|
* <p><strong>Security contract — read before calling:</strong>
|
||||||
|
* <ol>
|
||||||
|
* <li>This method intentionally bypasses per-document scope checks and
|
||||||
|
* tag-colour resolution. It must only be invoked after
|
||||||
|
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||||
|
* the controller layer, guaranteeing the caller is an authenticated
|
||||||
|
* author.</li>
|
||||||
|
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||||
|
* JOURNEY-type check that fires before this call — so the method is never
|
||||||
|
* reached for STORY-type Geschichten.</li>
|
||||||
|
* </ol>
|
||||||
|
* Under the current single-tenant model every authenticated author shares the
|
||||||
|
* same document scope, so skipping per-document scope checks is safe.
|
||||||
|
*/
|
||||||
|
public Document findSummaryByIdInternal(UUID id) {
|
||||||
|
return documentRepository.findById(id)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a document for the detail view, additionally flagging whether it has any
|
* Loads a document for the detail view, additionally flagging whether it has any
|
||||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||||
@@ -1075,11 +1099,13 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteDocument(UUID id) {
|
public void deleteDocument(UUID id, UUID actorId) {
|
||||||
if (!documentRepository.existsById(id)) {
|
if (!documentRepository.existsById(id)) {
|
||||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||||
}
|
}
|
||||||
|
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
||||||
documentRepository.deleteById(id);
|
documentRepository.deleteById(id);
|
||||||
|
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -122,6 +122,22 @@ public enum ErrorCode {
|
|||||||
// --- Geschichten (Stories) ---
|
// --- Geschichten (Stories) ---
|
||||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||||
GESCHICHTE_NOT_FOUND,
|
GESCHICHTE_NOT_FOUND,
|
||||||
|
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||||
|
JOURNEY_ITEM_NOT_FOUND,
|
||||||
|
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||||
|
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||||
|
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||||
|
JOURNEY_AT_CAPACITY,
|
||||||
|
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||||
|
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
|
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
|
||||||
|
GESCHICHTE_TYPE_IMMUTABLE,
|
||||||
|
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
|
||||||
|
JOURNEY_NOTE_TOO_LONG,
|
||||||
|
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
|
||||||
|
GESCHICHTE_TITLE_TOO_LONG,
|
||||||
|
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
|
||||||
|
GESCHICHTE_INTRO_TOO_LONG,
|
||||||
|
|
||||||
// --- Tags ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ public class GlobalExceptionHandler {
|
|||||||
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
|
// 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
|
// 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.
|
// 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()
|
return ResponseEntity.badRequest()
|
||||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
|
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import jakarta.persistence.*;
|
|||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private GeschichteType type = GeschichteType.STORY;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "author_id")
|
@JoinColumn(name = "author_id")
|
||||||
private AppUser author;
|
private AppUser author;
|
||||||
@@ -51,12 +59,18 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Person> persons = new HashSet<>();
|
private Set<Person> persons = new HashSet<>();
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
|
||||||
@JoinTable(name = "geschichten_documents",
|
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
// explicitly initialized inside the service transaction. getById() is
|
||||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
|
||||||
|
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
|
||||||
|
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
|
||||||
|
// fetch-joined List here will throw MultipleBagFetchException at boot.
|
||||||
|
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
|
||||||
|
fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("position ASC")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Document> documents = new HashSet<>();
|
private List<JourneyItem> items = new ArrayList<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
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.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -28,9 +31,10 @@ import java.util.UUID;
|
|||||||
public class GeschichteController {
|
public class GeschichteController {
|
||||||
|
|
||||||
private final GeschichteService geschichteService;
|
private final GeschichteService geschichteService;
|
||||||
|
private final JourneyItemService journeyItemService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Geschichte> list(
|
public List<GeschichteSummary> list(
|
||||||
@RequestParam(required = false) GeschichteStatus status,
|
@RequestParam(required = false) GeschichteStatus status,
|
||||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||||
@RequestParam(required = false) UUID documentId,
|
@RequestParam(required = false) UUID documentId,
|
||||||
@@ -43,20 +47,20 @@ public class GeschichteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Geschichte getById(@PathVariable UUID id) {
|
public GeschichteView getById(@PathVariable UUID id) {
|
||||||
return geschichteService.getById(id);
|
return geschichteService.getView(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||||
Geschichte created = geschichteService.create(dto);
|
GeschichteView created = geschichteService.create(dto);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||||
return geschichteService.update(id, dto);
|
return geschichteService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,4 +70,45 @@ public class GeschichteController {
|
|||||||
geschichteService.delete(id);
|
geschichteService.delete(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@PostMapping("/{id}/items")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public ResponseEntity<JourneyItemView> appendItem(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody JourneyItemCreateDTO dto) {
|
||||||
|
JourneyItemView view = journeyItemService.append(id, dto);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/items/{itemId}")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public JourneyItemView updateItemNote(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID itemId,
|
||||||
|
@RequestBody JourneyItemUpdateDTO dto) {
|
||||||
|
return journeyItemService.updateNote(id, itemId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/items/{itemId}")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public ResponseEntity<Void> deleteItem(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID itemId) {
|
||||||
|
journeyItemService.delete(id, itemId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/items/reorder")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
@Operation(
|
||||||
|
summary = "Reorder journey items",
|
||||||
|
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||||
|
)
|
||||||
|
public List<JourneyItemView> reorderItems(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody JourneyReorderDTO dto) {
|
||||||
|
return journeyItemService.reorder(id, dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin read-only service owning {@link GeschichteRepository}.
|
||||||
|
* Exists so that {@code JourneyItemService} can check Geschichte existence
|
||||||
|
* and load Geschichte instances without holding a direct reference to the
|
||||||
|
* Geschichte repository (cross-domain repository access is not allowed per
|
||||||
|
* layering rules).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GeschichteQueryService {
|
||||||
|
|
||||||
|
private final GeschichteRepository geschichteRepository;
|
||||||
|
|
||||||
|
public boolean existsById(UUID id) {
|
||||||
|
return geschichteRepository.existsById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Geschichte> findById(UUID id) {
|
||||||
|
return geschichteRepository.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,47 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
|
||||||
|
*
|
||||||
|
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
|
||||||
|
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
|
||||||
|
*
|
||||||
|
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
|
||||||
|
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
|
||||||
|
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||||
|
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
|
||||||
|
FROM Geschichte g
|
||||||
|
WHERE g.status = :effectiveStatus
|
||||||
|
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||||
|
AND (:personCount = 0 OR
|
||||||
|
(SELECT COUNT(DISTINCT p.id)
|
||||||
|
FROM Geschichte g2 JOIN g2.persons p
|
||||||
|
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||||
|
AND (:documentId IS NULL OR
|
||||||
|
EXISTS (SELECT 1 FROM JourneyItem ji
|
||||||
|
WHERE ji.geschichte = g AND ji.document.id = :documentId))
|
||||||
|
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||||
|
""")
|
||||||
|
List<GeschichteSummary> findSummaries(
|
||||||
|
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||||
|
@Param("authorId") UUID authorId,
|
||||||
|
@Param("personIds") Collection<UUID> personIds,
|
||||||
|
@Param("personCount") long personCount,
|
||||||
|
@Param("documentId") UUID documentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.owasp.html.HtmlPolicyBuilder;
|
import org.owasp.html.HtmlPolicyBuilder;
|
||||||
import org.owasp.html.PolicyFactory;
|
import org.owasp.html.PolicyFactory;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
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.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -41,6 +36,7 @@ public class GeschichteService {
|
|||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final JourneyItemService journeyItemService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||||
@@ -54,12 +50,22 @@ public class GeschichteService {
|
|||||||
private static final int DEFAULT_LIMIT = 50;
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
private static final int MAX_LIMIT = 200;
|
private static final int MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
|
||||||
|
// turns what would be a DB-level 500 into a friendly 400.
|
||||||
|
static final int MAX_TITLE_LENGTH = 255;
|
||||||
|
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
|
||||||
|
// same three-layer bound as journey notes: frontend maxlength, this check, and
|
||||||
|
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
|
||||||
|
// unbounded on purpose.
|
||||||
|
static final int MAX_INTRO_LENGTH = 4000;
|
||||||
|
|
||||||
// ─── Read API ────────────────────────────────────────────────────────────
|
// ─── Read API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public long countPublished() {
|
public long countPublished() {
|
||||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public Geschichte getById(UUID id) {
|
public Geschichte getById(UUID id) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
@@ -72,24 +78,57 @@ public class GeschichteService {
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GeschichteView getView(UUID id) {
|
||||||
|
Geschichte g = getById(id);
|
||||||
|
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||||
|
return toView(g, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
|
||||||
|
AppUser author = g.getAuthor();
|
||||||
|
GeschichteView.AuthorView authorView = null;
|
||||||
|
if (author != null) {
|
||||||
|
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
|
||||||
|
if (displayName.isBlank()) displayName = "[Unbekannt]";
|
||||||
|
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
|
||||||
|
}
|
||||||
|
Set<GeschichteView.PersonView> personViews = new HashSet<>();
|
||||||
|
for (Person p : g.getPersons()) {
|
||||||
|
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
|
||||||
|
}
|
||||||
|
return new GeschichteView(
|
||||||
|
g.getId(), g.getTitle(), g.getBody(),
|
||||||
|
g.getStatus(), g.getType(),
|
||||||
|
authorView, personViews,
|
||||||
|
items,
|
||||||
|
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
* 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
|
* must be associated with every person id supplied. An empty or null list applies no
|
||||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||||
|
*
|
||||||
|
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||||
|
* LazyInitializationException on the non-transactional list path.
|
||||||
*/
|
*/
|
||||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||||
|
|
||||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||||
Specification<Geschichte> spec = Specification.allOf(
|
|
||||||
GeschichteSpecifications.hasStatus(effective),
|
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||||
GeschichteSpecifications.hasAuthor(authorId),
|
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||||
GeschichteSpecifications.hasAllPersons(personIds),
|
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||||
GeschichteSpecifications.hasDocument(documentId),
|
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
|
||||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
: personIds;
|
||||||
);
|
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
|
||||||
|
return geschichteRepository
|
||||||
|
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||||
.stream()
|
.stream()
|
||||||
.limit(safeLimit)
|
.limit(safeLimit)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -97,46 +136,57 @@ public class GeschichteService {
|
|||||||
|
|
||||||
// ─── Write API ───────────────────────────────────────────────────────────
|
// ─── Write API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Write methods return GeschichteView, never the entity: Jackson serializes after
|
||||||
|
// the transaction closed, where the lazy items collection is a dead proxy.
|
||||||
|
// The view is assembled in-transaction, so no force-init tricks are needed.
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||||
requireTitle(dto.getTitle());
|
requireTitle(dto.getTitle());
|
||||||
|
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
|
||||||
Geschichte g = Geschichte.builder()
|
Geschichte g = Geschichte.builder()
|
||||||
.title(dto.getTitle().trim())
|
.title(dto.getTitle().trim())
|
||||||
.body(sanitize(dto.getBody()))
|
.body(bodyForType(type, dto.getBody()))
|
||||||
.status(GeschichteStatus.DRAFT)
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(type)
|
||||||
.author(currentUser())
|
.author(currentUser())
|
||||||
.persons(resolvePersons(dto.getPersonIds()))
|
.persons(resolvePersons(dto.getPersonIds()))
|
||||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
|
||||||
.build();
|
.build();
|
||||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
g.setPublishedAt(LocalDateTime.now());
|
g.setPublishedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
return geschichteRepository.save(g);
|
Geschichte saved = geschichteRepository.save(g);
|
||||||
|
// A freshly created Geschichte has no items by construction — items are only
|
||||||
|
// addable via the separate /items endpoints. Revisit if a create DTO ever
|
||||||
|
// accepts initial items.
|
||||||
|
return toView(saved, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||||
|
if (dto.getType() != null && dto.getType() != g.getType()) {
|
||||||
|
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
|
||||||
|
"The type of a Geschichte cannot be changed after creation");
|
||||||
|
}
|
||||||
if (dto.getTitle() != null) {
|
if (dto.getTitle() != null) {
|
||||||
requireTitle(dto.getTitle());
|
requireTitle(dto.getTitle());
|
||||||
g.setTitle(dto.getTitle().trim());
|
g.setTitle(dto.getTitle().trim());
|
||||||
}
|
}
|
||||||
if (dto.getBody() != null) {
|
if (dto.getBody() != null) {
|
||||||
g.setBody(sanitize(dto.getBody()));
|
g.setBody(bodyForType(g.getType(), dto.getBody()));
|
||||||
}
|
}
|
||||||
if (dto.getPersonIds() != null) {
|
if (dto.getPersonIds() != null) {
|
||||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||||
}
|
}
|
||||||
if (dto.getDocumentIds() != null) {
|
|
||||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
|
||||||
}
|
|
||||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||||
applyStatusTransition(g, dto.getStatus());
|
applyStatusTransition(g, dto.getStatus());
|
||||||
}
|
}
|
||||||
return geschichteRepository.save(g);
|
Geschichte saved = geschichteRepository.save(g);
|
||||||
|
return toView(saved, journeyItemService.getItems(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -164,6 +214,27 @@ public class GeschichteService {
|
|||||||
throw DomainException.badRequest(
|
throw DomainException.badRequest(
|
||||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
ErrorCode.VALIDATION_ERROR, "Title is required");
|
||||||
}
|
}
|
||||||
|
if (title.trim().length() > MAX_TITLE_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
|
||||||
|
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
|
||||||
|
* JOURNEY intros are plain text: the reader renders them via Svelte text
|
||||||
|
* interpolation (never {@code {@html}}), so entity-encoding them here would
|
||||||
|
* corrupt content ("&" → "&") and re-encode on every editor round-trip.
|
||||||
|
*/
|
||||||
|
private String bodyForType(GeschichteType type, String body) {
|
||||||
|
if (type != GeschichteType.JOURNEY) {
|
||||||
|
return sanitize(body);
|
||||||
|
}
|
||||||
|
if (body != null && body.length() > MAX_INTRO_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
|
||||||
|
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String sanitize(String body) {
|
private String sanitize(String body) {
|
||||||
@@ -176,15 +247,6 @@ public class GeschichteService {
|
|||||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
return new LinkedHashSet<>(personService.getAllById(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
|
||||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
|
||||||
Set<Document> out = new LinkedHashSet<>();
|
|
||||||
for (UUID id : ids) {
|
|
||||||
out.add(documentService.getDocumentById(id));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppUser currentUser() {
|
private AppUser currentUser() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
|
|||||||
import jakarta.persistence.criteria.Predicate;
|
import jakarta.persistence.criteria.Predicate;
|
||||||
import jakarta.persistence.criteria.Root;
|
import jakarta.persistence.criteria.Root;
|
||||||
import jakarta.persistence.criteria.Subquery;
|
import jakarta.persistence.criteria.Subquery;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
|
|||||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
|
||||||
return (root, query, cb) -> {
|
|
||||||
if (documentId == null) return null;
|
|
||||||
return cb.exists(documentSubquery(root, query, cb, documentId));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
||||||
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
|
|||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Subquery<UUID> documentSubquery(
|
|
||||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
|
||||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
|
||||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
|
||||||
Join<Geschichte, Document> documents = subRoot.join("documents");
|
|
||||||
sub.select(subRoot.get("id"))
|
|
||||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
|
||||||
cb.equal(documents.get("id"), documentId));
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List-projection for the /api/geschichten grid. Never carries items — avoids
|
||||||
|
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
|
||||||
|
* Mirrors the PersonSummaryDTO precedent.
|
||||||
|
*
|
||||||
|
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
|
||||||
|
* publishedAt, status, type). Does NOT carry items or persons.
|
||||||
|
*/
|
||||||
|
public interface GeschichteSummary {
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
UUID getId();
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
String getTitle();
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
GeschichteStatus getStatus();
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
GeschichteType getType();
|
||||||
|
|
||||||
|
/** Nested closed projection — exposes only the fields the grid card needs. */
|
||||||
|
AuthorSummary getAuthor();
|
||||||
|
|
||||||
|
LocalDateTime getPublishedAt();
|
||||||
|
|
||||||
|
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
LocalDateTime getUpdatedAt();
|
||||||
|
|
||||||
|
String getBody();
|
||||||
|
|
||||||
|
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
|
||||||
|
interface AuthorSummary {
|
||||||
|
String getFirstName();
|
||||||
|
String getLastName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
public enum GeschichteType {
|
||||||
|
STORY,
|
||||||
|
JOURNEY
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
|
|||||||
private String title;
|
private String title;
|
||||||
private String body;
|
private String body;
|
||||||
private GeschichteStatus status;
|
private GeschichteStatus status;
|
||||||
|
private GeschichteType type;
|
||||||
private List<UUID> personIds;
|
private List<UUID> personIds;
|
||||||
private List<UUID> documentIds;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail-view response for GET /api/geschichten/{id}. Assembled by
|
||||||
|
* GeschichteService — never the raw entity (author AppUser graph must not leak).
|
||||||
|
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
|
||||||
|
*/
|
||||||
|
public record GeschichteView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||||
|
String body,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
|
||||||
|
AuthorView author,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
|
||||||
|
LocalDateTime publishedAt,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
|
||||||
|
) {
|
||||||
|
/** Summarised author — exposes only id and displayName, never email or group memberships. */
|
||||||
|
public record AuthorView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
|
||||||
|
public record PersonView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for joining a person's first and last name into a display string.
|
||||||
|
* Centralises the logic that was previously duplicated across GeschichteService
|
||||||
|
* and JourneyItemService.
|
||||||
|
*/
|
||||||
|
public class PersonNameFormatter {
|
||||||
|
|
||||||
|
private PersonNameFormatter() {
|
||||||
|
// utility class — no instances
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String join(String firstName, String lastName) {
|
||||||
|
String first = firstName != null ? firstName.trim() : "";
|
||||||
|
String last = lastName != null ? lastName.trim() : "";
|
||||||
|
if (first.isEmpty() && last.isEmpty()) return "";
|
||||||
|
if (first.isEmpty()) return last;
|
||||||
|
if (last.isEmpty()) return first;
|
||||||
|
return first + " " + last;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lean read-model view of a Document for embedding in JourneyItemView.
|
||||||
|
* Built by JourneyItemService.toSummary(Document) — never serialised from
|
||||||
|
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
|
||||||
|
*/
|
||||||
|
public record DocumentSummary(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||||
|
LocalDate documentDate,
|
||||||
|
LocalDate documentDateEnd,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
|
||||||
|
String senderName,
|
||||||
|
String receiverName,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "journey_items")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class JourneyItem {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "geschichte_id", nullable = false)
|
||||||
|
@JsonIgnore
|
||||||
|
private Geschichte geschichte;
|
||||||
|
|
||||||
|
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
|
||||||
|
// — the editor is responsible for keeping them distinct.
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "document_id")
|
||||||
|
@JsonIgnore
|
||||||
|
private Document document;
|
||||||
|
|
||||||
|
// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
|
||||||
|
// renderer MUST escape this; only Svelte {note} is auto-safe.
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String note;
|
||||||
|
|
||||||
|
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
|
||||||
|
// Exposing only the UUID prevents circular references and large nested payloads.
|
||||||
|
public UUID getDocumentId() {
|
||||||
|
return document != null ? document.getId() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
|
||||||
|
@Data
|
||||||
|
public class JourneyItemCreateDTO {
|
||||||
|
private UUID documentId;
|
||||||
|
private String note;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
class JourneyItemDocumentDeleteListener {
|
||||||
|
|
||||||
|
private final JourneyItemRepository journeyItemRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
|
||||||
|
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
|
||||||
|
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
|
||||||
|
* See ADR-038. DocumentService cannot call JourneyItemService directly because
|
||||||
|
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
|
||||||
|
*/
|
||||||
|
@EventListener
|
||||||
|
void onDocumentDeleting(DocumentDeletingEvent event) {
|
||||||
|
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
|
||||||
|
|
||||||
|
/** Returns items ordered by position ASC for the read-model assembly path. */
|
||||||
|
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
|
||||||
|
|
||||||
|
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
|
||||||
|
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
|
||||||
|
|
||||||
|
/** Returns only the IDs — used for set-equality check in reorder. */
|
||||||
|
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||||
|
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||||
|
|
||||||
|
/** MAX position for computing the next append position; returns empty when journey has no items. */
|
||||||
|
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||||
|
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||||
|
|
||||||
|
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||||
|
long countByGeschichteId(UUID geschichteId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedup guard: true when the document is already linked to this journey.
|
||||||
|
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
|
||||||
|
* getter on JourneyItem makes Spring Data resolve the derived path as a
|
||||||
|
* direct {@code documentId} attribute, which Hibernate cannot map.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(i) > 0 FROM JourneyItem i
|
||||||
|
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
|
||||||
|
""")
|
||||||
|
boolean existsByGeschichteIdAndDocumentId(
|
||||||
|
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
|
||||||
|
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
|
||||||
|
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
|
||||||
|
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
|
||||||
|
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
|
||||||
|
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
|
||||||
|
* assertion never reads a stale entity. flushAutomatically = true makes the
|
||||||
|
* flush-before-delete contract explicit rather than relying on Hibernate AUTO flush mode.
|
||||||
|
*/
|
||||||
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
|
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||||
|
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||||
|
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||||
|
* lazily for each item. Items without a document (note-only) are included via
|
||||||
|
* LEFT JOIN. Ordered by position ASC.
|
||||||
|
*/
|
||||||
|
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
|
||||||
|
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class JourneyItemService {
|
||||||
|
|
||||||
|
static final int MAX_ITEMS = 100;
|
||||||
|
static final int POSITION_STEP = 10;
|
||||||
|
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
|
||||||
|
static final int MAX_NOTE_LENGTH = 2000;
|
||||||
|
|
||||||
|
private final JourneyItemRepository journeyItemRepository;
|
||||||
|
private final GeschichteQueryService geschichteQueryService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
|
||||||
|
Geschichte g = geschichteQueryService.findById(geschichteId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||||
|
"Geschichte not found: " + geschichteId));
|
||||||
|
|
||||||
|
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||||
|
if (count >= MAX_ITEMS) {
|
||||||
|
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||||
|
"Journey has reached the maximum of 100 items");
|
||||||
|
}
|
||||||
|
|
||||||
|
String note = normalizeNote(dto.getNote());
|
||||||
|
|
||||||
|
if (dto.getDocumentId() == null && note == null) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"At least one of documentId or note must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||||
|
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
Document doc = null;
|
||||||
|
if (dto.getDocumentId() != null) {
|
||||||
|
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
|
||||||
|
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
|
"Document already in journey: " + dto.getDocumentId());
|
||||||
|
}
|
||||||
|
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
|
||||||
|
.map(max -> max + POSITION_STEP)
|
||||||
|
.orElse(POSITION_STEP);
|
||||||
|
|
||||||
|
JourneyItem item = JourneyItem.builder()
|
||||||
|
.geschichte(g)
|
||||||
|
.position(nextPosition)
|
||||||
|
.document(doc)
|
||||||
|
.note(note)
|
||||||
|
.build();
|
||||||
|
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
|
||||||
|
// fires here, not at commit — two concurrent appends can both pass the
|
||||||
|
// exists() pre-check above, and the index is the atomic backstop (V74).
|
||||||
|
JourneyItem saved;
|
||||||
|
try {
|
||||||
|
saved = journeyItemRepository.saveAndFlush(item);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
// Only the dedup index earns the friendly 409 — any other integrity
|
||||||
|
// failure (e.g. an FK violation on a concurrently deleted document)
|
||||||
|
// must not be mislabeled as "already added".
|
||||||
|
if (!isDuplicateDocumentViolation(e)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
|
"Document already in journey: " + dto.getDocumentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
|
||||||
|
|
||||||
|
return toView(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
|
||||||
|
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||||
|
"Journey item not found: " + itemId));
|
||||||
|
|
||||||
|
// null = field absent from JSON → no-op
|
||||||
|
Optional<String> noteField = dto.getNote();
|
||||||
|
if (noteField == null) {
|
||||||
|
return toView(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
String note = normalizeNote(noteField.orElse(null));
|
||||||
|
|
||||||
|
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||||
|
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note == null && item.getDocumentId() == null) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Cannot clear note on an item that has no linked document");
|
||||||
|
}
|
||||||
|
|
||||||
|
item.setNote(note);
|
||||||
|
JourneyItem saved = journeyItemRepository.save(item);
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||||
|
|
||||||
|
return toView(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(UUID geschichteId, UUID itemId) {
|
||||||
|
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||||
|
"Journey item not found: " + itemId));
|
||||||
|
|
||||||
|
journeyItemRepository.delete(item);
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
|
||||||
|
if (!geschichteQueryService.existsById(geschichteId)) {
|
||||||
|
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||||
|
"Geschichte not found: " + geschichteId);
|
||||||
|
}
|
||||||
|
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
|
||||||
|
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
|
||||||
|
|
||||||
|
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Duplicate item IDs in reorder request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingIds.equals(new HashSet<>(requestedIds))) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Requested item IDs do not match the journey's existing items");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
|
||||||
|
Map<UUID, JourneyItem> itemMap = new HashMap<>();
|
||||||
|
for (JourneyItem item : items) {
|
||||||
|
itemMap.put(item.getId(), item);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
|
||||||
|
for (int i = 0; i < requestedIds.size(); i++) {
|
||||||
|
JourneyItem item = itemMap.get(requestedIds.get(i));
|
||||||
|
item.setPosition((i + 1) * POSITION_STEP);
|
||||||
|
toSave.add(item);
|
||||||
|
}
|
||||||
|
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
|
||||||
|
|
||||||
|
return reordered.stream().map(this::toView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<JourneyItemView> getItems(UUID geschichteId) {
|
||||||
|
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
|
||||||
|
.stream().map(this::toView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentSummary toSummary(Document doc) {
|
||||||
|
String senderName = buildSenderName(doc);
|
||||||
|
Set<Person> receivers = doc.getReceivers();
|
||||||
|
String receiverName = buildCanonicalReceiverName(receivers);
|
||||||
|
|
||||||
|
return new DocumentSummary(
|
||||||
|
doc.getId(),
|
||||||
|
doc.getTitle(),
|
||||||
|
doc.getDocumentDate(),
|
||||||
|
doc.getMetaDateEnd(),
|
||||||
|
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
|
||||||
|
senderName,
|
||||||
|
receiverName,
|
||||||
|
receivers != null ? receivers.size() : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JourneyItemView toView(JourneyItem item) {
|
||||||
|
DocumentSummary docSummary = null;
|
||||||
|
Document doc = item.getDocument();
|
||||||
|
if (doc != null) {
|
||||||
|
docSummary = toSummary(doc);
|
||||||
|
}
|
||||||
|
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildSenderName(Document doc) {
|
||||||
|
Person sender = doc.getSender();
|
||||||
|
if (sender != null) {
|
||||||
|
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
|
||||||
|
if (!name.isBlank()) return name;
|
||||||
|
}
|
||||||
|
String senderText = doc.getSenderText();
|
||||||
|
return (senderText != null && !senderText.isBlank()) ? senderText : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildCanonicalReceiverName(Set<Person> receivers) {
|
||||||
|
if (receivers == null || receivers.isEmpty()) return null;
|
||||||
|
return receivers.stream()
|
||||||
|
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
|
||||||
|
.map(p -> {
|
||||||
|
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
|
||||||
|
return name.isBlank() ? null : name;
|
||||||
|
})
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
|
||||||
|
Throwable cause = e.getMostSpecificCause();
|
||||||
|
String message = cause != null ? cause.getMessage() : e.getMessage();
|
||||||
|
return message != null && message.contains("uq_journey_items_geschichte_document");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeNote(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sortKey(String s) {
|
||||||
|
return s != null ? s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser currentUser() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
|
throw DomainException.unauthorized("Authentication required");
|
||||||
|
}
|
||||||
|
return userService.findByEmail(auth.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
|
||||||
|
* Three-way semantics via Optional<String>:
|
||||||
|
* null → field absent from JSON → leave note unchanged
|
||||||
|
* Optional.empty() → {"note": null} → clear the note
|
||||||
|
* Optional.of("x") → {"note": "x"} → set the note
|
||||||
|
*
|
||||||
|
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class JourneyItemUpdateDTO {
|
||||||
|
private Optional<String> note = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model response for a JourneyItem. Never the JPA entity (which has a
|
||||||
|
* Geschichte back-reference that would leak / hit LazyInitializationException).
|
||||||
|
*/
|
||||||
|
public record JourneyItemView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
|
||||||
|
DocumentSummary document,
|
||||||
|
String note
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Input for PUT /api/geschichten/{id}/items/reorder. */
|
||||||
|
@Data
|
||||||
|
public class JourneyReorderDTO {
|
||||||
|
private List<UUID> itemIds;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Production pre-requisite — run BEFORE applying this migration:
|
||||||
|
-- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||||
|
-- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"'
|
||||||
|
-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
|
||||||
|
-- --table=geschichten_documents \
|
||||||
|
-- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'
|
||||||
|
-- Take the dump even if geschichten_documents is empty — it captures the table DEFINITION
|
||||||
|
-- for emergency reconstruction. The DROP TABLE is the only irreversible step; the
|
||||||
|
-- INSERT...SELECT is a no-op when there is no data. No DDL rollback path exists after commit.
|
||||||
|
--
|
||||||
|
-- REVERSE PROCEDURE (if V72 must be rolled back): restore the pre-V72 dump, then re-derive
|
||||||
|
-- the junction from the new table:
|
||||||
|
-- INSERT INTO geschichten_documents (geschichte_id, document_id)
|
||||||
|
-- SELECT geschichte_id, document_id FROM journey_items WHERE document_id IS NOT NULL;
|
||||||
|
-- Note: the reconstructed junction FK is ON DELETE CASCADE per the original V58
|
||||||
|
-- (NOT the new SET NULL of journey_items). Domain FKs target app_users (post-V60) —
|
||||||
|
-- do NOT hand-type V58's verbatim "REFERENCES users" DDL nor copy journey_items' SET NULL
|
||||||
|
-- into the reconstructed junction.
|
||||||
|
--
|
||||||
|
-- ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order
|
||||||
|
-- existed. Ordering by meta_date is a plausible default a Lesereise lets curators
|
||||||
|
-- re-sequence. This is not a requirement; it is the best available approximation.
|
||||||
|
--
|
||||||
|
-- ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block;
|
||||||
|
-- this block visibly degrades to generic links (loss of per-document title AND date) for ALL
|
||||||
|
-- current readers during the stub window. Accepted because the reader follow-on is the
|
||||||
|
-- next-priority blocking dependency.
|
||||||
|
|
||||||
|
-- Step 1: Add type discriminator column to geschichten
|
||||||
|
ALTER TABLE geschichten
|
||||||
|
ADD COLUMN type VARCHAR(50) DEFAULT 'STORY' NOT NULL;
|
||||||
|
|
||||||
|
-- Step 2: Create journey_items table
|
||||||
|
CREATE TABLE journey_items (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
geschichte_id UUID NOT NULL,
|
||||||
|
position INT NOT NULL,
|
||||||
|
document_id UUID,
|
||||||
|
note TEXT,
|
||||||
|
CONSTRAINT pk_journey_items PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_journey_items_geschichte
|
||||||
|
FOREIGN KEY (geschichte_id) REFERENCES geschichten(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_journey_items_document
|
||||||
|
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT chk_journey_item_not_empty
|
||||||
|
CHECK (document_id IS NOT NULL OR note IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 3: Index for ordered retrieval by geschichte + position
|
||||||
|
CREATE INDEX idx_journey_items_geschichte_position
|
||||||
|
ON journey_items (geschichte_id, position ASC);
|
||||||
|
|
||||||
|
-- Step 4: Migrate geschichten_documents → journey_items
|
||||||
|
-- Positions are multiples of 1000 (headroom for drag-reorder).
|
||||||
|
-- Ordered by meta_date ASC NULLS LAST, then documents.id ASC as deterministic tiebreaker.
|
||||||
|
-- SELECT DISTINCT guards against duplicate junction rows producing duplicate journey items.
|
||||||
|
INSERT INTO journey_items (id, geschichte_id, position, document_id)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
gd.geschichte_id,
|
||||||
|
(ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY gd.geschichte_id
|
||||||
|
ORDER BY d.meta_date ASC NULLS LAST, d.id ASC
|
||||||
|
) * 1000)::INT AS position,
|
||||||
|
gd.document_id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT geschichte_id, document_id
|
||||||
|
FROM geschichten_documents
|
||||||
|
) gd
|
||||||
|
LEFT JOIN documents d ON d.id = gd.document_id;
|
||||||
|
|
||||||
|
-- Step 5: Drop the old junction table (irreversible — take the pg_dump first)
|
||||||
|
DROP TABLE geschichten_documents;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Adds the two constraints that V72 deferred:
|
||||||
|
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||||
|
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
|
||||||
|
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
|
||||||
|
-- in transaction mode — correct today; a future switch to statement-level would silently
|
||||||
|
-- break deferred checking at COMMIT).
|
||||||
|
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
|
||||||
|
--
|
||||||
|
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
|
||||||
|
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
|
||||||
|
|
||||||
|
ALTER TABLE journey_items
|
||||||
|
ADD CONSTRAINT uq_journey_items_geschichte_position
|
||||||
|
UNIQUE (geschichte_id, position)
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
ALTER TABLE journey_items
|
||||||
|
ADD CONSTRAINT chk_journey_item_position
|
||||||
|
CHECK (position > 0);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Two constraints the service-level checks need as atomic backstops:
|
||||||
|
--
|
||||||
|
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
|
||||||
|
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
|
||||||
|
-- concurrent appends of the same document can both pass the pre-check.
|
||||||
|
-- The index rejects the second INSERT; JourneyItemService.append translates
|
||||||
|
-- the DataIntegrityViolationException into the same 409
|
||||||
|
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
|
||||||
|
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
|
||||||
|
--
|
||||||
|
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
|
||||||
|
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
|
||||||
|
-- maxlength, and the i18n error message all agree (#793).
|
||||||
|
--
|
||||||
|
-- Defensive cleanup first: a database that served writes on the base branch
|
||||||
|
-- (no dedup guard, MAX_NOTE_LENGTH = 5000) can hold rows that would make the
|
||||||
|
-- DDL below fail mid-migration and boot-loop the backend on a failed Flyway
|
||||||
|
-- row. Both statements are no-ops on a clean database.
|
||||||
|
|
||||||
|
-- Keep the earliest-positioned row of each (geschichte, document) pair.
|
||||||
|
DELETE FROM journey_items a
|
||||||
|
USING journey_items b
|
||||||
|
WHERE a.geschichte_id = b.geschichte_id
|
||||||
|
AND a.document_id = b.document_id
|
||||||
|
AND a.document_id IS NOT NULL
|
||||||
|
AND a.position > b.position;
|
||||||
|
|
||||||
|
-- Clamp over-long notes written under the old 5000-char service limit.
|
||||||
|
UPDATE journey_items SET note = left(note, 2000) WHERE length(note) > 2000;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
|
||||||
|
ON journey_items (geschichte_id, document_id)
|
||||||
|
WHERE document_id IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE journey_items
|
||||||
|
ADD CONSTRAINT chk_journey_item_note_length
|
||||||
|
CHECK (note IS NULL OR length(note) <= 2000);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
|
||||||
|
-- three-layer bound as journey notes: frontend maxlength, the
|
||||||
|
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
|
||||||
|
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
|
||||||
|
--
|
||||||
|
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
|
||||||
|
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
|
||||||
|
|
||||||
|
-- Defensive clamp first: intros written before this migration may exceed the
|
||||||
|
-- cap. No-op on a clean database.
|
||||||
|
UPDATE geschichten SET body = left(body, 4000)
|
||||||
|
WHERE type = 'JOURNEY' AND length(body) > 4000;
|
||||||
|
|
||||||
|
ALTER TABLE geschichten
|
||||||
|
ADD CONSTRAINT chk_geschichte_journey_intro_length
|
||||||
|
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);
|
||||||
@@ -402,6 +402,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + id).with(csrf()))
|
.delete("/api/documents/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|||||||
@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
|
|||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
|
||||||
|
// q + default sort + no other filters → the relevance fast path
|
||||||
|
// (relevanceSortedPageFromSql), which loads documents by id outside any
|
||||||
|
// transaction and must still deliver an initialized tags collection.
|
||||||
|
Person sender = savedPerson("Hans", "FtSender");
|
||||||
|
Tag tag = savedTag("FtTag");
|
||||||
|
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||||
|
|
||||||
|
SearchFilters textOnly = new SearchFilters(
|
||||||
|
"Walter", null, null, null, null, null, null, null, null, false);
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
textOnly, null, "DESC", PageRequest.of(0, 10));
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isEqualTo(1);
|
||||||
|
assertThatCode(() ->
|
||||||
|
result.items().forEach(i -> i.tags().size()))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||||
Person sender = savedPerson("Hans", "SsSender");
|
Person sender = savedPerson("Hans", "SsSender");
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
|
|||||||
UUID id1 = UUID.randomUUID();
|
UUID id1 = UUID.randomUUID();
|
||||||
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any()))
|
when(documentRepository.findByIdIn(any()))
|
||||||
.thenReturn(List.of(doc(id1)));
|
.thenReturn(List.of(doc(id1)));
|
||||||
|
|
||||||
documentService.searchDocuments(
|
documentService.searchDocuments(
|
||||||
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
|
|||||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
|
|||||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
|
|||||||
List<Object[]> ftsRows = new ArrayList<>();
|
List<Object[]> ftsRows = new ArrayList<>();
|
||||||
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
|||||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||||
import org.raddatz.familienarchiv.tag.TagService;
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -75,6 +76,7 @@ class DocumentServiceTest {
|
|||||||
@Mock AuditLogQueryService auditLogQueryService;
|
@Mock AuditLogQueryService auditLogQueryService;
|
||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
@Mock ApplicationEventPublisher eventPublisher;
|
||||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||||
// shared composition rather than a stub — the #726 single source of truth.
|
// shared composition rather than a stub — the #726 single source of truth.
|
||||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||||
@@ -87,7 +89,7 @@ class DocumentServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentRepository.existsById(id)).thenReturn(true);
|
when(documentRepository.existsById(id)).thenReturn(true);
|
||||||
|
|
||||||
documentService.deleteDocument(id);
|
documentService.deleteDocument(id, UUID.randomUUID());
|
||||||
|
|
||||||
verify(documentRepository).deleteById(id);
|
verify(documentRepository).deleteById(id);
|
||||||
}
|
}
|
||||||
@@ -97,7 +99,7 @@ class DocumentServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentRepository.existsById(id)).thenReturn(false);
|
when(documentRepository.existsById(id)).thenReturn(false);
|
||||||
|
|
||||||
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining(id.toString());
|
.hasMessageContaining(id.toString());
|
||||||
verify(documentRepository, never()).deleteById(any());
|
verify(documentRepository, never()).deleteById(any());
|
||||||
@@ -2166,7 +2168,7 @@ class DocumentServiceTest {
|
|||||||
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
||||||
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
@@ -2202,7 +2204,7 @@ class DocumentServiceTest {
|
|||||||
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
||||||
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
|
||||||
|
* class level (see JourneyItemConstraintsTest for the rationale).
|
||||||
|
*
|
||||||
|
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
|
||||||
|
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class GeschichteConstraintsTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
private UUID insertGeschichte(String type, String body) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
|
||||||
|
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
|
||||||
|
id, "Constraints-Test", body, type);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void journey_intro_check_rejects_4001_chars() {
|
||||||
|
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void journey_intro_check_accepts_exactly_4000_chars() {
|
||||||
|
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void story_bodies_are_not_constrained_by_the_intro_check() {
|
||||||
|
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||||
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.hamcrest.CoreMatchers.nullValue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
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.patch;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(GeschichteController.class)
|
@WebMvcTest(GeschichteController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
|
|||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean GeschichteService geschichteService;
|
||||||
GeschichteService geschichteService;
|
@MockitoBean JourneyItemService journeyItemService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
@MockitoBean
|
|
||||||
CustomUserDetailsService customUserDetailsService;
|
|
||||||
|
|
||||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
|
|||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void list_returns200_forReader() throws Exception {
|
void list_returns200_forReader() throws Exception {
|
||||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
.thenReturn(List.of(summaryStub("Story A")));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten"))
|
mockMvc.perform(get("/api/geschichten"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -107,7 +106,7 @@ class GeschichteControllerTest {
|
|||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getById_returns200_whenFound() throws Exception {
|
void getById_returns200_whenFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
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))
|
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -119,7 +118,7 @@ class GeschichteControllerTest {
|
|||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(geschichteService.getById(id))
|
when(geschichteService.getView(id))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||||
@@ -151,7 +150,7 @@ class GeschichteControllerTest {
|
|||||||
void create_returns201_withBlogWrite() throws Exception {
|
void create_returns201_withBlogWrite() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||||
.thenReturn(draft(id, "New"));
|
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setTitle("New");
|
dto.setTitle("New");
|
||||||
@@ -179,7 +178,7 @@ class GeschichteControllerTest {
|
|||||||
void update_returns200_withBlogWrite() throws Exception {
|
void update_returns200_withBlogWrite() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||||
.thenReturn(published(id, "Updated"));
|
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -208,31 +207,202 @@ class GeschichteControllerTest {
|
|||||||
verify(geschichteService).delete(id);
|
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 ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Geschichte published(UUID id, String title) {
|
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||||
return Geschichte.builder()
|
return new JourneyItemView(id, position, null, note);
|
||||||
.id(id)
|
|
||||||
.title(title)
|
|
||||||
.body("<p>x</p>")
|
|
||||||
.status(GeschichteStatus.PUBLISHED)
|
|
||||||
.publishedAt(LocalDateTime.now())
|
|
||||||
.createdAt(LocalDateTime.now())
|
|
||||||
.updatedAt(LocalDateTime.now())
|
|
||||||
.persons(new HashSet<>())
|
|
||||||
.documents(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Geschichte draft(UUID id, String title) {
|
private GeschichteView viewStub(UUID id, String title) {
|
||||||
return Geschichte.builder()
|
return viewStub(id, title, GeschichteStatus.PUBLISHED);
|
||||||
.id(id)
|
}
|
||||||
.title(title)
|
|
||||||
.status(GeschichteStatus.DRAFT)
|
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
|
||||||
.createdAt(LocalDateTime.now())
|
return new GeschichteView(id, title, "<p>x</p>",
|
||||||
.updatedAt(LocalDateTime.now())
|
status, GeschichteType.STORY,
|
||||||
.persons(new HashSet<>())
|
null, new HashSet<>(), List.of(),
|
||||||
.documents(new HashSet<>())
|
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||||
.build();
|
}
|
||||||
|
|
||||||
|
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||||
|
private GeschichteSummary summaryStub(String title) {
|
||||||
|
return new GeschichteSummary() {
|
||||||
|
public UUID getId() { return UUID.randomUUID(); }
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
|
||||||
|
public GeschichteType getType() { return GeschichteType.STORY; }
|
||||||
|
public AuthorSummary getAuthor() { return null; }
|
||||||
|
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
|
||||||
|
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
|
||||||
|
public String getBody() { return null; }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
|
import org.raddatz.familienarchiv.user.UserGroup;
|
||||||
|
import org.raddatz.familienarchiv.user.UserGroupRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
|
||||||
|
*
|
||||||
|
* <p>No {@code @Transactional} at class level — that would keep a session open and
|
||||||
|
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
|
||||||
|
* directly via repositories and relies on the service's own transaction boundaries.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class GeschichteHttpTest {
|
||||||
|
|
||||||
|
@LocalServerPort int port;
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
@Autowired UserGroupRepository userGroupRepository;
|
||||||
|
@Autowired PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
private RestTemplate http;
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
|
||||||
|
private static final String WRITER_PASSWORD = "pass!Geschichte1";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
http = noThrowRestTemplate();
|
||||||
|
baseUrl = "http://localhost:" + port;
|
||||||
|
geschichteRepository.deleteAll();
|
||||||
|
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||||
|
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||||
|
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
|
||||||
|
appUserRepository.save(AppUser.builder()
|
||||||
|
.email(WRITER_EMAIL)
|
||||||
|
.password(passwordEncoder.encode(WRITER_PASSWORD))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_returns_200_and_empty_array_when_no_stories_exist() {
|
||||||
|
String session = loginAsWriter();
|
||||||
|
ResponseEntity<String> response = http.exchange(
|
||||||
|
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||||
|
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isEqualTo("[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
|
||||||
|
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
|
||||||
|
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
|
||||||
|
// must also never touch items.
|
||||||
|
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||||
|
Geschichte journey = Geschichte.builder()
|
||||||
|
.title("Reise durch die Briefe")
|
||||||
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.author(writer)
|
||||||
|
.publishedAt(LocalDateTime.now())
|
||||||
|
.items(new ArrayList<>())
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
JourneyItem item = JourneyItem.builder()
|
||||||
|
.geschichte(journey)
|
||||||
|
.position(1000)
|
||||||
|
.note("Einleitung")
|
||||||
|
.build();
|
||||||
|
journey.getItems().add(item);
|
||||||
|
geschichteRepository.save(journey);
|
||||||
|
|
||||||
|
String session = loginAsWriter();
|
||||||
|
ResponseEntity<String> response = http.exchange(
|
||||||
|
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||||
|
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).contains("Reise durch die Briefe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
|
||||||
|
// This test is the canonical guard against LazyInitializationException.
|
||||||
|
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
|
||||||
|
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
|
||||||
|
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||||
|
Geschichte journey = Geschichte.builder()
|
||||||
|
.title("Familiengeschichte")
|
||||||
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.author(writer)
|
||||||
|
.publishedAt(LocalDateTime.now())
|
||||||
|
.items(new ArrayList<>())
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
JourneyItem note = JourneyItem.builder()
|
||||||
|
.geschichte(journey).position(1000).note("Prolog").build();
|
||||||
|
JourneyItem note2 = JourneyItem.builder()
|
||||||
|
.geschichte(journey).position(2000).note("Epilog").build();
|
||||||
|
journey.getItems().add(note);
|
||||||
|
journey.getItems().add(note2);
|
||||||
|
Geschichte saved = geschichteRepository.save(journey);
|
||||||
|
|
||||||
|
String session = loginAsWriter();
|
||||||
|
ResponseEntity<String> response = http.exchange(
|
||||||
|
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||||
|
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody())
|
||||||
|
.contains("Familiengeschichte")
|
||||||
|
.contains("Prolog")
|
||||||
|
.contains("Epilog");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_returns_404_for_unknown_id() {
|
||||||
|
String session = loginAsWriter();
|
||||||
|
ResponseEntity<String> response = http.exchange(
|
||||||
|
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
|
||||||
|
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||||
|
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
|
||||||
|
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||||
|
Geschichte draft = Geschichte.builder()
|
||||||
|
.title("Geheimer Entwurf")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.author(writer)
|
||||||
|
.items(new ArrayList<>())
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
Geschichte saved = geschichteRepository.save(draft);
|
||||||
|
|
||||||
|
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
|
||||||
|
// so from the service's perspective they're a reader.
|
||||||
|
String session = loginAsWriter();
|
||||||
|
ResponseEntity<String> response = http.exchange(
|
||||||
|
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||||
|
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_returns_200_and_serializes_items_open_in_view_false() {
|
||||||
|
// Canonical guard for the write path: PATCH must not 500 when the response
|
||||||
|
// is serialized after the service transaction closed. The raw entity carries
|
||||||
|
// a dead lazy items proxy at that point — the endpoint must answer with a
|
||||||
|
// view assembled inside the transaction.
|
||||||
|
AppUser writer = blogWriter();
|
||||||
|
Geschichte journey = Geschichte.builder()
|
||||||
|
.title("Reise vor dem Umbenennen")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.author(writer)
|
||||||
|
.items(new ArrayList<>())
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
journey.getItems().add(JourneyItem.builder()
|
||||||
|
.geschichte(journey).position(1000).note("Prolog").build());
|
||||||
|
Geschichte saved = geschichteRepository.save(journey);
|
||||||
|
|
||||||
|
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
|
||||||
|
ResponseEntity<String> response = http.exchange(
|
||||||
|
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
|
||||||
|
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
|
||||||
|
String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody())
|
||||||
|
.contains("Reise nach dem Umbenennen")
|
||||||
|
.contains("Prolog");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
|
||||||
|
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
|
||||||
|
|
||||||
|
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
|
||||||
|
private AppUser blogWriter() {
|
||||||
|
UserGroup group = userGroupRepository.save(UserGroup.builder()
|
||||||
|
.name("HttpTest-BlogWriters")
|
||||||
|
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
|
||||||
|
.build());
|
||||||
|
return appUserRepository.save(AppUser.builder()
|
||||||
|
.email(BLOG_WRITER_EMAIL)
|
||||||
|
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
|
||||||
|
.groups(new HashSet<>(Set.of(group)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
|
||||||
|
private HttpHeaders csrfJsonHeaders(String sessionId) {
|
||||||
|
String xsrf = UUID.randomUUID().toString();
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
|
||||||
|
headers.set("X-XSRF-TOKEN", xsrf);
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loginAsWriter() {
|
||||||
|
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loginAs(String email, String password) {
|
||||||
|
String xsrf = UUID.randomUUID().toString();
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
|
||||||
|
headers.set("X-XSRF-TOKEN", xsrf);
|
||||||
|
String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
|
||||||
|
ResponseEntity<String> resp = http.postForEntity(
|
||||||
|
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
|
||||||
|
return extractFaSessionCookie(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpHeaders sessionHeaders(String sessionId) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Cookie", "fa_session=" + sessionId);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||||
|
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||||
|
if (setCookieHeader == null) return "";
|
||||||
|
return setCookieHeader.stream()
|
||||||
|
.filter(c -> c.startsWith("fa_session="))
|
||||||
|
.map(c -> c.split(";")[0].substring("fa_session=".length()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private RestTemplate noThrowRestTemplate() {
|
||||||
|
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
|
||||||
|
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
|
||||||
|
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||||
|
@Override
|
||||||
|
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class GeschichteListProjectionTest {
|
||||||
|
|
||||||
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
@Autowired JourneyItemRepository journeyItemRepository;
|
||||||
|
|
||||||
|
AppUser author;
|
||||||
|
AppUser otherAuthor;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
geschichteRepository.deleteAll();
|
||||||
|
author = appUserRepository.save(AppUser.builder()
|
||||||
|
.email("author@test").password("pw").build());
|
||||||
|
otherAuthor = appUserRepository.save(AppUser.builder()
|
||||||
|
.email("other@test").password("pw").build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findSummaries returns only the requested status ─────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
|
||||||
|
geschichteRepository.save(published("Veröffentlicht", author));
|
||||||
|
geschichteRepository.save(draft("Entwurf", author));
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
|
||||||
|
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
|
||||||
|
// projection must carry it for drafts, where publishedAt is null.
|
||||||
|
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getUpdatedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
|
||||||
|
geschichteRepository.save(draft("Nur Entwurf", author));
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AuthorSummary nested projection ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_exposes_nested_author_names_but_never_email() {
|
||||||
|
AppUser richAuthor = appUserRepository.save(AppUser.builder()
|
||||||
|
.firstName("Franz").lastName("Raddatz")
|
||||||
|
.email("franz@raddatz.de").password("pw").build());
|
||||||
|
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
||||||
|
assertThat(a.getFirstName()).isEqualTo("Franz");
|
||||||
|
assertThat(a.getLastName()).isEqualTo("Raddatz");
|
||||||
|
// Design rule (GeschichteView.AuthorView javadoc): author projections never
|
||||||
|
// expose email or group memberships to readers.
|
||||||
|
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
|
||||||
|
.extracting(java.lang.reflect.Method::getName)
|
||||||
|
.doesNotContain("getEmail");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GeschichteType is exposed ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_exposes_type_field() {
|
||||||
|
Geschichte journey = Geschichte.builder()
|
||||||
|
.title("Eine Reise")
|
||||||
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.author(author)
|
||||||
|
.publishedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
geschichteRepository.save(journey);
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_with_authorId_returns_only_own_drafts() {
|
||||||
|
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||||
|
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── personCount = 0 → no person filter ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
|
||||||
|
geschichteRepository.save(published("A", author));
|
||||||
|
geschichteRepository.save(published("B", author));
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_with_one_personId_returns_only_linked_stories() {
|
||||||
|
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||||
|
|
||||||
|
Geschichte withFranz = published("Franz story", author);
|
||||||
|
withFranz.getPersons().add(franz);
|
||||||
|
geschichteRepository.save(withFranz);
|
||||||
|
|
||||||
|
Geschichte withAnna = published("Anna story", author);
|
||||||
|
withAnna.getPersons().add(anna);
|
||||||
|
geschichteRepository.save(withAnna);
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_with_two_personIds_uses_AND_semantics() {
|
||||||
|
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||||
|
|
||||||
|
Geschichte both = published("Both", author);
|
||||||
|
both.getPersons().add(franz);
|
||||||
|
both.getPersons().add(anna);
|
||||||
|
geschichteRepository.save(both);
|
||||||
|
|
||||||
|
Geschichte onlyFranz = published("Only Franz", author);
|
||||||
|
onlyFranz.getPersons().add(franz);
|
||||||
|
geschichteRepository.save(onlyFranz);
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getTitle()).isEqualTo("Both");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_with_documentId_returns_journey_containing_that_document() {
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||||
|
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
|
||||||
|
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
|
||||||
|
journeyItemRepository.save(JourneyItem.builder()
|
||||||
|
.geschichte(withDoc).document(doc).position(1).build());
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
|
||||||
|
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSummaries_with_unknown_documentId_returns_empty() {
|
||||||
|
geschichteRepository.save(journey("Irgendeine Reise", author));
|
||||||
|
|
||||||
|
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||||
|
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Geschichte published(String title, AppUser writer) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.title(title)
|
||||||
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
|
.author(writer)
|
||||||
|
.publishedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geschichte draft(String title, AppUser writer) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.title(title)
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.author(writer)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geschichte journey(String title, AppUser writer) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.title(title)
|
||||||
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.author(writer)
|
||||||
|
.publishedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
|
||||||
|
private List<UUID> sentinel() {
|
||||||
|
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class GeschichteQueryServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
GeschichteRepository geschichteRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
GeschichteQueryService geschichteQueryService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsById_returns_true_when_geschichte_exists() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.existsById(id)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThat(geschichteQueryService.existsById(id)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsById_returns_false_when_geschichte_does_not_exist() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.existsById(id)).thenReturn(false);
|
||||||
|
|
||||||
|
assertThat(geschichteQueryService.existsById(id)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
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.person.Person;
|
||||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
S3Client s3Client;
|
S3Client s3Client;
|
||||||
|
|
||||||
@Autowired GeschichteService geschichteService;
|
@Autowired GeschichteService geschichteService;
|
||||||
|
@Autowired JourneyItemService journeyItemService;
|
||||||
@Autowired GeschichteRepository geschichteRepository;
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
@Autowired PersonRepository personRepository;
|
@Autowired PersonRepository personRepository;
|
||||||
@Autowired AppUserRepository appUserRepository;
|
@Autowired AppUserRepository appUserRepository;
|
||||||
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
|
|||||||
+ "<script>alert('xss')</script>");
|
+ "<script>alert('xss')</script>");
|
||||||
dto.setPersonIds(List.of(franz.getId()));
|
dto.setPersonIds(List.of(franz.getId()));
|
||||||
|
|
||||||
Geschichte created = geschichteService.create(dto);
|
GeschichteView created = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(created.getId()).isNotNull();
|
assertThat(created.id()).isNotNull();
|
||||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
assertThat(created.getBody())
|
assertThat(created.body())
|
||||||
.contains("<strong>jeden Sonntag</strong>")
|
.contains("<strong>jeden Sonntag</strong>")
|
||||||
.doesNotContain("<script>");
|
.doesNotContain("<script>");
|
||||||
|
|
||||||
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||||
|
|
||||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||||
UUID draftId = created.getId();
|
UUID draftId = created.id();
|
||||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||||
.hasMessageContaining("not found");
|
.hasMessageContaining("not found");
|
||||||
|
|
||||||
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
|
|||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
|
||||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
assertThat(publishedGesch.publishedAt()).isNotNull();
|
||||||
|
|
||||||
// Reader can now see and fetch it
|
// Reader can now see and fetch it
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||||
Geschichte fetched = geschichteService.getById(draftId);
|
Geschichte fetched = geschichteService.getById(draftId);
|
||||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
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
|
// Delete as writer; join rows go with it
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
|
|||||||
|
|
||||||
// No filter → all three
|
// No filter → all three
|
||||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||||
.extracting(Geschichte::getId)
|
.extracting(GeschichteSummary::getId)
|
||||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||||
|
|
||||||
// Single filter (Anna) → all three
|
// Single filter (Anna) → all three
|
||||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||||
.extracting(Geschichte::getId)
|
.extracting(GeschichteSummary::getId)
|
||||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||||
|
|
||||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||||
.extracting(Geschichte::getId)
|
.extracting(GeschichteSummary::getId)
|
||||||
.containsExactly(storyAB);
|
.containsExactly(storyAB);
|
||||||
|
|
||||||
// AND: Bertha AND Carl → none (no story has both)
|
// AND: Bertha AND Carl → none (no story has both)
|
||||||
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
geschichteService.create(dto);
|
geschichteService.create(dto);
|
||||||
|
|
||||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
assertThat(result).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
dto.setBody("<p>body</p>");
|
dto.setBody("<p>body</p>");
|
||||||
dto.setPersonIds(personIds);
|
dto.setPersonIds(personIds);
|
||||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
return geschichteService.create(dto).getId();
|
return geschichteService.create(dto).id();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||||
|
|||||||
@@ -7,26 +7,22 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
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.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -37,7 +33,10 @@ import java.util.stream.Collectors;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -45,17 +44,13 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class GeschichteServiceTest {
|
class GeschichteServiceTest {
|
||||||
|
|
||||||
@Mock
|
@Mock GeschichteRepository geschichteRepository;
|
||||||
GeschichteRepository geschichteRepository;
|
@Mock PersonService personService;
|
||||||
@Mock
|
@Mock DocumentService documentService;
|
||||||
PersonService personService;
|
@Mock UserService userService;
|
||||||
@Mock
|
@Mock JourneyItemService journeyItemService;
|
||||||
DocumentService documentService;
|
|
||||||
@Mock
|
|
||||||
UserService userService;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks GeschichteService geschichteService;
|
||||||
GeschichteService geschichteService;
|
|
||||||
|
|
||||||
AppUser writer;
|
AppUser writer;
|
||||||
AppUser reader;
|
AppUser reader;
|
||||||
@@ -96,7 +91,8 @@ class GeschichteServiceTest {
|
|||||||
|
|
||||||
Geschichte result = geschichteService.getById(id);
|
Geschichte result = geschichteService.getById(id);
|
||||||
|
|
||||||
assertThat(result).isSameAs(draft);
|
assertThat(result.getId()).isEqualTo(id);
|
||||||
|
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -108,7 +104,8 @@ class GeschichteServiceTest {
|
|||||||
|
|
||||||
Geschichte result = geschichteService.getById(id);
|
Geschichte result = geschichteService.getById(id);
|
||||||
|
|
||||||
assertThat(result).isSameAs(published);
|
assertThat(result.getId()).isEqualTo(id);
|
||||||
|
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -123,79 +120,175 @@ class GeschichteServiceTest {
|
|||||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
.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 ─────────────────────────────────────────────────────────────────
|
// ─── list ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
geschichteService.list(null, List.of(), null, 50);
|
||||||
|
|
||||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||||
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
|
||||||
// through the spec-aware repository method.
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
GeschichteSummary s1 = mock(GeschichteSummary.class);
|
||||||
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
GeschichteSummary s2 = mock(GeschichteSummary.class);
|
||||||
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
|
.thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
|
||||||
|
|
||||||
assertThat(out).hasSize(2);
|
assertThat(out).hasSize(2);
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
UUID personId = UUID.randomUUID();
|
UUID personId = UUID.randomUUID();
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(null, List.of(personId), null, 50);
|
geschichteService.list(null, List.of(personId), null, 50);
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
UUID a = UUID.randomUUID();
|
UUID a = UUID.randomUUID();
|
||||||
UUID b = UUID.randomUUID();
|
UUID b = UUID.randomUUID();
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(null, List.of(a, b), null, 50);
|
geschichteService.list(null, List.of(a, b), null, 50);
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_filters_by_documentId() {
|
void list_passes_documentId_to_repository_as_journey_item_filter() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
UUID documentId = UUID.randomUUID();
|
UUID documentId = UUID.randomUUID();
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(null, List.of(), documentId, 50);
|
geschichteService.list(null, List.of(), documentId, 50);
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
void list_caps_limit_at_max_when_caller_passes_huge_value() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
.thenReturn(List.of(mock(GeschichteSummary.class)));
|
||||||
|
|
||||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
|
||||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
|
||||||
|
|
||||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||||
}
|
}
|
||||||
@@ -213,11 +306,11 @@ class GeschichteServiceTest {
|
|||||||
dto.setTitle("My Story");
|
dto.setTitle("My Story");
|
||||||
dto.setBody("<p>plain text</p>");
|
dto.setBody("<p>plain text</p>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
assertThat(saved.getPublishedAt()).isNull();
|
assertThat(saved.publishedAt()).isNull();
|
||||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
assertThat(saved.author().id()).isEqualTo(writer.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -231,9 +324,9 @@ class GeschichteServiceTest {
|
|||||||
dto.setTitle("XSS attempt");
|
dto.setTitle("XSS attempt");
|
||||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getBody())
|
assertThat(saved.body())
|
||||||
.contains("<p>safe</p>")
|
.contains("<p>safe</p>")
|
||||||
.doesNotContain("<script>")
|
.doesNotContain("<script>")
|
||||||
.doesNotContain("onerror")
|
.doesNotContain("onerror")
|
||||||
@@ -252,9 +345,9 @@ class GeschichteServiceTest {
|
|||||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
||||||
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getBody())
|
assertThat(saved.body())
|
||||||
.contains("<h2>Heading</h2>")
|
.contains("<h2>Heading</h2>")
|
||||||
.contains("<strong>bold</strong>")
|
.contains("<strong>bold</strong>")
|
||||||
.contains("<em>italic</em>")
|
.contains("<em>italic</em>")
|
||||||
@@ -277,28 +370,9 @@ class GeschichteServiceTest {
|
|||||||
dto.setTitle("Linked");
|
dto.setTitle("Linked");
|
||||||
dto.setPersonIds(List.of(personId));
|
dto.setPersonIds(List.of(personId));
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getPersons()).containsExactly(person);
|
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_resolves_documentIds_via_DocumentService() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(docId).build();
|
|
||||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("Linked doc");
|
|
||||||
dto.setDocumentIds(List.of(docId));
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
|
||||||
|
|
||||||
assertThat(saved.getDocuments()).containsExactly(doc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -315,6 +389,202 @@ class GeschichteServiceTest {
|
|||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_preserves_JOURNEY_type_from_dto() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("My Journey");
|
||||||
|
dto.setType(GeschichteType.JOURNEY);
|
||||||
|
|
||||||
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_defaults_to_STORY_when_type_is_null() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("My Story");
|
||||||
|
|
||||||
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||||
|
// The journey intro is plain text: JourneyReader renders it via Svelte text
|
||||||
|
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
|
||||||
|
// would corrupt real content ("Müller & Söhne" → "Müller & Söhne") and
|
||||||
|
// re-encode cumulatively on every editor round-trip.
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Winterbriefe");
|
||||||
|
dto.setType(GeschichteType.JOURNEY);
|
||||||
|
dto.setBody("Müller & Söhne, Temperatur < 0");
|
||||||
|
|
||||||
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.JOURNEY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setBody("Temperatur < 0 & Schnee");
|
||||||
|
|
||||||
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
|
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_still_sanitizes_STORY_body() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.STORY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||||
|
|
||||||
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
|
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── length caps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("x".repeat(256));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_accepts_title_of_exactly_255_chars() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("x".repeat(255));
|
||||||
|
|
||||||
|
assertThat(geschichteService.create(dto).title()).hasSize(255);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("x".repeat(256));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Winterbriefe");
|
||||||
|
dto.setType(GeschichteType.JOURNEY);
|
||||||
|
dto.setBody("x".repeat(4001));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Winterbriefe");
|
||||||
|
dto.setType(GeschichteType.JOURNEY);
|
||||||
|
dto.setBody("x".repeat(4000));
|
||||||
|
|
||||||
|
assertThat(geschichteService.create(dto).body()).hasSize(4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.JOURNEY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setBody("x".repeat(4001));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
|
||||||
|
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
|
||||||
|
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.STORY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
|
||||||
|
|
||||||
|
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── update ──────────────────────────────────────────────────────────────
|
// ─── update ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -330,10 +600,10 @@ class GeschichteServiceTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||||
assertThat(saved.getPublishedAt()).isNotNull();
|
assertThat(saved.publishedAt()).isNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -349,10 +619,10 @@ class GeschichteServiceTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setStatus(GeschichteStatus.DRAFT);
|
dto.setStatus(GeschichteStatus.DRAFT);
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
assertThat(saved.getPublishedAt()).isNull();
|
assertThat(saved.publishedAt()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -366,9 +636,46 @@ class GeschichteServiceTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.STORY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setType(GeschichteType.JOURNEY);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_accepts_dto_carrying_the_unchanged_type() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.STORY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setType(GeschichteType.STORY);
|
||||||
|
dto.setTitle("Unverändert getypt");
|
||||||
|
|
||||||
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
|
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||||
|
assertThat(saved.title()).isEqualTo("Unverändert getypt");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -426,7 +733,7 @@ class GeschichteServiceTest {
|
|||||||
.body("<p>body</p>")
|
.body("<p>body</p>")
|
||||||
.status(GeschichteStatus.DRAFT)
|
.status(GeschichteStatus.DRAFT)
|
||||||
.persons(new HashSet<>())
|
.persons(new HashSet<>())
|
||||||
.documents(new HashSet<>())
|
.items(new ArrayList<>())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +745,7 @@ class GeschichteServiceTest {
|
|||||||
.status(GeschichteStatus.PUBLISHED)
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||||
.persons(new HashSet<>())
|
.persons(new HashSet<>())
|
||||||
.documents(new HashSet<>())
|
.items(new ArrayList<>())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
|
||||||
|
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
|
||||||
|
* rollback-only and cascades into TransactionSystemException on teardown.
|
||||||
|
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class JourneyItemConstraintsTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
private UUID geschichteId;
|
||||||
|
private UUID documentId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbcTemplate.execute("DELETE FROM journey_items");
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Constraints-Test-Doc")
|
||||||
|
.originalFilename("ct.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
documentId = doc.getId();
|
||||||
|
Geschichte g = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Constraints-Test-Journey")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.build());
|
||||||
|
geschichteId = g.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unique_constraint_is_deferrable_initially_deferred() {
|
||||||
|
Boolean condeferrable = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||||
|
Boolean.class);
|
||||||
|
Boolean condeferred = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||||
|
Boolean.class);
|
||||||
|
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
|
||||||
|
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unique_index_rejects_duplicate_document_per_geschichte() {
|
||||||
|
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 20, documentId))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unique_index_allows_same_document_in_different_journeys() {
|
||||||
|
Geschichte other = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Andere Lesereise")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.build());
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), other.getId(), 10, documentId);
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unique_index_allows_multiple_note_only_items() {
|
||||||
|
// document_id IS NULL rows must not collide — the index is partial.
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void note_length_check_rejects_2001_chars() {
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void note_length_check_accepts_exactly_2000_chars() {
|
||||||
|
// Pins the boundary at the DB layer too — a future <= vs < migration edit
|
||||||
|
// must fail here, not only in the mock-based service test.
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void position_check_rejects_nonpositive() {
|
||||||
|
UUID itemId = UUID.randomUUID();
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||||
|
itemId, geschichteId, 0, "test"))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unique_constraint_rejects_duplicate_position_per_geschichte() {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, documentId))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.reset;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class JourneyItemDocumentDeleteTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
AuditService auditService;
|
||||||
|
|
||||||
|
@MockitoSpyBean
|
||||||
|
DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
EntityManager em;
|
||||||
|
|
||||||
|
@Autowired DocumentService documentService;
|
||||||
|
@Autowired JourneyItemRepository journeyItemRepository;
|
||||||
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
|
@Autowired DocumentRepository docRepo;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
Geschichte journey;
|
||||||
|
Document doc;
|
||||||
|
AppUser writer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
writer = appUserRepository.save(AppUser.builder()
|
||||||
|
.email("delete-test-writer@test")
|
||||||
|
.password("hash")
|
||||||
|
.build());
|
||||||
|
doc = docRepo.save(Document.builder()
|
||||||
|
.title("Testbrief")
|
||||||
|
.originalFilename("testbrief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
journey = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Eine Lesereise")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.build());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
|
||||||
|
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
reset(documentRepository);
|
||||||
|
// Deletion order is FK-load-bearing: journey_items reference both documents
|
||||||
|
// and geschichten, so children must be removed before their parents.
|
||||||
|
journeyItemRepository.deleteAll();
|
||||||
|
docRepo.deleteAll();
|
||||||
|
geschichteRepository.deleteAll();
|
||||||
|
appUserRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AC-1: headline ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
|
||||||
|
JourneyItem item = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
|
||||||
|
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
|
||||||
|
assertThat(docRepo.findById(doc.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_document_preserves_note_carrying_item_as_placeholder() {
|
||||||
|
JourneyItem item = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
|
||||||
|
assertThat(surviving.getDocumentId()).isNull();
|
||||||
|
assertThat(surviving.getNote()).isEqualTo("curator context");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AC-3: note-only item untouched ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_document_does_not_affect_note_only_item() {
|
||||||
|
JourneyItem noteOnly = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getDocumentId()).isNull();
|
||||||
|
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_document_applies_independently_per_referencing_item() {
|
||||||
|
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Zweite Reise")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
JourneyItem noteLess = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||||
|
JourneyItem noteCarrying = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
|
||||||
|
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
|
||||||
|
assertThat(surviving.getDocumentId()).isNull();
|
||||||
|
assertThat(surviving.getNote()).isEqualTo("Begleittext");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listener_deletes_roll_back_when_document_delete_fails() {
|
||||||
|
JourneyItem item = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
doThrow(new RuntimeException("simulated failure"))
|
||||||
|
.when(documentRepository).deleteById(any());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
|
||||||
|
.isInstanceOf(RuntimeException.class);
|
||||||
|
|
||||||
|
em.clear();
|
||||||
|
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AC-6: empty-string note boundary ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
|
||||||
|
// uq_journey_items_geschichte_document prevents two items with the same
|
||||||
|
// (geschichte_id, document_id) in one journey — use two separate journeys.
|
||||||
|
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Zweite Reise für AC-6")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
UUID emptyNoteItemId = UUID.randomUUID();
|
||||||
|
UUID whitespaceNoteItemId = UUID.randomUUID();
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||||
|
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||||
|
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
|
||||||
|
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
|
||||||
|
assertThat(whitespaceItem.getDocumentId()).isNull();
|
||||||
|
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Idempotency / no-collateral ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_document_in_zero_journeys_returns_no_collateral() {
|
||||||
|
Document unlinked = docRepo.save(Document.builder()
|
||||||
|
.title("Unverknüpfter Brief")
|
||||||
|
.originalFilename("other.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
JourneyItem unrelated = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
documentService.deleteDocument(unlinked.getId(), writer.getId());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
|
||||||
|
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
|
||||||
|
assertThat(journeyItemRepository.count()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
|
||||||
|
journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
|
||||||
|
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
@Transactional
|
||||||
|
class JourneyItemIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
AuditService auditService;
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
EntityManager em;
|
||||||
|
|
||||||
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
|
@Autowired JourneyItemRepository journeyItemRepository;
|
||||||
|
@Autowired JourneyItemService journeyItemService;
|
||||||
|
@Autowired DocumentService documentService;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
|
||||||
|
Geschichte journey;
|
||||||
|
Document doc;
|
||||||
|
AppUser writer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
writer = appUserRepository.save(AppUser.builder()
|
||||||
|
.email("journey-writer@test")
|
||||||
|
.password("hash")
|
||||||
|
.build());
|
||||||
|
doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Testbrief")
|
||||||
|
.originalFilename("testbrief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
journey = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Eine Lesereise")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.build());
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearSecurity() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||||
|
var authorities = java.util.Arrays.stream(permissions)
|
||||||
|
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||||
|
.toList();
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── @OrderBy ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void items_are_returned_in_position_order_regardless_of_insertion_order() {
|
||||||
|
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
|
||||||
|
// Distinct content per item — V74's partial unique index forbids the same
|
||||||
|
// document twice in one journey, and ordering doesn't depend on it.
|
||||||
|
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
|
||||||
|
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
|
||||||
|
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
|
||||||
|
managed.getItems().addAll(List.of(third, first, second));
|
||||||
|
geschichteRepository.save(managed);
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
|
||||||
|
|
||||||
|
assertThat(positions).containsExactly(1000, 2000, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_geschichte_cascade_deletes_all_journey_items() {
|
||||||
|
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
|
||||||
|
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
|
||||||
|
geschichteRepository.save(managed);
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
UUID geschichteId = journey.getId();
|
||||||
|
geschichteRepository.deleteById(geschichteId);
|
||||||
|
em.flush();
|
||||||
|
|
||||||
|
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removing_item_from_items_list_triggers_orphan_removal() {
|
||||||
|
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
|
||||||
|
managed.getItems().add(item);
|
||||||
|
Geschichte saved = geschichteRepository.save(managed);
|
||||||
|
em.flush();
|
||||||
|
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||||
|
em.clear();
|
||||||
|
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
|
||||||
|
geschichteRepository.save(reloaded);
|
||||||
|
em.flush();
|
||||||
|
|
||||||
|
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GeschichteType round-trip ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void type_persists_as_JOURNEY_and_roundtrips() {
|
||||||
|
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void type_defaults_to_STORY_for_new_geschichten() {
|
||||||
|
Geschichte story = geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Erinnerung")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.build());
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void note_only_item_persists_without_document() {
|
||||||
|
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
JourneyItem note = JourneyItem.builder()
|
||||||
|
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
|
||||||
|
managed.getItems().add(note);
|
||||||
|
Geschichte saved = geschichteRepository.save(managed);
|
||||||
|
em.flush();
|
||||||
|
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
|
||||||
|
em.clear();
|
||||||
|
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
|
||||||
|
assertThat(reloaded.getDocumentId()).isNull();
|
||||||
|
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Document-backed item exposes documentId ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
|
||||||
|
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
JourneyItem item = JourneyItem.builder()
|
||||||
|
.geschichte(managed).position(1000).document(doc).build();
|
||||||
|
managed.getItems().add(item);
|
||||||
|
Geschichte saved = geschichteRepository.save(managed);
|
||||||
|
em.flush();
|
||||||
|
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||||
|
em.clear();
|
||||||
|
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
|
||||||
|
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleting_document_sets_item_document_to_null_not_delete_item() {
|
||||||
|
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
JourneyItem item = JourneyItem.builder()
|
||||||
|
.geschichte(managed).position(1000).document(doc).note("still here").build();
|
||||||
|
managed.getItems().add(item);
|
||||||
|
Geschichte saved = geschichteRepository.save(managed);
|
||||||
|
em.flush();
|
||||||
|
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
// Route through service so the DocumentDeletingEvent fires and the listener
|
||||||
|
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
|
||||||
|
assertThat(surviving.getDocumentId()).isNull();
|
||||||
|
assertThat(surviving.getNote()).isEqualTo("still here");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
|
||||||
|
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||||
|
JourneyItem empty = JourneyItem.builder()
|
||||||
|
.geschichte(managed).position(1000).build();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> {
|
||||||
|
journeyItemRepository.save(empty);
|
||||||
|
journeyItemRepository.flush();
|
||||||
|
}).isInstanceOf(Exception.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_persists_item_at_position_10() {
|
||||||
|
// Arrange: authenticate as a user with BLOG_WRITE
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("First stop");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
// Assert: item exists in DB at position 10
|
||||||
|
assertThat(view.position()).isEqualTo(10);
|
||||||
|
assertThat(view.note()).isEqualTo("First stop");
|
||||||
|
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||||
|
assertThat(persisted).hasSize(1);
|
||||||
|
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||||
|
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_document_persists_and_rejects_duplicate() {
|
||||||
|
// Covers the document branch of append, including the duplicate guard —
|
||||||
|
// the derived exists query must resolve document.id, which the transient
|
||||||
|
// getDocumentId() getter on JourneyItem shadows for Spring Data.
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(doc.getId());
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
assertThat(view.document()).isNotNull();
|
||||||
|
assertThat(view.document().id()).isEqualTo(doc.getId());
|
||||||
|
|
||||||
|
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
|
||||||
|
duplicate.setDocumentId(doc.getId());
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
|
||||||
|
.hasFieldOrPropertyWithValue("code",
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void story_type_can_hold_journey_items_through_service() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
Geschichte story = savedStory();
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(doc.getId());
|
||||||
|
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||||
|
assertThat(items).hasSize(1);
|
||||||
|
assertThat(items.get(0).id()).isEqualTo(appended.id());
|
||||||
|
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v72_migrated_story_items_keep_position_order_and_are_removable() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
Geschichte story = savedStory();
|
||||||
|
Document docB = documentRepository.save(Document.builder()
|
||||||
|
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
|
||||||
|
Document docC = documentRepository.save(Document.builder()
|
||||||
|
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
|
||||||
|
|
||||||
|
// V72 inserted journey_items rows directly with position gaps — mirror that
|
||||||
|
// by writing through the repository instead of the service.
|
||||||
|
JourneyItem first = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
|
||||||
|
JourneyItem second = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
|
||||||
|
JourneyItem third = journeyItemRepository.save(
|
||||||
|
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
assertThat(journeyItemService.getItems(story.getId()))
|
||||||
|
.extracting(JourneyItemView::position)
|
||||||
|
.containsExactly(10, 20, 30);
|
||||||
|
|
||||||
|
journeyItemService.delete(story.getId(), second.getId());
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
assertThat(journeyItemService.getItems(story.getId()))
|
||||||
|
.extracting(JourneyItemView::id)
|
||||||
|
.containsExactly(first.getId(), third.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void story_item_with_deleted_document_survives_and_remains_deletable() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
Geschichte story = savedStory();
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(doc.getId());
|
||||||
|
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
|
||||||
|
// SET NULL clears document_id — a note-less item would block the
|
||||||
|
// document delete at the DB instead.
|
||||||
|
dto.setNote("Begleittext");
|
||||||
|
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
|
||||||
|
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||||
|
assertThat(items).hasSize(1);
|
||||||
|
assertThat(items.get(0).document()).isNull();
|
||||||
|
|
||||||
|
journeyItemService.delete(story.getId(), appended.id());
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geschichte savedStory() {
|
||||||
|
return geschichteRepository.save(Geschichte.builder()
|
||||||
|
.title("Eine Geschichte")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(GeschichteType.STORY)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_swaps_positions_atomically() {
|
||||||
|
// Arrange: append two items (pos 10, pos 20)
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
|
||||||
|
dto1.setNote("Item one");
|
||||||
|
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
|
||||||
|
dto2.setNote("Item two");
|
||||||
|
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
|
||||||
|
|
||||||
|
assertThat(item1View.position()).isEqualTo(10);
|
||||||
|
assertThat(item2View.position()).isEqualTo(20);
|
||||||
|
|
||||||
|
// Act: reorder with [item2, item1]
|
||||||
|
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
|
||||||
|
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
|
||||||
|
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
// Assert: item2 is now at position 10, item1 is at position 20
|
||||||
|
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||||
|
assertThat(persisted).hasSize(2);
|
||||||
|
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
|
||||||
|
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||||
|
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
|
||||||
|
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
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.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class JourneyItemServiceTest {
|
||||||
|
|
||||||
|
@Mock JourneyItemRepository journeyItemRepository;
|
||||||
|
@Mock GeschichteQueryService geschichteQueryService;
|
||||||
|
@Mock DocumentService documentService;
|
||||||
|
@Mock AuditService auditService;
|
||||||
|
@Mock UserService userService;
|
||||||
|
|
||||||
|
@InjectMocks JourneyItemService journeyItemService;
|
||||||
|
|
||||||
|
UUID geschichteId = UUID.randomUUID();
|
||||||
|
UUID itemId = UUID.randomUUID();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setupAuth() {
|
||||||
|
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
|
||||||
|
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
|
||||||
|
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken("test@test.de", null,
|
||||||
|
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── toSummary — name composition ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSummary_uses_linked_person_firstName_lastName() {
|
||||||
|
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
|
||||||
|
Document doc = makeDoc(docId, sender, List.of(), null, null);
|
||||||
|
|
||||||
|
var summary = journeyItemService.toSummary(doc);
|
||||||
|
|
||||||
|
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSummary_falls_back_to_senderText_when_no_person() {
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
|
||||||
|
|
||||||
|
var summary = journeyItemService.toSummary(doc);
|
||||||
|
|
||||||
|
assertThat(summary.senderName()).isEqualTo("Familie Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||||
|
|
||||||
|
var summary = journeyItemService.toSummary(doc);
|
||||||
|
|
||||||
|
assertThat(summary.senderName()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||||
|
|
||||||
|
var summary = journeyItemService.toSummary(doc);
|
||||||
|
|
||||||
|
assertThat(summary.receiverCount()).isEqualTo(0);
|
||||||
|
assertThat(summary.receiverName()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
|
||||||
|
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
|
||||||
|
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
|
||||||
|
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
|
||||||
|
|
||||||
|
var summary = journeyItemService.toSummary(doc);
|
||||||
|
|
||||||
|
assertThat(summary.receiverCount()).isEqualTo(2);
|
||||||
|
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSummary_datePrecision_SEASON_roundtrips() {
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||||
|
doc.setMetaDatePrecision(DatePrecision.SEASON);
|
||||||
|
|
||||||
|
var summary = journeyItemService.toSummary(doc);
|
||||||
|
|
||||||
|
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSummary_datePrecision_APPROX_roundtrips() {
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||||
|
doc.setMetaDatePrecision(DatePrecision.APPROX);
|
||||||
|
|
||||||
|
var summary = journeyItemService.toSummary(doc);
|
||||||
|
|
||||||
|
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── append ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_to_empty_journey_starts_at_10() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||||
|
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||||
|
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("Note");
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||||
|
|
||||||
|
assertThat(view.position()).isEqualTo(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_after_reorder_continues_from_max_position() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
|
||||||
|
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
|
||||||
|
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("Note");
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||||
|
|
||||||
|
assertThat(view.position()).isEqualTo(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_returns400_when_neither_documentId_nor_note() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("documentId or note");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_returns400_when_note_trims_to_empty_and_no_document() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote(" \n ");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||||
|
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("x".repeat(2001));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_accepts_note_of_exactly_2000_chars() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||||
|
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
|
||||||
|
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("x".repeat(2000));
|
||||||
|
|
||||||
|
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_returns404_when_documentId_does_not_exist() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
when(documentService.findSummaryByIdInternal(docId))
|
||||||
|
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(docId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_returns409_when_100_items_exist() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("Note");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_returns409_when_document_already_in_journey() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||||
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(docId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_to_STORY_type_creates_journey_item() {
|
||||||
|
Geschichte story = story(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||||
|
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||||
|
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(docId);
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||||
|
|
||||||
|
assertThat(view.position()).isEqualTo(10);
|
||||||
|
assertThat(view.document().id()).isEqualTo(docId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_to_STORY_type_respects_capacity_cap() {
|
||||||
|
Geschichte story = story(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(docId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_to_STORY_type_rejects_duplicate_document() {
|
||||||
|
Geschichte story = story(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||||
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(docId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||||
|
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
|
||||||
|
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
|
||||||
|
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("Note");
|
||||||
|
|
||||||
|
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() {
|
||||||
|
// Two concurrent appends can both pass the exists() pre-check; the partial
|
||||||
|
// unique index then rejects the second INSERT at flush. The service must
|
||||||
|
// translate that into the same friendly 409 as the pre-check.
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||||
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||||
|
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||||
|
when(journeyItemRepository.saveAndFlush(any()))
|
||||||
|
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||||
|
"duplicate key value violates unique constraint \"uq_journey_items_geschichte_document\""));
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() {
|
||||||
|
// An FK violation (document deleted between load and flush) must NOT be
|
||||||
|
// translated into "already added" — only the dedup index earns that 409.
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||||
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||||
|
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||||
|
when(journeyItemRepository.saveAndFlush(any()))
|
||||||
|
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||||
|
"insert or update on table \"journey_items\" violates foreign key constraint \"fk_journey_items_document\""));
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_audits_JOURNEY_ITEM_ADDED() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||||
|
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||||
|
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||||
|
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setNote("Note");
|
||||||
|
journeyItemService.append(geschichteId, dto);
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateNote ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNote_absent_leaves_note_unchanged() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
// note is null by default — absent from JSON, no-op
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||||
|
|
||||||
|
assertThat(view.note()).isEqualTo("Original note");
|
||||||
|
verify(journeyItemRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNote_null_clears_note_when_document_is_present() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||||
|
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||||
|
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
dto.setNote(Optional.empty());
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||||
|
|
||||||
|
assertThat(view.note()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNote_string_sets_note() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||||
|
item.setNote(null);
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||||
|
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
dto.setNote(Optional.of("New note"));
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||||
|
|
||||||
|
assertThat(view.note()).isEqualTo("New note");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNote_null_returns400_when_item_has_no_document() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
dto.setNote(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNote_whitespace_only_including_newlines_stored_as_null() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||||
|
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||||
|
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
dto.setNote(Optional.of("\n \n"));
|
||||||
|
|
||||||
|
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||||
|
|
||||||
|
assertThat(view.note()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
dto.setNote(Optional.of("x".repeat(2001)));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNote_auditsNoteUpdate() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||||
|
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
dto.setNote(Optional.of("New note"));
|
||||||
|
journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patch_returns404_when_item_belongs_to_different_journey() {
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||||
|
dto.setNote(Optional.of("text"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── delete ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_returns404_when_item_already_deleted() {
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_no_audit_when_item_not_found() {
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
|
||||||
|
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||||
|
|
||||||
|
journeyItemService.delete(geschichteId, itemId);
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── reorder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_unknownGeschichteId_throws404() {
|
||||||
|
UUID unknownId = UUID.randomUUID();
|
||||||
|
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_returns400_when_itemIds_contain_duplicates() {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of(id1, id1)); // duplicate
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_returns400_when_itemId_belongs_to_different_journey() {
|
||||||
|
UUID foreignId = UUID.randomUUID();
|
||||||
|
UUID localId = UUID.randomUUID();
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of(foreignId));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_returns400_when_ids_have_extra_items() {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of(id1, id2));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_returns200_when_empty_on_empty_journey() {
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of());
|
||||||
|
|
||||||
|
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_returns400_when_empty_on_nonempty_journey() {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_returns_items_in_new_order_starting_at_10() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
|
||||||
|
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
|
||||||
|
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
|
||||||
|
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of(id1, id2)); // want id1 first
|
||||||
|
|
||||||
|
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||||
|
|
||||||
|
assertThat(views).hasSize(2);
|
||||||
|
assertThat(views.get(0).id()).isEqualTo(id1);
|
||||||
|
assertThat(views.get(0).position()).isEqualTo(10);
|
||||||
|
assertThat(views.get(1).id()).isEqualTo(id2);
|
||||||
|
assertThat(views.get(1).position()).isEqualTo(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_identical_order_returns200() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||||
|
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||||
|
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of(id1));
|
||||||
|
|
||||||
|
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||||
|
|
||||||
|
assertThat(views).hasSize(1);
|
||||||
|
assertThat(views.get(0).position()).isEqualTo(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_of_grandfathered_over_cap_journey_succeeds() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
|
||||||
|
List<UUID> ids = new java.util.ArrayList<>();
|
||||||
|
List<JourneyItem> items = new java.util.ArrayList<>();
|
||||||
|
for (int i = 1; i <= 130; i++) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
ids.add(id);
|
||||||
|
items.add(savedItem(id, journey, i * 10, null, "item " + i));
|
||||||
|
}
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
|
||||||
|
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
|
||||||
|
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(ids);
|
||||||
|
|
||||||
|
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||||
|
|
||||||
|
assertThat(views).hasSize(130);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||||
|
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||||
|
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||||
|
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||||
|
dto.setItemIds(List.of(id1));
|
||||||
|
journeyItemService.reorder(geschichteId, dto);
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Geschichte journey(UUID id) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Test Journey")
|
||||||
|
.type(GeschichteType.JOURNEY)
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geschichte story(UUID id) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Test Story")
|
||||||
|
.type(GeschichteType.STORY)
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||||
|
return JourneyItem.builder()
|
||||||
|
.id(id)
|
||||||
|
.geschichte(g)
|
||||||
|
.position(position)
|
||||||
|
.document(null) // no document entity to avoid LAZY issues in unit tests
|
||||||
|
.note(note)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||||
|
JourneyItem item = JourneyItem.builder()
|
||||||
|
.id(id)
|
||||||
|
.geschichte(g)
|
||||||
|
.position(position)
|
||||||
|
.document(doc)
|
||||||
|
.note(note)
|
||||||
|
.build();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Test Doc")
|
||||||
|
.originalFilename("test.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.senderText(senderText)
|
||||||
|
.receiverText(receiverText)
|
||||||
|
.sender(sender)
|
||||||
|
.build();
|
||||||
|
doc.setReceivers(new HashSet<>(receivers));
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
|
|||||||
|
|
||||||
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
||||||
|
|
||||||
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
|
**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (document attachments / editorial notes shared by both subtypes — no application-level type guard). Two subtypes: `STORY` (prose + attached documents) and `JOURNEY` (ordered curated sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL). See ADR-037.
|
||||||
|
|
||||||
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
|||||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
|
||||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||||
|
|||||||
@@ -149,7 +149,20 @@ _See also [Chronik](#chronik-internal)._
|
|||||||
|
|
||||||
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
||||||
|
|
||||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s and attaching documents via `journey_items`) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||||
|
|
||||||
|
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a document attachment or editorial note belonging to a `Geschichte` of either subtype. JOURNEY-type Geschichten use items for their ordered reading sequence; STORY-type Geschichten use items to attach referenced documents (no type guard is enforced at the application layer — both subtypes share this table). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per Geschichte). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`). See ADR-037.
|
||||||
|
|
||||||
|
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.
|
||||||
|
|
||||||
|
**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`.
|
||||||
|
|
||||||
|
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
|
||||||
|
|
||||||
|
**Interlude / Zwischentext** `[user-facing]` — an editorial paragraph inserted between document items in a *Lesereise*. An interlude is a `JourneyItem` with `document_id IS NULL` and a non-empty `note`; its content is a plain-text string stored in the `note` column (not `body` or `text`). Visually distinguished by `--color-interlude-bg/border/label` CSS tokens and a `ZWISCHENTEXT` label. Interludes cannot have their note removed (removing the interlude deletes the entire item).
|
||||||
|
_Not to be confused with a document item's optional note_ — a document item's note is curator commentary attached to a linked letter; an interlude is standalone editorial prose with no backing document.
|
||||||
|
|
||||||
|
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
|
||||||
|
|
||||||
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
||||||
|
|
||||||
|
|||||||
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ADR-035 — `Optional<String>` for three-way PATCH semantics
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Issue:** #751 (JourneyItem CRUD API)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The `PATCH /api/geschichten/{id}/items/{itemId}` endpoint must distinguish three cases for the `note` field:
|
||||||
|
|
||||||
|
| JSON body | Intended meaning |
|
||||||
|
|-------------------|-----------------------|
|
||||||
|
| `{"note": "text"}`| Set note to "text" |
|
||||||
|
| `{"note": null}` | Clear the note |
|
||||||
|
| `{}` (absent) | Leave note unchanged |
|
||||||
|
|
||||||
|
The standard library for this on Jackson 2.x is `jackson-databind-nullable` (`JsonNullable<T>` from `org.openapitools`). However, that library targets `com.fasterxml.jackson.*` (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses `tools.jackson.*` (Jackson 3.x). The module fails to register and throws at startup.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use `Optional<String>` with Java's default field initializer (`= null`) to encode the three states:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
public class JourneyItemUpdateDTO {
|
||||||
|
private Optional<String> note = null; // Java default — absent = no-op
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Java value | JSON wire | Semantics |
|
||||||
|
|--------------------|-------------------|---------------|
|
||||||
|
| `null` (default) | field absent | no-op |
|
||||||
|
| `Optional.empty()` | `{"note": null}` | clear |
|
||||||
|
| `Optional.of("x")` | `{"note": "x"}` | set |
|
||||||
|
|
||||||
|
Jackson 3.x natively maps a JSON `null` to `Optional.empty()` and leaves absent fields at their Java default. No custom module is needed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- No external dependency for PATCH semantics — simpler pom.xml.
|
||||||
|
- The DTO field type is `Optional<String>`, not `String` — service code must null-check the field first (`if (noteField == null) return;`) and then call `.orElse(null)` to unwrap.
|
||||||
|
- This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
|
||||||
|
- `jackson-databind-nullable` is removed from `pom.xml`; `JacksonConfig.java` is kept as a placeholder for future custom modules.
|
||||||
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal file
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR-036 — Geschichte responses are views assembled in-transaction, never entities
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Issue:** #753 (JourneyEditor frontend), PR #792 review
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The project convention (CLAUDE.md §DTOs) has been: *"Response types are the model
|
||||||
|
entities themselves (no response DTOs)."* That convention assumed entities whose
|
||||||
|
associations are either eager or initialized by the time Jackson serializes.
|
||||||
|
|
||||||
|
The lazy-fetch migration (ADR-022, `open-in-view: false`) broke that assumption:
|
||||||
|
Jackson serializes **after** the service transaction has closed, so any lazy
|
||||||
|
collection on a returned entity is a dead proxy. `Geschichte.items` (added with the
|
||||||
|
Lesereisen data model, #750) made this concrete: every `PATCH /api/geschichten/{id}`
|
||||||
|
(save draft, publish) failed with HTTP 500
|
||||||
|
`LazyInitializationException: Geschichte.items … (no session)`.
|
||||||
|
|
||||||
|
Per-endpoint force-initialization (`g.getItems().size()` inside the transaction)
|
||||||
|
worked for `getById()` but is a footgun: every new write method must remember the
|
||||||
|
trick, the entity carries a warning comment nobody reads, and the raw entity also
|
||||||
|
leaks the `author` `AppUser` graph (email, password hash, groups).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
In the **geschichte domain**, controllers never return entities. Every response is a
|
||||||
|
purpose-built read model assembled **inside** the service transaction:
|
||||||
|
|
||||||
|
- `GET /api/geschichten` → `GeschichteSummary` (projection; never carries items;
|
||||||
|
author exposes names only — never email)
|
||||||
|
- `GET /api/geschichten/{id}` → `GeschichteView` (with `AuthorView`, `PersonView`,
|
||||||
|
`JourneyItemView` items)
|
||||||
|
- `POST /api/geschichten`, `PATCH /api/geschichten/{id}` → `GeschichteView`
|
||||||
|
- JourneyItem endpoints → `JourneyItemView`
|
||||||
|
|
||||||
|
The invariant: **entities never cross the controller boundary in this domain.**
|
||||||
|
A view is constructed while the Hibernate session is open, so serialization can
|
||||||
|
never touch a lazy proxy, and the response shape is an explicit, security-reviewed
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
- **`@Transactional` on read/write methods + force-init (`getItems().size()`)** —
|
||||||
|
fixes one endpoint at a time, silently regresses when the next write method is
|
||||||
|
added, and still serializes the raw `AppUser` author graph.
|
||||||
|
- **`open-in-view: true`** — re-opens the session during rendering; hides N+1
|
||||||
|
queries and couples the HTTP layer to Hibernate session lifetime. Rejected
|
||||||
|
already by ADR-022.
|
||||||
|
- **Jackson `@JsonIgnore` on lazy fields** — loses the data the client needs
|
||||||
|
(items ARE the journey) instead of loading it deliberately.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- CLAUDE.md §DTOs names the geschichte domain as the exception to the
|
||||||
|
entities-as-responses convention. Other domains (document, person, tag) still
|
||||||
|
return entities; they predate ADR-022's lazy collections on their hot paths and
|
||||||
|
migrate opportunistically when they grow lazy collections of their own.
|
||||||
|
- `npm run generate:api` must run after any view change — the generated
|
||||||
|
`Geschichte` schema no longer exists; frontend consumers use
|
||||||
|
`GeschichteView`/`GeschichteSummary`.
|
||||||
|
- New geschichte endpoints must add a view (or extend an existing one), not return
|
||||||
|
the entity. The regression guards are `GeschichteHttpTest`
|
||||||
|
(`update_returns_200_and_serializes_items_open_in_view_false`) and
|
||||||
|
`GeschichteListProjectionTest`.
|
||||||
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR-037 — `journey_items` serves both STORY and JOURNEY Geschichte subtypes
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-11
|
||||||
|
**Issue:** #795 (restore document management for STORY-type Geschichten), PR #804 review
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
V72 added the `journey_items` table as the backing store for Lesereisen (JOURNEY-type
|
||||||
|
Geschichten). At the same time, the previous `geschichten_documents` join table was
|
||||||
|
dropped (#753) and the restoration of a STORY-level document attachment mechanism was
|
||||||
|
deferred to a future issue.
|
||||||
|
|
||||||
|
`JourneyItemService.append()` contained an application-level type guard that rejected
|
||||||
|
`append()` calls on STORY-type Geschichten with `GESCHICHTE_TYPE_MISMATCH`. This guard
|
||||||
|
was the only place where the STORY restriction was encoded — the database schema never
|
||||||
|
enforced it (no CHECK constraint, no partial index on `type='JOURNEY'`).
|
||||||
|
|
||||||
|
When #795 restored document attachment for STORY-type Geschichten, the type guard was
|
||||||
|
the only obstacle. Two implementation paths were considered:
|
||||||
|
|
||||||
|
1. Keep an allowlist (`if type not in (JOURNEY, STORY) throw ...`) — dead code today
|
||||||
|
because `GeschichteType` is a two-constant enum; the branch can never be reached and
|
||||||
|
would fail the JaCoCo branch-coverage gate.
|
||||||
|
2. Delete the guard entirely — the schema never encoded the restriction; deleting dead
|
||||||
|
application logic rather than replacing it with more dead logic.
|
||||||
|
|
||||||
|
Path 2 was chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`journey_items` is the document-attachment mechanism for **both** `STORY` and `JOURNEY`
|
||||||
|
subtypes. No application-level type guard governs which subtype may hold items. The only
|
||||||
|
behavioral difference between the two subtypes' use of items is at the UI layer:
|
||||||
|
|
||||||
|
- JOURNEY: items form an ordered reading sequence rendered as a *Lesereise*.
|
||||||
|
- STORY: items are a set of attached reference documents rendered as a sidebar panel.
|
||||||
|
|
||||||
|
Both subtypes share the same capacity cap (100 items), dedup index, position semantics,
|
||||||
|
and DEFERRABLE constraint — enforced at the database layer, not re-implemented per subtype.
|
||||||
|
|
||||||
|
The `GESCHICHTE_TYPE_MISMATCH` error code was removed end-to-end (backend enum,
|
||||||
|
frontend `ErrorCode` type + `getErrorMessage()` case, all three locale files).
|
||||||
|
`GESCHICHTE_TYPE_IMMUTABLE` is unrelated and was left intact.
|
||||||
|
|
||||||
|
## Naming asymmetry (intentional)
|
||||||
|
|
||||||
|
The error codes `JOURNEY_AT_CAPACITY` and `JOURNEY_DOCUMENT_ALREADY_ADDED` carry
|
||||||
|
journey-flavored names. Renaming them would ripple through `ErrorCode.java`, `errors.ts`,
|
||||||
|
and three locale files for zero behavior change. `StoryDocumentPanel` remaps these two
|
||||||
|
codes to story-worded user messages at the presentation layer — the asymmetry is a
|
||||||
|
documented decision, not an accident.
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
- **Separate `story_documents` join table for STORY** — creates two nearly-identical
|
||||||
|
schemas for the same concept (document attachment with dedup and ordering), doubles the
|
||||||
|
migration surface, and splits the capacity/dedup logic. Rejected as unnecessary
|
||||||
|
duplication.
|
||||||
|
- **Allowlist type guard (`if type not in (JOURNEY, STORY)`)** — unreachable dead code
|
||||||
|
under a two-constant enum; fails the JaCoCo branch gate. Rejected.
|
||||||
|
- **Per-subtype application validation** — the schema never encoded the restriction; an
|
||||||
|
application-only rule with no schema backing is the weakest kind of invariant and was
|
||||||
|
removed when the product decision reversed it.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `JourneyItemService.append()` accepts items for any `Geschichte`, regardless of subtype.
|
||||||
|
The 100-item cap and dedup constraint apply to all.
|
||||||
|
- GLOSSARY.md and ARCHITECTURE.md updated to reflect that `JourneyItem` is not
|
||||||
|
JOURNEY-specific.
|
||||||
|
- The `l3-backend-3g-supporting.puml` C4 diagram updated: type-guard language removed,
|
||||||
|
`geschQuerySvc` rel label reads "Checks Geschichte existence" (not "and type").
|
||||||
|
- `StoryDocumentPanel.svelte` is the STORY-side consumer; `JourneyEditor.svelte` is the
|
||||||
|
JOURNEY-side consumer. Neither is aware of the other.
|
||||||
|
- Known pre-existing constraint conflict: `ON DELETE SET NULL` on `journey_items.document_id`
|
||||||
|
combined with `chk_journey_item_not_empty` causes a DB-level 500 when a document linked
|
||||||
|
via a note-less item is deleted. Pre-existing; tracked in follow-up issue.
|
||||||
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# ADR-038 — Domain event drives note-less journey-item cleanup on document delete
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-11
|
||||||
|
**Issue:** #805 (P1 — deleting a document linked via a note-less journey_item 500s at DB constraint)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two constraints in V72 encode contradictory rules for a journey item that has a
|
||||||
|
`document_id` but no `note`:
|
||||||
|
|
||||||
|
- **`fk_journey_items_document` → `ON DELETE SET NULL`** — when a document is deleted,
|
||||||
|
Postgres nulls out `document_id`.
|
||||||
|
- **`chk_journey_item_not_empty`** — requires at least one of `document_id` or `note`
|
||||||
|
to be non-null.
|
||||||
|
|
||||||
|
A note-less item (`document_id` set, `note IS NULL`) satisfies the CHECK while the
|
||||||
|
document exists. Deleting the document causes Postgres to attempt `SET NULL`, which
|
||||||
|
would leave both columns null — a direct CHECK violation. Postgres aborts the
|
||||||
|
transaction with a 500 that bypasses `GlobalExceptionHandler`.
|
||||||
|
|
||||||
|
The natural fix — delete note-less items inside `DocumentService.deleteDocument` before
|
||||||
|
`deleteById` runs — cannot call `JourneyItemService` directly: `JourneyItemService`
|
||||||
|
already injects `DocumentService`, and Spring Framework 7 (used by Spring Boot 4)
|
||||||
|
**fully prohibits constructor-injection cycles**. The application will not start if such
|
||||||
|
a cycle is introduced.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`DocumentService.deleteDocument` publishes a **`DocumentDeletingEvent`** (plain record,
|
||||||
|
payload: `documentId` UUID only) via `ApplicationEventPublisher` **before**
|
||||||
|
`documentRepository.deleteById`. A dedicated `@Component`
|
||||||
|
`JourneyItemDocumentDeleteListener` in the `geschichte.journeyitem` package consumes
|
||||||
|
this event and calls `journeyItemRepository.deleteNoteLessByDocumentId(documentId)`
|
||||||
|
directly — bypassing `JourneyItemService` to avoid re-introducing the cycle and to
|
||||||
|
suppress the per-item `JOURNEY_ITEM_REMOVED` audit emission (see audit decision below).
|
||||||
|
|
||||||
|
### Load-bearing listener-phase choice: plain `@EventListener`
|
||||||
|
|
||||||
|
The listener is annotated with `@EventListener` (not
|
||||||
|
`@TransactionalEventListener(AFTER_COMMIT)`, not `@Async`). **This choice is
|
||||||
|
load-bearing:**
|
||||||
|
|
||||||
|
- **`AFTER_COMMIT` would break the fix entirely.** `AFTER_COMMIT` fires *after* the
|
||||||
|
surrounding transaction has committed. By that point, `documentRepository.deleteById`
|
||||||
|
has already executed and Postgres has already tried `ON DELETE SET NULL` — the
|
||||||
|
constraint violation fires before the listener ever runs.
|
||||||
|
- **`@Async` would break rollback atomicity (AC-5).** An async listener runs on a
|
||||||
|
separate thread in its own transaction. If `deleteDocument` subsequently rolls back
|
||||||
|
(e.g. due to an unrelated failure), the listener's deletes are in a committed async
|
||||||
|
transaction and cannot be undone.
|
||||||
|
- **Plain `@EventListener` runs synchronously in the publisher's thread and
|
||||||
|
transaction.** The listener's JPQL delete and the `deleteById` are a single atomic
|
||||||
|
unit: if either fails, both roll back together.
|
||||||
|
|
||||||
|
### Repository method
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Modifying(clearAutomatically = true)
|
||||||
|
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||||
|
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`i.document.id` (the real association path) is used instead of `i.documentId`: the
|
||||||
|
transient `getDocumentId()` getter on `JourneyItem` makes Spring Data unable to resolve
|
||||||
|
a derived query path (same trap documented at `JourneyItemRepository:33-44`).
|
||||||
|
|
||||||
|
`clearAutomatically = true` invalidates the L1 cache so subsequent reads in the same
|
||||||
|
session do not return stale entities.
|
||||||
|
|
||||||
|
The predicate `(note IS NULL OR note = '')` covers the `note = ''` edge case that the
|
||||||
|
service layer can never produce (normalizeNote converts blank strings to null), but that
|
||||||
|
may exist via raw SQL inserts or legacy data. Whitespace-only notes (`note = ' '`)
|
||||||
|
do not match and are preserved as note-carrying placeholders.
|
||||||
|
|
||||||
|
### Audit decision
|
||||||
|
|
||||||
|
The listener calls the repository directly rather than routing through
|
||||||
|
`JourneyItemService.delete`. This deliberately bypasses the `JOURNEY_ITEM_REMOVED`
|
||||||
|
audit emission: a document used in multiple journeys would otherwise produce N audit
|
||||||
|
rows for a single user action. The `DOCUMENT_DELETED` entry written by `deleteDocument`
|
||||||
|
is the sole audit record for the operation.
|
||||||
|
|
||||||
|
### Boundary: documents must not depend on journey
|
||||||
|
|
||||||
|
The event direction is `document → journey`, never the reverse. `DocumentService`
|
||||||
|
publishes events it knows nothing about the consumers of; `JourneyItemService`'s
|
||||||
|
dependency on `DocumentService` is unchanged and remains the only cross-domain
|
||||||
|
reference. This direction is the prerequisite for the cycle constraint to hold.
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
- **DB trigger on `journey_items`** — trigger logic is opaque to Java developers,
|
||||||
|
invisible to code review, and not covered by the JPA test harness.
|
||||||
|
- **RESTRICT instead of SET NULL** — breaks the existing note-carrying placeholder
|
||||||
|
UX: deleting a document with a note-carrying journey item would 409 instead of
|
||||||
|
preserving the item as a placeholder.
|
||||||
|
- **Relax `chk_journey_item_not_empty`** — the constraint enforces a real invariant
|
||||||
|
(every item must have at least document or note). Removing it would allow empty rows.
|
||||||
|
- **`@Lazy` on the `JourneyItemService → DocumentService` injection** — Spring Boot 4 /
|
||||||
|
Spring Framework 7 prohibits constructor-injection cycles regardless of `@Lazy`.
|
||||||
|
- **Make `DocumentService` call `JourneyItemService`** — introduces the prohibited
|
||||||
|
cycle. Rejected at design time.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **No schema change** — no new Flyway migration, no `db-orm.puml` /
|
||||||
|
`db-relationships.puml` update.
|
||||||
|
- This is the **first custom domain event** in the codebase. No prior
|
||||||
|
`ApplicationEventPublisher` usage existed in `main/`. New cross-domain cleanup that
|
||||||
|
cannot use direct service calls should follow this pattern.
|
||||||
|
- All tests that delete documents and then assert journey-item state **must route
|
||||||
|
through `DocumentService.deleteDocument`**, not `documentRepository.deleteById`.
|
||||||
|
The existing `JourneyItemIntegrationTest` tests that covered the note-carrying
|
||||||
|
placeholder UX have been updated accordingly.
|
||||||
|
- The `DOCUMENT_DELETED` `AuditKind` was added as part of this fix to give AC-7's
|
||||||
|
audit assertion a positive check (absence-only assertions pass vacuously if all
|
||||||
|
auditing regresses).
|
||||||
@@ -16,8 +16,11 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
|||||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
Component(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). Sanitizes HTML body with an allowlist policy.")
|
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.")
|
||||||
|
Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.")
|
||||||
|
Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Serves both STORY and JOURNEY subtypes.")
|
||||||
|
Component(journeyListener, "JourneyItemDocumentDeleteListener", "Spring @EventListener", "Consumes DocumentDeletingEvent synchronously inside the delete transaction and removes note-less journey items before ON DELETE SET NULL fires, preventing a chk_journey_item_not_empty violation. See ADR-038.")
|
||||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +41,12 @@ Rel(notifCtrl, notifSvc, "Delegates to")
|
|||||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
||||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
||||||
Rel(geschCtrl, geschSvc, "Delegates to")
|
Rel(geschCtrl, geschSvc, "Delegates to")
|
||||||
|
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
|
||||||
|
Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence")
|
||||||
|
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
|
||||||
|
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
|
||||||
|
Rel(documentSvc, journeyListener, "DocumentDeletingEvent", "in-process event")
|
||||||
|
Rel(journeyListener, db, "Deletes note-less journey_items", "JDBC")
|
||||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
|||||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||||
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
|
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
|
||||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
||||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
|
||||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||||
@@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc
|
|||||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||||
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
|
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
|
||||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
|
||||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@startuml db-orm
|
@startuml db-orm
|
||||||
' Schema source: Flyway V1–V69 (excl. V37, V43 — intentionally removed)
|
' Schema source: Flyway V1–V72 (excl. V37, V43 — intentionally removed)
|
||||||
' Schema as of: V69 (2026-05-27)
|
' Schema as of: V72 (2026-06-08)
|
||||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||||
|
|
||||||
hide circle
|
hide circle
|
||||||
@@ -357,8 +357,9 @@ package "Supporting" {
|
|||||||
id : UUID <<PK>>
|
id : UUID <<PK>>
|
||||||
--
|
--
|
||||||
title : VARCHAR(255) NOT NULL
|
title : VARCHAR(255) NOT NULL
|
||||||
body : TEXT
|
body : TEXT CHECK (JOURNEY: length <= 4000)
|
||||||
status : VARCHAR(32) NOT NULL
|
status : VARCHAR(32) NOT NULL
|
||||||
|
type : VARCHAR(32) NOT NULL
|
||||||
author_id : UUID <<FK>>
|
author_id : UUID <<FK>>
|
||||||
created_at : TIMESTAMP NOT NULL
|
created_at : TIMESTAMP NOT NULL
|
||||||
updated_at : TIMESTAMP NOT NULL
|
updated_at : TIMESTAMP NOT NULL
|
||||||
@@ -370,9 +371,16 @@ package "Supporting" {
|
|||||||
person_id : UUID <<FK>>
|
person_id : UUID <<FK>>
|
||||||
}
|
}
|
||||||
|
|
||||||
entity geschichten_documents {
|
entity journey_items {
|
||||||
|
id : UUID <<PK>>
|
||||||
|
--
|
||||||
geschichte_id : UUID <<FK>>
|
geschichte_id : UUID <<FK>>
|
||||||
document_id : UUID <<FK>>
|
document_id : UUID <<FK>>
|
||||||
|
position : INTEGER NOT NULL CHECK (position > 0)
|
||||||
|
note : TEXT CHECK (length <= 2000)
|
||||||
|
==
|
||||||
|
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||||
|
UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +444,7 @@ audit_log }o--o| documents : document_id
|
|||||||
geschichten }o--o| app_users : author_id
|
geschichten }o--o| app_users : author_id
|
||||||
geschichten_persons }o--|| geschichten : geschichte_id
|
geschichten_persons }o--|| geschichten : geschichte_id
|
||||||
geschichten_persons }o--|| persons : person_id
|
geschichten_persons }o--|| persons : person_id
|
||||||
geschichten_documents }o--|| geschichten : geschichte_id
|
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||||
geschichten_documents }o--|| documents : document_id
|
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ package "Supporting" {
|
|||||||
entity audit_log
|
entity audit_log
|
||||||
entity geschichten
|
entity geschichten
|
||||||
entity geschichten_persons
|
entity geschichten_persons
|
||||||
entity geschichten_documents
|
entity journey_items
|
||||||
}
|
}
|
||||||
|
|
||||||
' Auth relationships
|
' Auth relationships
|
||||||
@@ -129,7 +129,9 @@ audit_log }o--o| documents : document_id
|
|||||||
geschichten }o--o| app_users : author_id
|
geschichten }o--o| app_users : author_id
|
||||||
geschichten_persons }o--|| geschichten : geschichte_id
|
geschichten_persons }o--|| geschichten : geschichte_id
|
||||||
geschichten_persons }o--|| persons : person_id
|
geschichten_persons }o--|| persons : person_id
|
||||||
geschichten_documents }o--|| geschichten : geschichte_id
|
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||||
geschichten_documents }o--|| documents : document_id
|
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||||
|
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
|
||||||
|
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -421,16 +421,16 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||||
<tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
<tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
||||||
<tr><td>Editorial list card</td><td>bg-white shadow-sm border border-brand-sand rounded-sm</td><td>wraps alle Zeilen</td></tr>
|
<tr><td>Editorial list card</td><td><s>bg-white shadow-sm border border-brand-sand rounded-sm</s> <em>(implementiert: bg-surface border-line — semantische Tokens für Dark Mode)</em></td><td>wraps alle Zeilen</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Listenzeile</td></tr>
|
<tr class="grp"><td colspan="3">Listenzeile</td></tr>
|
||||||
<tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr>
|
<tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr>
|
||||||
<tr><td>Meta column</td><td>w-[88px] shrink-0 flex flex-col gap-1 p-3 border-r border-brand-sand</td><td>feste Breite</td></tr>
|
<tr><td>Meta column</td><td>w-40 shrink-0 flex flex-col gap-1 p-3 border-r border-line-2</td><td>feste Breite — breit genug für text-sm Namen ohne Umbruch</td></tr>
|
||||||
<tr><td>Author avatar</td><td>w-7 h-7 rounded-full text-[9px] font-bold text-white flex items-center justify-center</td><td>personAvatarColor(userId)</td></tr>
|
<tr><td>Author avatar</td><td>w-7 h-7 rounded-full text-[9px] font-bold text-white flex items-center justify-center</td><td>personAvatarColor(userId)</td></tr>
|
||||||
<tr><td>Author name</td><td>font-sans text-xs font-semibold text-ink</td><td></td></tr>
|
<tr><td>Author name</td><td>font-sans text-sm font-semibold text-ink</td><td></td></tr>
|
||||||
<tr><td>Date</td><td>font-sans text-xs text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
<tr><td>Date</td><td>font-sans text-sm text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
||||||
<tr><td>Person chip</td><td>inline-flex items-center gap-1 rounded-full bg-surface border border-line px-2 py-0.5 text-[10px] font-medium text-ink</td><td>links zu /persons/[id]; optional</td></tr>
|
<tr><td>Person chip</td><td>inline-flex items-center gap-1 rounded-full bg-surface border border-line px-2 py-0.5 text-[10px] font-medium text-ink</td><td>links zu /persons/[id]; optional</td></tr>
|
||||||
<tr><td>Story title</td><td>font-serif text-[15px] text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
<tr><td>Story title</td><td>font-serif text-lg text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
||||||
<tr><td>Excerpt</td><td>font-sans text-xs text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
<tr><td>Excerpt</td><td>font-sans text-sm text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Filter</td></tr>
|
<tr class="grp"><td colspan="3">Filter</td></tr>
|
||||||
<tr><td>Filter pill (inaktiv)</td><td>rounded-full border border-line px-3 py-1 text-xs font-semibold text-ink-2 hover:bg-muted</td><td>aria-pressed="false"</td></tr>
|
<tr><td>Filter pill (inaktiv)</td><td>rounded-full border border-line px-3 py-1 text-xs font-semibold text-ink-2 hover:bg-muted</td><td>aria-pressed="false"</td></tr>
|
||||||
<tr><td>Filter pill (aktiv)</td><td>rounded-full bg-primary text-primary-fg px-3 py-1 text-xs font-semibold</td><td>aria-pressed="true"</td></tr>
|
<tr><td>Filter pill (aktiv)</td><td>rounded-full bg-primary text-primary-fg px-3 py-1 text-xs font-semibold</td><td>aria-pressed="true"</td></tr>
|
||||||
@@ -640,7 +640,8 @@
|
|||||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="grp"><td colspan="3">Artikel-Container</td></tr>
|
<tr class="grp"><td colspan="3">Artikel-Container</td></tr>
|
||||||
<tr><td>Article container</td><td>max-w-3xl mx-auto px-4 py-10</td><td>zentriert, volle Breite auf Mobile</td></tr>
|
<tr><td>Article container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>Seite so breit wie Dokumente/Personen; Textspalte bleibt lesbar zentriert</td></tr>
|
||||||
|
<tr><td>Article sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet); BackButton bleibt außerhalb</td></tr>
|
||||||
<tr><td>Story title</td><td>font-family:var(--font-display);font-size:clamp(22px,4vw,32px);color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
<tr><td>Story title</td><td>font-family:var(--font-display);font-size:clamp(22px,4vw,32px);color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
||||||
<tr><td>Back button</td><td><BackButton /> aus $lib/components/BackButton.svelte</td><td>history.back(); nicht <a href></td></tr>
|
<tr><td>Back button</td><td><BackButton /> aus $lib/components/BackButton.svelte</td><td>history.back(); nicht <a href></td></tr>
|
||||||
<tr class="grp"><td colspan="3">Metazeile</td></tr>
|
<tr class="grp"><td colspan="3">Metazeile</td></tr>
|
||||||
@@ -657,7 +658,7 @@
|
|||||||
<tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr>
|
<tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr>
|
||||||
<tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr>
|
<tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||||
<tr><td>… Menü (Mobile)</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
|
<tr><td>… Menü (Mobile)</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: Bearbeiten/Löschen bleiben inline in der Metazeile auf allen Breiten — kein BottomSheet)</em></td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
|
||||||
<tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr>
|
<tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr>
|
||||||
<tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr>
|
<tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -712,7 +713,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li>
|
<li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li>
|
||||||
<li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li>
|
<li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li>
|
||||||
<li>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</li>
|
<li><s>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets, kein BottomSheet)</em></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Barrierefreiheit</h3>
|
<h3>Barrierefreiheit</h3>
|
||||||
|
|||||||
@@ -500,7 +500,7 @@
|
|||||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
||||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
|
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: <del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> / <code>--color-interlude-border</code> CSS tokens</td></tr>
|
||||||
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
||||||
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
||||||
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
||||||
@@ -508,18 +508,18 @@
|
|||||||
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
||||||
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
||||||
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
|
<tr><td>„Notiz hinzufügen" Link</td><td><del>text-xs font-semibold text-blue-600</del> → <code>text-xs text-ink-3 underline hover:text-accent</code></td><td>togglet Notiz-Textarea</td></tr>
|
||||||
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||||
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
<tr><td>Interlude-Container</td><td><del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> left-accent border via <code>--color-interlude-border</code></td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||||
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
|
<tr><td>Label „Zwischentext"</td><td><del>text-orange-700</del> → <code>color: var(--color-interlude-label)</code></td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||||
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
<tr><td>Zwischentext-Textarea</td><td><del>border-orange-200 focus:border-orange-400</del> → <code>border-line focus-visible:ring-focus-ring</code></td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
||||||
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
||||||
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
||||||
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
||||||
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
|
<tr><td>Bibliothek</td><td><del>@dnd-kit/core oder svelte-dnd-action</del> → <code>createBlockDragDrop<JourneyItemView></code> aus <code>$lib/document/transcription/useBlockDragDrop.svelte</code></td><td>kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt</td></tr>
|
||||||
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
||||||
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -720,7 +720,7 @@
|
|||||||
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||||
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||||
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
|
<tr><td>Drag auf Mobile</td><td>Move-Up/Down Buttons statt Drag (44px touch targets)</td><td><del>dnd-kit unterstützt Touch nativ</del> → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons</td></tr>
|
||||||
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
||||||
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
||||||
@@ -779,7 +779,7 @@
|
|||||||
|
|
||||||
<h3>Drag-to-Reorder</h3>
|
<h3>Drag-to-Reorder</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
|
<li><del>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist.</del> → Implementiert mit <code>createBlockDragDrop<JourneyItemView></code> (kein externes Package).</li>
|
||||||
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
||||||
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -629,27 +629,28 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||||
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
|
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
|
||||||
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
|
<tr><td>Artikel-Container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>gleich wie StoryReader (R-2)</td></tr>
|
||||||
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
<tr><td>Artikel-Sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet), gleich wie Story (R-2); BackButton bleibt außerhalb</td></tr>
|
||||||
|
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-journey-tint text-journey border border-journey-border mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||||
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
|
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
|
||||||
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
|
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
|
||||||
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
|
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; <s>auf Mobile im ··· BottomSheet</s> <em>(implementiert: inline in der Metazeile auf allen Breiten)</em></td><td>gleich wie Story</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
|
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
|
||||||
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
<tr><td>Intro (body)</td><td>font-serif text-lg text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||||
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
|
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
|
||||||
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
|
<tr><td>Dokumentkarte</td><td>bg-surface border border-line rounded-sm p-3</td><td></td></tr>
|
||||||
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
<tr><td>Brieftitel</td><td>font-serif text-base text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
<tr><td>Briefmeta</td><td>text-sm text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||||
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
|
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
|
||||||
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-brand-mint bg-muted rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||||
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
|
<tr><td>Annotations-Text</td><td>text-base italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||||
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-journey-border bg-journey-tint rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||||
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
<tr><td>Interlude-Text</td><td>text-base italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||||
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
<tr><td>··· Menü</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: kein BottomSheet — Aktionen inline)</em></td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||||
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -710,7 +711,7 @@
|
|||||||
<h3>Berechtigungen</h3>
|
<h3>Berechtigungen</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
|
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
|
||||||
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
|
<li><s>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets)</em></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Barrierefreiheit</h3>
|
<h3>Barrierefreiheit</h3>
|
||||||
|
|||||||
@@ -301,6 +301,8 @@
|
|||||||
"comp_multiselect_placeholder": "Namen tippen...",
|
"comp_multiselect_placeholder": "Namen tippen...",
|
||||||
"comp_multiselect_remove": "Entfernen",
|
"comp_multiselect_remove": "Entfernen",
|
||||||
"comp_multiselect_loading": "Suche...",
|
"comp_multiselect_loading": "Suche...",
|
||||||
|
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"comp_typeahead_no_results": "Keine Treffer",
|
||||||
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
|
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
|
||||||
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
|
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
|
||||||
"comp_taginput_remove": "Schlagwort entfernen",
|
"comp_taginput_remove": "Schlagwort entfernen",
|
||||||
@@ -1023,6 +1025,10 @@
|
|||||||
"nav_stammbaum": "Stammbaum",
|
"nav_stammbaum": "Stammbaum",
|
||||||
"nav_geschichten": "Geschichten",
|
"nav_geschichten": "Geschichten",
|
||||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||||
|
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
||||||
|
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
||||||
|
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
|
||||||
|
"journey_item_document_deleted": "[Dokument gelöscht]",
|
||||||
"geschichten_index_title": "Geschichten",
|
"geschichten_index_title": "Geschichten",
|
||||||
"geschichten_new_button": "Neue Geschichte",
|
"geschichten_new_button": "Neue Geschichte",
|
||||||
"geschichten_filter_all_pill": "Alle",
|
"geschichten_filter_all_pill": "Alle",
|
||||||
@@ -1035,8 +1041,10 @@
|
|||||||
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
||||||
"geschichten_back_to_index": "Zurück zu Geschichten",
|
"geschichten_back_to_index": "Zurück zu Geschichten",
|
||||||
"geschichten_published_on": "veröffentlicht am {date}",
|
"geschichten_published_on": "veröffentlicht am {date}",
|
||||||
|
"journey_compiled_on": "zusammengestellt am {date}",
|
||||||
"geschichten_persons_section": "Personen in dieser Geschichte",
|
"geschichten_persons_section": "Personen in dieser Geschichte",
|
||||||
"geschichten_documents_section": "Erwähnte Dokumente",
|
"geschichten_documents_section": "Erwähnte Dokumente",
|
||||||
|
"geschichten_document_link_placeholder": "Dokument öffnen",
|
||||||
"geschichten_card_heading": "Geschichten",
|
"geschichten_card_heading": "Geschichten",
|
||||||
"geschichten_card_write_action": "+ Geschichte schreiben",
|
"geschichten_card_write_action": "+ Geschichte schreiben",
|
||||||
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
||||||
@@ -1044,6 +1052,7 @@
|
|||||||
"geschichten_card_show_all": "Alle anzeigen",
|
"geschichten_card_show_all": "Alle anzeigen",
|
||||||
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
||||||
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
||||||
|
"geschichte_sidebar_status": "Status",
|
||||||
"geschichte_editor_status_draft": "ENTWURF",
|
"geschichte_editor_status_draft": "ENTWURF",
|
||||||
"geschichte_editor_status_published": "VERÖFFENTLICHT",
|
"geschichte_editor_status_published": "VERÖFFENTLICHT",
|
||||||
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
|
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
|
||||||
@@ -1058,8 +1067,17 @@
|
|||||||
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
||||||
"geschichte_editor_personen_heading": "Personen",
|
"geschichte_editor_personen_heading": "Personen",
|
||||||
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
|
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
|
||||||
"geschichte_editor_dokumente_heading": "Dokumente",
|
"geschichte_documents_heading": "Briefe & Dokumente",
|
||||||
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
|
"geschichte_documents_hint": "Welche Dokumente gehören zu dieser Geschichte?",
|
||||||
|
"geschichte_documents_empty": "Noch keine Dokumente verknüpft. Suche unten nach einem Brief, um ihn dieser Geschichte hinzuzufügen.",
|
||||||
|
"geschichte_documents_picker_label": "Dokument hinzufügen",
|
||||||
|
"geschichte_documents_picker_placeholder": "Brief oder Dokument suchen…",
|
||||||
|
"geschichte_documents_deleted_placeholder": "Dokument wurde gelöscht",
|
||||||
|
"geschichte_documents_remove_label": "Dokument entfernen: {title}",
|
||||||
|
"geschichte_documents_capacity": "Diese Geschichte hat bereits die maximale Anzahl von Dokumenten (100) erreicht.",
|
||||||
|
"geschichte_documents_duplicate": "Dieses Dokument ist bereits mit der Geschichte verknüpft.",
|
||||||
|
"geschichte_documents_added_announce": "Hinzugefügt: {title}",
|
||||||
|
"geschichte_documents_removed_announce": "Entfernt: {title}",
|
||||||
"geschichte_editor_search_person": "Person suchen…",
|
"geschichte_editor_search_person": "Person suchen…",
|
||||||
"geschichte_editor_search_document": "Dokument suchen…",
|
"geschichte_editor_search_document": "Dokument suchen…",
|
||||||
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
|
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
|
||||||
@@ -1153,5 +1171,58 @@
|
|||||||
"themen_alle": "Alle Themen",
|
"themen_alle": "Alle Themen",
|
||||||
"themen_leer": "Noch keine Themen vergeben.",
|
"themen_leer": "Noch keine Themen vergeben.",
|
||||||
"themen_weitere": "+ {count} weitere",
|
"themen_weitere": "+ {count} weitere",
|
||||||
"themen_dokumente": "{count} Dokumente"
|
"themen_dokumente": "{count} Dokumente",
|
||||||
|
"journey_badge_list": "REISE",
|
||||||
|
"journey_badge_detail": "LESEREISE",
|
||||||
|
"journey_selector_question": "Was möchtest du erstellen?",
|
||||||
|
"journey_selector_story_title": "Geschichte",
|
||||||
|
"journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.",
|
||||||
|
"journey_selector_journey_title": "Lesereise",
|
||||||
|
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
|
||||||
|
"journey_selector_next_btn": "Weiter",
|
||||||
|
"journey_placeholder_back": "andere Auswahl",
|
||||||
|
"journey_create_submit": "Lesereise erstellen",
|
||||||
|
"journey_item_open_aria": "Brief vom {date} öffnen",
|
||||||
|
"journey_item_open_aria_undated": "Brief öffnen",
|
||||||
|
"journey_item_open": "Brief öffnen",
|
||||||
|
"journey_item_meta_from_to": "von {sender} an {receiver}",
|
||||||
|
"journey_empty_state": "Diese Lesereise ist noch leer.",
|
||||||
|
"journey_interlude_aria_label": "Kuratorennotiz",
|
||||||
|
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.",
|
||||||
|
"journey_add_document": "Brief hinzufügen",
|
||||||
|
"journey_add_interlude": "Zwischentext hinzufügen",
|
||||||
|
"journey_interlude_label": "Zwischentext",
|
||||||
|
"journey_item_pending_remove": "wird entfernt…",
|
||||||
|
"journey_publish_disabled_hint": "Titel und mindestens ein Eintrag erforderlich.",
|
||||||
|
"journey_title_aria_label": "Titel der Lesereise",
|
||||||
|
"journey_intro_aria_label": "Einleitung der Lesereise",
|
||||||
|
"journey_note_add": "Notiz hinzufügen",
|
||||||
|
"journey_note_remove": "Notiz entfernen",
|
||||||
|
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",
|
||||||
|
"journey_intro_save_hint": "Wird mit 'Speichern' gesichert.",
|
||||||
|
"journey_already_added": "Bereits enthalten",
|
||||||
|
"journey_note_aria_label": "Kuratoren-Notiz für {title}",
|
||||||
|
"journey_move_up": "'{title}' nach oben verschieben",
|
||||||
|
"journey_move_down": "'{title}' nach unten verschieben",
|
||||||
|
"journey_note_error": "Notiz konnte nicht gespeichert werden",
|
||||||
|
"journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben",
|
||||||
|
"journey_remove_item_aria": "'{title}' entfernen",
|
||||||
|
"journey_remove_confirm": "Wirklich entfernen?",
|
||||||
|
"journey_remove_confirm_yes": "Bestätigen",
|
||||||
|
"journey_remove_confirm_cancel": "Abbrechen",
|
||||||
|
"journey_mutation_error_reload": "Aktion fehlgeschlagen – bitte Seite neu laden.",
|
||||||
|
"journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.",
|
||||||
|
"journey_intro_placeholder": "Einleitung (optional)",
|
||||||
|
"journey_interlude_placeholder": "Zwischentext eingeben…",
|
||||||
|
"journey_add_interlude_confirm": "Hinzufügen",
|
||||||
|
"journey_edit_title_story": "Geschichte bearbeiten",
|
||||||
|
"journey_edit_title_journey": "Lesereise bearbeiten",
|
||||||
|
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
|
||||||
|
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
|
||||||
|
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
|
||||||
|
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
|
||||||
|
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
|
||||||
|
"person_unknown": "[Unbekannt]",
|
||||||
|
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
|
||||||
|
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,6 +301,8 @@
|
|||||||
"comp_multiselect_placeholder": "Type a name...",
|
"comp_multiselect_placeholder": "Type a name...",
|
||||||
"comp_multiselect_remove": "Remove",
|
"comp_multiselect_remove": "Remove",
|
||||||
"comp_multiselect_loading": "Searching...",
|
"comp_multiselect_loading": "Searching...",
|
||||||
|
"comp_typeahead_error": "Search failed. Please try again.",
|
||||||
|
"comp_typeahead_no_results": "No matches",
|
||||||
"comp_taginput_placeholder_create": "Add tags...",
|
"comp_taginput_placeholder_create": "Add tags...",
|
||||||
"comp_taginput_placeholder_filter": "Filter by tags...",
|
"comp_taginput_placeholder_filter": "Filter by tags...",
|
||||||
"comp_taginput_remove": "Remove tag",
|
"comp_taginput_remove": "Remove tag",
|
||||||
@@ -1023,6 +1025,10 @@
|
|||||||
"nav_stammbaum": "Family tree",
|
"nav_stammbaum": "Family tree",
|
||||||
"nav_geschichten": "Stories",
|
"nav_geschichten": "Stories",
|
||||||
"error_geschichte_not_found": "The story was not found.",
|
"error_geschichte_not_found": "The story was not found.",
|
||||||
|
"error_journey_item_not_found": "The journey item was not found.",
|
||||||
|
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
|
||||||
|
"error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.",
|
||||||
|
"journey_item_document_deleted": "[Document deleted]",
|
||||||
"geschichten_index_title": "Stories",
|
"geschichten_index_title": "Stories",
|
||||||
"geschichten_new_button": "New story",
|
"geschichten_new_button": "New story",
|
||||||
"geschichten_filter_all_pill": "All",
|
"geschichten_filter_all_pill": "All",
|
||||||
@@ -1035,8 +1041,10 @@
|
|||||||
"geschichten_empty_no_filter": "There are no published stories yet.",
|
"geschichten_empty_no_filter": "There are no published stories yet.",
|
||||||
"geschichten_back_to_index": "Back to stories",
|
"geschichten_back_to_index": "Back to stories",
|
||||||
"geschichten_published_on": "published on {date}",
|
"geschichten_published_on": "published on {date}",
|
||||||
|
"journey_compiled_on": "compiled on {date}",
|
||||||
"geschichten_persons_section": "People in this story",
|
"geschichten_persons_section": "People in this story",
|
||||||
"geschichten_documents_section": "Referenced documents",
|
"geschichten_documents_section": "Referenced documents",
|
||||||
|
"geschichten_document_link_placeholder": "Open document",
|
||||||
"geschichten_card_heading": "Stories",
|
"geschichten_card_heading": "Stories",
|
||||||
"geschichten_card_write_action": "+ Write a story",
|
"geschichten_card_write_action": "+ Write a story",
|
||||||
"geschichten_card_attach_action": "+ Attach a story",
|
"geschichten_card_attach_action": "+ Attach a story",
|
||||||
@@ -1044,6 +1052,7 @@
|
|||||||
"geschichten_card_show_all": "Show all",
|
"geschichten_card_show_all": "Show all",
|
||||||
"geschichte_editor_title_placeholder": "Story title",
|
"geschichte_editor_title_placeholder": "Story title",
|
||||||
"geschichte_editor_body_placeholder": "Write your story here…",
|
"geschichte_editor_body_placeholder": "Write your story here…",
|
||||||
|
"geschichte_sidebar_status": "Status",
|
||||||
"geschichte_editor_status_draft": "DRAFT",
|
"geschichte_editor_status_draft": "DRAFT",
|
||||||
"geschichte_editor_status_published": "PUBLISHED",
|
"geschichte_editor_status_published": "PUBLISHED",
|
||||||
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
|
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
|
||||||
@@ -1058,8 +1067,17 @@
|
|||||||
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
|
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
|
||||||
"geschichte_editor_personen_heading": "People",
|
"geschichte_editor_personen_heading": "People",
|
||||||
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
|
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
|
||||||
"geschichte_editor_dokumente_heading": "Documents",
|
"geschichte_documents_heading": "Letters & documents",
|
||||||
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
|
"geschichte_documents_hint": "Which documents belong to this story?",
|
||||||
|
"geschichte_documents_empty": "No documents linked yet. Search below for a letter to add it to this story.",
|
||||||
|
"geschichte_documents_picker_label": "Add document",
|
||||||
|
"geschichte_documents_picker_placeholder": "Search for a letter or document…",
|
||||||
|
"geschichte_documents_deleted_placeholder": "Document was deleted",
|
||||||
|
"geschichte_documents_remove_label": "Remove document: {title}",
|
||||||
|
"geschichte_documents_capacity": "This story has already reached the maximum of 100 documents.",
|
||||||
|
"geschichte_documents_duplicate": "This document is already linked to the story.",
|
||||||
|
"geschichte_documents_added_announce": "Added: {title}",
|
||||||
|
"geschichte_documents_removed_announce": "Removed: {title}",
|
||||||
"geschichte_editor_search_person": "Search person…",
|
"geschichte_editor_search_person": "Search person…",
|
||||||
"geschichte_editor_search_document": "Search document…",
|
"geschichte_editor_search_document": "Search document…",
|
||||||
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
|
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
|
||||||
@@ -1153,5 +1171,58 @@
|
|||||||
"themen_alle": "All Topics",
|
"themen_alle": "All Topics",
|
||||||
"themen_leer": "No topics assigned yet.",
|
"themen_leer": "No topics assigned yet.",
|
||||||
"themen_weitere": "+ {count} more",
|
"themen_weitere": "+ {count} more",
|
||||||
"themen_dokumente": "{count} documents"
|
"themen_dokumente": "{count} documents",
|
||||||
|
"journey_badge_list": "JOURNEY",
|
||||||
|
"journey_badge_detail": "READING JOURNEY",
|
||||||
|
"journey_selector_question": "What would you like to create?",
|
||||||
|
"journey_selector_story_title": "Story",
|
||||||
|
"journey_selector_story_desc": "A narrative story with images and text.",
|
||||||
|
"journey_selector_journey_title": "Reading Journey",
|
||||||
|
"journey_selector_journey_desc": "A curated selection of letters with notes.",
|
||||||
|
"journey_selector_next_btn": "Continue",
|
||||||
|
"journey_placeholder_back": "different selection",
|
||||||
|
"journey_create_submit": "Create reading journey",
|
||||||
|
"journey_item_open_aria": "Open letter from {date}",
|
||||||
|
"journey_item_open_aria_undated": "Open letter",
|
||||||
|
"journey_item_open": "Open letter",
|
||||||
|
"journey_item_meta_from_to": "from {sender} to {receiver}",
|
||||||
|
"journey_empty_state": "This reading journey is still empty.",
|
||||||
|
"journey_interlude_aria_label": "Curator's note",
|
||||||
|
"journey_selector_aria_live_hint": "Please select a type to continue.",
|
||||||
|
"journey_add_document": "Add letter",
|
||||||
|
"journey_add_interlude": "Add interlude",
|
||||||
|
"journey_interlude_label": "Interlude",
|
||||||
|
"journey_item_pending_remove": "removing…",
|
||||||
|
"journey_publish_disabled_hint": "A title and at least one entry are required.",
|
||||||
|
"journey_title_aria_label": "Title of the reading journey",
|
||||||
|
"journey_intro_aria_label": "Introduction of the reading journey",
|
||||||
|
"journey_note_add": "Add note",
|
||||||
|
"journey_note_remove": "Remove note",
|
||||||
|
"journey_note_save_hint": "Saved when you leave the field.",
|
||||||
|
"journey_intro_save_hint": "Saved when you click 'Save'.",
|
||||||
|
"journey_already_added": "Already included",
|
||||||
|
"journey_note_aria_label": "Curator note for {title}",
|
||||||
|
"journey_move_up": "Move '{title}' up",
|
||||||
|
"journey_move_down": "Move '{title}' down",
|
||||||
|
"journey_note_error": "Could not save note",
|
||||||
|
"journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}",
|
||||||
|
"journey_remove_item_aria": "Remove '{title}'",
|
||||||
|
"journey_remove_confirm": "Really remove?",
|
||||||
|
"journey_remove_confirm_yes": "Confirm",
|
||||||
|
"journey_remove_confirm_cancel": "Cancel",
|
||||||
|
"journey_mutation_error_reload": "Action failed – please reload the page.",
|
||||||
|
"journey_published_empty_warning": "This journey will remain published without any entries.",
|
||||||
|
"journey_intro_placeholder": "Introduction (optional)",
|
||||||
|
"journey_interlude_placeholder": "Enter interlude text…",
|
||||||
|
"journey_add_interlude_confirm": "Add",
|
||||||
|
"journey_edit_title_story": "Edit story",
|
||||||
|
"journey_edit_title_journey": "Edit reading journey",
|
||||||
|
"journey_publish_disabled_title": "Title and at least one entry required",
|
||||||
|
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
|
||||||
|
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
|
||||||
|
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
|
||||||
|
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
|
||||||
|
"person_unknown": "[Unknown]",
|
||||||
|
"error_journey_document_already_added": "This letter is already included in the reading journey.",
|
||||||
|
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,6 +301,8 @@
|
|||||||
"comp_multiselect_placeholder": "Escriba un nombre...",
|
"comp_multiselect_placeholder": "Escriba un nombre...",
|
||||||
"comp_multiselect_remove": "Eliminar",
|
"comp_multiselect_remove": "Eliminar",
|
||||||
"comp_multiselect_loading": "Buscando...",
|
"comp_multiselect_loading": "Buscando...",
|
||||||
|
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
|
||||||
|
"comp_typeahead_no_results": "Sin resultados",
|
||||||
"comp_taginput_placeholder_create": "Añadir etiquetas...",
|
"comp_taginput_placeholder_create": "Añadir etiquetas...",
|
||||||
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
|
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
|
||||||
"comp_taginput_remove": "Eliminar etiqueta",
|
"comp_taginput_remove": "Eliminar etiqueta",
|
||||||
@@ -1023,6 +1025,10 @@
|
|||||||
"nav_stammbaum": "Árbol genealógico",
|
"nav_stammbaum": "Árbol genealógico",
|
||||||
"nav_geschichten": "Historias",
|
"nav_geschichten": "Historias",
|
||||||
"error_geschichte_not_found": "No se encontró la historia.",
|
"error_geschichte_not_found": "No se encontró la historia.",
|
||||||
|
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
|
||||||
|
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
|
||||||
|
"error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.",
|
||||||
|
"journey_item_document_deleted": "[Documento eliminado]",
|
||||||
"geschichten_index_title": "Historias",
|
"geschichten_index_title": "Historias",
|
||||||
"geschichten_new_button": "Nueva historia",
|
"geschichten_new_button": "Nueva historia",
|
||||||
"geschichten_filter_all_pill": "Todas",
|
"geschichten_filter_all_pill": "Todas",
|
||||||
@@ -1035,8 +1041,10 @@
|
|||||||
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
||||||
"geschichten_back_to_index": "Volver a Historias",
|
"geschichten_back_to_index": "Volver a Historias",
|
||||||
"geschichten_published_on": "publicada el {date}",
|
"geschichten_published_on": "publicada el {date}",
|
||||||
|
"journey_compiled_on": "recopilada el {date}",
|
||||||
"geschichten_persons_section": "Personas en esta historia",
|
"geschichten_persons_section": "Personas en esta historia",
|
||||||
"geschichten_documents_section": "Documentos mencionados",
|
"geschichten_documents_section": "Documentos mencionados",
|
||||||
|
"geschichten_document_link_placeholder": "Abrir documento",
|
||||||
"geschichten_card_heading": "Historias",
|
"geschichten_card_heading": "Historias",
|
||||||
"geschichten_card_write_action": "+ Escribir historia",
|
"geschichten_card_write_action": "+ Escribir historia",
|
||||||
"geschichten_card_attach_action": "+ Adjuntar historia",
|
"geschichten_card_attach_action": "+ Adjuntar historia",
|
||||||
@@ -1044,6 +1052,7 @@
|
|||||||
"geschichten_card_show_all": "Mostrar todas",
|
"geschichten_card_show_all": "Mostrar todas",
|
||||||
"geschichte_editor_title_placeholder": "Título de la historia",
|
"geschichte_editor_title_placeholder": "Título de la historia",
|
||||||
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
||||||
|
"geschichte_sidebar_status": "Estado",
|
||||||
"geschichte_editor_status_draft": "BORRADOR",
|
"geschichte_editor_status_draft": "BORRADOR",
|
||||||
"geschichte_editor_status_published": "PUBLICADA",
|
"geschichte_editor_status_published": "PUBLICADA",
|
||||||
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
|
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
|
||||||
@@ -1058,8 +1067,17 @@
|
|||||||
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
|
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
|
||||||
"geschichte_editor_personen_heading": "Personas",
|
"geschichte_editor_personen_heading": "Personas",
|
||||||
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
|
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
|
||||||
"geschichte_editor_dokumente_heading": "Documentos",
|
"geschichte_documents_heading": "Cartas y documentos",
|
||||||
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
|
"geschichte_documents_hint": "¿Qué documentos pertenecen a esta historia?",
|
||||||
|
"geschichte_documents_empty": "Aún no hay documentos vinculados. Busca abajo una carta para añadirla a esta historia.",
|
||||||
|
"geschichte_documents_picker_label": "Añadir documento",
|
||||||
|
"geschichte_documents_picker_placeholder": "Buscar una carta o documento…",
|
||||||
|
"geschichte_documents_deleted_placeholder": "El documento fue eliminado",
|
||||||
|
"geschichte_documents_remove_label": "Quitar documento: {title}",
|
||||||
|
"geschichte_documents_capacity": "Esta historia ya ha alcanzado el número máximo de documentos (100).",
|
||||||
|
"geschichte_documents_duplicate": "Este documento ya está vinculado a la historia.",
|
||||||
|
"geschichte_documents_added_announce": "Añadido: {title}",
|
||||||
|
"geschichte_documents_removed_announce": "Quitado: {title}",
|
||||||
"geschichte_editor_search_person": "Buscar persona…",
|
"geschichte_editor_search_person": "Buscar persona…",
|
||||||
"geschichte_editor_search_document": "Buscar documento…",
|
"geschichte_editor_search_document": "Buscar documento…",
|
||||||
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
|
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
|
||||||
@@ -1153,5 +1171,58 @@
|
|||||||
"themen_alle": "Todos los temas",
|
"themen_alle": "Todos los temas",
|
||||||
"themen_leer": "Aún no hay temas.",
|
"themen_leer": "Aún no hay temas.",
|
||||||
"themen_weitere": "+ {count} más",
|
"themen_weitere": "+ {count} más",
|
||||||
"themen_dokumente": "{count} documentos"
|
"themen_dokumente": "{count} documentos",
|
||||||
|
"journey_badge_list": "VIAJE",
|
||||||
|
"journey_badge_detail": "VIAJE DE LECTURA",
|
||||||
|
"journey_selector_question": "¿Qué deseas crear?",
|
||||||
|
"journey_selector_story_title": "Historia",
|
||||||
|
"journey_selector_story_desc": "Una historia narrada con imágenes y texto.",
|
||||||
|
"journey_selector_journey_title": "Viaje de lectura",
|
||||||
|
"journey_selector_journey_desc": "Una selección curada de cartas con notas.",
|
||||||
|
"journey_selector_next_btn": "Continuar",
|
||||||
|
"journey_placeholder_back": "otra selección",
|
||||||
|
"journey_create_submit": "Crear viaje de lectura",
|
||||||
|
"journey_item_open_aria": "Abrir carta del {date}",
|
||||||
|
"journey_item_open_aria_undated": "Abrir carta",
|
||||||
|
"journey_item_open": "Abrir carta",
|
||||||
|
"journey_item_meta_from_to": "de {sender} a {receiver}",
|
||||||
|
"journey_empty_state": "Este viaje de lectura está vacío.",
|
||||||
|
"journey_interlude_aria_label": "Nota del curador",
|
||||||
|
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar.",
|
||||||
|
"journey_add_document": "Añadir carta",
|
||||||
|
"journey_add_interlude": "Añadir interludio",
|
||||||
|
"journey_interlude_label": "Interludio",
|
||||||
|
"journey_item_pending_remove": "eliminando…",
|
||||||
|
"journey_publish_disabled_hint": "Se requieren un título y al menos una entrada.",
|
||||||
|
"journey_title_aria_label": "Título del viaje de lectura",
|
||||||
|
"journey_intro_aria_label": "Introducción del viaje de lectura",
|
||||||
|
"journey_note_add": "Añadir nota",
|
||||||
|
"journey_note_remove": "Eliminar nota",
|
||||||
|
"journey_note_save_hint": "Se guarda al salir del campo.",
|
||||||
|
"journey_intro_save_hint": "Se guarda al hacer clic en 'Guardar'.",
|
||||||
|
"journey_already_added": "Ya incluido",
|
||||||
|
"journey_note_aria_label": "Nota del curador para {title}",
|
||||||
|
"journey_move_up": "Subir '{title}'",
|
||||||
|
"journey_move_down": "Bajar '{title}'",
|
||||||
|
"journey_note_error": "No se pudo guardar la nota",
|
||||||
|
"journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}",
|
||||||
|
"journey_remove_item_aria": "Eliminar '{title}'",
|
||||||
|
"journey_remove_confirm": "¿Realmente eliminar?",
|
||||||
|
"journey_remove_confirm_yes": "Confirmar",
|
||||||
|
"journey_remove_confirm_cancel": "Cancelar",
|
||||||
|
"journey_mutation_error_reload": "Acción fallida – por favor recarga la página.",
|
||||||
|
"journey_published_empty_warning": "Este viaje permanecerá publicado sin entradas.",
|
||||||
|
"journey_intro_placeholder": "Introducción (opcional)",
|
||||||
|
"journey_interlude_placeholder": "Escribe el texto del interludio…",
|
||||||
|
"journey_add_interlude_confirm": "Añadir",
|
||||||
|
"journey_edit_title_story": "Editar historia",
|
||||||
|
"journey_edit_title_journey": "Editar viaje de lectura",
|
||||||
|
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
|
||||||
|
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
|
||||||
|
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
|
||||||
|
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
|
||||||
|
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
|
||||||
|
"person_unknown": "[Desconocido]",
|
||||||
|
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
|
||||||
|
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,6 @@ describe('DashboardNeedsMetadata', () => {
|
|||||||
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
|
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
|
||||||
const docs = [makeDoc('d1', 'Only one')];
|
const docs = [makeDoc('d1', 'Only one')];
|
||||||
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
|
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
|
||||||
await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /Alle 50/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
import {
|
||||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
createDocumentTypeahead,
|
||||||
|
formatDocumentOption,
|
||||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
type DocumentOption
|
||||||
|
} from './documentTypeahead';
|
||||||
/**
|
|
||||||
* Exactly the fields this picker reads — id for selection/dedup, the rest for
|
|
||||||
* the honest date label. A full `Document` and a `DocumentListItem` are both
|
|
||||||
* structurally assignable, so the search results need no cast.
|
|
||||||
*/
|
|
||||||
type DocumentOption = Pick<
|
|
||||||
DocumentListItem,
|
|
||||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
|
||||||
>;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedDocuments?: DocumentOption[];
|
selectedDocuments?: DocumentOption[];
|
||||||
@@ -30,13 +20,16 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let results: DocumentOption[] = $state([]);
|
|
||||||
let showDropdown = $state(false);
|
|
||||||
let loading = $state(false);
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
||||||
let inputEl: HTMLInputElement;
|
let inputEl: HTMLInputElement;
|
||||||
let dropdownStyle = $state('');
|
let dropdownStyle = $state('');
|
||||||
|
|
||||||
|
const picker = createDocumentTypeahead();
|
||||||
|
|
||||||
|
// Filter out already-selected documents from typeahead results.
|
||||||
|
const filteredResults = $derived(
|
||||||
|
picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id))
|
||||||
|
);
|
||||||
|
|
||||||
function updateDropdownPosition() {
|
function updateDropdownPosition() {
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
const rect = inputEl.getBoundingClientRect();
|
const rect = inputEl.getBoundingClientRect();
|
||||||
@@ -44,57 +37,22 @@ function updateDropdownPosition() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
showDropdown = true;
|
if (searchTerm.trim().length >= 1) {
|
||||||
clearTimeout(debounceTimer);
|
picker.setQuery(searchTerm);
|
||||||
debounceTimer = setTimeout(async () => {
|
} else {
|
||||||
if (searchTerm.length < 1) {
|
picker.close();
|
||||||
results = [];
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
|
||||||
if (res.ok) {
|
|
||||||
const body: { items: DocumentListItem[] } = await res.json();
|
|
||||||
const docs: DocumentOption[] = body.items.map((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
title: it.title,
|
|
||||||
documentDate: it.documentDate,
|
|
||||||
metaDatePrecision: it.metaDatePrecision,
|
|
||||||
metaDateEnd: it.metaDateEnd
|
|
||||||
}));
|
|
||||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
results = [];
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDocument(doc: DocumentOption) {
|
function selectDocument(doc: DocumentOption) {
|
||||||
selectedDocuments = [...selectedDocuments, doc];
|
selectedDocuments = [...selectedDocuments, doc];
|
||||||
searchTerm = '';
|
searchTerm = '';
|
||||||
showDropdown = false;
|
picker.close();
|
||||||
results = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDocument(id: string | undefined) {
|
function removeDocument(id: string | undefined) {
|
||||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDocLabel(doc: DocumentOption): string {
|
|
||||||
if (!doc.documentDate) return doc.title;
|
|
||||||
const label = formatDocumentDate(
|
|
||||||
doc.documentDate,
|
|
||||||
doc.metaDatePrecision as DatePrecision,
|
|
||||||
doc.metaDateEnd,
|
|
||||||
null,
|
|
||||||
getLocale()
|
|
||||||
);
|
|
||||||
return `${doc.title} · ${label}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
@@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
|||||||
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||||
>
|
>
|
||||||
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
|||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||||
>
|
>
|
||||||
{formatDocLabel(doc)}
|
{formatDocumentOption(doc)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeDocument(doc.id)}
|
onclick={() => removeDocument(doc.id)}
|
||||||
@@ -136,24 +94,23 @@ function formatDocLabel(doc: DocumentOption): string {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={() => {
|
onfocus={() => updateDropdownPosition()}
|
||||||
updateDropdownPosition();
|
|
||||||
showDropdown = true;
|
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showDropdown && (results.length > 0 || loading)}
|
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
|
||||||
<div
|
<div
|
||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if picker.loading}
|
||||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||||
|
{:else if picker.error}
|
||||||
|
<div role="alert" class="p-2 text-sm text-danger">{m.comp_typeahead_error()}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as doc (doc.id)}
|
{#each filteredResults as doc (doc.id)}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||||
onclick={() => selectDocument(doc)}
|
onclick={() => selectDocument(doc)}
|
||||||
@@ -161,7 +118,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
{formatDocLabel(doc)}
|
{formatDocumentOption(doc)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
|
|
||||||
@@ -124,6 +125,28 @@ describe('DocumentMultiSelect — search and select', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DocumentMultiSelect — search failure', () => {
|
||||||
|
it('shows an error row when the search request fails instead of looking like "no results"', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(DocumentMultiSelect);
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
const alert = page.getByRole('alert');
|
||||||
|
await expect.element(alert).toBeInTheDocument();
|
||||||
|
await expect.element(alert).toHaveTextContent(m.comp_typeahead_error());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('DocumentMultiSelect — remove', () => {
|
describe('DocumentMultiSelect — remove', () => {
|
||||||
it('removes a chip when its × button is clicked', async () => {
|
it('removes a chip when its × button is clicked', async () => {
|
||||||
render(DocumentMultiSelect, {
|
render(DocumentMultiSelect, {
|
||||||
|
|||||||
155
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
155
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
|
import {
|
||||||
|
createDocumentTypeahead,
|
||||||
|
formatDocumentOption,
|
||||||
|
type DocumentOption
|
||||||
|
} from './documentTypeahead';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alreadyAddedIds?: Set<string>;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Set when a visible <label for> is wired externally — replaces the aria-label fallback. */
|
||||||
|
inputId?: string;
|
||||||
|
onSelect: (doc: DocumentOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
alreadyAddedIds = new Set(),
|
||||||
|
placeholder = m.journey_add_document(),
|
||||||
|
inputId = undefined,
|
||||||
|
onSelect
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
const listboxId = `doc-picker-listbox-${uid}`;
|
||||||
|
const resolvedInputId = $derived(inputId ?? `doc-picker-input-${uid}`);
|
||||||
|
|
||||||
|
const picker = createDocumentTypeahead();
|
||||||
|
|
||||||
|
let inputValue = $state('');
|
||||||
|
|
||||||
|
const activeOptionId = $derived(
|
||||||
|
picker.isOpen && picker.activeIndex >= 0 && picker.results[picker.activeIndex]
|
||||||
|
? `${listboxId}-option-${picker.activeIndex}`
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const q = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
inputValue = q;
|
||||||
|
picker.setActiveIndex(-1);
|
||||||
|
if (q.trim().length >= 1) {
|
||||||
|
picker.setQuery(q);
|
||||||
|
} else {
|
||||||
|
picker.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(doc: DocumentOption) {
|
||||||
|
if (alreadyAddedIds.has(doc.id!)) return;
|
||||||
|
inputValue = '';
|
||||||
|
picker.close();
|
||||||
|
onSelect(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!picker.isOpen) return;
|
||||||
|
|
||||||
|
const results = picker.results;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length > 0) {
|
||||||
|
picker.setActiveIndex((picker.activeIndex + 1) % results.length);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length > 0) {
|
||||||
|
picker.setActiveIndex((picker.activeIndex - 1 + results.length) % results.length);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const active = results[picker.activeIndex];
|
||||||
|
// handleSelect is a no-op for already-added (aria-disabled) options.
|
||||||
|
if (active) handleSelect(active);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
picker.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
autocomplete="off"
|
||||||
|
id={resolvedInputId}
|
||||||
|
aria-label={inputId ? undefined : placeholder}
|
||||||
|
aria-expanded={picker.isOpen}
|
||||||
|
aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
|
||||||
|
? listboxId
|
||||||
|
: undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={activeOptionId}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={inputValue}
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if picker.isOpen}
|
||||||
|
{#if picker.loading}
|
||||||
|
<div
|
||||||
|
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||||
|
>
|
||||||
|
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</p>
|
||||||
|
</div>
|
||||||
|
{:else if picker.error}
|
||||||
|
<div
|
||||||
|
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||||
|
>
|
||||||
|
<p role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</p>
|
||||||
|
</div>
|
||||||
|
{:else if picker.results.length === 0}
|
||||||
|
<div
|
||||||
|
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||||
|
>
|
||||||
|
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul
|
||||||
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||||
|
>
|
||||||
|
{#each picker.results as doc, i (doc.id)}
|
||||||
|
{@const disabled = alreadyAddedIds.has(doc.id!)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<li
|
||||||
|
id={`${listboxId}-option-${i}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === picker.activeIndex}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
onclick={() => handleSelect(doc)}
|
||||||
|
class={[
|
||||||
|
'px-3 py-2 text-ink select-none',
|
||||||
|
i === picker.activeIndex ? 'bg-muted' : '',
|
||||||
|
disabled
|
||||||
|
? 'cursor-default opacity-50'
|
||||||
|
: 'cursor-pointer hover:bg-muted'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{formatDocumentOption(doc)}
|
||||||
|
{#if disabled}
|
||||||
|
<span class="sr-only">{m.journey_already_added()}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
261
frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
Normal file
261
frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
|
|
||||||
|
const docFactory = (id: string, title: string) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
documentDate: '1880-01-01',
|
||||||
|
metaDatePrecision: 'DAY' as const,
|
||||||
|
metaDateEnd: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ items })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — empty query guard', () => {
|
||||||
|
it('does not call fetch on empty query', async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), '');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — already-added indicator', () => {
|
||||||
|
it('shows already-added document as aria-disabled with sr-only hint', async () => {
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, {
|
||||||
|
alreadyAddedIds: new Set(['d1']),
|
||||||
|
onSelect: vi.fn()
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
const disabledItem = page
|
||||||
|
.getByText(/Brief von Eugenie/i)
|
||||||
|
.element()
|
||||||
|
.closest('li')!;
|
||||||
|
expect(disabledItem.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
// Screen-reader text "bereits enthalten" must be present in the item
|
||||||
|
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — selection', () => {
|
||||||
|
it('calls onSelect with the item when a non-disabled option is clicked', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, { onSelect });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
await userEvent.click(page.getByText(/Brief von Eugenie/i));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onSelect when an aria-disabled option is clicked', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, {
|
||||||
|
alreadyAddedIds: new Set(['d1']),
|
||||||
|
onSelect
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
await page.getByText(/Brief von Eugenie/i).click({ force: true });
|
||||||
|
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — keyboard navigation', () => {
|
||||||
|
it('selects the first option via ArrowDown then Enter', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, { onSelect });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not select an aria-disabled option on Enter', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, {
|
||||||
|
alreadyAddedIds: new Set(['d1']),
|
||||||
|
onSelect
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the dropdown on Escape', async () => {
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points aria-activedescendant at the active option', async () => {
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
const input = page.getByRole('combobox');
|
||||||
|
await userEvent.fill(input, 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
expect(input.element().getAttribute('aria-activedescendant')).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
|
||||||
|
const activeId = input.element().getAttribute('aria-activedescendant');
|
||||||
|
expect(activeId).toMatch(/-option-0$/);
|
||||||
|
const firstOption = page
|
||||||
|
.getByText(/Brief von Eugenie/i)
|
||||||
|
.element()
|
||||||
|
.closest('li')!;
|
||||||
|
expect(firstOption.id).toBe(activeId);
|
||||||
|
expect(firstOption.getAttribute('aria-selected')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — no results', () => {
|
||||||
|
it('shows a non-interactive no-results row when the search returns zero hits', async () => {
|
||||||
|
mockSearchResponse([]);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'xyz');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — search failure', () => {
|
||||||
|
it('shows an error message when the search request fails instead of vanishing', async () => {
|
||||||
|
// 500 from /api/documents/search — must surface, not render as "no results"
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — ARIA listbox integrity', () => {
|
||||||
|
it('does not render a listbox when results are empty (no aria-required-children violation)', async () => {
|
||||||
|
mockSearchResponse([]);
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'xyz');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
// no-results message must be visible, but NOT inside a listbox
|
||||||
|
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('[role="listbox"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a listbox when loading (no aria-required-children violation)', async () => {
|
||||||
|
let resolveSearch!: (v: unknown) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockReturnValue(new Promise((resolve) => (resolveSearch = resolve)))
|
||||||
|
);
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
|
||||||
|
// While in-flight, no listbox should exist
|
||||||
|
expect(document.querySelector('[role="listbox"]')).toBeNull();
|
||||||
|
resolveSearch({ ok: true, json: () => Promise.resolve({ items: [] }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('option elements do not have tabindex (combobox pattern: focus stays on input)', async () => {
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief A'), docFactory('d2', 'Brief B')]);
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
const options = document.querySelectorAll('[role="listbox"] [role="option"]');
|
||||||
|
expect(options.length).toBeGreaterThan(0);
|
||||||
|
options.forEach((opt) => {
|
||||||
|
expect(opt).not.toHaveAttribute('tabindex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentPickerDropdown — external label wiring (#795)', () => {
|
||||||
|
it('renders a generated default id on the input and keeps the aria-label fallback', async () => {
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||||
|
|
||||||
|
const input = page.getByRole('combobox').element() as HTMLInputElement;
|
||||||
|
expect(input.id).toMatch(/^doc-picker-input-/);
|
||||||
|
expect(input.getAttribute('aria-label')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the provided inputId and drops the aria-label so an external label wins', async () => {
|
||||||
|
render(DocumentPickerDropdown, { onSelect: vi.fn(), inputId: 'story-doc-picker' });
|
||||||
|
|
||||||
|
const input = page.getByRole('combobox').element() as HTMLInputElement;
|
||||||
|
expect(input.id).toBe('story-doc-picker');
|
||||||
|
expect(input.getAttribute('aria-label')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
45
frontend/src/lib/document/documentTypeahead.ts
Normal file
45
frontend/src/lib/document/documentTypeahead.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte';
|
||||||
|
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||||
|
|
||||||
|
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||||
|
|
||||||
|
export type DocumentOption = Pick<
|
||||||
|
DocumentListItem,
|
||||||
|
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createDocumentTypeahead() {
|
||||||
|
return createTypeahead<DocumentOption>({
|
||||||
|
fetchUrl: (q) =>
|
||||||
|
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
|
||||||
|
.then((r) => {
|
||||||
|
// Without this check a 401/500 parses as JSON without `items` and
|
||||||
|
// renders as "no results" — errors must reach the hook's error state.
|
||||||
|
if (!r.ok) throw new Error(`document search failed: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((b: { items: DocumentListItem[] }) =>
|
||||||
|
b.items.map((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
title: it.title,
|
||||||
|
documentDate: it.documentDate,
|
||||||
|
metaDatePrecision: it.metaDatePrecision,
|
||||||
|
metaDateEnd: it.metaDateEnd
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDocumentOption(doc: DocumentOption): string {
|
||||||
|
if (!doc.documentDate) return doc.title;
|
||||||
|
const label = formatDocumentDate(
|
||||||
|
doc.documentDate,
|
||||||
|
doc.metaDatePrecision as DatePrecision,
|
||||||
|
doc.metaDateEnd,
|
||||||
|
null,
|
||||||
|
getLocale()
|
||||||
|
);
|
||||||
|
return `${doc.title} · ${label}`;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte';
|
|||||||
import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte';
|
import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte';
|
||||||
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
|
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
|
||||||
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
|
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
|
||||||
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
|
||||||
import { csrfFetch } from '$lib/shared/cookies';
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -84,6 +84,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/geschichten/{id}/items/reorder": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Reorder journey items
|
||||||
|
* @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request.
|
||||||
|
*/
|
||||||
|
put: operations["reorderItems"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{id}": {
|
"/api/documents/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -420,6 +440,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/geschichten/{id}/items": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["appendItem"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents": {
|
"/api/documents": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -804,6 +840,22 @@ export interface paths {
|
|||||||
patch: operations["update"];
|
patch: operations["update"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/geschichten/{id}/items/{itemId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteItem"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["updateItemNote"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{id}/training-labels": {
|
"/api/documents/{id}/training-labels": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -875,7 +927,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get: operations["search_1"];
|
get: operations["search"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1339,7 +1391,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get: operations["search_2"];
|
get: operations["search_1"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1690,6 +1742,32 @@ export interface components {
|
|||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
};
|
};
|
||||||
|
JourneyReorderDTO: {
|
||||||
|
itemIds?: string[];
|
||||||
|
};
|
||||||
|
DocumentSummary: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** Format: date */
|
||||||
|
documentDate?: string;
|
||||||
|
/** Format: date */
|
||||||
|
documentDateEnd?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||||
|
senderName?: string;
|
||||||
|
receiverName?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
receiverCount: number;
|
||||||
|
};
|
||||||
|
JourneyItemView: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
position: number;
|
||||||
|
document?: components["schemas"]["DocumentSummary"];
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
DocumentUpdateDTO: {
|
DocumentUpdateDTO: {
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
@@ -1819,75 +1897,6 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
targetId: string;
|
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: {
|
CreateRelationshipRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
relatedPersonId: string;
|
relatedPersonId: string;
|
||||||
@@ -2015,25 +2024,44 @@ export interface components {
|
|||||||
body?: string;
|
body?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status?: "DRAFT" | "PUBLISHED";
|
status?: "DRAFT" | "PUBLISHED";
|
||||||
|
/** @enum {string} */
|
||||||
|
type?: "STORY" | "JOURNEY";
|
||||||
personIds?: string[];
|
personIds?: string[];
|
||||||
documentIds?: string[];
|
|
||||||
};
|
};
|
||||||
Geschichte: {
|
AuthorView: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
GeschichteView: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "DRAFT" | "PUBLISHED";
|
status: "DRAFT" | "PUBLISHED";
|
||||||
author?: components["schemas"]["AppUser"];
|
/** @enum {string} */
|
||||||
persons?: components["schemas"]["Person"][];
|
type: "STORY" | "JOURNEY";
|
||||||
documents?: components["schemas"]["Document"][];
|
author?: components["schemas"]["AuthorView"];
|
||||||
|
persons: components["schemas"]["PersonView"][];
|
||||||
|
items: components["schemas"]["JourneyItemView"][];
|
||||||
|
/** Format: date-time */
|
||||||
|
publishedAt?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/** Format: date-time */
|
};
|
||||||
publishedAt?: string;
|
PersonView: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
|
JourneyItemCreateDTO: {
|
||||||
|
/** Format: uuid */
|
||||||
|
documentId?: string;
|
||||||
|
note?: string;
|
||||||
};
|
};
|
||||||
CreateTranscriptionBlockDTO: {
|
CreateTranscriptionBlockDTO: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -2233,6 +2261,9 @@ export interface components {
|
|||||||
actorName?: string;
|
actorName?: string;
|
||||||
documentTitle?: string;
|
documentTitle?: string;
|
||||||
};
|
};
|
||||||
|
JourneyItemUpdateDTO: {
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
TrainingLabelRequest: {
|
TrainingLabelRequest: {
|
||||||
label?: string;
|
label?: string;
|
||||||
enrolled?: boolean;
|
enrolled?: boolean;
|
||||||
@@ -2273,6 +2304,11 @@ export interface components {
|
|||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
transcriptionCount: number;
|
transcriptionCount: number;
|
||||||
};
|
};
|
||||||
|
ActivityActorDTO: {
|
||||||
|
initials: string;
|
||||||
|
color: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
TranscriptionQueueItemDTO: {
|
TranscriptionQueueItemDTO: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2335,13 +2371,13 @@ export interface components {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
documentCount?: number;
|
documentCount?: number;
|
||||||
|
alias?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
provisional?: boolean;
|
provisional?: boolean;
|
||||||
alias?: string;
|
|
||||||
personType?: string;
|
personType?: string;
|
||||||
familyMember?: boolean;
|
familyMember?: boolean;
|
||||||
};
|
};
|
||||||
@@ -2440,6 +2476,8 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
pageable?: components["schemas"]["PageableObject"];
|
pageable?: components["schemas"]["PageableObject"];
|
||||||
|
first?: boolean;
|
||||||
|
last?: boolean;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
size?: number;
|
size?: number;
|
||||||
content?: components["schemas"]["NotificationDTO"][];
|
content?: components["schemas"]["NotificationDTO"][];
|
||||||
@@ -2448,8 +2486,6 @@ export interface components {
|
|||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
numberOfElements?: number;
|
numberOfElements?: number;
|
||||||
first?: boolean;
|
|
||||||
last?: boolean;
|
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
};
|
};
|
||||||
PageableObject: {
|
PageableObject: {
|
||||||
@@ -2472,6 +2508,25 @@ export interface components {
|
|||||||
nodes: components["schemas"]["PersonNodeDTO"][];
|
nodes: components["schemas"]["PersonNodeDTO"][];
|
||||||
edges: components["schemas"]["RelationshipDTO"][];
|
edges: components["schemas"]["RelationshipDTO"][];
|
||||||
};
|
};
|
||||||
|
AuthorSummary: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
|
GeschichteSummary: {
|
||||||
|
body?: string;
|
||||||
|
title: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: "STORY" | "JOURNEY";
|
||||||
|
/** @enum {string} */
|
||||||
|
status: "DRAFT" | "PUBLISHED";
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
|
author?: components["schemas"]["AuthorSummary"];
|
||||||
|
/** Format: date-time */
|
||||||
|
publishedAt?: string;
|
||||||
|
};
|
||||||
DocumentVersionSummary: {
|
DocumentVersionSummary: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2513,6 +2568,63 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages?: number;
|
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: {
|
IncompleteDocumentDTO: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2561,7 +2673,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ActivityFeedItemDTO: {
|
ActivityFeedItemDTO: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
|
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
|
||||||
actor?: components["schemas"]["ActivityActorDTO"];
|
actor?: components["schemas"]["ActivityActorDTO"];
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -2871,6 +2983,32 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
reorderItems: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["JourneyReorderDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["JourneyItemView"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getDocument: {
|
getDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3591,7 +3729,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Geschichte"][];
|
"*/*": components["schemas"]["GeschichteSummary"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -3615,7 +3753,33 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Geschichte"];
|
"*/*": components["schemas"]["GeschichteView"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
appendItem: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["JourneyItemCreateDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["JourneyItemView"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4291,7 +4455,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Geschichte"];
|
"*/*": components["schemas"]["GeschichteView"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4337,7 +4501,55 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Geschichte"];
|
"*/*": components["schemas"]["GeschichteView"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteItem: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
itemId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateItemNote: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
itemId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["JourneyItemUpdateDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["JourneyItemView"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4486,7 +4698,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
search_1: {
|
search: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -5110,7 +5322,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
search_2: {
|
search_1: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -5325,7 +5537,7 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
||||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
|
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -5,34 +5,26 @@ import { Editor } from '@tiptap/core';
|
|||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte';
|
||||||
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
|
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
|
||||||
|
|
||||||
type Geschichte = components['schemas']['Geschichte'];
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
type Document = components['schemas']['Document'];
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
geschichte?: Geschichte | null;
|
geschichte?: GeschichteView | null;
|
||||||
initialPersons?: Person[];
|
initialPersons?: Person[];
|
||||||
initialDocuments?: Document[];
|
/** Must reject when the save failed — the editor keeps its dirty state then. */
|
||||||
onSubmit: (payload: {
|
onSubmit: (payload: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
status: 'DRAFT' | 'PUBLISHED';
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
personIds: string[];
|
personIds: string[];
|
||||||
documentIds: string[];
|
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
submitting?: boolean;
|
submitting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props();
|
||||||
geschichte = null,
|
|
||||||
initialPersons = [],
|
|
||||||
initialDocuments = [],
|
|
||||||
onSubmit,
|
|
||||||
submitting = false
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Initial-state snapshot from incoming props. The editor owns these values
|
// Initial-state snapshot from incoming props. The editor owns these values
|
||||||
// after mount; the parent should re-mount the component with a different
|
// after mount; the parent should re-mount the component with a different
|
||||||
@@ -41,11 +33,8 @@ let {
|
|||||||
let title = $state(geschichte?.title ?? '');
|
let title = $state(geschichte?.title ?? '');
|
||||||
let body = $state(geschichte?.body ?? '');
|
let body = $state(geschichte?.body ?? '');
|
||||||
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
||||||
let selectedPersons: Person[] = $state(
|
let selectedPersons: PersonOption[] = $state(
|
||||||
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
geschichte?.persons ? Array.from(geschichte.persons).map(toPersonOption) : initialPersons
|
||||||
);
|
|
||||||
let selectedDocuments: Document[] = $state(
|
|
||||||
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let dirty = $state(false);
|
let dirty = $state(false);
|
||||||
@@ -118,14 +107,17 @@ function handleTitleInput() {
|
|||||||
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||||
titleTouched = true;
|
titleTouched = true;
|
||||||
if (titleEmpty) return;
|
if (titleEmpty) return;
|
||||||
await onSubmit({
|
try {
|
||||||
title: title.trim(),
|
await onSubmit({
|
||||||
body,
|
title: title.trim(),
|
||||||
status: nextStatus,
|
body,
|
||||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
|
status: nextStatus,
|
||||||
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
|
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||||
});
|
});
|
||||||
dirty = false;
|
dirty = false;
|
||||||
|
} catch {
|
||||||
|
// onSubmit signalled failure — keep dirty so the unsaved guard stays armed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
||||||
@@ -148,6 +140,7 @@ function exec(action: () => void) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
|
maxlength="255"
|
||||||
oninput={handleTitleInput}
|
oninput={handleTitleInput}
|
||||||
onblur={handleTitleBlur}
|
onblur={handleTitleBlur}
|
||||||
placeholder={m.geschichte_editor_title_placeholder()}
|
placeholder={m.geschichte_editor_title_placeholder()}
|
||||||
@@ -241,43 +234,12 @@ function exec(action: () => void) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="flex flex-col gap-6">
|
<GeschichteSidebar
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
status={status}
|
||||||
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
|
bind:selectedPersons={selectedPersons}
|
||||||
<p class="mb-3">
|
geschichteId={geschichte?.id}
|
||||||
<span
|
items={geschichte?.items ?? []}
|
||||||
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
/>
|
||||||
? 'bg-muted text-ink-2'
|
|
||||||
: 'bg-accent-bg text-ink'}"
|
|
||||||
>
|
|
||||||
{isDraft
|
|
||||||
? m.geschichte_editor_status_draft()
|
|
||||||
: m.geschichte_editor_status_published()}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p class="font-sans text-xs text-ink-3">
|
|
||||||
{isDraft
|
|
||||||
? m.geschichte_editor_status_draft_hint()
|
|
||||||
: m.geschichte_editor_status_published_hint()}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
|
||||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.geschichte_editor_personen_heading()}
|
|
||||||
</h2>
|
|
||||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
|
||||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
|
||||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.geschichte_editor_dokumente_heading()}
|
|
||||||
</h2>
|
|
||||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
|
|
||||||
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save bar -->
|
<!-- Save bar -->
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import GeschichteEditor from './GeschichteEditor.svelte';
|
import GeschichteEditor from './GeschichteEditor.svelte';
|
||||||
|
|
||||||
const personFactory = (id: string, displayName: string) => ({
|
const personFactory = (id: string, displayName: string) => ({
|
||||||
@@ -8,19 +9,9 @@ const personFactory = (id: string, displayName: string) => ({
|
|||||||
firstName: displayName.split(' ')[0],
|
firstName: displayName.split(' ')[0],
|
||||||
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
||||||
displayName,
|
displayName,
|
||||||
personType: 'PERSON' as const
|
personType: 'PERSON' as const,
|
||||||
});
|
familyMember: false,
|
||||||
|
provisional: false
|
||||||
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
documentDate: date,
|
|
||||||
originalFilename: `${title}.pdf`,
|
|
||||||
status: 'UPLOADED' as const,
|
|
||||||
metadataComplete: false,
|
|
||||||
scriptType: 'UNKNOWN' as const,
|
|
||||||
createdAt: '2024-01-01T00:00:00',
|
|
||||||
updatedAt: '2024-01-01T00:00:00'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@@ -28,8 +19,9 @@ const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
title: 'Existing draft',
|
title: 'Existing draft',
|
||||||
body: '<p>Hello world</p>',
|
body: '<p>Hello world</p>',
|
||||||
status: 'DRAFT' as const,
|
status: 'DRAFT' as const,
|
||||||
|
type: 'STORY' as const,
|
||||||
persons: [],
|
persons: [],
|
||||||
documents: [],
|
items: [],
|
||||||
createdAt: '2024-01-01T00:00:00',
|
createdAt: '2024-01-01T00:00:00',
|
||||||
updatedAt: '2024-01-01T00:00:00',
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
...overrides
|
...overrides
|
||||||
@@ -63,6 +55,22 @@ describe('GeschichteEditor — title-required guard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GeschichteEditor — onSubmit rejects on failure', () => {
|
||||||
|
it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => {
|
||||||
|
// Contract: onSubmit rejects on failure. Without the catch in save(), this
|
||||||
|
// click would surface as an unhandled promise rejection and fail the run.
|
||||||
|
const onSubmit = vi.fn().mockRejectedValue(new Error('save failed'));
|
||||||
|
render(GeschichteEditor, { geschichte: draftFactory(), onSubmit });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||||
|
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// Editor still functional — a second save attempt goes through
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||||
|
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GeschichteEditor — save bar adapts to status', () => {
|
describe('GeschichteEditor — save bar adapts to status', () => {
|
||||||
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
|
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
|
||||||
render(GeschichteEditor, { onSubmit: vi.fn() });
|
render(GeschichteEditor, { onSubmit: vi.fn() });
|
||||||
@@ -93,14 +101,6 @@ describe('GeschichteEditor — pre-fill', () => {
|
|||||||
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders initial documents as chips', async () => {
|
|
||||||
render(GeschichteEditor, {
|
|
||||||
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
|
|
||||||
onSubmit: vi.fn()
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('populates the title input from a geschichte prop', async () => {
|
it('populates the title input from a geschichte prop', async () => {
|
||||||
render(GeschichteEditor, {
|
render(GeschichteEditor, {
|
||||||
geschichte: draftFactory({ title: 'My existing story' }),
|
geschichte: draftFactory({ title: 'My existing story' }),
|
||||||
@@ -154,11 +154,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
|
it('passes personIds from initial props through onSubmit', async () => {
|
||||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
render(GeschichteEditor, {
|
render(GeschichteEditor, {
|
||||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||||
initialDocuments: [docFactory('d1', 'Brief A')],
|
|
||||||
onSubmit
|
onSubmit
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +170,38 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
|||||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
const payload = onSubmit.mock.calls[0][0];
|
const payload = onSubmit.mock.calls[0][0];
|
||||||
expect(payload.personIds).toEqual(['p1']);
|
expect(payload.personIds).toEqual(['p1']);
|
||||||
expect(payload.documentIds).toEqual(['d1']);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GeschichteEditor — story document panel (#795)', () => {
|
||||||
|
it('shows the document panel with the story items when editing an existing story', async () => {
|
||||||
|
render(GeschichteEditor, {
|
||||||
|
geschichte: draftFactory({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'i1',
|
||||||
|
position: 10,
|
||||||
|
document: {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Brief von Eugenie',
|
||||||
|
datePrecision: 'DAY' as const,
|
||||||
|
receiverCount: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
onSubmit: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the document panel when no geschichte is set (creation flow)', async () => {
|
||||||
|
render(GeschichteEditor, { onSubmit: vi.fn() });
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
85
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
85
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||||
|
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||||
|
import { formatAuthorName, formatPublishedAt } from './utils';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteRow = Pick<
|
||||||
|
components['schemas']['GeschichteSummary'],
|
||||||
|
'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
let { geschichte }: { geschichte: GeschichteRow } = $props();
|
||||||
|
|
||||||
|
const isJourney = $derived(geschichte.type === 'JOURNEY');
|
||||||
|
|
||||||
|
const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'));
|
||||||
|
|
||||||
|
const authorName = $derived(formatAuthorName(geschichte.author));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/geschichten/{geschichte.id}"
|
||||||
|
class="group flex min-h-[44px] transition-colors hover:bg-canvas/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<!-- Meta column (desktop) -->
|
||||||
|
<div class="hidden w-40 shrink-0 flex-col items-start gap-1 border-r border-line-2 p-3 sm:flex">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white"
|
||||||
|
style="background-color: {personAvatarColor(authorName)}"
|
||||||
|
>
|
||||||
|
{getInitials(authorName)}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</span>
|
||||||
|
{#if publishedAt}
|
||||||
|
<span class="font-sans text-sm text-ink-3">{publishedAt}</span>
|
||||||
|
{/if}
|
||||||
|
{#if isJourney}
|
||||||
|
<span
|
||||||
|
data-testid="journey-badge"
|
||||||
|
class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase"
|
||||||
|
>
|
||||||
|
{m.journey_badge_list()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content column -->
|
||||||
|
<div class="min-w-0 flex-1 p-3 sm:px-4">
|
||||||
|
<!-- Compact meta line (mobile only) -->
|
||||||
|
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
|
||||||
|
<!-- 7px initials render as smudge at this size — a plain color dot reads better -->
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style="background-color: {personAvatarColor(authorName)}"
|
||||||
|
></span>
|
||||||
|
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
|
||||||
|
{#if publishedAt}
|
||||||
|
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-1 flex items-center gap-1.5">
|
||||||
|
<h2 class="font-serif text-lg leading-snug text-ink group-hover:underline">
|
||||||
|
{geschichte.title}
|
||||||
|
</h2>
|
||||||
|
{#if isJourney}
|
||||||
|
<span
|
||||||
|
data-testid="journey-badge-mobile"
|
||||||
|
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase sm:hidden"
|
||||||
|
>
|
||||||
|
{m.journey_badge_list()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if geschichte.body}
|
||||||
|
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||||
|
<p class="line-clamp-2 font-sans text-sm leading-relaxed text-ink-3">
|
||||||
|
{plainExcerpt(geschichte.body, 150)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
94
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
94
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const baseRow = (overrides = {}) => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Die Reise nach Berlin',
|
||||||
|
body: '<p>Im Jahr 1923...</p>',
|
||||||
|
type: 'STORY' as 'STORY' | 'JOURNEY',
|
||||||
|
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
|
||||||
|
author: { firstName: 'Anna', lastName: 'Schmidt' },
|
||||||
|
publishedAt: '2026-04-15T10:00:00Z',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GeschichteListRow', () => {
|
||||||
|
it('renders the title', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('heading', { level: 2 }))
|
||||||
|
.toHaveTextContent('Die Reise nach Berlin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('row text sizes suit the full-width list: title text-lg, excerpt/meta text-sm (#802)', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||||
|
|
||||||
|
const title = document.querySelector('h2');
|
||||||
|
expect(title!.className).toContain('text-lg');
|
||||||
|
expect(title!.className).not.toContain('text-[15px]');
|
||||||
|
|
||||||
|
const excerpt = document.querySelector('p');
|
||||||
|
expect(excerpt!.className).toContain('text-sm');
|
||||||
|
expect(excerpt!.className).not.toContain('text-xs');
|
||||||
|
|
||||||
|
const meta = Array.from(document.querySelectorAll('span')).filter((s) =>
|
||||||
|
s.textContent?.includes('Anna Schmidt')
|
||||||
|
);
|
||||||
|
expect(meta.length).toBeGreaterThan(0);
|
||||||
|
for (const span of meta) {
|
||||||
|
expect(span.className).toContain('text-sm');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desktop meta column is wide enough for text-sm names (w-40, #802)', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||||
|
|
||||||
|
const metaColumn = document.querySelector('[class*="border-r"]');
|
||||||
|
expect(metaColumn).not.toBeNull();
|
||||||
|
expect(metaColumn!.className).toContain('w-40');
|
||||||
|
expect(metaColumn!.className).not.toContain('w-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no badge for STORY type', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } });
|
||||||
|
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no badge when type is undefined', async () => {
|
||||||
|
render(GeschichteListRow, {
|
||||||
|
props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) }
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows REISE badge for JOURNEY type', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||||
|
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge?.textContent?.trim()).toBe('REISE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badge is a plain <span>, not a nested interactive element', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||||
|
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||||
|
expect(badge?.tagName.toLowerCase()).toBe('span');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badge uses the 12px label size — text-xs is the visible-text floor', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||||
|
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||||
|
expect(badge!.className).toContain('text-xs');
|
||||||
|
// 10px was below the house floor for the 60+ audience (round-3 review)
|
||||||
|
expect(badge!.className).not.toContain('text-[10px]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders author name in meta line', async () => {
|
||||||
|
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||||
|
expect(document.body.textContent).toContain('Anna Schmidt');
|
||||||
|
});
|
||||||
|
});
|
||||||
82
frontend/src/lib/geschichte/GeschichteSidebar.svelte
Normal file
82
frontend/src/lib/geschichte/GeschichteSidebar.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||||
|
import type { PersonOption } from '$lib/person/personOption';
|
||||||
|
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
|
||||||
|
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
selectedPersons: PersonOption[];
|
||||||
|
/** When set, the story document panel is rendered (STORY edit only). */
|
||||||
|
geschichteId?: string;
|
||||||
|
items?: JourneyItemView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
status,
|
||||||
|
selectedPersons = $bindable(),
|
||||||
|
geschichteId = undefined,
|
||||||
|
items = []
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isDraft = $derived(status === 'DRAFT');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="flex flex-col gap-6">
|
||||||
|
<!-- Status section -->
|
||||||
|
<details open class="sm:contents">
|
||||||
|
<summary
|
||||||
|
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||||
|
>
|
||||||
|
{m.geschichte_sidebar_status()}
|
||||||
|
</summary>
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<!-- hidden below sm: the <summary> already shows this label there -->
|
||||||
|
<h2
|
||||||
|
class="mb-1 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||||
|
>
|
||||||
|
{m.geschichte_sidebar_status()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||||
|
? 'bg-muted text-ink-2'
|
||||||
|
: 'bg-accent-bg text-ink'}"
|
||||||
|
>
|
||||||
|
{isDraft ? m.geschichte_editor_status_draft() : m.geschichte_editor_status_published()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{isDraft
|
||||||
|
? m.geschichte_editor_status_draft_hint()
|
||||||
|
: m.geschichte_editor_status_published_hint()}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Persons section -->
|
||||||
|
<details open class="sm:contents">
|
||||||
|
<summary
|
||||||
|
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_personen_heading()}
|
||||||
|
</summary>
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2
|
||||||
|
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_personen_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||||
|
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||||
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Documents section — STORY edit only; journeys manage items in the editor column -->
|
||||||
|
{#if geschichteId}
|
||||||
|
<StoryDocumentPanel geschichteId={geschichteId} items={items} />
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
40
frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts
Normal file
40
frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
id: 'i1',
|
||||||
|
position: 10,
|
||||||
|
document: {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Brief von Eugenie',
|
||||||
|
datePrecision: 'DAY' as const,
|
||||||
|
receiverCount: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('GeschichteSidebar — document panel contract (#795)', () => {
|
||||||
|
it('renders the document panel when geschichteId and items are provided', async () => {
|
||||||
|
render(GeschichteSidebar, {
|
||||||
|
status: 'DRAFT',
|
||||||
|
selectedPersons: [],
|
||||||
|
geschichteId: 'g1',
|
||||||
|
items: [item]
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the document panel without geschichteId', async () => {
|
||||||
|
render(GeschichteSidebar, { status: 'DRAFT', selectedPersons: [] });
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,12 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import { formatAuthorName } from './utils';
|
||||||
|
|
||||||
type Geschichte = components['schemas']['Geschichte'];
|
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
geschichten: Geschichte[];
|
geschichten: GeschichteSummary[];
|
||||||
personId: string;
|
personId: string;
|
||||||
personName: string;
|
personName: string;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
@@ -18,16 +19,13 @@ let { geschichten, personId, personName, canWrite }: Props = $props();
|
|||||||
const visible = $derived(geschichten.slice(0, 3));
|
const visible = $derived(geschichten.slice(0, 3));
|
||||||
const hasOverflow = $derived(geschichten.length >= 3);
|
const hasOverflow = $derived(geschichten.length >= 3);
|
||||||
|
|
||||||
function formatPublishedDate(g: Geschichte): string | null {
|
function formatPublishedDate(g: GeschichteSummary): string | null {
|
||||||
if (!g.publishedAt) return null;
|
if (!g.publishedAt) return null;
|
||||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||||
}
|
}
|
||||||
|
|
||||||
function authorName(g: Geschichte): string {
|
function authorName(g: GeschichteSummary): string {
|
||||||
const a = g.author;
|
return formatAuthorName(g.author);
|
||||||
if (!a) return '';
|
|
||||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
|
||||||
return full || a.email || '';
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||||
|
|
||||||
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
|
const makeStory = (id: string, title: string, body: string | undefined = '<p>Body</p>') => ({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
status: 'PUBLISHED' as const,
|
status: 'PUBLISHED' as const,
|
||||||
|
type: 'STORY' as const,
|
||||||
publishedAt: '2024-04-01T12:00:00',
|
publishedAt: '2024-04-01T12:00:00',
|
||||||
createdAt: '2024-03-01T12:00:00',
|
createdAt: '2024-03-01T12:00:00',
|
||||||
updatedAt: '2024-04-01T12:00:00',
|
updatedAt: '2024-04-01T12:00:00',
|
||||||
persons: [],
|
persons: [],
|
||||||
documents: [],
|
items: [],
|
||||||
author: {
|
author: {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
email: 'marcel@example.com',
|
|
||||||
firstName: 'Marcel',
|
firstName: 'Marcel',
|
||||||
lastName: 'Raddatz',
|
lastName: 'Raddatz',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -120,6 +120,16 @@ describe('GeschichtenCard', () => {
|
|||||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: false
|
||||||
|
});
|
||||||
|
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a plain-text excerpt without HTML markup', async () => {
|
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||||
render(GeschichtenCard, {
|
render(GeschichtenCard, {
|
||||||
geschichten: [
|
geschichten: [
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ import { describe, it, expect, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
const makeGeschichte = (overrides: Record<string, unknown> = {}): GeschichteSummary =>
|
||||||
id: 'g1',
|
({
|
||||||
title: 'Reise nach Berlin',
|
id: 'g1',
|
||||||
body: '<p>Brief text</p>',
|
title: 'Reise nach Berlin',
|
||||||
publishedAt: '2026-04-15T10:00:00Z',
|
body: '<p>Brief text</p>',
|
||||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown,
|
status: 'PUBLISHED' as const,
|
||||||
...overrides
|
type: 'STORY' as const,
|
||||||
});
|
publishedAt: '2026-04-15T10:00:00Z',
|
||||||
|
author: {
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Schmidt'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
}) as GeschichteSummary;
|
||||||
|
|
||||||
const baseProps = (overrides: Record<string, unknown> = {}) => ({
|
const baseProps = (overrides: Record<string, unknown> = {}) => ({
|
||||||
geschichten: [] as ReturnType<typeof makeGeschichte>[],
|
geschichten: [] as GeschichteSummary[],
|
||||||
personId: 'p-1',
|
personId: 'p-1',
|
||||||
personName: 'Anna Schmidt',
|
personName: 'Anna Schmidt',
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
@@ -93,17 +102,17 @@ describe('GeschichtenCard', () => {
|
|||||||
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
|
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to author email when no name', async () => {
|
it('falls back to [Unbekannt] when no name', async () => {
|
||||||
render(GeschichtenCard, {
|
render(GeschichtenCard, {
|
||||||
props: baseProps({
|
props: baseProps({
|
||||||
geschichten: [
|
geschichten: [
|
||||||
makeGeschichte({
|
makeGeschichte({
|
||||||
author: { firstName: undefined, lastName: undefined, email: 'fallback@x' }
|
author: { firstName: undefined, lastName: undefined }
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText(/fallback@x/)).toBeVisible();
|
await expect.element(page.getByText('[Unbekannt]')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
126
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
126
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||||
|
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alreadyAddedIds?: Set<string>;
|
||||||
|
onAddDocument: (doc: DocumentOption) => void;
|
||||||
|
onAddInterlude: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $props();
|
||||||
|
|
||||||
|
let showPicker = $state(false);
|
||||||
|
let showInterludeForm = $state(false);
|
||||||
|
let interludeDraft = $state('');
|
||||||
|
let rootEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
|
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
|
||||||
|
|
||||||
|
async function togglePicker() {
|
||||||
|
showPicker = !showPicker;
|
||||||
|
showInterludeForm = false;
|
||||||
|
if (showPicker) {
|
||||||
|
// Keyboard users need a perceivable result of activating the toggle.
|
||||||
|
await tick();
|
||||||
|
rootEl?.querySelector<HTMLInputElement>('#journey-add-picker input')?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleInterludeForm() {
|
||||||
|
showInterludeForm = !showInterludeForm;
|
||||||
|
showPicker = false;
|
||||||
|
if (showInterludeForm) {
|
||||||
|
await tick();
|
||||||
|
rootEl?.querySelector<HTMLTextAreaElement>('#journey-add-interlude textarea')?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentSelect(doc: DocumentOption) {
|
||||||
|
showPicker = false;
|
||||||
|
onAddDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInterludeConfirm() {
|
||||||
|
if (!canConfirmInterlude) return;
|
||||||
|
const text = interludeDraft.trim();
|
||||||
|
interludeDraft = '';
|
||||||
|
showInterludeForm = false;
|
||||||
|
onAddInterlude(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInterludeCancel() {
|
||||||
|
interludeDraft = '';
|
||||||
|
showInterludeForm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={rootEl} class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-add-document
|
||||||
|
onclick={togglePicker}
|
||||||
|
aria-expanded={showPicker}
|
||||||
|
aria-controls={showPicker ? 'journey-add-picker' : undefined}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
+ {m.journey_add_document()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleInterludeForm}
|
||||||
|
aria-expanded={showInterludeForm}
|
||||||
|
aria-controls={showInterludeForm ? 'journey-add-interlude' : undefined}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
+ {m.journey_add_interlude()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showPicker}
|
||||||
|
<div id="journey-add-picker">
|
||||||
|
<DocumentPickerDropdown
|
||||||
|
alreadyAddedIds={alreadyAddedIds}
|
||||||
|
onSelect={handleDocumentSelect}
|
||||||
|
placeholder={m.journey_add_document()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showInterludeForm}
|
||||||
|
<div id="journey-add-interlude" class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
bind:value={interludeDraft}
|
||||||
|
placeholder={m.journey_interlude_placeholder()}
|
||||||
|
rows={3}
|
||||||
|
maxlength={2000}
|
||||||
|
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleInterludeConfirm}
|
||||||
|
disabled={!canConfirmInterlude}
|
||||||
|
class={[
|
||||||
|
'inline-flex h-11 items-center rounded px-4 font-sans text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring',
|
||||||
|
canConfirmInterlude
|
||||||
|
? 'bg-primary text-primary-fg hover:opacity-90'
|
||||||
|
: 'cursor-not-allowed bg-primary/40 text-primary-fg/60'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{m.journey_add_interlude_confirm()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleInterludeCancel}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.journey_remove_confirm_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
72
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
72
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyAddBar — interlude flow', () => {
|
||||||
|
it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => {
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
|
||||||
|
const confirmBtn = page.getByRole('button', {
|
||||||
|
name: m.journey_add_interlude_confirm(),
|
||||||
|
exact: true
|
||||||
|
});
|
||||||
|
await expect.element(confirmBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm becomes enabled after typing text', async () => {
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
|
||||||
|
|
||||||
|
const confirmBtn = page.getByRole('button', {
|
||||||
|
name: m.journey_add_interlude_confirm(),
|
||||||
|
exact: true
|
||||||
|
});
|
||||||
|
await expect.element(confirmBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onAddInterlude with text on confirm', async () => {
|
||||||
|
const onAddInterlude = vi.fn();
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the interlude textarea to 2000 characters', async () => {
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('textbox')).toHaveAttribute('maxlength', '2000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyAddBar — document picker', () => {
|
||||||
|
it('reveals picker when "Brief hinzufügen" is clicked', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
|
||||||
|
);
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
408
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
408
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
|
||||||
|
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
|
||||||
|
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||||
|
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||||
|
import GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||||
|
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||||
|
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte: GeschichteView;
|
||||||
|
/** Must reject when the save failed — the editor keeps its dirty state then. */
|
||||||
|
onSubmit: (payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
submitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichte, onSubmit, submitting = false }: Props = $props();
|
||||||
|
|
||||||
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
|
let title = $state(geschichte.title ?? '');
|
||||||
|
|
||||||
|
let body = $state(geschichte.body ?? '');
|
||||||
|
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte.status ?? 'DRAFT');
|
||||||
|
let selectedPersons: PersonOption[] = $state(
|
||||||
|
geschichte.persons ? Array.from(geschichte.persons).map(toPersonOption) : []
|
||||||
|
);
|
||||||
|
let items: JourneyItemView[] = $state(
|
||||||
|
[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
|
||||||
|
);
|
||||||
|
|
||||||
|
let titleTouched = $state(false);
|
||||||
|
let mutationError = $state('');
|
||||||
|
let pendingRemoveIds: string[] = $state([]);
|
||||||
|
let liveAnnounce = $state('');
|
||||||
|
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function scheduleAnnounceReset() {
|
||||||
|
if (announceTimer) clearTimeout(announceTimer);
|
||||||
|
announceTimer = setTimeout(() => {
|
||||||
|
liveAnnounce = '';
|
||||||
|
announceTimer = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleEmpty = $derived(title.trim().length === 0);
|
||||||
|
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||||
|
const isDraft = $derived(status === 'DRAFT');
|
||||||
|
const alreadyAddedIds = $derived(
|
||||||
|
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||||
|
);
|
||||||
|
const canPublish = $derived(items.length > 0 && !titleEmpty);
|
||||||
|
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
|
||||||
|
|
||||||
|
// Skip the initial run so mounting with pre-existing persons doesn't mark dirty.
|
||||||
|
let _personEffectMounted = false;
|
||||||
|
$effect(() => {
|
||||||
|
void selectedPersons.length;
|
||||||
|
if (!_personEffectMounted) {
|
||||||
|
_personEffectMounted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsaved.markDirty();
|
||||||
|
});
|
||||||
|
|
||||||
|
let listEl: HTMLElement | null = $state(null);
|
||||||
|
let editorColEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
|
const dragDrop = createBlockDragDrop<JourneyItemView>({
|
||||||
|
getSortedBlocks: () => items,
|
||||||
|
onReorder: handleReorder
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dragDrop.setListElement(listEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Maps a failed mutation response to a user-facing message via its backend error code. */
|
||||||
|
async function failureMessage(res: Response): Promise<string> {
|
||||||
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
|
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves keyboard focus to a control inside the row of the given item. */
|
||||||
|
async function focusRowControl(itemId: string, selector: string) {
|
||||||
|
await tick();
|
||||||
|
editorColEl?.querySelector<HTMLElement>(`[data-block-id="${itemId}"] ${selector}`)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReorder(itemIds: string[]) {
|
||||||
|
const prev = [...items];
|
||||||
|
items = itemIds.map((id) => items.find((i) => i.id === id)!);
|
||||||
|
mutationError = '';
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemIds })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
items = prev;
|
||||||
|
mutationError = await failureMessage(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated: JourneyItemView[] = await res.json();
|
||||||
|
items = updated.sort((a, b) => a.position - b.position);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Journey reorder failed', e);
|
||||||
|
items = prev;
|
||||||
|
mutationError = m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pessimistic append shared by both add paths — items update only on API success. */
|
||||||
|
async function appendItem(body: { documentId?: string; note?: string }) {
|
||||||
|
mutationError = '';
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
mutationError = await failureMessage(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newItem: JourneyItemView = await res.json();
|
||||||
|
items = [...items, newItem];
|
||||||
|
// Move-up is disabled on the first row — fall back to the remove button then.
|
||||||
|
await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Journey item append failed', e);
|
||||||
|
mutationError = m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddDocument(doc: DocumentOption) {
|
||||||
|
await appendItem({ documentId: doc.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddInterlude(text: string) {
|
||||||
|
await appendItem({ note: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(itemId: string) {
|
||||||
|
const idx = items.findIndex((i) => i.id === itemId);
|
||||||
|
mutationError = '';
|
||||||
|
pendingRemoveIds = [...pendingRemoveIds, itemId];
|
||||||
|
liveAnnounce = m.journey_item_pending_remove();
|
||||||
|
scheduleAnnounceReset();
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
mutationError = await failureMessage(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items = items.filter((i) => i.id !== itemId);
|
||||||
|
await tick();
|
||||||
|
if (items.length === 0 || idx <= 0) {
|
||||||
|
editorColEl?.querySelector<HTMLElement>('[data-add-document]')?.focus();
|
||||||
|
} else {
|
||||||
|
await focusRowControl(items[idx - 1].id, '[data-remove-btn]');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Journey item remove failed', e);
|
||||||
|
mutationError = m.journey_mutation_error_reload();
|
||||||
|
} finally {
|
||||||
|
pendingRemoveIds = pendingRemoveIds.filter((id) => id !== itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotePatch(itemId: string, note: string | null) {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note: note })
|
||||||
|
});
|
||||||
|
// Carry the backend error code's message so the row can show the specific
|
||||||
|
// reason (e.g. JOURNEY_NOTE_TOO_LONG) instead of a generic alert.
|
||||||
|
if (!res.ok) throw new Error(await failureMessage(res));
|
||||||
|
const updated: JourneyItemView = await res.json();
|
||||||
|
items = items.map((i) => (i.id === itemId ? updated : i));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMoveUp(index: number) {
|
||||||
|
if (index === 0) return;
|
||||||
|
const total = items.length;
|
||||||
|
const ids = items.map((i) => i.id);
|
||||||
|
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||||
|
await handleReorder(ids);
|
||||||
|
// Announce only after the server confirmed (or rejected) the reorder —
|
||||||
|
// announcing beforehand would claim success for a move that rolled back.
|
||||||
|
liveAnnounce = mutationError
|
||||||
|
? mutationError
|
||||||
|
: m.journey_item_moved({ position: index + 1, total, newPosition: index });
|
||||||
|
scheduleAnnounceReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMoveDown(index: number) {
|
||||||
|
if (index === items.length - 1) return;
|
||||||
|
const total = items.length;
|
||||||
|
const ids = items.map((i) => i.id);
|
||||||
|
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||||
|
await handleReorder(ids);
|
||||||
|
liveAnnounce = mutationError
|
||||||
|
? mutationError
|
||||||
|
: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
|
||||||
|
scheduleAnnounceReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||||
|
titleTouched = true;
|
||||||
|
if (titleEmpty) return;
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
body,
|
||||||
|
status: nextStatus,
|
||||||
|
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||||
|
});
|
||||||
|
unsaved.clearOnSuccess();
|
||||||
|
} catch {
|
||||||
|
// onSubmit signalled failure — keep dirty flag so the banner stays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Screen-reader live region for move announcements -->
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||||
|
|
||||||
|
{#if unsaved.showUnsavedWarning}
|
||||||
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<!-- Editor column -->
|
||||||
|
<div bind:this={editorColEl} class="flex flex-col gap-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
maxlength="255"
|
||||||
|
oninput={() => unsaved.markDirty()}
|
||||||
|
onblur={() => (titleTouched = true)}
|
||||||
|
placeholder={m.geschichte_editor_title_placeholder()}
|
||||||
|
aria-label={m.journey_title_aria_label()}
|
||||||
|
aria-invalid={showTitleError}
|
||||||
|
aria-describedby={showTitleError ? 'journey-title-error' : undefined}
|
||||||
|
class="block w-full rounded border {showTitleError
|
||||||
|
? 'border-danger'
|
||||||
|
: 'border-line'} bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
{#if showTitleError}
|
||||||
|
<p id="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||||
|
{m.geschichte_editor_title_required()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intro textarea -->
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
bind:value={body}
|
||||||
|
maxlength="4000"
|
||||||
|
oninput={() => unsaved.markDirty()}
|
||||||
|
placeholder={m.journey_intro_placeholder()}
|
||||||
|
aria-label={m.journey_intro_aria_label()}
|
||||||
|
rows={3}
|
||||||
|
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item list -->
|
||||||
|
{#if showPublishedEmptyWarning}
|
||||||
|
<p
|
||||||
|
class="rounded border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] px-3 py-2 font-sans text-sm text-[var(--color-warning-text)]"
|
||||||
|
>
|
||||||
|
{m.journey_published_empty_warning()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mutationError}
|
||||||
|
<p
|
||||||
|
class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{mutationError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length === 0}
|
||||||
|
<p class="font-sans text-sm text-ink-3">{m.journey_empty_state()}</p>
|
||||||
|
{:else}
|
||||||
|
<!-- pointer events managed by createBlockDragDrop; keyboard reorder available via move-up/down buttons on each item -->
|
||||||
|
<ol
|
||||||
|
bind:this={listEl}
|
||||||
|
onpointermove={(e) => dragDrop.handlePointerMove(e)}
|
||||||
|
onpointerup={() => dragDrop.handlePointerUp()}
|
||||||
|
class="m-0 flex list-none flex-col gap-2 p-0"
|
||||||
|
>
|
||||||
|
{#each items as item, i (item.id)}
|
||||||
|
<!-- pointerdown initiates drag; the drag handle button inside is the semantic interactive element -->
|
||||||
|
<li
|
||||||
|
data-block-wrapper
|
||||||
|
onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
|
||||||
|
class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
|
||||||
|
style={dragDrop.draggedBlockId === item.id
|
||||||
|
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||||
|
: ''}
|
||||||
|
>
|
||||||
|
{#if dragDrop.dropTargetIdx === i}
|
||||||
|
<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
|
||||||
|
{/if}
|
||||||
|
<JourneyItemRow
|
||||||
|
item={item}
|
||||||
|
index={i}
|
||||||
|
total={items.length}
|
||||||
|
pendingRemove={pendingRemoveIds.includes(item.id)}
|
||||||
|
onMoveUp={() => handleMoveUp(i)}
|
||||||
|
onMoveDown={() => handleMoveDown(i)}
|
||||||
|
onRemove={() => handleRemove(item.id)}
|
||||||
|
onNotePatch={(note) => handleNotePatch(item.id, note)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<JourneyAddBar
|
||||||
|
alreadyAddedIds={alreadyAddedIds}
|
||||||
|
onAddDocument={handleAddDocument}
|
||||||
|
onAddInterlude={handleAddInterlude}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save bar -->
|
||||||
|
<div
|
||||||
|
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col items-start gap-1 sm:items-end">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if isDraft}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('DRAFT')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_save_draft()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('PUBLISHED')}
|
||||||
|
disabled={submitting || !canPublish}
|
||||||
|
title={canPublish ? undefined : m.journey_publish_disabled_title()}
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_publish()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('DRAFT')}
|
||||||
|
disabled={submitting}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-[var(--color-warning-text)] hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_unpublish()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('PUBLISHED')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_save()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isDraft && !canPublish}
|
||||||
|
<p class="font-sans text-xs text-ink-3">{m.journey_publish_disabled_hint()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
813
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
813
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,813 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import JourneyEditor from './JourneyEditor.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
|
||||||
|
const docSummary = (id: string, title: string) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
datePrecision: 'DAY' as const,
|
||||||
|
receiverCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DocumentListItem fixture as returned by the picker search endpoint. */
|
||||||
|
const makeSearchResultItem = (id: string, title: string) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
documentDate: '1880-01-01',
|
||||||
|
metaDatePrecision: 'DAY',
|
||||||
|
originalFilename: 'brief.pdf',
|
||||||
|
receivers: [],
|
||||||
|
tags: [],
|
||||||
|
completionPercentage: 0,
|
||||||
|
contributors: [],
|
||||||
|
matchData: {
|
||||||
|
titleOffsets: [],
|
||||||
|
senderMatched: false,
|
||||||
|
matchedReceiverIds: [],
|
||||||
|
matchedTagIds: [],
|
||||||
|
snippetOffsets: [],
|
||||||
|
summaryOffsets: []
|
||||||
|
},
|
||||||
|
status: 'UPLOADED',
|
||||||
|
metadataComplete: false,
|
||||||
|
scriptType: 'UNKNOWN',
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00'
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Briefe der Familie Raddatz',
|
||||||
|
body: '',
|
||||||
|
status: 'DRAFT' as const,
|
||||||
|
type: 'JOURNEY' as const,
|
||||||
|
persons: [],
|
||||||
|
items: [],
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
geschichte: makeGeschichte(),
|
||||||
|
onSubmit: vi.fn().mockResolvedValue(undefined),
|
||||||
|
submitting: false,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockCsrfFetch(responseFactory: () => object) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue(responseFactory())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — empty state', () => {
|
||||||
|
it('renders title input and intro textarea', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels the title input and intro textarea for screen readers', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state message when items list is empty', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — items in position order', () => {
|
||||||
|
it('renders items sorted by position', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
|
||||||
|
];
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
// Brief A (position 0) must appear before Brief B (position 1) in DOM order
|
||||||
|
const briefA = page.getByText('Brief A').element();
|
||||||
|
const briefB = page.getByText('Brief B').element();
|
||||||
|
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — publish surface', () => {
|
||||||
|
it('publish button disabled when no items', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a visible hint while publishing is disabled', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publish stays disabled until title is non-empty', async () => {
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({
|
||||||
|
geschichte: makeGeschichte({
|
||||||
|
title: '',
|
||||||
|
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||||
|
|
||||||
|
const titleInput = page.getByPlaceholder(/Titel/);
|
||||||
|
await userEvent.fill(titleInput, 'Meine Reise');
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding an item enables the publish button (canPublish becomes true)', async () => {
|
||||||
|
const newItem = { id: 'i1', position: 0, note: 'Test' };
|
||||||
|
mockCsrfFetch(() => newItem);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
|
||||||
|
// Publish should be disabled before adding item
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||||
|
|
||||||
|
// Add interlude
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
// After item add, publish becomes enabled — item was added and state is correct
|
||||||
|
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({
|
||||||
|
onSubmit,
|
||||||
|
geschichte: makeGeschichte({
|
||||||
|
title: ' Meine Reise ',
|
||||||
|
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({
|
||||||
|
onSubmit,
|
||||||
|
geschichte: makeGeschichte({
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — add document', () => {
|
||||||
|
it('calls POST with documentId when document selected from picker', async () => {
|
||||||
|
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
// picker search results
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
// POST /items
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue(newItem)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||||
|
// dropdown option appears after the typeahead debounce
|
||||||
|
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
|
||||||
|
await userEvent.click(page.getByText(/Brief von Karl ·/));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/items'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — add interlude', () => {
|
||||||
|
it('calls POST with note on interlude confirm', async () => {
|
||||||
|
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
|
||||||
|
mockCsrfFetch(() => newItem);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
await userEvent.fill(
|
||||||
|
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||||
|
'Reise nach Wien'
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/items'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note: 'Reise nach Wien' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves keyboard focus into the new row after the interlude is added', async () => {
|
||||||
|
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
|
||||||
|
mockCsrfFetch(() => newItem);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
await userEvent.fill(
|
||||||
|
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||||
|
'Reise nach Wien'
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — mutation error code routing', () => {
|
||||||
|
it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
await userEvent.fill(
|
||||||
|
page.getByPlaceholder(m.journey_interlude_placeholder()),
|
||||||
|
'Reise nach Wien'
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByText(m.error_journey_document_already_added()))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — remove with pending state', () => {
|
||||||
|
it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
|
||||||
|
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||||
|
let resolveFetch!: (value: unknown) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Row still present, marked as pending (text appears in the row AND the live region,
|
||||||
|
// so scope the query to the row instead of using a page-wide locator)
|
||||||
|
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const row = document.querySelector('[data-block-id="i1"]');
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
expect(row!.textContent).toContain(m.journey_item_pending_remove());
|
||||||
|
expect(row!.className).toContain('opacity-60');
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveFetch({ ok: true });
|
||||||
|
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => {
|
||||||
|
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
// Click remove (no note → direct remove)
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the row on successful DELETE', async () => {
|
||||||
|
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses a sensible target after a successful remove (not body)', async () => {
|
||||||
|
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.activeElement).not.toBe(document.body);
|
||||||
|
expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — reorder via move buttons', () => {
|
||||||
|
it('move-up calls PUT reorder with swapped IDs', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||||
|
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/items/reorder'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('move-down calls PUT reorder with swapped IDs', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||||
|
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/items/reorder'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the server-confirmed order after a successful reorder', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||||
|
];
|
||||||
|
// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const briefB = page.getByText('Brief B').element();
|
||||||
|
const briefA = page.getByText('Brief A').element();
|
||||||
|
expect(
|
||||||
|
briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
const briefA = page.getByText('Brief A').element();
|
||||||
|
const briefB = page.getByText('Brief B').element();
|
||||||
|
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the original DOM order and shows an alert when the reorder request rejects', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||||
|
];
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
const briefA = page.getByText('Brief A').element();
|
||||||
|
const briefB = page.getByText('Brief B').element();
|
||||||
|
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(consoleError).toHaveBeenCalled();
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — live announce region', () => {
|
||||||
|
it('announces the move only after the reorder resolved, then clears', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||||
|
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||||
|
|
||||||
|
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
expect((liveRegion?.textContent ?? '').trim()).toBe('');
|
||||||
|
},
|
||||||
|
{ timeout: 2000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces the error text instead of a success message when the move fails', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||||
|
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||||
|
|
||||||
|
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — note patch body', () => {
|
||||||
|
it('sends {"note":null} when note textarea is cleared and blurred', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
|
||||||
|
];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||||
|
await userEvent.clear(textarea);
|
||||||
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/items/i1'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ note: null })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — duplicate document aria-disabled', () => {
|
||||||
|
it('already-added document appears as aria-disabled in picker', async () => {
|
||||||
|
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||||
|
|
||||||
|
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
|
||||||
|
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
|
||||||
|
const option = page
|
||||||
|
.getByText(/Brief von Karl ·/)
|
||||||
|
.element()
|
||||||
|
.closest('li')!;
|
||||||
|
expect(option.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — unsaved warning banner', () => {
|
||||||
|
function triggerNavigationAttempt() {
|
||||||
|
const calls = vi.mocked(beforeNavigate).mock.calls;
|
||||||
|
if (calls.length === 0) return;
|
||||||
|
const [callback] = calls[calls.length - 1];
|
||||||
|
const cancel = vi.fn();
|
||||||
|
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
|
||||||
|
cancel,
|
||||||
|
to: { url: new URL('http://localhost/geschichten') }
|
||||||
|
});
|
||||||
|
return cancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('banner is absent before any edit or navigation attempt', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('banner appears when dirty and a navigation is attempted', async () => {
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
|
||||||
|
// Mark dirty by editing the title
|
||||||
|
const titleInput = page.getByPlaceholder(/Titel/);
|
||||||
|
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
// Simulate the user trying to navigate away
|
||||||
|
const cancel = triggerNavigationAttempt();
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
|
||||||
|
const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({
|
||||||
|
onSubmit,
|
||||||
|
geschichte: makeGeschichte({
|
||||||
|
title: 'Titel',
|
||||||
|
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark dirty
|
||||||
|
const titleInput = page.getByPlaceholder(/Titel/);
|
||||||
|
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
// Trigger navigation → banner appears
|
||||||
|
triggerNavigationAttempt();
|
||||||
|
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Attempt save — onSubmit throws
|
||||||
|
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
|
||||||
|
|
||||||
|
// Banner must still be visible (isDirty was not cleared)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
|
||||||
|
// Regression guard for clearOnSuccess(): without it, a curator who edits the
|
||||||
|
// title and saves successfully stays trapped — the page goto() gets cancelled
|
||||||
|
// by the still-armed guard and the banner appears after a *successful* save.
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(JourneyEditor, defaultProps({ onSubmit }));
|
||||||
|
|
||||||
|
// Mark dirty
|
||||||
|
const titleInput = page.getByPlaceholder(/Titel/);
|
||||||
|
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
// Dirty state blocks navigation
|
||||||
|
expect(triggerNavigationAttempt()).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Save succeeds
|
||||||
|
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
|
||||||
|
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Guard is disarmed again — navigation passes and no banner shows
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
|
||||||
|
mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
|
||||||
|
render(JourneyEditor, defaultProps());
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_add_interlude()));
|
||||||
|
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
|
||||||
|
);
|
||||||
|
// The new interlude row renders its note textarea once the POST resolved
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
|
||||||
|
// The item was persisted by its own POST — navigating away loses nothing
|
||||||
|
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — selectedPersons marks dirty', () => {
|
||||||
|
function getNavCallback() {
|
||||||
|
const calls = vi.mocked(beforeNavigate).mock.calls;
|
||||||
|
const [callback] = calls[calls.length - 1];
|
||||||
|
return (cancel = vi.fn()) => {
|
||||||
|
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
|
||||||
|
cancel,
|
||||||
|
to: { url: new URL('http://localhost/geschichten') }
|
||||||
|
});
|
||||||
|
return cancel;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('removing a person chip marks the editor dirty', async () => {
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({
|
||||||
|
geschichte: makeGeschichte({
|
||||||
|
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Confirm navigation is NOT blocked initially (clean state)
|
||||||
|
const triggerNav = getNavCallback();
|
||||||
|
expect(triggerNav()).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen")
|
||||||
|
await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() }));
|
||||||
|
|
||||||
|
// After person removal, navigation should be blocked
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const cancel = triggerNav();
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyEditor — person chips from GeschichteView', () => {
|
||||||
|
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
|
||||||
|
render(
|
||||||
|
JourneyEditor,
|
||||||
|
defaultProps({
|
||||||
|
geschichte: makeGeschichte({
|
||||||
|
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
18
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
18
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { note }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="note"
|
||||||
|
aria-label={m.journey_interlude_aria_label()}
|
||||||
|
class="my-4 rounded-r-sm border-l-2 border-journey-border bg-journey-tint py-2 pr-3 pl-3"
|
||||||
|
>
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="text-base leading-relaxed text-ink italic">{note}</p>
|
||||||
|
</div>
|
||||||
64
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
64
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__xss_interlude?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JourneyInterlude', () => {
|
||||||
|
it('renders the note text as plaintext', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-label from i18n (journey_interlude_aria_label)', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||||
|
|
||||||
|
const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`);
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has role="note" so the aria-label is announced by screen readers', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||||
|
|
||||||
|
const el = document.querySelector('[role="note"]');
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses mode-aware journey tokens, not raw orange utilities (#801)', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||||
|
|
||||||
|
const block = document.querySelector('[role="note"]');
|
||||||
|
expect(block!.className).toContain('bg-journey-tint');
|
||||||
|
expect(block!.className).toContain('border-journey-border');
|
||||||
|
expect(block!.className).not.toContain('bg-orange-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('note text uses readable body size (text-base, #800)', async () => {
|
||||||
|
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||||
|
|
||||||
|
const text = document.querySelector('[role="note"] p');
|
||||||
|
expect(text!.className).toContain('text-base');
|
||||||
|
expect(text!.className).not.toContain('text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||||
|
// Interlude uses Svelte text interpolation ({note}), NOT {@html}.
|
||||||
|
render(JourneyInterlude, {
|
||||||
|
props: { note: '<img src=x onerror="window.__xss_interlude=1">' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.__xss_interlude).toBeUndefined();
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
66
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import { formatDocumentMetaLine } from './utils';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: JourneyItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { item }: Props = $props();
|
||||||
|
|
||||||
|
// Safe: JourneyReader filters out items where document === null before rendering this component.
|
||||||
|
const doc = $derived(item.document!);
|
||||||
|
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||||
|
const metaLine = $derived(formatDocumentMetaLine(doc));
|
||||||
|
const openAriaLabel = $derived(
|
||||||
|
formattedDate
|
||||||
|
? m.journey_item_open_aria({ date: formattedDate })
|
||||||
|
: m.journey_item_open_aria_undated()
|
||||||
|
);
|
||||||
|
const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-3">
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="mb-0.5 font-serif text-base leading-snug text-ink">{doc.title}</p>
|
||||||
|
{#if metaLine}
|
||||||
|
<p class="mb-2 text-sm text-ink-3">{metaLine}</p>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href="/documents/{doc.id}"
|
||||||
|
aria-label={openAriaLabel}
|
||||||
|
class="-my-2 inline-flex min-h-[44px] items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
|
||||||
|
<rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />
|
||||||
|
<path
|
||||||
|
d="M3 4h4M3 6.5h4M3 9h2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width=".7"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{m.journey_item_open()}
|
||||||
|
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 10" fill="none">
|
||||||
|
<path
|
||||||
|
d="M4 2l4 3-4 3"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{#if hasNote}
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<div class="mt-3 rounded-r-sm border-l-2 border-brand-mint bg-muted py-1.5 pr-2 pl-3">
|
||||||
|
<p class="text-base leading-relaxed text-ink-2 italic">{item.note}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
188
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
188
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__xss_note?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView => ({
|
||||||
|
id: 'item1',
|
||||||
|
position: 0,
|
||||||
|
document: {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Brief an Helene',
|
||||||
|
documentDate: '1923-05-15',
|
||||||
|
datePrecision: 'DAY',
|
||||||
|
receiverCount: 0
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemCard', () => {
|
||||||
|
it('renders the document title', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the document date in the meta line when documentDate is present', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/1923/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Brief öffnen" link points to /documents/:id', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
const link = page.getByRole('link', { name: /öffnen/i });
|
||||||
|
await expect.element(link).toBeInTheDocument();
|
||||||
|
const el = await link.element();
|
||||||
|
expect(el.getAttribute('href')).toContain('/documents/d1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Brief öffnen" link meets the 44px touch-target floor', async () => {
|
||||||
|
// Primary tap action of the phone read path — WCAG 2.5.5 / house rule.
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
const link = page.getByRole('link', { name: /öffnen/i });
|
||||||
|
await expect.element(link).toBeInTheDocument();
|
||||||
|
const height = link.element().getBoundingClientRect().height;
|
||||||
|
expect(height).toBeGreaterThanOrEqual(44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
const link = page.getByRole('link', { name: /1923/i });
|
||||||
|
await expect.element(link).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Brief öffnen" link has undated aria-label when documentDate is absent', async () => {
|
||||||
|
render(JourneyItemCard, {
|
||||||
|
props: {
|
||||||
|
item: baseItem({
|
||||||
|
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = page.getByRole('link', { name: m.journey_item_open_aria_undated() });
|
||||||
|
await expect.element(link).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits date from meta line when documentDate is absent', async () => {
|
||||||
|
render(JourneyItemCard, {
|
||||||
|
props: {
|
||||||
|
item: baseItem({
|
||||||
|
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sender→receiver in meta line when both are present', async () => {
|
||||||
|
render(JourneyItemCard, {
|
||||||
|
props: {
|
||||||
|
item: baseItem({
|
||||||
|
document: {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Brief an Helene',
|
||||||
|
documentDate: '1923-05-15',
|
||||||
|
datePrecision: 'DAY',
|
||||||
|
senderName: 'Franz Raddatz',
|
||||||
|
receiverName: 'Emma Müller',
|
||||||
|
receiverCount: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Franz Raddatz/)).toBeVisible();
|
||||||
|
await expect.element(page.getByText(/Emma Müller/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders note as annotation block when note is present', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annotation block uses the brand mint accent border (#798)', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||||
|
|
||||||
|
const note = document.querySelector('[class*="border-l-2"]');
|
||||||
|
expect(note).not.toBeNull();
|
||||||
|
expect(note!.className).toContain('border-brand-mint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card uses the surface token, not bg-white, so dark mode remaps it', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||||
|
|
||||||
|
const card = document.querySelector('[class*="border-line"]');
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(card!.className).toContain('bg-surface');
|
||||||
|
expect(card!.className).not.toContain('bg-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annotation block is tinted with bg-muted to stand off the white card', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||||
|
|
||||||
|
const note = document.querySelector('[class*="border-l-2"]');
|
||||||
|
expect(note!.className).toContain('bg-muted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reading text sizes meet the accessibility floor (#800)', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||||
|
|
||||||
|
const title = page.getByText('Brief an Helene');
|
||||||
|
expect((await title.element()).className).toContain('text-base');
|
||||||
|
|
||||||
|
const link = await page.getByRole('link', { name: /öffnen/i }).element();
|
||||||
|
expect(link.className).toContain('text-sm');
|
||||||
|
expect(link.className).not.toContain('text-xs');
|
||||||
|
|
||||||
|
const noteText = document.querySelector('[class*="border-l-2"] p');
|
||||||
|
expect(noteText!.className).toContain('text-base');
|
||||||
|
expect(noteText!.className).not.toContain('text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits annotation block when note is blank or whitespace', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/ {3}/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits annotation block when note is absent', async () => {
|
||||||
|
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
|
||||||
|
|
||||||
|
const notes = document.querySelectorAll('[class*="border-mint"]');
|
||||||
|
expect(notes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||||
|
// Note uses Svelte text interpolation ({item.note}), NOT {@html}.
|
||||||
|
render(JourneyItemCard, {
|
||||||
|
props: {
|
||||||
|
item: baseItem({
|
||||||
|
note: '<img src=x onerror="window.__xss_note=1">'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.__xss_note).toBeUndefined();
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
268
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
268
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatDocumentMetaLine } from './utils';
|
||||||
|
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: JourneyItemView;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
pendingRemove?: boolean;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onNotePatch: (note: string | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
pendingRemove = false,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onRemove,
|
||||||
|
onNotePatch
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isInterlude = $derived(!item.document);
|
||||||
|
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
|
||||||
|
// Spec LE-2 "Briefmeta": date · von X an Y disambiguates near-identical titles.
|
||||||
|
const metaLine = $derived(item.document ? formatDocumentMetaLine(item.document) : '');
|
||||||
|
const needsConfirmOnRemove = $derived(!!item.note);
|
||||||
|
|
||||||
|
let rootEl: HTMLElement | null = $state(null);
|
||||||
|
let showNote = $state(!!item.note);
|
||||||
|
let noteDraft = $state(item.note ?? '');
|
||||||
|
let noteSaving = $state(false);
|
||||||
|
let noteError = $state('');
|
||||||
|
let showRemoveConfirm = $state(false);
|
||||||
|
|
||||||
|
async function handleNoteBlur() {
|
||||||
|
if (noteSaving) return;
|
||||||
|
const normalizedDraft = noteDraft.trim().length === 0 ? null : noteDraft;
|
||||||
|
// '' and undefined both mean "no note" — never PATCH a no-op.
|
||||||
|
if (normalizedDraft === (item.note ?? null)) {
|
||||||
|
// Opened "Notiz hinzufügen" and blurred without typing → collapse again.
|
||||||
|
if (!isInterlude && normalizedDraft === null) showNote = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInterlude && normalizedDraft === null) {
|
||||||
|
// Interludes must keep a note — restore the draft so the UI doesn't show
|
||||||
|
// an emptied text that the server still holds.
|
||||||
|
noteDraft = item.note ?? '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noteSaving = true;
|
||||||
|
noteError = '';
|
||||||
|
try {
|
||||||
|
await onNotePatch(normalizedDraft);
|
||||||
|
// Clearing an existing note collapses the textarea after the PATCH lands.
|
||||||
|
if (normalizedDraft === null) showNote = false;
|
||||||
|
} catch (e) {
|
||||||
|
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
|
||||||
|
} finally {
|
||||||
|
noteSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNoteRemove() {
|
||||||
|
const prevDraft = noteDraft;
|
||||||
|
const prevShowNote = showNote;
|
||||||
|
noteDraft = '';
|
||||||
|
showNote = false;
|
||||||
|
noteError = '';
|
||||||
|
try {
|
||||||
|
await onNotePatch(null);
|
||||||
|
} catch (e) {
|
||||||
|
noteDraft = prevDraft;
|
||||||
|
showNote = prevShowNote;
|
||||||
|
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNoteOpen() {
|
||||||
|
showNote = true;
|
||||||
|
// Spec LE-3: focus moves into the revealed textarea.
|
||||||
|
await tick();
|
||||||
|
rootEl?.querySelector<HTMLTextAreaElement>('textarea')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveClick() {
|
||||||
|
if (needsConfirmOnRemove) {
|
||||||
|
showRemoveConfirm = true;
|
||||||
|
await tick();
|
||||||
|
rootEl?.querySelector<HTMLElement>('[data-remove-confirm-cancel]')?.focus();
|
||||||
|
} else {
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveConfirm() {
|
||||||
|
showRemoveConfirm = false;
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveCancel() {
|
||||||
|
showRemoveConfirm = false;
|
||||||
|
await tick();
|
||||||
|
rootEl?.querySelector<HTMLElement>('[data-remove-btn]')?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={rootEl}
|
||||||
|
data-block-id={item.id}
|
||||||
|
class={[
|
||||||
|
'flex min-w-0 flex-col rounded border transition-colors',
|
||||||
|
pendingRemove ? 'opacity-60' : '',
|
||||||
|
isInterlude
|
||||||
|
? 'border-l-4 border-line border-l-interlude-border bg-interlude-bg'
|
||||||
|
: 'border-line bg-surface'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-1 px-2 py-1">
|
||||||
|
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-drag-handle
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="hidden shrink-0 cursor-grab items-center justify-center self-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
|
||||||
|
style="min-height: 44px; min-width: 44px;"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Move up/down (mobile + always visible) -->
|
||||||
|
<div class="flex shrink-0 flex-col self-start">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-move-up
|
||||||
|
onclick={onMoveUp}
|
||||||
|
disabled={index === 0}
|
||||||
|
aria-label={m.journey_move_up({ title: itemTitle })}
|
||||||
|
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onMoveDown}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
aria-label={m.journey_move_down({ title: itemTitle })}
|
||||||
|
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content (title + note inline) -->
|
||||||
|
<div class="min-w-0 flex-1 py-1 break-words">
|
||||||
|
{#if isInterlude}
|
||||||
|
<span class="font-sans text-xs font-bold tracking-widest text-interlude-label uppercase">
|
||||||
|
{m.journey_interlude_label()}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
|
||||||
|
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
|
||||||
|
{#if metaLine}
|
||||||
|
<p class="mt-0.5 font-sans text-xs text-ink-3">{metaLine}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showNote}
|
||||||
|
<div class="mt-2">
|
||||||
|
<textarea
|
||||||
|
aria-label={m.journey_note_aria_label({ title: itemTitle })}
|
||||||
|
bind:value={noteDraft}
|
||||||
|
onblur={handleNoteBlur}
|
||||||
|
maxlength={2000}
|
||||||
|
rows={2}
|
||||||
|
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-1 flex items-center justify-between gap-2">
|
||||||
|
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
|
||||||
|
{#if !isInterlude}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleNoteRemove}
|
||||||
|
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.journey_note_remove()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if noteError}
|
||||||
|
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !isInterlude}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleNoteOpen}
|
||||||
|
aria-expanded={showNote}
|
||||||
|
class="mt-0.5 inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.journey_note_add()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remove button / confirm / pending -->
|
||||||
|
<div class="shrink-0 self-start">
|
||||||
|
{#if pendingRemove}
|
||||||
|
<span class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 italic">
|
||||||
|
{m.journey_item_pending_remove()}
|
||||||
|
</span>
|
||||||
|
{:else if showRemoveConfirm}
|
||||||
|
<div role="group" aria-label={m.journey_remove_confirm()} class="flex items-center gap-2">
|
||||||
|
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleRemoveConfirm}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
|
||||||
|
class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.journey_remove_confirm_yes()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-remove-confirm-cancel
|
||||||
|
onclick={handleRemoveCancel}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
|
||||||
|
class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.journey_remove_confirm_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-remove-btn
|
||||||
|
onclick={handleRemoveClick}
|
||||||
|
aria-label={m.journey_remove_item_aria({ title: itemTitle })}
|
||||||
|
class="-m-1 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
354
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
354
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||||
|
|
||||||
|
const docItem = (overrides: Partial<{ note: string }> = {}) => ({
|
||||||
|
id: 'item-1',
|
||||||
|
position: 0,
|
||||||
|
document: {
|
||||||
|
id: 'doc-1',
|
||||||
|
title: 'Brief von Karl',
|
||||||
|
datePrecision: 'DAY' as const,
|
||||||
|
receiverCount: 0
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const interludeItem = (note = 'Reise nach Wien') => ({
|
||||||
|
id: 'item-2',
|
||||||
|
position: 1,
|
||||||
|
note
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = (overrides = {}) => ({
|
||||||
|
index: 0,
|
||||||
|
total: 3,
|
||||||
|
onMoveUp: vi.fn(),
|
||||||
|
onMoveDown: vi.fn(),
|
||||||
|
onRemove: vi.fn(),
|
||||||
|
onNotePatch: vi.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('JourneyItemRow — interlude label', () => {
|
||||||
|
it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => {
|
||||||
|
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "Zwischentext" in the move button aria-labels', async () => {
|
||||||
|
render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.journey_move_up({ title: m.journey_interlude_label() })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — note textarea', () => {
|
||||||
|
it('opens note textarea on "Notiz hinzufügen" click', async () => {
|
||||||
|
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blur without typing does not call onNotePatch and collapses the textarea', async () => {
|
||||||
|
// '' (untouched draft) and undefined (no note) both mean "no note" — a
|
||||||
|
// spurious PATCH {note: null} must not fire, and the empty textarea closes.
|
||||||
|
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||||
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||||
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||||
|
|
||||||
|
expect(onNotePatch).not.toHaveBeenCalled();
|
||||||
|
await expect.element(page.getByText(m.journey_note_add())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus into the textarea when "Notiz hinzufügen" opens it', async () => {
|
||||||
|
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||||
|
|
||||||
|
const toggle = page.getByText(m.journey_note_add());
|
||||||
|
expect(toggle.element().getAttribute('aria-expanded')).toBe('false');
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textarea = page
|
||||||
|
.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })
|
||||||
|
.element();
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onNotePatch on textarea blur with non-empty value', async () => {
|
||||||
|
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||||
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||||
|
await userEvent.fill(textarea, 'Eine neue Notiz');
|
||||||
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||||
|
|
||||||
|
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the note textarea to 2000 characters', async () => {
|
||||||
|
render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() });
|
||||||
|
|
||||||
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||||
|
await expect.element(textarea).toHaveAttribute('maxlength', '2000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — note error state', () => {
|
||||||
|
it('shows role=alert error message when onNotePatch rejects', async () => {
|
||||||
|
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
||||||
|
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
||||||
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||||
|
await userEvent.fill(textarea, 'Eine Notiz');
|
||||||
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — note remove error state', () => {
|
||||||
|
it('restores note and shows error when onNotePatch rejects during remove', async () => {
|
||||||
|
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'keep me' }),
|
||||||
|
...defaultProps({ onNotePatch })
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText(m.journey_note_remove()));
|
||||||
|
|
||||||
|
// textarea should be visible again (showNote restored)
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
// error alert should be shown
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — interlude rules', () => {
|
||||||
|
it('does not show "Notiz entfernen" for interlude items', async () => {
|
||||||
|
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
||||||
|
|
||||||
|
// Note section should be visible (interlude always shows note)
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
// But "Notiz entfernen" must be absent
|
||||||
|
await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks saving empty text on interlude note blur', async () => {
|
||||||
|
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: interludeItem('original text'),
|
||||||
|
...defaultProps({ onNotePatch })
|
||||||
|
});
|
||||||
|
|
||||||
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||||
|
await userEvent.clear(textarea);
|
||||||
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||||
|
|
||||||
|
expect(onNotePatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the original note text after a blocked empty-clear blur', async () => {
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: interludeItem('original text'),
|
||||||
|
...defaultProps()
|
||||||
|
});
|
||||||
|
|
||||||
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||||
|
await userEvent.clear(textarea);
|
||||||
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||||
|
|
||||||
|
await expect.element(textarea).toHaveValue('original text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — remove confirm', () => {
|
||||||
|
it('shows inline confirm when removing a document item that has a note', async () => {
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'Wichtige Notiz' }),
|
||||||
|
...defaultProps()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click remove (x button)
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: m.journey_remove_confirm_yes() }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Bestätigen invokes onRemove (destructive path)', async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'Wichtige Notiz' }),
|
||||||
|
...defaultProps({ onRemove })
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
);
|
||||||
|
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() }));
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm cancel restores remove button without calling onRemove', async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'Notiz' }),
|
||||||
|
...defaultProps({ onRemove })
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
);
|
||||||
|
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
|
||||||
|
|
||||||
|
expect(onRemove).not.toHaveBeenCalled();
|
||||||
|
// The remove button should be back
|
||||||
|
await expect
|
||||||
|
.element(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
)
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm cancel returns keyboard focus to the row remove button', async () => {
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'Notiz' }),
|
||||||
|
...defaultProps()
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
);
|
||||||
|
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const removeBtn = page
|
||||||
|
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
.element();
|
||||||
|
expect(document.activeElement).toBe(removeBtn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — remove confirm a11y', () => {
|
||||||
|
it('confirm area is wrapped in role=group with an accessible label', async () => {
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'Wichtige Notiz' }),
|
||||||
|
...defaultProps()
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
const group = document.querySelector('[role="group"]');
|
||||||
|
expect(group).toBeTruthy();
|
||||||
|
expect(group!.getAttribute('aria-label')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keyboard focus moves to Cancel button when confirm appears', async () => {
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'Wichtige Notiz' }),
|
||||||
|
...defaultProps()
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const cancelBtn = page
|
||||||
|
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
|
||||||
|
.element();
|
||||||
|
expect(document.activeElement).toBe(cancelBtn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pressing Escape while confirm is open hides confirm and refocuses remove button', async () => {
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem({ note: 'Wichtige Notiz' }),
|
||||||
|
...defaultProps()
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const cancelBtn = page
|
||||||
|
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
|
||||||
|
.element();
|
||||||
|
expect(document.activeElement).toBe(cancelBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const removeBtn = page
|
||||||
|
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
.element();
|
||||||
|
expect(document.activeElement).toBe(removeBtn);
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText(m.journey_remove_confirm())).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — pending remove state', () => {
|
||||||
|
it('renders dimmed with the pending text and without a remove button', async () => {
|
||||||
|
render(JourneyItemRow, {
|
||||||
|
item: docItem(),
|
||||||
|
...defaultProps({ pendingRemove: true })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(
|
||||||
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
||||||
|
)
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const root = document.querySelector('[data-block-id="item-1"]')!;
|
||||||
|
expect(root.className).toContain('opacity-60');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyItemRow — drag handle', () => {
|
||||||
|
it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => {
|
||||||
|
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||||
|
|
||||||
|
const handle = document.querySelector('[data-drag-handle]')!;
|
||||||
|
expect(handle.getAttribute('tabindex')).toBe('-1');
|
||||||
|
expect(handle.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
53
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
53
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import JourneyItemCard from './JourneyItemCard.svelte';
|
||||||
|
import JourneyInterlude from './JourneyInterlude.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte: GeschichteView;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichte: g }: Props = $props();
|
||||||
|
|
||||||
|
// Render intro only when body is a non-empty, non-whitespace string.
|
||||||
|
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||||
|
|
||||||
|
// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard).
|
||||||
|
const validItems = $derived(
|
||||||
|
g.items.filter(
|
||||||
|
(item: JourneyItemView) =>
|
||||||
|
item.document != null || (item.note != null && item.note.trim().length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if introText}
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p
|
||||||
|
class="mb-6 border-b border-dashed border-line-2 pb-4 font-serif text-lg leading-relaxed text-ink-2 italic"
|
||||||
|
>
|
||||||
|
{introText}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if validItems.length === 0}
|
||||||
|
<p class="font-sans text-sm text-ink-3" data-testid="journey-empty-state">
|
||||||
|
{m.journey_empty_state()}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ol class="flex list-none flex-col">
|
||||||
|
{#each validItems as item (item.id)}
|
||||||
|
<li>
|
||||||
|
{#if item.document != null}
|
||||||
|
<JourneyItemCard item={item} />
|
||||||
|
{:else}
|
||||||
|
<JourneyInterlude note={item.note!} />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
174
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal file
174
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
const { default: JourneyReader } = await import('./JourneyReader.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__xss_journey?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Lesereise Berlin',
|
||||||
|
body: null as unknown as undefined,
|
||||||
|
type: 'JOURNEY',
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
persons: [],
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
document: {
|
||||||
|
id: `d${id}`,
|
||||||
|
title,
|
||||||
|
datePrecision: 'DAY',
|
||||||
|
documentDate: '1923-05-15',
|
||||||
|
receiverCount: 0
|
||||||
|
},
|
||||||
|
note
|
||||||
|
});
|
||||||
|
|
||||||
|
const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
document: undefined,
|
||||||
|
note
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
|
||||||
|
|
||||||
|
describe('JourneyReader', () => {
|
||||||
|
it('renders intro paragraph when body is non-empty', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('intro paragraph uses readable body size (text-lg, #800)', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
|
||||||
|
});
|
||||||
|
|
||||||
|
const intro = document.querySelector('p');
|
||||||
|
expect(intro!.className).toContain('text-lg');
|
||||||
|
expect(intro!.className).not.toContain('text-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits intro paragraph when body is null', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ body: undefined }) }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only empty state should render
|
||||||
|
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits intro paragraph when body is only whitespace', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ body: ' ' }) }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whitespace-only body must NOT produce a visible intro paragraph.
|
||||||
|
// The only rendered content should be the empty-state message.
|
||||||
|
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
||||||
|
const paragraphs = document.querySelectorAll('p:not([data-testid])');
|
||||||
|
expect(paragraphs.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty-state message when items array is empty', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte({ items: [] }) }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both intro and empty-state when body is set but items is empty', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ body: 'Eine Einleitung.', items: [] })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine Einleitung.')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders document items (JourneyItemCard)', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders interlude items (JourneyInterlude)', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
items: [
|
||||||
|
{ id: 'dangling', position: 0, document: undefined, note: ' ' },
|
||||||
|
docItem('item2', 'Echter Brief', 1)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Echter Brief')).toBeVisible();
|
||||||
|
// Empty-state must NOT render when valid items exist
|
||||||
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
|
||||||
|
// JourneyReader uses Svelte text interpolation, NOT {@html}.
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
body: '<img src=x onerror="window.__xss_journey=1">'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.__xss_journey).toBeUndefined();
|
||||||
|
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# geschichte (frontend)
|
# geschichte (frontend)
|
||||||
|
|
||||||
UI for family stories: the rich-text editor, story cards, and story list view.
|
UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-text editor, story/journey readers, type badge, and list rows.
|
||||||
|
|
||||||
## What this domain owns
|
## What this domain owns
|
||||||
|
|
||||||
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
|
Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `StoryDocumentPanel.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
|
||||||
|
Utilities: `utils.ts`.
|
||||||
|
|
||||||
## What this domain does NOT own
|
## What this domain does NOT own
|
||||||
|
|
||||||
@@ -14,14 +15,44 @@ Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
|
|||||||
|
|
||||||
## Key components
|
## Key components
|
||||||
|
|
||||||
| Component | Used in | Notes |
|
| Component | Used in | Notes |
|
||||||
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
|
| --------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
|
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` |
|
||||||
| `GeschichtenCard.svelte` | `/geschichten` (list), dashboard | Story preview card with cover image and publish status |
|
| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `<details>` mobile collapsibles with 44px touch targets |
|
||||||
|
| `StoryDocumentPanel.svelte` | `GeschichteSidebar` (STORY edit only) | Sidebar document list for stories: picker add (POST), optimistic remove (DELETE), deleted-doc placeholders |
|
||||||
|
| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish |
|
||||||
|
| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
|
||||||
|
| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
|
||||||
|
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
|
||||||
|
| `GeschichteListRow.svelte` | `/geschichten` (list) | Editorial list row: meta column (avatar, author, date, REISE badge), title + excerpt content column |
|
||||||
|
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
|
||||||
|
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
|
||||||
|
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Card per document item: title, meta line (date · von X an Y), "Brief öffnen →" link, mint-border note |
|
||||||
|
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Left-accent interlude box between letters (mode-aware tokens); `aria-label="Kuratorennotiz"` |
|
||||||
|
|
||||||
|
## utils.ts
|
||||||
|
|
||||||
|
`formatAuthorName(author)` — joins `firstName + lastName`, falls back to the localized `person_unknown` key (for list/summary shapes; email is not exposed).
|
||||||
|
`formatAuthorDisplayName(author)` — returns `displayName`, localizing the server's `[Unbekannt]` fallback (for detail `AuthorView` shape).
|
||||||
|
`formatDocumentMetaLine(doc)` — `"12.07.1938 · von Franz an Emma"`; shared by `JourneyItemCard`, `JourneyItemRow`, and the story doc-reference cards.
|
||||||
|
`formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail).
|
||||||
|
|
||||||
|
## Public list is PUBLISHED-only
|
||||||
|
|
||||||
|
`GET /api/geschichten` constrains `status=PUBLISHED`, so DRAFT journeys never appear in the list.
|
||||||
|
The REISE badge is only ever seen for published journeys.
|
||||||
|
Empty-state and draft-preview paths are reachable ONLY via the **detail route** (`/geschichten/[id]`), not the list.
|
||||||
|
Wire empty-state E2E tests through the detail route, not by expecting a draft journey in the list.
|
||||||
|
|
||||||
|
## TypeSelector route component
|
||||||
|
|
||||||
|
`TypeSelector.svelte` lives in `src/routes/geschichten/new/` (single-use route UI).
|
||||||
|
It is NOT in `$lib/geschichte/` — route-specific, not reused elsewhere.
|
||||||
|
`StoryCreate.svelte` (also in `new/`) wraps `GeschichteEditor` so tree-shaking excludes TipTap from the JOURNEY placeholder path.
|
||||||
|
|
||||||
## Audience note
|
## Audience note
|
||||||
|
|
||||||
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone.
|
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone. JourneyReader mobile layout is Critical; TypeSelector is Minor.
|
||||||
|
|
||||||
## Cross-domain imports
|
## Cross-domain imports
|
||||||
|
|
||||||
|
|||||||
221
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
221
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||||
|
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||||
|
|
||||||
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichteId: string;
|
||||||
|
items?: JourneyItemView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichteId, items: initialItems = [] }: Props = $props();
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
const pickerInputId = `story-doc-picker-${uid}`;
|
||||||
|
|
||||||
|
// Initial-state snapshot — the panel owns the list after mount and updates
|
||||||
|
// it from API responses; the parent re-mounts to reset (same contract as
|
||||||
|
// GeschichteEditor/JourneyEditor).
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position));
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let liveAnnounce = $state('');
|
||||||
|
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let sectionEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
|
$effect(() => () => {
|
||||||
|
if (announceTimer) clearTimeout(announceTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyAddedIds = $derived(
|
||||||
|
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
function announce(message: string) {
|
||||||
|
liveAnnounce = message;
|
||||||
|
if (announceTimer) clearTimeout(announceTimer);
|
||||||
|
announceTimer = setTimeout(() => {
|
||||||
|
liveAnnounce = '';
|
||||||
|
announceTimer = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTitle(item: JourneyItemView): string {
|
||||||
|
return item.document?.title ?? m.geschichte_documents_deleted_placeholder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a failed mutation to a user-facing message — story wording for the
|
||||||
|
* two journey-flavored 409s, whose generic messages say "Lesereise". */
|
||||||
|
async function failureMessage(res: Response): Promise<string> {
|
||||||
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
|
if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity();
|
||||||
|
if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate();
|
||||||
|
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pessimistic append — the list updates only with the server's response. */
|
||||||
|
async function handleAdd(doc: DocumentOption) {
|
||||||
|
errorMessage = '';
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ documentId: doc.id })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
errorMessage = await failureMessage(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newItem: JourneyItemView = await res.json();
|
||||||
|
items = [...items, newItem];
|
||||||
|
announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Story document add failed', e);
|
||||||
|
errorMessage = m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The removed row's button leaves the DOM — without this, focus drops to
|
||||||
|
* <body> and a keyboard user is teleported to page top. */
|
||||||
|
async function moveFocusAfterRemove(removedIdx: number) {
|
||||||
|
await tick();
|
||||||
|
if (items.length === 0) {
|
||||||
|
sectionEl?.querySelector<HTMLElement>(`#${pickerInputId}`)?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = items[Math.max(removedIdx - 1, 0)];
|
||||||
|
sectionEl
|
||||||
|
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(target.id)}"] [data-remove-btn]`)
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optimistic removal with snapshot-and-rollback. */
|
||||||
|
async function handleRemove(item: JourneyItemView) {
|
||||||
|
const idx = items.findIndex((i) => i.id === item.id);
|
||||||
|
const prev = [...items];
|
||||||
|
errorMessage = '';
|
||||||
|
items = items.filter((i) => i.id !== item.id);
|
||||||
|
await moveFocusAfterRemove(idx);
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
items = prev;
|
||||||
|
await tick();
|
||||||
|
sectionEl
|
||||||
|
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
|
||||||
|
?.focus();
|
||||||
|
errorMessage = await failureMessage(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Story document remove failed', e);
|
||||||
|
items = prev;
|
||||||
|
await tick();
|
||||||
|
sectionEl
|
||||||
|
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
|
||||||
|
?.focus();
|
||||||
|
errorMessage = m.journey_mutation_error_reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Screen-reader live region for add/remove confirmations -->
|
||||||
|
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||||
|
|
||||||
|
<details open class="sm:contents">
|
||||||
|
<summary
|
||||||
|
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||||
|
>
|
||||||
|
{m.geschichte_documents_heading()}
|
||||||
|
</summary>
|
||||||
|
<section bind:this={sectionEl} class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2
|
||||||
|
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||||
|
>
|
||||||
|
{m.geschichte_documents_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_hint()}</p>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
class="mb-3 flex items-start gap-2 rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mt-0.5 h-4 w-4 flex-none"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if items.length === 0}
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_empty()}</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="m-0 mb-3 flex list-none flex-col p-0">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li
|
||||||
|
data-item-id={item.id}
|
||||||
|
class="flex items-center justify-between gap-2 border-b border-line/60 last:border-b-0"
|
||||||
|
>
|
||||||
|
{#if item.document}
|
||||||
|
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||||
|
{item.document.title}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-3 italic">
|
||||||
|
{m.geschichte_documents_deleted_placeholder()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-remove-btn
|
||||||
|
onclick={() => handleRemove(item)}
|
||||||
|
aria-label={m.geschichte_documents_remove_label({ title: itemTitle(item) })}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label for={pickerInputId} class="mb-1 block font-sans text-xs font-medium text-ink-2">
|
||||||
|
{m.geschichte_documents_picker_label()}
|
||||||
|
</label>
|
||||||
|
<DocumentPickerDropdown
|
||||||
|
inputId={pickerInputId}
|
||||||
|
alreadyAddedIds={alreadyAddedIds}
|
||||||
|
placeholder={m.geschichte_documents_picker_placeholder()}
|
||||||
|
onSelect={handleAdd}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</details>
|
||||||
446
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
446
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
|
||||||
|
|
||||||
|
const docSummary = (id: string, title: string) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
datePrecision: 'DAY' as const,
|
||||||
|
receiverCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeItem = (
|
||||||
|
id: string,
|
||||||
|
position: number,
|
||||||
|
document?: ReturnType<typeof docSummary>,
|
||||||
|
note?: string
|
||||||
|
) => ({ id, position, document, note });
|
||||||
|
|
||||||
|
/** DocumentListItem fixture as returned by the picker search endpoint. */
|
||||||
|
const makeSearchResultItem = (id: string, title: string) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
documentDate: '1880-01-01',
|
||||||
|
metaDatePrecision: 'DAY',
|
||||||
|
originalFilename: 'brief.pdf',
|
||||||
|
receivers: [],
|
||||||
|
tags: [],
|
||||||
|
completionPercentage: 0,
|
||||||
|
contributors: [],
|
||||||
|
matchData: {
|
||||||
|
titleOffsets: [],
|
||||||
|
senderMatched: false,
|
||||||
|
matchedReceiverIds: [],
|
||||||
|
matchedTagIds: [],
|
||||||
|
snippetOffsets: [],
|
||||||
|
summaryOffsets: []
|
||||||
|
},
|
||||||
|
status: 'UPLOADED',
|
||||||
|
metadataComplete: false,
|
||||||
|
scriptType: 'UNKNOWN',
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00'
|
||||||
|
});
|
||||||
|
|
||||||
|
type MutationResponse = { ok: boolean; status?: number; body?: object };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes the picker's GET search to `searchItems` and every mutation
|
||||||
|
* (POST/DELETE) to `mutation` — the panel talks to both endpoints.
|
||||||
|
*/
|
||||||
|
function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) {
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const method = (init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (method === 'GET') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ items: searchItems })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: mutation.ok,
|
||||||
|
status: mutation.status ?? (mutation.ok ? 200 : 500),
|
||||||
|
json: () => Promise.resolve(mutation.body ?? {})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
geschichteId: 'g1',
|
||||||
|
items: [],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addViaPicker(title: RegExp) {
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await expect.element(page.getByText(title)).toBeInTheDocument();
|
||||||
|
await userEvent.click(page.getByText(title));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StoryDocumentPanel — rendering', () => {
|
||||||
|
it('renders linked documents sorted by position', async () => {
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({
|
||||||
|
items: [
|
||||||
|
makeItem('i3', 30, docSummary('d3', 'Dritter Brief')),
|
||||||
|
makeItem('i1', 10, docSummary('d1', 'Erster Brief'))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||||
|
expect(rows[0]).toContain('Erster Brief');
|
||||||
|
expect(rows[1]).toContain('Dritter Brief');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when no items are linked', async () => {
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a deleted-document item as placeholder row that is still removable', async () => {
|
||||||
|
render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] }));
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByText(m.geschichte_documents_deleted_placeholder()))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({
|
||||||
|
title: m.geschichte_documents_deleted_placeholder()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires a visible label to the picker input', async () => {
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
const input = page.getByRole('combobox').element() as HTMLInputElement;
|
||||||
|
const label = document.querySelector(`label[for="${input.id}"]`);
|
||||||
|
expect(label?.textContent).toContain(m.geschichte_documents_picker_label());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StoryDocumentPanel — add', () => {
|
||||||
|
it('POSTs to the items endpoint and appends the created item', async () => {
|
||||||
|
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||||
|
ok: true,
|
||||||
|
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||||
|
});
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
|
||||||
|
await addViaPicker(/Brief von Eugenie/i);
|
||||||
|
|
||||||
|
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||||
|
expect(post?.[0]).toBe('/api/geschichten/g1/items');
|
||||||
|
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' });
|
||||||
|
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||||
|
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an already-linked document as not selectable in the dropdown', async () => {
|
||||||
|
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
|
await expect.element(page.getByRole('option')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const option = document.querySelector('[role="listbox"] [role="option"]');
|
||||||
|
expect(option?.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => {
|
||||||
|
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' }
|
||||||
|
});
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
|
||||||
|
await addViaPicker(/Brief von Eugenie/i);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('alert'))
|
||||||
|
.toHaveTextContent(m.geschichte_documents_duplicate());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => {
|
||||||
|
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
body: { code: 'JOURNEY_AT_CAPACITY' }
|
||||||
|
});
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
|
||||||
|
await addViaPicker(/Brief von Eugenie/i);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('alert'))
|
||||||
|
.toHaveTextContent(m.geschichte_documents_capacity());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a successful add via the polite live region', async () => {
|
||||||
|
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||||
|
ok: true,
|
||||||
|
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||||
|
});
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
|
||||||
|
await addViaPicker(/Brief von Eugenie/i);
|
||||||
|
|
||||||
|
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||||
|
expect(liveRegion?.textContent).toBe(
|
||||||
|
m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes a 403 response through getErrorMessage on POST', async () => {
|
||||||
|
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
body: { code: 'FORBIDDEN' }
|
||||||
|
});
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
|
||||||
|
await addViaPicker(/Brief von Eugenie/i);
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
const alertText = page.getByRole('alert').element().textContent ?? '';
|
||||||
|
expect(alertText).not.toBe('');
|
||||||
|
expect(alertText).not.toContain('FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the generic reload message when POST throws a network error', async () => {
|
||||||
|
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if ((init?.method ?? 'GET').toUpperCase() === 'GET') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({ items: [makeSearchResultItem('d1', 'Brief von Eugenie')] })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('Network error'));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
|
||||||
|
await addViaPicker(/Brief von Eugenie/i);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('alert'))
|
||||||
|
.toHaveTextContent(m.journey_mutation_error_reload());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches X-XSRF-TOKEN header from cookie on POST', async () => {
|
||||||
|
document.cookie = 'XSRF-TOKEN=test-csrf-token';
|
||||||
|
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||||
|
ok: true,
|
||||||
|
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||||
|
});
|
||||||
|
render(StoryDocumentPanel, defaultProps());
|
||||||
|
|
||||||
|
await addViaPicker(/Brief von Eugenie/i);
|
||||||
|
|
||||||
|
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||||
|
const headers = post?.[1]?.headers as Headers;
|
||||||
|
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
|
||||||
|
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StoryDocumentPanel — remove', () => {
|
||||||
|
it('DELETEs the item endpoint and removes the row', async () => {
|
||||||
|
const fetchMock = stubFetch([], { ok: true, body: {} });
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
||||||
|
expect(del?.[0]).toBe('/api/geschichten/g1/items/i1');
|
||||||
|
expect(document.querySelectorAll('li').length).toBe(0);
|
||||||
|
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the row and shows an error when the DELETE fails', async () => {
|
||||||
|
stubFetch([], { ok: false, status: 500, body: {} });
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||||
|
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus to the previous row remove button instead of dropping to body', async () => {
|
||||||
|
stubFetch([], { ok: true, body: {} });
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({
|
||||||
|
items: [
|
||||||
|
makeItem('i1', 10, docSummary('d1', 'Erster Brief')),
|
||||||
|
makeItem('i2', 20, docSummary('d2', 'Zweiter Brief'))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.activeElement).not.toBe(document.body);
|
||||||
|
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||||
|
m.geschichte_documents_remove_label({ title: 'Erster Brief' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus to the picker input when the last item is removed', async () => {
|
||||||
|
stubFetch([], { ok: true, body: {} });
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = page.getByRole('combobox').element();
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a successful remove via the polite live region', async () => {
|
||||||
|
stubFetch([], { ok: true, body: {} });
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||||
|
expect(liveRegion?.textContent).toBe(
|
||||||
|
m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the item remove button when DELETE fails with !res.ok', async () => {
|
||||||
|
stubFetch([], { ok: false, status: 500, body: {} });
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||||
|
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the item remove button when DELETE throws a network error', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||||
|
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the generic reload message when DELETE throws a network error', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('alert'))
|
||||||
|
.toHaveTextContent(m.journey_mutation_error_reload());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches X-XSRF-TOKEN header from cookie on DELETE', async () => {
|
||||||
|
document.cookie = 'XSRF-TOKEN=test-csrf-token';
|
||||||
|
const fetchMock = stubFetch([], { ok: true, body: {} });
|
||||||
|
render(
|
||||||
|
StoryDocumentPanel,
|
||||||
|
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
||||||
|
const headers = del?.[1]?.headers as Headers;
|
||||||
|
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
|
||||||
|
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
|
||||||
|
});
|
||||||
|
});
|
||||||
123
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
123
frontend/src/lib/geschichte/StoryReader.svelte
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { safeHtml } from '$lib/shared/utils/sanitize';
|
||||||
|
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||||
|
import { formatDocumentMetaLine } from './utils';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte: GeschichteView;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichte: g }: Props = $props();
|
||||||
|
|
||||||
|
const sanitized = $derived(safeHtml(g.body));
|
||||||
|
|
||||||
|
const documentItems = $derived(g.items.filter((i) => i.document));
|
||||||
|
|
||||||
|
function personName(p: { firstName?: string; lastName?: string }): string {
|
||||||
|
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
|
||||||
|
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
|
||||||
|
and produces a much narrower column inside an already narrow page, which
|
||||||
|
Leonie flagged as unreadable for the senior-author persona.
|
||||||
|
|
||||||
|
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html sanitized}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personen -->
|
||||||
|
{#if g.persons && g.persons.length > 0}
|
||||||
|
<section class="mt-10 border-t border-line pt-6">
|
||||||
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.geschichten_persons_section()}
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
{#each g.persons as p (p.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/persons/{p.id}"
|
||||||
|
style="display: inline-flex; min-height: 44px"
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-2 rounded-full border border-line bg-surface px-3 py-1.5 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-sans text-[8px] font-bold text-white"
|
||||||
|
style="background-color: {personAvatarColor(p.id)}"
|
||||||
|
>
|
||||||
|
{getInitials(personName(p))}
|
||||||
|
</span>
|
||||||
|
{personName(p)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dokumente (JourneyItems) -->
|
||||||
|
{#if documentItems.length > 0}
|
||||||
|
<section class="mt-8 border-t border-line pt-6">
|
||||||
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.geschichten_documents_section()}
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{#each documentItems as item (item.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/documents/{item.document!.id}"
|
||||||
|
class="flex items-start gap-3 rounded-sm border border-line bg-surface p-3 transition-shadow hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-muted"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 text-ink-3" viewBox="0 0 10 12" fill="none">
|
||||||
|
<rect
|
||||||
|
x="1"
|
||||||
|
y="1"
|
||||||
|
width="8"
|
||||||
|
height="10"
|
||||||
|
rx="1"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 4h4M3 6.5h4M3 9h2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width=".8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block font-sans text-sm leading-snug font-semibold text-ink">
|
||||||
|
{item.document!.title}
|
||||||
|
</span>
|
||||||
|
{#if formatDocumentMetaLine(item.document!)}
|
||||||
|
<span class="block font-sans text-xs text-ink-3">
|
||||||
|
{formatDocumentMetaLine(item.document!)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{#if item.note}
|
||||||
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
|
<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
134
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
134
frontend/src/lib/geschichte/StoryReader.svelte.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
const { default: StoryReader } = await import('./StoryReader.svelte');
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
|
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Die Reise nach Berlin',
|
||||||
|
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
|
||||||
|
type: 'STORY',
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
author: { id: 'u1', displayName: 'Anna Schmidt' },
|
||||||
|
persons: [],
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StoryReader', () => {
|
||||||
|
it('renders body HTML content', async () => {
|
||||||
|
render(StoryReader, { props: { geschichte: baseGeschichte() } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits persons section when persons array is empty', async () => {
|
||||||
|
render(StoryReader, { props: { geschichte: baseGeschichte({ persons: [] }) } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders persons section with firstName + lastName joined', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
persons: [
|
||||||
|
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
||||||
|
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Helene Schmidt')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Karl Müller')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits documents section when no items have documents', async () => {
|
||||||
|
render(StoryReader, { props: { geschichte: baseGeschichte({ items: [] }) } });
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders document reference cards with title and link for items with documents', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'i1',
|
||||||
|
position: 0,
|
||||||
|
document: {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Brief vom 12. Juli 1938',
|
||||||
|
documentDate: '1938-07-12',
|
||||||
|
senderName: 'Franz Raddatz',
|
||||||
|
receiverName: 'Emma Müller',
|
||||||
|
datePrecision: 'DAY',
|
||||||
|
receiverCount: 1
|
||||||
|
} as unknown as NonNullable<components['schemas']['JourneyItemView']['document']>,
|
||||||
|
note: 'Wichtiger Brief'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Brief vom 12. Juli 1938')).toBeVisible();
|
||||||
|
await expect.element(page.getByText(/von Franz Raddatz an Emma Müller/)).toBeVisible();
|
||||||
|
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
|
||||||
|
|
||||||
|
const link = document.querySelector<HTMLAnchorElement>('a[href="/documents/d1"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('person chip link meets 44px touch-target minimum height', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector<HTMLAnchorElement>('a[href^="/persons/"]');
|
||||||
|
const rect = link?.getBoundingClientRect();
|
||||||
|
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('person chip shows avatar initials', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chip = document.querySelector<HTMLAnchorElement>('a[href="/persons/p1"]');
|
||||||
|
expect(chip?.textContent).toContain('HS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
||||||
|
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
||||||
|
render(StoryReader, {
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
body: '<img src=x onerror="(window as any).__xss_story=1">'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as { __xss_story?: number }).__xss_story).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
65
frontend/src/lib/geschichte/utils.test.ts
Normal file
65
frontend/src/lib/geschichte/utils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './utils';
|
||||||
|
|
||||||
|
describe('formatAuthorName', () => {
|
||||||
|
it('joins firstName and lastName with a space', () => {
|
||||||
|
expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt' })).toBe('Anna Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns firstName alone when lastName is absent', () => {
|
||||||
|
expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns lastName alone when firstName is absent', () => {
|
||||||
|
expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to [Unbekannt] when both names are absent', () => {
|
||||||
|
expect(formatAuthorName({})).toBe('[Unbekannt]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for null input', () => {
|
||||||
|
expect(formatAuthorName(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for undefined input', () => {
|
||||||
|
expect(formatAuthorName(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAuthorDisplayName', () => {
|
||||||
|
it('returns displayName when present', () => {
|
||||||
|
expect(formatAuthorDisplayName({ displayName: 'Anna Schmidt' })).toBe('Anna Schmidt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for null input', () => {
|
||||||
|
expect(formatAuthorDisplayName(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for undefined input', () => {
|
||||||
|
expect(formatAuthorDisplayName(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPublishedAt', () => {
|
||||||
|
it('returns null for null input', () => {
|
||||||
|
expect(formatPublishedAt(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for undefined input', () => {
|
||||||
|
expect(formatPublishedAt(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats an ISO datetime string to a localised date', () => {
|
||||||
|
const result = formatPublishedAt('2026-04-15T10:00:00Z', 'short');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result).toContain('2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slices to date-only before formatting (no TZ off-by-one)', () => {
|
||||||
|
// Both dates should format identically regardless of timezone offset
|
||||||
|
const a = formatPublishedAt('2026-04-15T00:00:00Z', 'short');
|
||||||
|
const b = formatPublishedAt('2026-04-15T23:59:59Z', 'short');
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/src/lib/geschichte/utils.ts
Normal file
39
frontend/src/lib/geschichte/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { joinNameOrUnknown, unknownPersonName } from '$lib/person/personFormat';
|
||||||
|
|
||||||
|
type AuthorSummary = { firstName?: string; lastName?: string };
|
||||||
|
type DocumentMeta = { documentDate?: string; senderName?: string; receiverName?: string };
|
||||||
|
type AuthorView = { displayName: string };
|
||||||
|
|
||||||
|
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
|
||||||
|
if (!author) return '';
|
||||||
|
// Email is no longer exposed — names or the localized fallback only.
|
||||||
|
return joinNameOrUnknown(author.firstName, author.lastName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
|
||||||
|
if (!author) return '';
|
||||||
|
// The server-side fallback is the literal '[Unbekannt]' — localize it here.
|
||||||
|
return author.displayName === '[Unbekannt]' ? unknownPersonName() : author.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPublishedAt(
|
||||||
|
publishedAt: string | null | undefined,
|
||||||
|
style: 'short' | 'long' = 'short'
|
||||||
|
): string | null {
|
||||||
|
if (!publishedAt) return null;
|
||||||
|
return formatDate(publishedAt.slice(0, 10), style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "12.07.1938 · von Franz an Emma" — shared by JourneyItemCard and the story doc-reference cards. */
|
||||||
|
export function formatDocumentMetaLine(doc: DocumentMeta): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (doc.documentDate) parts.push(formatDate(doc.documentDate, 'short'));
|
||||||
|
if (doc.senderName && doc.receiverName) {
|
||||||
|
parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName }));
|
||||||
|
} else if (doc.senderName) {
|
||||||
|
parts.push(doc.senderName);
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user