Compare commits
274 Commits
7679596c70
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65296172f | ||
|
|
6230deaa96 | ||
|
|
00baf0d881 | ||
|
|
84c5ff5b3f | ||
|
|
e9b8920b3b | ||
|
|
c2b98e6e84 | ||
|
|
eefc67bd81 | ||
|
|
44869d64f7 | ||
|
|
7ca6492fc0 | ||
|
|
9ea21f60ea | ||
|
|
a6184fa121 | ||
|
|
232721214d | ||
|
|
d91bedbaaf | ||
|
|
8e4810d5da | ||
|
|
05652a18ee | ||
|
|
6b2dd2f259 | ||
|
|
f43df6082d | ||
|
|
2121d8469f | ||
|
|
e8437b79d1 | ||
|
|
4f0a660cb8 | ||
|
|
48f2c67ffc | ||
|
|
4961c74a01 | ||
|
|
d0fc8ce995 | ||
|
|
73b10e6f8c | ||
|
|
ac6da154da | ||
|
|
66e9309d8a | ||
|
|
836c9594d4 | ||
|
|
07725cfa39 | ||
|
|
b4fcbd7efc | ||
|
|
8995b6e922 | ||
|
|
b9bed19610 | ||
|
|
98805f596a | ||
|
|
2e9902e8a7 | ||
|
|
0884a1abcd | ||
|
|
50def73d80 | ||
|
|
264d7268c4 | ||
|
|
2d38122833 | ||
|
|
9c524443cf | ||
|
|
3b58ac0457 | ||
|
|
63acb5417f | ||
|
|
b926bdefde | ||
|
|
cbf4432d01 | ||
|
|
f2ebaacfb1 | ||
|
|
07ed9719e7 | ||
|
|
906e75ec96 | ||
|
|
90a1bd4082 | ||
|
|
b0d75b26cd | ||
|
|
166003b33a | ||
|
|
7ad2132a92 | ||
|
|
c3afd57e19 | ||
|
|
74b94ccd84 | ||
|
|
585244d65b | ||
|
|
a65a55448e | ||
|
|
4374f75d3c | ||
|
|
f10b0cb73e | ||
|
|
7977d22d0b | ||
|
|
98e3d924e5 | ||
|
|
c4606cef8b | ||
|
|
d6ac88a211 | ||
|
|
d34afb2298 | ||
|
|
e077dba595 | ||
|
|
b3d54a12c4 | ||
|
|
02efe738a0 | ||
|
|
ae0b143685 | ||
|
|
4e28f2f31b | ||
|
|
282c819e2a | ||
|
|
6e006bafd0 | ||
|
|
e988c3eae7 | ||
|
|
522d1f3ec9 | ||
|
|
44f15dd4a2 | ||
|
|
bd5e6e6fbe | ||
|
|
3e54b6e90a | ||
|
|
abe5d62102 | ||
|
|
ba394e1165 | ||
|
|
0130c4fa99 | ||
|
|
d689a66275 | ||
|
|
401371fd18 | ||
|
|
33fe93a591 | ||
|
|
d35a165881 | ||
|
|
7df640201a | ||
|
|
8adc39e2ce | ||
|
|
67861239f8 | ||
|
|
b30c0d7f96 | ||
|
|
e1ca2c6831 | ||
|
|
55989058a5 | ||
|
|
1cfa03d1f0 | ||
|
|
63bc24d2f1 | ||
|
|
e5f276a164 | ||
|
|
949421a076 | ||
|
|
353945c952 | ||
|
|
4572572c94 | ||
|
|
dfc065b727 | ||
|
|
ed4ef88598 | ||
|
|
65e196292e | ||
|
|
afe7e5586a | ||
|
|
53f259243e | ||
|
|
db4a5c0028 | ||
|
|
1b2776aa89 | ||
|
|
8df5b77189 | ||
|
|
06b8c99ce7 | ||
|
|
39e07fc02e | ||
|
|
7e6030a4fc | ||
|
|
dd917460b0 | ||
|
|
573e5c43d7 | ||
|
|
a5754162ce | ||
|
|
ddcf61cc5e | ||
|
|
1f9107b620 | ||
|
|
ae0cb93a9e | ||
|
|
a17eec537f | ||
|
|
9a178210fa | ||
|
|
d88cde06a0 | ||
|
|
65d241f69e | ||
|
|
a619f950a5 | ||
|
|
65b79a337b | ||
|
|
4de664f4f6 | ||
|
|
bee055e615 | ||
|
|
9be24f2613 | ||
|
|
d5441d3e55 | ||
|
|
c131507e30 | ||
|
|
c50f04bafa | ||
|
|
f004b1f2a6 | ||
|
|
75de56928e | ||
|
|
6ed8ecf571 | ||
|
|
4c75680977 | ||
|
|
930f69e884 | ||
|
|
eea8e6bf5a | ||
|
|
55e3e4c531 | ||
|
|
7a5c2d0ba3 | ||
|
|
994772564a | ||
|
|
a0930b62b0 | ||
|
|
3572de487a | ||
|
|
f9cdc02a77 | ||
|
|
4c24bbb002 | ||
|
|
91d9dae6fd | ||
|
|
4184d0775b | ||
|
|
97026fec11 | ||
|
|
565eddd743 | ||
|
|
0b9e8c2abb | ||
|
|
8a6bc27979 | ||
|
|
8fea94cb61 | ||
|
|
0d47bcb4a1 | ||
|
|
825a622413 | ||
|
|
81a12ba35c | ||
| 0780c09bb4 | |||
|
|
77cbbd34a0 | ||
|
|
84b47f1836 | ||
|
|
99111273e5 | ||
|
|
f09c79744e | ||
|
|
1108277472 | ||
|
|
9db3b41fdb | ||
|
|
73004ce49f | ||
|
|
c31f82a69c | ||
|
|
f9ae6a91ba | ||
|
|
70da532f54 | ||
|
|
ad90ae75bf | ||
|
|
164178ecf1 | ||
|
|
3f36d2a7f1 | ||
|
|
5b2ee31292 | ||
|
|
3d80bc656c | ||
|
|
4a0fed617a | ||
|
|
7ba6342a84 | ||
|
|
598ad622e7 | ||
|
|
c5611250ec | ||
|
|
e400b1d77e | ||
|
|
1fb0c41216 | ||
|
|
147aa56386 | ||
|
|
7c06609816 | ||
|
|
2ae1bb3a30 | ||
|
|
69db198319 | ||
|
|
e157d90b53 | ||
|
|
97f22e1ce8 | ||
|
|
5539158e8f | ||
|
|
7ed0032661 | ||
|
|
2f471155b8 | ||
|
|
4eb6abd920 | ||
|
|
6fc5ce6ddd | ||
|
|
4603e335fd | ||
|
|
a0fa8f4d02 | ||
|
|
d29f217328 | ||
|
|
fdc9273c86 | ||
|
|
2ad5c36e3c | ||
|
|
160ca1c3e9 | ||
|
|
7b06c3adec | ||
|
|
408ae3345c | ||
|
|
0b17724785 | ||
|
|
df5d880e09 | ||
|
|
45500cc5e2 | ||
|
|
b1819867be | ||
|
|
2c5f7ac12d | ||
|
|
13fa4123c1 | ||
|
|
ccdf358b40 | ||
|
|
e6c890c61e | ||
|
|
93ef26690f | ||
|
|
439385dd35 | ||
|
|
b3ce9b930f | ||
| d650b6c066 | |||
|
|
8e63867ad8 | ||
|
|
6b0a06e8b1 | ||
|
|
7c1eef710c | ||
|
|
03e22a2f26 | ||
|
|
6878419156 | ||
|
|
09b77e9b36 | ||
|
|
9d202b042b | ||
|
|
8429b1e9f8 | ||
|
|
6959651b36 | ||
|
|
0ef4f4f07c | ||
|
|
f1bb9d3a69 | ||
|
|
ca52145556 | ||
|
|
9a26bf75b0 | ||
|
|
9c616f9fb8 | ||
|
|
0fe0ae5235 | ||
|
|
2c909f49a8 | ||
|
|
87fd0f39bb | ||
|
|
7f3ad8ce89 | ||
|
|
aa1f6436cc | ||
|
|
b825076733 | ||
|
|
01df815bad | ||
|
|
dcd0e725a7 | ||
|
|
39ff63921d | ||
|
|
5a09cd4cb4 | ||
|
|
4e0ebc72c8 | ||
|
|
0f0d89702d | ||
|
|
fb41affd4c | ||
|
|
dc366ed403 | ||
|
|
64b7b2315d | ||
|
|
2a7e133717 | ||
|
|
5387bc9247 | ||
|
|
847874abb3 | ||
|
|
573bca4986 | ||
|
|
86690fdbb6 | ||
|
|
6cb1025881 | ||
|
|
fc557bd9ae | ||
|
|
e94414b81a | ||
|
|
7eee688ce9 | ||
|
|
8905135006 | ||
|
|
8bd8390891 | ||
|
|
ed98729f75 | ||
|
|
db87a64cc0 | ||
|
|
d7d6d0638c | ||
|
|
a2f37f85a6 | ||
|
|
f22a1a1cfa | ||
|
|
2a0863cf3e | ||
|
|
9e97687d0f | ||
|
|
b665e1132d | ||
|
|
87af9ab446 | ||
|
|
0058b297d8 | ||
|
|
230f23e37c | ||
|
|
e604967a3f | ||
|
|
169e1ad9de | ||
|
|
f2f42ed415 | ||
|
|
5945824b54 | ||
|
|
fa41394e66 | ||
|
|
fb00c7818e | ||
|
|
8ed65f8602 | ||
|
|
9e425c98a1 | ||
|
|
ddce268113 | ||
| 4a43962c98 | |||
|
|
9a9e1c4c40 | ||
|
|
62c8ce4cb2 | ||
|
|
4c620619d4 | ||
|
|
44baff9c9c | ||
|
|
4634da9865 | ||
|
|
79e4a3f9db | ||
|
|
70e8a6e6ad | ||
|
|
3af1095d13 | ||
|
|
8c835e957a | ||
|
|
fe8fcba7a7 | ||
|
|
e0c80ac193 | ||
|
|
005265b5a8 | ||
|
|
684c6e63de | ||
|
|
e27d52b9ee | ||
|
|
6f5497c7bf | ||
|
|
e0fac783e8 | ||
|
|
202ea85a58 |
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
|
||||||
|
|||||||
@@ -41,6 +41,27 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-servlet</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-servlets</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-webapp</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty</groupId>
|
||||||
|
<artifactId>jetty-ee</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -137,6 +158,12 @@
|
|||||||
<artifactId>archunit-junit5</artifactId>
|
<artifactId>archunit-junit5</artifactId>
|
||||||
<version>1.3.0</version>
|
<version>1.3.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.wiremock</groupId>
|
||||||
|
<artifactId>wiremock-jetty12</artifactId>
|
||||||
|
<version>3.9.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Excel Bearbeitung (Apache POI) -->
|
<!-- Excel Bearbeitung (Apache POI) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -57,6 +64,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@EntityGraph("Document.full")
|
@EntityGraph("Document.full")
|
||||||
List<Document> findByReceiversId(UUID receiverId);
|
List<Document> findByReceiversId(UUID receiverId);
|
||||||
|
|
||||||
|
|
||||||
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ 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;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import jakarta.persistence.criteria.JoinType;
|
||||||
|
import jakarta.persistence.criteria.Predicate;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -78,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) {}
|
||||||
|
|
||||||
@@ -849,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());
|
||||||
@@ -1004,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
|
||||||
@@ -1033,6 +1059,28 @@ public class DocumentService {
|
|||||||
return documentRepository.findByReceiversId(receiverId);
|
return documentRepository.findByReceiversId(receiverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
|
||||||
|
Person person = personService.getById(personId);
|
||||||
|
Specification<Document> spec = buildPersonSpec(person, from, to);
|
||||||
|
Page<Document> page = documentRepository.findAll(spec, pageable);
|
||||||
|
List<DocumentListItem> items = enrichItems(page.getContent(), null);
|
||||||
|
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
|
||||||
|
return (root, query, cb) -> {
|
||||||
|
if (query != null) query.distinct(true);
|
||||||
|
var receiversJoin = root.join("receivers", JoinType.LEFT);
|
||||||
|
var senderPredicate = cb.equal(root.get("sender"), person);
|
||||||
|
var receiverPredicate = cb.equal(receiversJoin, person);
|
||||||
|
var personPredicate = cb.or(senderPredicate, receiverPredicate);
|
||||||
|
var predicates = new ArrayList<>(List.of(personPredicate));
|
||||||
|
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||||
|
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||||
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public long getIncompleteCount() {
|
public long getIncompleteCount() {
|
||||||
return documentRepository.countByMetadataCompleteFalse();
|
return documentRepository.countByMetadataCompleteFalse();
|
||||||
}
|
}
|
||||||
@@ -1051,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
|
||||||
|
|||||||
@@ -78,4 +78,8 @@ public class DomainException extends RuntimeException {
|
|||||||
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DomainException serviceUnavailable(ErrorCode code, String message) {
|
||||||
|
return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
|
||||||
|
* strength. {@code direct} = every query token is a whole-token match across the person's name
|
||||||
|
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
|
||||||
|
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
|
||||||
|
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
|
||||||
|
*/
|
||||||
|
public record NameMatches(List<Person> direct, List<Person> partial) {
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
|
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||||
List<Person> searchByName(@Param("query") String query);
|
List<Person> searchByName(@Param("query") String query);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
@@ -24,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class PersonService {
|
public class PersonService {
|
||||||
|
|
||||||
|
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
|
||||||
|
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
|
||||||
|
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
|
||||||
|
// past position 10 among partials is never discarded.
|
||||||
|
private static final int MAX_TOKENS = 8;
|
||||||
|
private static final int MAX_CANDIDATES = 10;
|
||||||
|
|
||||||
private final PersonRepository personRepository;
|
private final PersonRepository personRepository;
|
||||||
private final PersonNameAliasRepository aliasRepository;
|
private final PersonNameAliasRepository aliasRepository;
|
||||||
|
|
||||||
@@ -99,6 +114,96 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Person> findByDisplayNameContaining(String fragment) {
|
||||||
|
return personRepository.searchByName(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
|
||||||
|
// drop empties. Applied symmetrically to the query and to every candidate name component so
|
||||||
|
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
|
||||||
|
static Set<String> tokenize(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> tokens = new LinkedHashSet<>();
|
||||||
|
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
|
||||||
|
if (!part.isEmpty()) {
|
||||||
|
tokens.add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
|
||||||
|
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
|
||||||
|
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
|
||||||
|
* are reachable during classification (see ADR-022).
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public NameMatches resolveByName(String name) {
|
||||||
|
Set<String> queryTokens = capTokens(tokenize(name));
|
||||||
|
if (queryTokens.isEmpty()) {
|
||||||
|
log.debug("resolveByName outcome=no-match tokens=0");
|
||||||
|
return new NameMatches(List.of(), List.of());
|
||||||
|
}
|
||||||
|
return classify(fetchPool(queryTokens), queryTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> capTokens(Set<String> tokens) {
|
||||||
|
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Person> fetchPool(Set<String> queryTokens) {
|
||||||
|
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
|
||||||
|
for (String token : queryTokens) {
|
||||||
|
for (Person candidate : findByDisplayNameContaining(token)) {
|
||||||
|
pool.putIfAbsent(candidate.getId(), candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(pool.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
|
||||||
|
List<Person> direct = new ArrayList<>();
|
||||||
|
List<Person> partial = new ArrayList<>();
|
||||||
|
for (Person candidate : pool) {
|
||||||
|
if (personTokens(candidate).containsAll(queryTokens)) {
|
||||||
|
direct.add(candidate);
|
||||||
|
} else {
|
||||||
|
partial.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Person> cappedDirect = cap(direct);
|
||||||
|
List<Person> cappedPartial = cap(partial);
|
||||||
|
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
|
||||||
|
return new NameMatches(cappedDirect, cappedPartial);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> personTokens(Person person) {
|
||||||
|
Set<String> tokens = new LinkedHashSet<>();
|
||||||
|
tokens.addAll(tokenize(person.getFirstName()));
|
||||||
|
tokens.addAll(tokenize(person.getLastName()));
|
||||||
|
tokens.addAll(tokenize(person.getAlias()));
|
||||||
|
tokens.addAll(tokenize(person.getTitle()));
|
||||||
|
for (PersonNameAlias alias : person.getNameAliases()) {
|
||||||
|
tokens.addAll(tokenize(alias.getFirstName()));
|
||||||
|
tokens.addAll(tokenize(alias.getLastName()));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Person> cap(List<Person> people) {
|
||||||
|
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String outcome(List<Person> direct, List<Person> partial) {
|
||||||
|
if (direct.size() == 1) return "direct=1";
|
||||||
|
if (direct.size() >= 2) return "direct>=2";
|
||||||
|
if (!partial.isEmpty()) return "partial-only";
|
||||||
|
return "no-match";
|
||||||
|
}
|
||||||
|
|
||||||
public List<Person> findAllFamilyMembers() {
|
public List<Person> findAllFamilyMembers() {
|
||||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
|||||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||||
| `findAll(String q)` | document, dashboard | List all persons |
|
| `findAll(String q)` | document, dashboard | List all persons |
|
||||||
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
||||||
|
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
|
||||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
||||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ public class TagService {
|
|||||||
return enrichWithRelatives(matched);
|
return enrichWithRelatives(matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Tag> findByNameContaining(String fragment) {
|
||||||
|
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
public Tag getById(UUID id) {
|
public Tag getById(UUID id) {
|
||||||
return tagRepository.findById(id)
|
return tagRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ springdoc:
|
|||||||
swagger-ui:
|
swagger-ui:
|
||||||
enabled: true
|
enabled: true
|
||||||
path: /swagger-ui.html
|
path: /swagger-ui.html
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -624,4 +624,88 @@ class DocumentRepositoryTest {
|
|||||||
.reviewed(reviewed)
|
.reviewed(reviewed)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── searchDocumentsByPersonId (via Specification) ───────────────────────
|
||||||
|
|
||||||
|
private Page<Document> searchByPerson(Person person, LocalDate from, LocalDate to) {
|
||||||
|
Specification<Document> spec = (root, query, cb) -> {
|
||||||
|
if (query != null) query.distinct(true);
|
||||||
|
var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT);
|
||||||
|
var personPredicate = cb.or(
|
||||||
|
cb.equal(root.get("sender"), person),
|
||||||
|
cb.equal(receiversJoin, person));
|
||||||
|
var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate));
|
||||||
|
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||||
|
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||||
|
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
|
||||||
|
};
|
||||||
|
return documentRepository.findAll(spec, PageRequest.of(0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByPersonSpec_returnsDocument_whenPersonIsSender() {
|
||||||
|
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Senderbrief").originalFilename("sender.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(person).build());
|
||||||
|
|
||||||
|
Page<Document> result = searchByPerson(person, null, null);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() {
|
||||||
|
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Empfängerbrief").originalFilename("receiver.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.receivers(new java.util.HashSet<>(List.of(person))).build());
|
||||||
|
|
||||||
|
Page<Document> result = searchByPerson(person, null, null);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() {
|
||||||
|
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("SenderEmpfänger").originalFilename("both.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(person)
|
||||||
|
.receivers(new java.util.HashSet<>(List.of(person))).build());
|
||||||
|
|
||||||
|
Page<Document> result = searchByPerson(person, null, null);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByPersonSpec_excludesDocuments_outsideDateRange() {
|
||||||
|
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||||
|
Document inside = documentRepository.save(Document.builder()
|
||||||
|
.title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(person).documentDate(LocalDate.of(1918, 6, 15)).build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(person).documentDate(LocalDate.of(1920, 1, 1)).build());
|
||||||
|
|
||||||
|
Page<Document> result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() {
|
||||||
|
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||||
|
Person other = personRepository.save(Person.builder().lastName("Braun").build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Fremder Brief").originalFilename("other.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(other).build());
|
||||||
|
|
||||||
|
Page<Document> result = searchByPerson(person, null, null);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -428,6 +428,67 @@ class PersonRepositoryTest {
|
|||||||
assertThat(results).hasSize(1);
|
assertThat(results).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByName_findsByAliasFirstName() {
|
||||||
|
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||||
|
aliasRepository.save(PersonNameAlias.builder()
|
||||||
|
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||||
|
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||||
|
|
||||||
|
List<Person> results = personRepository.searchByName("Wilhelmina");
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByName_ordersByLastNameThenFirstName() {
|
||||||
|
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||||
|
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
|
||||||
|
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
|
||||||
|
|
||||||
|
List<Person> results = personRepository.searchByName("Cram");
|
||||||
|
|
||||||
|
assertThat(results).extracting(Person::getFirstName)
|
||||||
|
.containsExactly("Anna", "Bernd", "Clara");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
|
||||||
|
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
|
||||||
|
// fetch query actually finds an alias-only match and feeds it into classification. These walk
|
||||||
|
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
|
||||||
|
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||||
|
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
|
||||||
|
aliasRepository.save(PersonNameAlias.builder()
|
||||||
|
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
|
||||||
|
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
|
||||||
|
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
NameMatches matches = personService.resolveByName("Clara Cram");
|
||||||
|
|
||||||
|
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
|
||||||
|
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||||
|
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||||
|
aliasRepository.save(PersonNameAlias.builder()
|
||||||
|
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||||
|
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
NameMatches matches = personService.resolveByName("Wilhelmina");
|
||||||
|
|
||||||
|
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── searchWithDocumentCount with aliases ────────────────────────────────
|
// ─── searchWithDocumentCount with aliases ────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -898,4 +898,165 @@ class PersonServiceTest {
|
|||||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
.isEqualTo(403);
|
.isEqualTo(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByDisplayNameContaining_delegatesToSearchByName() {
|
||||||
|
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
||||||
|
when(personRepository.searchByName("Walter")).thenReturn(List.of(walter));
|
||||||
|
|
||||||
|
List<Person> result = personService.findByDisplayNameContaining("Walter");
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(walter);
|
||||||
|
verify(personRepository).searchByName("Walter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tokenize (name-match contract) ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenize_hyphenatedName_splitsOnHyphen() {
|
||||||
|
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenize_apostropheName_splitsOnApostrophe() {
|
||||||
|
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenize_umlautName_lowercasesToSingleToken() {
|
||||||
|
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenize_doubleSpace_dropsEmptyTokens() {
|
||||||
|
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenize_allWhitespace_returnsEmpty() {
|
||||||
|
assertThat(PersonService.tokenize(" ")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenize_null_returnsEmpty() {
|
||||||
|
assertThat(PersonService.tokenize(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── resolveByName (direct / partial classification) ──────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_singleDirectMatch_classifiesAsDirect() {
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||||
|
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||||
|
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Clara Cram");
|
||||||
|
|
||||||
|
assertThat(result.direct()).containsExactly(clara);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_maidenAliasToken_classifiesAsDirect() {
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
|
||||||
|
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
|
||||||
|
.type(PersonNameAliasType.MAIDEN_NAME).build()))
|
||||||
|
.build();
|
||||||
|
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||||
|
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Clara Cram");
|
||||||
|
|
||||||
|
assertThat(result.direct()).containsExactly(clara);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
|
||||||
|
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
|
||||||
|
.type(PersonNameAliasType.BIRTH).build()))
|
||||||
|
.build();
|
||||||
|
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Wilhelmina");
|
||||||
|
|
||||||
|
assertThat(result.direct()).containsExactly(clara);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_middleName_stillDirect() {
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
|
||||||
|
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||||
|
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Clara Cram");
|
||||||
|
|
||||||
|
assertThat(result.direct()).containsExactly(clara);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_reorderedTokens_stillDirect() {
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||||
|
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||||
|
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Cram Clara");
|
||||||
|
|
||||||
|
assertThat(result.direct()).containsExactly(clara);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_cramVsCramer_classifiesAsPartial() {
|
||||||
|
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
|
||||||
|
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
|
||||||
|
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Clara Cram");
|
||||||
|
|
||||||
|
assertThat(result.partial()).containsExactly(cramer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
|
||||||
|
NameMatches result = personService.resolveByName(" - ");
|
||||||
|
|
||||||
|
assertThat(result.direct()).isEmpty();
|
||||||
|
verify(personRepository, never()).searchByName(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
|
||||||
|
List<Person> pool = new java.util.ArrayList<>();
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
|
||||||
|
}
|
||||||
|
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||||
|
pool.add(direct);
|
||||||
|
when(personRepository.searchByName("clara")).thenReturn(pool);
|
||||||
|
when(personRepository.searchByName("cram")).thenReturn(pool);
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Clara Cram");
|
||||||
|
|
||||||
|
assertThat(result.direct()).containsExactly(direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
|
||||||
|
personService.resolveByName("a b c d e f g h i j");
|
||||||
|
|
||||||
|
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
|
||||||
|
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
|
||||||
|
// candidate is classified once, not twice.
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||||
|
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||||
|
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||||
|
|
||||||
|
NameMatches result = personService.resolveByName("Clara Cram");
|
||||||
|
|
||||||
|
assertThat(result.direct()).hasSize(1);
|
||||||
|
assertThat(result.partial()).isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -666,4 +666,17 @@ class TagServiceTest {
|
|||||||
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
|
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
|
||||||
verify(tagRepository, atLeastOnce()).findAllById(any());
|
verify(tagRepository, atLeastOnce()).findAllById(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── findByNameContaining ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByNameContaining_delegatesToRepository() {
|
||||||
|
Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||||
|
when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg));
|
||||||
|
|
||||||
|
List<Tag> result = tagService.findByNameContaining("krieg");
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(krieg);
|
||||||
|
verify(tagRepository).findByNameContainingIgnoreCase("krieg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,65 +141,6 @@ services:
|
|||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
# --- Ollama: Model init (one-shot pull) ---
|
|
||||||
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama_models volume on first start.
|
|
||||||
# On subsequent starts (model already in volume), exits quickly without re-downloading.
|
|
||||||
# Not started in CI — CI uses explicit service selection
|
|
||||||
# (docker-compose.ci.yml: db minio create-buckets)
|
|
||||||
ollama-model-init:
|
|
||||||
image: ollama/ollama:0.30.6
|
|
||||||
restart: "no"
|
|
||||||
networks:
|
|
||||||
- archiv-net
|
|
||||||
volumes:
|
|
||||||
- ollama_models:/root/.ollama
|
|
||||||
mem_limit: 2g
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:size=512m
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
command: >
|
|
||||||
sh -c "ollama serve & SERVE_PID=$$! && until curl -sf http://localhost:11434/api/tags; do sleep 1; done && ollama pull qwen2.5:7b-instruct-q4_K_M && kill $$SERVE_PID"
|
|
||||||
|
|
||||||
# --- Ollama: LLM inference server ---
|
|
||||||
# Serves the pre-pulled model for NL search inference.
|
|
||||||
# Not started in CI — CI uses explicit service selection
|
|
||||||
# (docker-compose.ci.yml: db minio create-buckets)
|
|
||||||
ollama:
|
|
||||||
image: ollama/ollama:0.30.6
|
|
||||||
container_name: archive-ollama
|
|
||||||
restart: unless-stopped
|
|
||||||
expose:
|
|
||||||
- "11434"
|
|
||||||
networks:
|
|
||||||
- archiv-net
|
|
||||||
volumes:
|
|
||||||
- ollama_models:/root/.ollama
|
|
||||||
environment:
|
|
||||||
OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
|
|
||||||
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
|
|
||||||
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
|
||||||
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:size=512m
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 60s # model weights are pre-loaded by ollama-model-init; service only needs to bind port
|
|
||||||
depends_on:
|
|
||||||
ollama-model-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
|
|
||||||
# --- Backend: Spring Boot ---
|
# --- Backend: Spring Boot ---
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -243,8 +184,6 @@ services:
|
|||||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||||
APP_OCR_BASE_URL: http://ocr-service:8000
|
APP_OCR_BASE_URL: http://ocr-service:8000
|
||||||
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||||
APP_OLLAMA_BASE_URL: "${APP_OLLAMA_BASE_URL:-http://ollama:11434}"
|
|
||||||
APP_OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
|
|
||||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
||||||
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
|
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
|
||||||
@@ -308,4 +247,3 @@ volumes:
|
|||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
ocr_models:
|
ocr_models:
|
||||||
ocr_cache:
|
ocr_cache:
|
||||||
ollama_models:
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
|
|||||||
|
|
||||||
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
**`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 |
|
||||||
|
|||||||
@@ -50,17 +50,15 @@ graph TD
|
|||||||
|
|
||||||
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
|
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
|
||||||
|
|
||||||
| Production target | RAM | Recommended OCR limit | NL Search | Notes |
|
| Production target | RAM | Recommended OCR limit | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Supported | Default `mem_limit: 12g` works comfortably; plenty of headroom for Ollama |
|
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
|
||||||
| ≥ 16 GB RAM | 16+ GB | 12 GB | Supported | Default works |
|
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
|
||||||
| 8 GB RAM | 8 GB | 6 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
||||||
| 4 GB RAM | 4 GB | — | Unsupported | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||||
|
|
||||||
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
|
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
|
||||||
|
|
||||||
> **Memory budget:** OCR (~6 GB active) + Ollama (~8 GB) = ~14 GB. On servers with less than 16 GB RAM, do not run `docker-compose.observability.yml` continuously alongside both OCR and Ollama.
|
|
||||||
|
|
||||||
### Dev vs production differences
|
### Dev vs production differences
|
||||||
|
|
||||||
| Concern | Dev (`docker-compose.yml`) | Prod (`docker-compose.prod.yml`) |
|
| Concern | Dev (`docker-compose.yml`) | Prod (`docker-compose.prod.yml`) |
|
||||||
@@ -147,16 +145,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
|||||||
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
|
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
|
||||||
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
|
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
|
||||||
|
|
||||||
### Ollama (NL search) service
|
|
||||||
|
|
||||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `APP_OLLAMA_BASE_URL` | Base URL for the Ollama service. Leave empty to disable NL search. | `http://ollama:11434` | — | — |
|
|
||||||
| `APP_OLLAMA_API_KEY` | API key passed as `Authorization: Bearer` to Ollama. Leave empty for unauthenticated access. Note: `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 (see ADR-028). | — | — | YES |
|
|
||||||
| `OLLAMA_CPU_LIMIT` | Docker CPU quota for the Ollama container. On CX42 (8 vCPUs) can be raised to `7.5`. | `4.0` | — | — |
|
|
||||||
| `OLLAMA_MEM_LIMIT` | Memory limit for the Ollama container. Requires CX42 (16 GB RAM). | `8g` | — | — |
|
|
||||||
| `OLLAMA_API_KEY` | API key set on the Ollama service itself. Same value as `APP_OLLAMA_API_KEY`. Leave empty for unauthenticated. | — | — | YES |
|
|
||||||
|
|
||||||
### Observability stack (`docker-compose.observability.yml`)
|
### Observability stack (`docker-compose.observability.yml`)
|
||||||
|
|
||||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||||
@@ -277,18 +265,6 @@ git.raddatz.cloud A <server IP>
|
|||||||
|
|
||||||
### 3.4 First deploy
|
### 3.4 First deploy
|
||||||
|
|
||||||
> **First start — Ollama model pull:** On first `docker compose up -d`, the `ollama-model-init` container pulls `qwen2.5:7b-instruct-q4_K_M` (~4.7 GB). At 10 Mbps this takes approximately 60–90 minutes; at 100 Mbps approximately 6–10 minutes. The pull is a one-time operation — subsequent restarts skip it (model already on the `ollama_models` volume). Monitor progress with `docker logs -f $(docker ps -q --filter name=ollama-model-init)`.
|
|
||||||
>
|
|
||||||
> **Do not use `--wait` on first deploy** — `docker compose up -d --wait` waits for all services to reach their health/completion target, including `ollama-model-init`. On first pull this blocks for 60–90 minutes and will time out any CI/deploy script that uses `--wait`.
|
|
||||||
>
|
|
||||||
> **Re-deploy idempotency:** on subsequent `docker compose up -d` runs (including `--force-recreate`), `ollama-model-init` re-executes but exits in seconds — Ollama's CLI skips the download when the model digest already matches what is on the volume.
|
|
||||||
>
|
|
||||||
> **Verify NL search is active** after enabling Ollama (`APP_OLLAMA_BASE_URL=http://ollama:11434`):
|
|
||||||
> ```bash
|
|
||||||
> curl -s http://localhost:8080/api/nl-search?q=brief+von+grossmutter
|
|
||||||
> # Returns 200 with results → NL search is active
|
|
||||||
> # Returns 503 NL_SEARCH_UNAVAILABLE → Ollama is not reachable or APP_OLLAMA_BASE_URL is unset
|
|
||||||
> ```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
|
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
|
||||||
@@ -585,24 +561,6 @@ bash scripts/download-kraken-models.sh
|
|||||||
|
|
||||||
> Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated.
|
> Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated.
|
||||||
|
|
||||||
### Upgrade the Ollama model
|
|
||||||
|
|
||||||
To switch to a newer model version (e.g. a future release of `qwen2.5`):
|
|
||||||
|
|
||||||
1. Update the model name in the `ollama-model-init` `command:` in `docker-compose.yml`.
|
|
||||||
2. Remove the existing model volume to free the old weights:
|
|
||||||
```bash
|
|
||||||
docker volume rm familienarchiv_ollama_models
|
|
||||||
```
|
|
||||||
(In production the volume name is prefixed with the compose project: `archiv-production_ollama_models`.)
|
|
||||||
3. Restart the stack:
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
The `ollama-model-init` container pulls the new model weights on first start (~4–8 GB download depending on the model). The `ollama` inference server will not start until the pull completes (`condition: service_completed_successfully`).
|
|
||||||
|
|
||||||
> **`ollama_models` volume:** holds model weights only — fully reproducible by re-pull, no backup needed.
|
|
||||||
|
|
||||||
### Trigger a canonical import
|
### Trigger a canonical import
|
||||||
|
|
||||||
The importer no longer parses the raw spreadsheet. It consumes the **canonical artifacts**
|
The importer no longer parses the raw spreadsheet. It consumes the **canonical artifacts**
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -165,6 +178,8 @@ _See also [Chronik](#chronik-internal)._
|
|||||||
|
|
||||||
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
|
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
|
||||||
|
|
||||||
|
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Infrastructure Terms
|
## Infrastructure Terms
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
# ADR-028: Ollama Docker Compose service for NL search
|
|
||||||
|
|
||||||
**Date:** 2026-06-06
|
|
||||||
**Status:** Accepted
|
|
||||||
**Deciders:** Marcel Raddatz
|
|
||||||
**Relates to:** #737 (infrastructure), #735 (NL search epic)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Issue #735 introduces natural-language document search, requiring a local LLM to generate embeddings and/or run inference at query time. The family archive stores personal family history — data privacy is non-negotiable, so cloud-based inference APIs are excluded. The production target is a Hetzner CX42 (16 GB RAM, 8 vCPUs, CPU-only, ~32 EUR/month).
|
|
||||||
|
|
||||||
Alternatives considered:
|
|
||||||
|
|
||||||
| Option | Reason rejected |
|
|
||||||
|---|---|
|
|
||||||
| **llama.cpp** | No HTTP API out of the box; requires custom wrapper; higher ops burden |
|
|
||||||
| **vLLM** | GPU-first; significant overhead on CPU-only hardware; overkill for this scale |
|
|
||||||
| **Cloud APIs** (OpenAI, Gemini, etc.) | Vendor lock-in; per-token cost at scale; data leaves the server — unacceptable for a private family archive |
|
|
||||||
| **Ollama** | Self-contained Docker image; built-in HTTP REST API; actively maintained; CPU-compatible; zero egress |
|
|
||||||
|
|
||||||
**Decision:** run Ollama as a Docker Compose service alongside the existing stack.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### 1. Hardware minimums and CPU-only constraint
|
|
||||||
|
|
||||||
All inference runs on CPU. The target is the Hetzner CX42 (16 GB RAM, 8 vCPUs).
|
|
||||||
|
|
||||||
| Tier | RAM | NL search |
|
|
||||||
|---|---|---|
|
|
||||||
| CX42 | 16 GB | Supported — full stack including Ollama |
|
|
||||||
| CX32 | 8 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) to skip Ollama entirely |
|
|
||||||
| CX22 | 4 GB | Unsupported for NL search |
|
|
||||||
|
|
||||||
### 2. Memory budget on CX42
|
|
||||||
|
|
||||||
| Component | `mem_limit` | Typical active RSS |
|
|
||||||
|---|---|---|
|
|
||||||
| OCR service | 12g (hard ceiling) | ~6 GB |
|
|
||||||
| Ollama | 8g | ~8 GB |
|
|
||||||
| **Total** | | **~14 GB active** |
|
|
||||||
|
|
||||||
`memswap_limit` on the Ollama service is set to `8g` (matching `mem_limit`) to prevent Linux from swapping model weights into swap under OCR memory pressure. Swapping model weights does not crash the container but silently degrades inference latency. This mirrors the pattern already applied to the OCR service.
|
|
||||||
|
|
||||||
**Operational constraint:** do NOT run `docker-compose.observability.yml` continuously alongside both OCR and Ollama on a CX42. The observability stack adds ~2 GB, which leaves no headroom.
|
|
||||||
|
|
||||||
### 3. Graceful-degradation contract
|
|
||||||
|
|
||||||
`app.ollama.base-url` absent OR blank → Ollama bean NOT registered → NL search returns HTTP 503 with `ErrorCode: NL_SEARCH_UNAVAILABLE`.
|
|
||||||
|
|
||||||
This single code path covers all unavailability scenarios: base-url unset, service unreachable, health check failed, and request timeout.
|
|
||||||
|
|
||||||
#### Why not `@ConditionalOnProperty`
|
|
||||||
|
|
||||||
`@ConditionalOnProperty` registers the bean when the property is present but blank (`APP_OLLAMA_BASE_URL=`). This produces a `RestClient` with an empty base URL that fails at runtime with an opaque error rather than a clean 503.
|
|
||||||
|
|
||||||
#### Correct condition expression
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ConditionalOnExpression("!'${app.ollama.base-url:}'.isBlank()")
|
|
||||||
```
|
|
||||||
|
|
||||||
When the property is absent, the placeholder resolves to `''`; `.isBlank()` returns `true`; negation makes the condition `false`; the bean is not registered. Same result for an explicit empty string (`APP_OLLAMA_BASE_URL=`).
|
|
||||||
|
|
||||||
### 4. Backend configuration pattern
|
|
||||||
|
|
||||||
Use a `@ConfigurationProperties` record, not separate `@Value` injections:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@ConfigurationProperties("app.ollama")
|
|
||||||
record OllamaProperties(String baseUrl, String apiKey) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
`OllamaProperties` is registered unconditionally — it is a plain value holder with no side effects.
|
|
||||||
|
|
||||||
`@ConditionalOnExpression` belongs **only** on `RestClientOllamaClient` (the bean that creates a live network client).
|
|
||||||
|
|
||||||
**Deliberate divergence from the OCR pattern:** the OCR service uses `@Value`-with-default because OCR is always-on and `http://ocr-service:8000` is a safe default. Ollama is truly optional — a missing URL means "feature disabled", not "use this default server". There is no safe default Ollama URL.
|
|
||||||
|
|
||||||
### 5. Optional<OllamaClient> injection
|
|
||||||
|
|
||||||
The NL search service uses constructor injection with `Optional<OllamaClient>`:
|
|
||||||
|
|
||||||
```java
|
|
||||||
private final Optional<OllamaClient> ollamaClient;
|
|
||||||
```
|
|
||||||
|
|
||||||
When empty (bean not registered), the service method returns 503 immediately:
|
|
||||||
|
|
||||||
```java
|
|
||||||
var client = ollamaClient.orElseThrow(
|
|
||||||
() -> DomainException.internal(ErrorCode.NL_SEARCH_UNAVAILABLE, "Ollama not configured"));
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer this over `@Autowired(required = false)` with a null check — the null-check pattern is noisy when the service already uses `@RequiredArgsConstructor`.
|
|
||||||
|
|
||||||
### 6. Empty API key guard
|
|
||||||
|
|
||||||
`RestClientOllamaClient` omits the `Authorization` header entirely when `apiKey` is blank:
|
|
||||||
|
|
||||||
```java
|
|
||||||
if (!apiKey.isBlank()) {
|
|
||||||
request.header("Authorization", "Bearer " + apiKey);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Sending `Authorization: Bearer ` (empty token) has undefined or potentially broken behavior depending on the Ollama version. This mirrors the `trainingToken` guard in `RestClientOcrClient.java:107`.
|
|
||||||
|
|
||||||
### 7. OLLAMA_API_KEY behavior in Ollama 0.6.5 and 0.30.6
|
|
||||||
|
|
||||||
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `OLLAMA_API_KEY` does **not** enforce request authentication in either version.
|
|
||||||
|
|
||||||
Test matrix run against `/api/tags`:
|
|
||||||
|
|
||||||
| Configuration | No auth header | `Authorization: Bearer ` (empty) | `Authorization: Bearer wrongkey` | `Authorization: Bearer correctkey` |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `OLLAMA_API_KEY=` (empty) | 200 | 200 | — | — |
|
|
||||||
| `OLLAMA_API_KEY` unset | 200 | — | — | — |
|
|
||||||
| `OLLAMA_API_KEY=testkey99` | 200 | 200 | 200 | 200 |
|
|
||||||
|
|
||||||
**Finding:** The `OLLAMA_API_KEY` environment variable is not listed in Ollama's startup config dump and does not gate any HTTP request in either tested version. All configurations — empty string, fully unset, and a real key — accept all requests without authentication.
|
|
||||||
|
|
||||||
**Practical implication:** `OLLAMA_API_KEY` provides no defense-in-depth in the tested versions. `archiv-net` network isolation is the only effective security control. The env var is retained in the Compose definition and `.env.example` for forward compatibility if Ollama enables enforcement in a future version, but operators must not rely on it for access control.
|
|
||||||
|
|
||||||
**Backend guard still valid:** the `RestClientOllamaClient` code-level guard (omit `Authorization` header when `apiKey.isBlank()`) remains correct behavior regardless — it prevents a malformed `Authorization: Bearer ` header from being sent.
|
|
||||||
|
|
||||||
### 8. read_only: true feasibility
|
|
||||||
|
|
||||||
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `read_only: true` works with Ollama. All three operations — `ollama serve`, `ollama pull qwen2.5:7b-instruct-q4_K_M`, and `ollama list` — succeeded with exit code 0 in both versions.
|
|
||||||
|
|
||||||
Test run:
|
|
||||||
```bash
|
|
||||||
docker run --rm --read-only \
|
|
||||||
-v ollama_models:/root/.ollama \
|
|
||||||
--tmpfs /tmp \
|
|
||||||
--entrypoint sh ollama/ollama:0.30.6 \
|
|
||||||
-c "ollama serve & sleep 5 && ollama pull qwen2.5:7b-instruct-q4_K_M && ollama list"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** the entrypoint must be overridden to `sh` for the test command — the container's default entrypoint is `/bin/ollama` and does not accept `sh` as a subcommand. This is a Docker invocation detail; the Compose service definition uses the image's default entrypoint and `command:` override for the init container, which works correctly.
|
|
||||||
|
|
||||||
**Result:** `read_only: true` and `tmpfs: - /tmp:size=512m` are applied to both `ollama` and `ollama-model-init`. The `ollama_models` volume handles all persistent writes; no other paths require write access during normal operation.
|
|
||||||
|
|
||||||
### 9. Peak RSS of init container during pull
|
|
||||||
|
|
||||||
**Empirically verified (2026-06-06):** Peak RSS during `qwen2.5:7b-instruct-q4_K_M` pull was **~108 MiB**.
|
|
||||||
|
|
||||||
`docker stats` samples during the pull (15-second intervals):
|
|
||||||
|
|
||||||
| Sample | MEM |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 54.89 MiB |
|
|
||||||
| 2 | 66.3 MiB |
|
|
||||||
| 5 | 97.25 MiB |
|
|
||||||
| 9 | **107.8 MiB** (peak) |
|
|
||||||
|
|
||||||
`mem_limit: 2g` is adequate — the model weights stream directly to the named volume; RSS is dominated by the Ollama server process alone (~100 MB), not the model data. No bump to 4 GB needed.
|
|
||||||
|
|
||||||
### 10. Init container pull mechanism
|
|
||||||
|
|
||||||
The `ollama-model-init` container uses a curl-based readiness loop with captured PID:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ollama serve & SERVE_PID=$!
|
|
||||||
until curl -sf http://localhost:11434/api/tags; do sleep 1; done
|
|
||||||
ollama pull qwen2.5:7b-instruct-q4_K_M
|
|
||||||
kill $SERVE_PID
|
|
||||||
```
|
|
||||||
|
|
||||||
`kill %1` (job-control syntax) is unreliable in non-interactive `sh -c` contexts. Capturing the PID via `SERVE_PID=$!` is reliable.
|
|
||||||
|
|
||||||
The same endpoint (`/api/tags`) is used for both the init container readiness loop and the main service `healthcheck`.
|
|
||||||
|
|
||||||
### 11. start_period: 60s rationale
|
|
||||||
|
|
||||||
The model is pre-pulled by `ollama-model-init` before the main service starts (via `condition: service_completed_successfully`). At main service startup, Ollama only loads model weights from the named volume and binds port 11434.
|
|
||||||
|
|
||||||
60 seconds is appropriate for this cold-start profile. 300 seconds was considered — that would be appropriate if the service pulled the model itself — but overstates actual startup time when the model is already present on the volume.
|
|
||||||
|
|
||||||
### 12. Security threat model
|
|
||||||
|
|
||||||
**Primary control:** `archiv-net` network isolation. Ollama has no externally exposed port (`expose:` only, not `ports:`). The Caddyfile must not route any path to the Ollama service.
|
|
||||||
|
|
||||||
**Note on `OLLAMA_API_KEY`:** Per §7, `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 and provides no authentication barrier against a compromised backend container. `archiv-net` network isolation is the sole effective security control. The env var is retained for forward compatibility only — do not rely on it for access control.
|
|
||||||
|
|
||||||
Both `ollama` and `ollama-model-init` receive the ADR-019 hardening baseline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt: [no-new-privileges:true]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 13. CI exclusion strategy
|
|
||||||
|
|
||||||
Docker Compose profiles are not used — they would add developer friction (requiring `--profile ...` for all local dev commands).
|
|
||||||
|
|
||||||
CI uses explicit service selection in `docker-compose.ci.yml`:
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.ci.yml up -d db minio create-buckets
|
|
||||||
```
|
|
||||||
|
|
||||||
Ollama is simply not listed and is never started in CI. A YAML comment on the `ollama` service block documents this:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Not started in CI — CI uses explicit service selection
|
|
||||||
# (docker-compose.ci.yml: db minio create-buckets)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 14. ollama_models volume operational note
|
|
||||||
|
|
||||||
The `ollama_models` named volume holds model weights only — fully reproducible by re-pull. No backup is needed.
|
|
||||||
|
|
||||||
If the volume fills after a model upgrade:
|
|
||||||
```bash
|
|
||||||
docker volume rm ollama_models && docker compose up -d
|
|
||||||
```
|
|
||||||
The init container re-pulls the model on next startup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
- NL search runs entirely on-premises; no data leaves the server and no per-token cloud cost.
|
|
||||||
- Graceful degradation is a first-class concern: smaller or budget-constrained instances can run the app without Ollama with a single env var change.
|
|
||||||
- The init container pattern keeps model pull out of the critical startup path for the main service, giving accurate healthcheck timings.
|
|
||||||
- `@ConditionalOnExpression` with a blank-check is more correct than `@ConditionalOnProperty` for optional features with no safe default URL.
|
|
||||||
|
|
||||||
### Risks and operational implications
|
|
||||||
|
|
||||||
- **Memory pressure:** OCR + Ollama together consume ~14 GB on a 16 GB host. Running the observability stack simultaneously risks OOM kills. Monitor with `docker stats`.
|
|
||||||
- **CPU inference latency:** `qwen2.5:7b-instruct-q4_K_M` is chosen for CPU viability, but inference on 8 vCPUs will be noticeably slower than GPU-accelerated alternatives. This is acceptable for the family archive use case (low concurrency, not real-time).
|
|
||||||
- All three empirical TBD items from the original issue spec were resolved — see §7 (OLLAMA_API_KEY not enforced), §8 (`read_only: true` works), §9 (peak RSS ~108 MiB).
|
|
||||||
- Model upgrades require a `docker volume rm` to free old weights before pulling the replacement. Document this in runbook/DEPLOYMENT.md.
|
|
||||||
53
docs/adr/034-remove-nl-search.md
Normal file
53
docs/adr/034-remove-nl-search.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR-034 — Remove NL/smart-search (supersedes ADR-028 ×2, ADR-034-ollama, ADR-035)
|
||||||
|
|
||||||
|
**Date:** 2026-06-07
|
||||||
|
**Status:** Accepted
|
||||||
|
**Issue:** #772
|
||||||
|
**Supersedes:** ADR-028 (nl-search-ollama), ADR-028 (ollama-docker-compose-service), ADR-034 (ollama-production-deployment-and-keep-alive), ADR-035 (rule-based-nlp-service)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The natural-language search feature ("KI-Suche" / smart search) allowed users to enter
|
||||||
|
free-form queries like *"Was hat Walter an Emma im Krieg geschrieben?"* and have them
|
||||||
|
interpreted by an LLM into structured filters (persons, tags, date range, keywords).
|
||||||
|
|
||||||
|
The feature went through two major iterations:
|
||||||
|
1. **Ollama integration** (ADR-028): an `ollama` Docker service running a local LLM
|
||||||
|
(llama3.2/gemma3) parsed queries via a JSON-mode prompt.
|
||||||
|
2. **Rule-based NLP service** (ADR-035): after Ollama proved too slow and unreliable on
|
||||||
|
CPU-only hardware, a Python FastAPI microservice (`nlp-service`, port 8001) replaced
|
||||||
|
it with deterministic regex + spaCy parsing plus a lightweight LLM call.
|
||||||
|
|
||||||
|
Both approaches shared the same fundamental problem: inference on the production server
|
||||||
|
(Hetzner Serverbörse, no GPU, 64 GB RAM, i7-6700) was too slow to be useful, with
|
||||||
|
typical query latencies of 10–30 seconds. Users got better and faster results from
|
||||||
|
the existing keyword search with date/person/tag filters.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Remove the NL search feature entirely.** The Python `nlp-service` microservice, the
|
||||||
|
Spring Boot `search/` package (`NlSearchController`, `NlQueryParserService`,
|
||||||
|
`RestClientNlpClient`, `NlSearchRateLimiter`, and all supporting classes), the frontend
|
||||||
|
NL search components (`SmartModeToggle`, `SmartSearchStatus`, `InterpretationChipRow`,
|
||||||
|
`DisambiguationPicker`), the related Docker Compose services, Prometheus scrape job,
|
||||||
|
Grafana dashboard, and all i18n keys are removed.
|
||||||
|
|
||||||
|
The existing structured search (FTS keyword + person/tag/date/directional filters) is
|
||||||
|
sufficient for the archive's current audience and search workload.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Capability removed:** users can no longer enter free-form natural-language queries.
|
||||||
|
They must use the structured filter bar (keyword text box + person/tag/date/directional
|
||||||
|
dropdowns). For documents where these filters are sufficient, there is no regression.
|
||||||
|
- **Operational simplification:** the Docker Compose stack loses two services
|
||||||
|
(`nlp-service` and previously `ollama`/`ollama-model-init`). Memory budget on the
|
||||||
|
production host is freed. No external model weights need to be kept warm.
|
||||||
|
- **Future reinstatement:** if a GPU-capable host becomes available, re-implementing
|
||||||
|
server-side LLM inference would be straightforward given the clean separation of the
|
||||||
|
`NlSearchController` entry point. However, this ADR deliberately avoids leaving dead
|
||||||
|
infrastructure or stub code in place — start clean if and when that becomes viable.
|
||||||
|
- **No data or schema change:** only query/endpoint code and Docker services are removed.
|
||||||
|
The `documents`, `persons`, and `tags` tables and their FTS indexes are untouched.
|
||||||
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ADR-035 — `Optional<String>` for three-way PATCH semantics
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Issue:** #751 (JourneyItem CRUD API)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The `PATCH /api/geschichten/{id}/items/{itemId}` endpoint must distinguish three cases for the `note` field:
|
||||||
|
|
||||||
|
| JSON body | Intended meaning |
|
||||||
|
|-------------------|-----------------------|
|
||||||
|
| `{"note": "text"}`| Set note to "text" |
|
||||||
|
| `{"note": null}` | Clear the note |
|
||||||
|
| `{}` (absent) | Leave note unchanged |
|
||||||
|
|
||||||
|
The standard library for this on Jackson 2.x is `jackson-databind-nullable` (`JsonNullable<T>` from `org.openapitools`). However, that library targets `com.fasterxml.jackson.*` (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses `tools.jackson.*` (Jackson 3.x). The module fails to register and throws at startup.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use `Optional<String>` with Java's default field initializer (`= null`) to encode the three states:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
public class JourneyItemUpdateDTO {
|
||||||
|
private Optional<String> note = null; // Java default — absent = no-op
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Java value | JSON wire | Semantics |
|
||||||
|
|--------------------|-------------------|---------------|
|
||||||
|
| `null` (default) | field absent | no-op |
|
||||||
|
| `Optional.empty()` | `{"note": null}` | clear |
|
||||||
|
| `Optional.of("x")` | `{"note": "x"}` | set |
|
||||||
|
|
||||||
|
Jackson 3.x natively maps a JSON `null` to `Optional.empty()` and leaves absent fields at their Java default. No custom module is needed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- No external dependency for PATCH semantics — simpler pom.xml.
|
||||||
|
- The DTO field type is `Optional<String>`, not `String` — service code must null-check the field first (`if (noteField == null) return;`) and then call `.orElse(null)` to unwrap.
|
||||||
|
- This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
|
||||||
|
- `jackson-databind-nullable` is removed from `pom.xml`; `JacksonConfig.java` is kept as a placeholder for future custom modules.
|
||||||
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).
|
||||||
@@ -9,10 +9,12 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
|
|||||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||||
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
|
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
|
||||||
|
System_Ext(ollama, "Ollama (self-hosted)", "Local LLM inference server (qwen2.5:7b). Parses natural-language search queries into structured filters. Runs in the same Docker Compose stack.")
|
||||||
|
|
||||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||||
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
|
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
|
||||||
|
Rel(familienarchiv, ollama, "NL query parsing for natural-language search", "HTTP / REST (internal)")
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -12,15 +12,13 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
|||||||
Container(frontend, "Web Frontend", "SvelteKit / Node adapter / port 3000", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
|
Container(frontend, "Web Frontend", "SvelteKit / Node adapter / port 3000", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
|
||||||
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty / port 8080", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications. Trusts X-Forwarded-* headers from Caddy.")
|
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty / port 8080", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications. Trusts X-Forwarded-* headers from Caddy.")
|
||||||
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
|
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
|
||||||
Container(ollama, "Ollama LLM Service", "ollama/ollama:0.30.6 / port 11434 (internal only)", "Local LLM inference server for NL search. Runs qwen2.5:7b-instruct-q4_K_M on CPU. Reachable only on the internal Docker network; no external port exposed. Disabled when APP_OLLAMA_BASE_URL is unset or blank.")
|
|
||||||
' Named volume: ollama_models — model weights, fully reproducible, no backup needed
|
|
||||||
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
|
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
|
||||||
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Backend uses a bucket-scoped service account (archiv-app), not MinIO root.")
|
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Backend uses a bucket-scoped service account (archiv-app), not MinIO root.")
|
||||||
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
|
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
|
||||||
}
|
}
|
||||||
|
|
||||||
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
|
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
|
||||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), Ollama (11434 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
|
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||||
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
|
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
|
||||||
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
|
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
|
||||||
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
|
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
|
||||||
@@ -47,8 +45,7 @@ Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
|
|||||||
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
|
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
|
||||||
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
|
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
|
||||||
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
|
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
|
||||||
Rel(backend, ollama, "NL search inference requests", "HTTP / REST / JSON")
|
|
||||||
Rel(prometheus, ollama, "Scrapes LLM request metrics", "HTTP 11434 /metrics")
|
|
||||||
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
|||||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||||
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
||||||
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
||||||
|
Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.")
|
||||||
|
Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.")
|
||||||
|
Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.")
|
||||||
|
Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.")
|
||||||
|
Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.")
|
||||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||||
@@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
|||||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||||
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
||||||
Rel(homePage, timelineFilter, "Mounts above the result list")
|
Rel(homePage, timelineFilter, "Mounts above the result list")
|
||||||
|
Rel(homePage, searchFilterBar, "Mounts the search/filter card")
|
||||||
|
Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input")
|
||||||
|
Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")
|
||||||
|
Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels")
|
||||||
|
Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal")
|
||||||
|
Rel(homePage, disambig, "Renders the picker when names are ambiguous")
|
||||||
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
||||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
|||||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
Component(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>
|
||||||
|
|||||||
808
docs/specs/lesereisen-editor-spec.html
Normal file
808
docs/specs/lesereisen-editor-spec.html
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Lesereisen — Journey-Editor · Familienarchiv</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||||
|
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||||
|
.section{margin-bottom:64px;}
|
||||||
|
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||||
|
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||||
|
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||||
|
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||||
|
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||||
|
.jh-o .jn{color:var(--orange);}
|
||||||
|
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||||
|
.scr{margin-bottom:56px;}
|
||||||
|
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||||
|
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||||
|
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||||
|
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||||
|
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||||
|
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||||
|
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||||
|
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||||
|
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||||
|
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||||
|
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.fa-link.active{color:var(--mint);}
|
||||||
|
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||||
|
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||||
|
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||||
|
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||||
|
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||||
|
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||||
|
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||||
|
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||||
|
|
||||||
|
/* ── impl-ref table ── */
|
||||||
|
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||||
|
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||||
|
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||||
|
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||||
|
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||||
|
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||||
|
.at tr:last-child td{border-bottom:none;}
|
||||||
|
.at td:first-child{color:#7A7A72;}
|
||||||
|
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||||
|
.at td:nth-child(3){color:#5A5A55;}
|
||||||
|
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||||
|
|
||||||
|
/* ── LLM guide ── */
|
||||||
|
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||||
|
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||||
|
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||||
|
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||||
|
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||||
|
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||||
|
.llm li{margin-bottom:4px;}
|
||||||
|
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||||
|
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||||
|
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.llm td{color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
/* ── Editor chrome (shared with writer spec) ── */
|
||||||
|
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||||
|
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||||
|
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||||
|
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||||
|
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||||
|
.ed-status-pub{background:var(--green-tint);color:var(--green-dark);border:1px solid #A0D8A8;}
|
||||||
|
.ed-delete-link{font-size:8px;font-weight:600;color:#DC4C3E;margin-left:8px;}
|
||||||
|
.ed-split{display:flex;flex:1;overflow:hidden;}
|
||||||
|
.ed-sidebar{width:210px;flex-shrink:0;border-left:1px solid #e4e2d7;background:#fff;display:flex;flex-direction:column;overflow-y:auto;}
|
||||||
|
.ed-sb-section{padding:12px 12px 10px;}
|
||||||
|
.ed-sb-title{font-size:8px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
|
||||||
|
.ed-sb-divider{height:1px;background:#e4e2d7;}
|
||||||
|
.ed-search-row{display:flex;align-items:center;gap:6px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px 8px;margin-bottom:6px;}
|
||||||
|
.ed-search-input{font-size:9px;color:var(--color-text-muted);}
|
||||||
|
.ed-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 7px;background:var(--sand);border:1px solid var(--color-border);border-radius:12px;font-size:8px;font-weight:500;color:var(--color-text);margin:0 4px 4px 0;}
|
||||||
|
.ed-chip-x{color:var(--color-text-muted);font-size:9px;cursor:pointer;margin-left:2px;}
|
||||||
|
.ed-hint{font-size:8px;color:var(--color-text-muted);line-height:1.5;margin-top:4px;}
|
||||||
|
.ed-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:9px 14px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;gap:10px;}
|
||||||
|
.ed-savebar-hint{font-size:8px;color:var(--color-text-muted);}
|
||||||
|
.ed-savebar-actions{display:flex;align-items:center;gap:7px;}
|
||||||
|
.ed-btn-ghost{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);border:1px solid var(--color-border);color:var(--color-text);background:#fff;cursor:pointer;white-space:nowrap;}
|
||||||
|
.ed-btn-ghost.retract{color:#B46820;border-color:#E8D5B0;}
|
||||||
|
.ed-btn-primary{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);background:var(--navy);color:#fff;border:none;cursor:pointer;white-space:nowrap;}
|
||||||
|
|
||||||
|
/* ── Journey Editor main area ── */
|
||||||
|
.je-main{flex:1;display:flex;flex-direction:column;padding:14px 16px;overflow-y:auto;gap:8px;background:var(--color-page);}
|
||||||
|
.je-title-input{font-family:var(--font-display);font-size:15px;font-weight:400;color:var(--color-text);border:none;border-bottom:1px solid var(--color-border);padding:4px 0 6px;width:100%;outline:none;background:transparent;letter-spacing:-.01em;}
|
||||||
|
.je-title-input.placeholder{color:var(--color-text-muted);font-style:italic;}
|
||||||
|
.je-sep{height:1px;background:var(--color-border);margin:2px 0;}
|
||||||
|
.je-intro-area{font-family:Georgia,serif;font-size:9px;line-height:1.7;color:var(--color-text-muted);font-style:italic;border:none;padding:5px 0;width:100%;outline:none;background:transparent;min-height:36px;resize:none;}
|
||||||
|
.je-intro-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:2px;}
|
||||||
|
.je-list-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;margin-top:4px;}
|
||||||
|
|
||||||
|
/* ── Item rows ── */
|
||||||
|
.je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:4px;margin-bottom:5px;overflow:hidden;}
|
||||||
|
.je-drag{width:16px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;}
|
||||||
|
.je-drag-dots{display:flex;flex-direction:column;gap:2px;}
|
||||||
|
.je-drag-dot{width:3px;height:3px;border-radius:50%;background:#C4C3BC;}
|
||||||
|
.je-num{width:20px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;font-size:8px;font-weight:700;color:#9B9A93;flex-shrink:0;}
|
||||||
|
.je-body{flex:1;padding:7px 8px 7px 4px;}
|
||||||
|
.je-doc-title{font-size:9px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:2px;}
|
||||||
|
.je-doc-meta{font-size:7.5px;color:var(--color-text-muted);margin-bottom:5px;}
|
||||||
|
.je-note-area{width:100%;min-height:32px;font-family:Georgia,serif;font-size:8px;line-height:1.55;color:var(--color-text);font-style:italic;border:1px solid var(--color-border);border-radius:3px;background:var(--color-surface);padding:4px 6px;resize:none;outline:none;}
|
||||||
|
.je-note-add{font-size:7.5px;font-weight:600;color:var(--blue);cursor:pointer;display:inline-flex;align-items:center;gap:2px;}
|
||||||
|
.je-remove{width:24px;display:flex;align-items:flex-start;justify-content:center;padding-top:7px;flex-shrink:0;}
|
||||||
|
.je-remove-x{font-size:11px;color:#C4C3BC;cursor:pointer;line-height:1;font-weight:300;}
|
||||||
|
.je-interlude-bg{background:var(--orange-tint);border-color:#F0C99A;}
|
||||||
|
.je-interlude-icon{font-size:8px;color:var(--orange);margin-bottom:2px;}
|
||||||
|
.je-interlude-area{width:100%;min-height:36px;font-family:Georgia,serif;font-size:8px;line-height:1.6;color:var(--color-text);font-style:italic;border:1px solid #F0C99A;border-radius:3px;background:rgba(255,255,255,.6);padding:4px 6px;resize:none;outline:none;}
|
||||||
|
.je-empty{padding:16px;text-align:center;border:1px dashed var(--color-border);border-radius:4px;background:var(--color-surface);}
|
||||||
|
.je-empty-text{font-family:Georgia,serif;font-size:8px;color:var(--color-text-muted);font-style:italic;}
|
||||||
|
|
||||||
|
/* ── Add bar ── */
|
||||||
|
.je-add-bar{display:flex;gap:7px;padding:6px 0 4px;}
|
||||||
|
.je-add-btn{font-size:8px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px dashed var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:3px;}
|
||||||
|
.je-add-btn:hover{border-color:var(--navy);color:var(--navy);}
|
||||||
|
|
||||||
|
/* ── Inline note editing state (highlight) ── */
|
||||||
|
.je-note-editing{border-color:var(--navy);background:#fff;}
|
||||||
|
|
||||||
|
/* ── Mobile journey editor ── */
|
||||||
|
.mob-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 10px;gap:6px;height:34px;flex-shrink:0;}
|
||||||
|
.mob-back{font-size:8px;color:var(--color-text-muted);}
|
||||||
|
.mob-label{font-family:var(--font-sans);font-size:9px;font-weight:500;color:var(--color-text);flex:1;}
|
||||||
|
.mob-body{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:7px;background:var(--color-page);}
|
||||||
|
.mob-title-input{font-family:var(--font-display);font-size:13px;color:var(--color-text-muted);font-style:italic;border:none;border-bottom:1px solid var(--color-border);padding:3px 0 5px;width:100%;background:transparent;outline:none;}
|
||||||
|
.mob-collapsible{background:#fff;border:1px solid #e4e2d7;border-radius:3px;overflow:hidden;}
|
||||||
|
.mob-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:7px 9px;font-size:8.5px;font-weight:600;color:var(--color-text);}
|
||||||
|
.mob-coll-chevron{font-size:9px;color:var(--color-text-muted);}
|
||||||
|
.mob-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:8px 10px;display:flex;gap:6px;flex-shrink:0;}
|
||||||
|
.mob-btn{font-size:8.5px;font-weight:600;padding:7px 0;border-radius:3px;text-align:center;flex:1;}
|
||||||
|
.mob-btn-ghost{border:1px solid var(--color-border);color:var(--color-text);background:#fff;}
|
||||||
|
.mob-btn-primary{background:var(--navy);color:#fff;border:none;}
|
||||||
|
.mob-je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:3px;margin-bottom:4px;overflow:hidden;}
|
||||||
|
.mob-je-drag{width:14px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.mob-je-body{flex:1;padding:6px 7px;}
|
||||||
|
.mob-je-title{font-size:8.5px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:1px;}
|
||||||
|
.mob-je-meta{font-size:7px;color:var(--color-text-muted);}
|
||||||
|
.mob-je-note{margin-top:4px;padding:3px 5px;background:var(--color-surface);border-left:2px solid var(--mint);font-size:7.5px;font-style:italic;color:var(--color-text-muted);}
|
||||||
|
.mob-je-interlude{background:var(--orange-tint);border-color:#F0C99A;}
|
||||||
|
.mob-je-interlude-text{font-size:7.5px;font-style:italic;color:var(--color-text);}
|
||||||
|
|
||||||
|
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- ═══ DOC HEADER ═══ -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>Lesereisen — Journey-Editor</h1>
|
||||||
|
<p>Kuratierungs-Oberfläche für <code>JourneyEditor</code> auf <code>/geschichten/[id]/edit</code> (wenn <code>type === 'JOURNEY'</code>). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
Familienarchiv<br/>
|
||||||
|
<span class="pill pill-o">Final Spec</span><br/>
|
||||||
|
2026-06-07 · @leonievoss<br/>
|
||||||
|
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #753</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||||
|
<div class="jh jh-o">
|
||||||
|
<div class="jn">E</div>
|
||||||
|
<div>
|
||||||
|
<h2>Journey-Editor</h2>
|
||||||
|
<p>BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.</p>
|
||||||
|
<div class="fl">/geschichten/[id]/edit (type === 'JOURNEY')</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ KONZEPT ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Konzept</div>
|
||||||
|
<p class="prose">Der <code>JourneyEditor</code> ist eine parallele Implementierung zum bestehenden <code>GeschichteEditor</code> und wird auf derselben Edit-Route eingeblendet wenn <code>type === 'JOURNEY'</code>. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.</p>
|
||||||
|
<p class="prose">Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (<code>body</code>), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.</p>
|
||||||
|
<p class="prose">Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.</p>
|
||||||
|
<p class="prose">Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN LE-1: EMPTY EDITOR ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Screens — Leerer Editor</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head">
|
||||||
|
<h3>LE-1 — Journey-Editor leer</h3>
|
||||||
|
<span class="scr-id">Issue #753 · LE-1</span>
|
||||||
|
</div>
|
||||||
|
<p class="scr-desc">Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).</p>
|
||||||
|
<p class="scr-var"><strong>Varianten:</strong> Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste</p>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||||
|
<span class="bp-lbl">Desktop — 1040px · Neuer Entwurf</span>
|
||||||
|
<div class="desk" style="min-height:500px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<span class="fa-logo">ARCHIV</span>
|
||||||
|
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||||
|
<span class="fa-link">Dokumente</span>
|
||||||
|
<span class="fa-link">Personen</span>
|
||||||
|
<span class="fa-link active">Geschichten</span>
|
||||||
|
<span class="fa-link">Chronik</span>
|
||||||
|
<div class="fa-nav-r">
|
||||||
|
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-topbar">
|
||||||
|
<div class="ed-back">←</div>
|
||||||
|
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||||
|
Neue Lesereise
|
||||||
|
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||||
|
</div>
|
||||||
|
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-split">
|
||||||
|
<!-- Left: Journey editor area -->
|
||||||
|
<div class="je-main">
|
||||||
|
<input class="je-title-input placeholder" type="text" value="" placeholder="Titel der Lesereise" readonly/>
|
||||||
|
<div class="je-sep"></div>
|
||||||
|
<div>
|
||||||
|
<div class="je-intro-label">Einleitung (optional)</div>
|
||||||
|
<textarea class="je-intro-area" placeholder="Optionaler Einleitungstext für diese Lesereise…" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="je-sep"></div>
|
||||||
|
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||||
|
<div class="je-empty">
|
||||||
|
<div class="je-empty-text">Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-add-bar">
|
||||||
|
<button class="je-add-btn">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
Brief hinzufügen
|
||||||
|
</button>
|
||||||
|
<button class="je-add-btn">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
Zwischentext hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right: Sidebar -->
|
||||||
|
<div class="ed-sidebar">
|
||||||
|
<div class="ed-sb-section">
|
||||||
|
<div class="ed-sb-title">Personen</div>
|
||||||
|
<div class="ed-search-row">
|
||||||
|
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||||
|
<div class="ed-search-input">Person suchen…</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-hint">Welche historischen Personen kommen in dieser Lesereise vor?</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-sb-divider"></div>
|
||||||
|
<div class="ed-sb-section">
|
||||||
|
<div class="ed-sb-title">Status</div>
|
||||||
|
<div class="ed-status-pill ed-status-draft" style="font-size:9px;">ENTWURF</div>
|
||||||
|
<div class="ed-hint" style="margin-top:6px;">Noch nicht öffentlich sichtbar. Füge mindestens einen Brief hinzu, um zu veröffentlichen.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-savebar">
|
||||||
|
<span class="ed-savebar-hint">Alle Änderungen werden als Entwurf gespeichert.</span>
|
||||||
|
<div class="ed-savebar-actions">
|
||||||
|
<button class="ed-btn-ghost">Entwurf speichern</button>
|
||||||
|
<button class="ed-btn-primary" style="opacity:.4;cursor:not-allowed;">Veröffentlichen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>impl-ref — LE-1 Leerer Editor</h4>
|
||||||
|
<table class="at">
|
||||||
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||||
|
<tr><td>Bedingte Verzweigung</td><td>{#if geschichte.type === 'JOURNEY'}<JourneyEditor />{:else}<GeschichteEditor />{/if}</td><td>in edit/+page.svelte; Props: geschichte: Geschichte</td></tr>
|
||||||
|
<tr><td>Split-Layout</td><td>flex flex-1 overflow-hidden (gleich wie GeschichteEditor)</td><td>70/30; Sidebar identisch</td></tr>
|
||||||
|
<tr><td>Topbar-Badge</td><td>„REISE" Pill neben dem Titel-Label</td><td>orange; kein interaktives Element; zeigt Typ</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Titel-Input</td></tr>
|
||||||
|
<tr><td>Titel-Input</td><td>font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none</td><td>bind:value={title}; gleiche Validierung wie GeschichteEditor (required)</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Einleitungs-Textarea</td></tr>
|
||||||
|
<tr><td>Intro-Textarea</td><td>font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1</td><td>bind:value={body}; plaintext; auto-resize per rows-attr oder JS</td></tr>
|
||||||
|
<tr><td>Label</td><td>text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1</td><td>„EINLEITUNG (OPTIONAL)"</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Leerstate</td></tr>
|
||||||
|
<tr><td>Leerstate-Container</td><td>py-8 text-center border border-dashed border-line rounded-sm bg-surface</td><td>verschwindet sobald erstes Item vorhanden</td></tr>
|
||||||
|
<tr><td>Leerstate-Text</td><td>font-serif text-xs text-ink-3 italic</td><td></td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Veröffentlichen-Button</td></tr>
|
||||||
|
<tr><td>Disabled-Zustand</td><td>disabled={items.length === 0 || !title.trim()}</td><td>opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN LE-2: EDITOR WITH ITEMS ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Screens — Editor mit Einträgen</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head">
|
||||||
|
<h3>LE-2 — Journey-Editor mit Einträgen</h3>
|
||||||
|
<span class="scr-id">Issue #753 · LE-2</span>
|
||||||
|
</div>
|
||||||
|
<p class="scr-desc">Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.</p>
|
||||||
|
<p class="scr-var"><strong>Varianten:</strong> Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile</p>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||||
|
<span class="bp-lbl">Desktop — 1040px · VERÖFFENTLICHT</span>
|
||||||
|
<div class="desk" style="min-height:580px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<span class="fa-logo">ARCHIV</span>
|
||||||
|
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||||
|
<span class="fa-link">Dokumente</span>
|
||||||
|
<span class="fa-link">Personen</span>
|
||||||
|
<span class="fa-link active">Geschichten</span>
|
||||||
|
<div class="fa-nav-r">
|
||||||
|
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">KR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-topbar">
|
||||||
|
<div class="ed-back">←</div>
|
||||||
|
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||||
|
Lesereise bearbeiten
|
||||||
|
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||||
|
</div>
|
||||||
|
<div class="ed-status-pill ed-status-pub">VERÖFFENTLICHT</div>
|
||||||
|
<span class="ed-delete-link">Löschen</span>
|
||||||
|
</div>
|
||||||
|
<div class="ed-split">
|
||||||
|
<!-- Left: Journey editor area with items -->
|
||||||
|
<div class="je-main">
|
||||||
|
<input class="je-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||||
|
<div class="je-sep"></div>
|
||||||
|
<div>
|
||||||
|
<div class="je-intro-label">Einleitung (optional)</div>
|
||||||
|
<textarea class="je-intro-area" readonly style="color:var(--color-text);">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="je-sep"></div>
|
||||||
|
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||||
|
|
||||||
|
<!-- Item 1: Document, no note -->
|
||||||
|
<div class="je-item">
|
||||||
|
<div class="je-drag">
|
||||||
|
<div class="je-drag-dots">
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-num">1</div>
|
||||||
|
<div class="je-body">
|
||||||
|
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||||
|
<div class="je-doc-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||||
|
<div class="je-note-add">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
Notiz hinzufügen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item 2: Interlude -->
|
||||||
|
<div class="je-item je-interlude-bg">
|
||||||
|
<div class="je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;">
|
||||||
|
<div class="je-drag-dots">
|
||||||
|
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||||
|
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-num" style="color:var(--orange-dark);">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style="margin-top:7px;"><path d="M2 4h8M2 7h5" stroke="var(--orange)" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="je-body" style="padding-top:6px;">
|
||||||
|
<div style="font-size:7.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:4px;">Zwischentext</div>
|
||||||
|
<textarea class="je-interlude-area" readonly>Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="je-remove"><div class="je-remove-x" style="color:#D4A574;">×</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item 3: Document with note -->
|
||||||
|
<div class="je-item">
|
||||||
|
<div class="je-drag">
|
||||||
|
<div class="je-drag-dots">
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-num">2</div>
|
||||||
|
<div class="je-body">
|
||||||
|
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||||
|
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||||
|
<textarea class="je-note-area" readonly>Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?</textarea>
|
||||||
|
<div class="je-note-add" style="margin-top:3px;color:var(--color-text-muted);">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
Notiz entfernen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item 4: Document, no note -->
|
||||||
|
<div class="je-item">
|
||||||
|
<div class="je-drag">
|
||||||
|
<div class="je-drag-dots">
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-num">3</div>
|
||||||
|
<div class="je-body">
|
||||||
|
<div class="je-doc-title">Brief vom 3. September 1939</div>
|
||||||
|
<div class="je-doc-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||||
|
<div class="je-note-add">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
Notiz hinzufügen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add bar -->
|
||||||
|
<div class="je-add-bar">
|
||||||
|
<button class="je-add-btn">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
Brief hinzufügen
|
||||||
|
</button>
|
||||||
|
<button class="je-add-btn">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
Zwischentext hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Sidebar -->
|
||||||
|
<div class="ed-sidebar">
|
||||||
|
<div class="ed-sb-section">
|
||||||
|
<div class="ed-sb-title">Personen</div>
|
||||||
|
<div class="ed-search-row">
|
||||||
|
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||||
|
<div class="ed-search-input">Person suchen…</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;margin-top:4px;">
|
||||||
|
<span class="ed-chip">
|
||||||
|
<span style="width:10px;height:10px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);">FR</span>
|
||||||
|
Franz Raddatz
|
||||||
|
<span class="ed-chip-x">×</span>
|
||||||
|
</span>
|
||||||
|
<span class="ed-chip">
|
||||||
|
<span style="width:10px;height:10px;border-radius:50%;background:#534AB7;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;">EM</span>
|
||||||
|
Emma Müller
|
||||||
|
<span class="ed-chip-x">×</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-sb-divider"></div>
|
||||||
|
<div class="ed-sb-section">
|
||||||
|
<div class="ed-sb-title">Status</div>
|
||||||
|
<div class="ed-status-pill ed-status-pub" style="font-size:9px;">VERÖFFENTLICHT</div>
|
||||||
|
<div class="ed-hint" style="margin-top:6px;">Änderungen gehen sofort live.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-savebar">
|
||||||
|
<span class="ed-savebar-hint">Änderungen sofort live — Leser sehen die aktuelle Version.</span>
|
||||||
|
<div class="ed-savebar-actions">
|
||||||
|
<button class="ed-btn-ghost retract">Zurück zu Entwurf</button>
|
||||||
|
<button class="ed-btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>impl-ref — LE-2 Items-Liste</h4>
|
||||||
|
<table class="at">
|
||||||
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
||||||
|
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: <del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> / <code>--color-interlude-border</code> CSS tokens</td></tr>
|
||||||
|
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
||||||
|
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
||||||
|
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||||
|
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||||
|
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
||||||
|
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
||||||
|
<tr><td>„Notiz hinzufügen" Link</td><td><del>text-xs font-semibold text-blue-600</del> → <code>text-xs text-ink-3 underline hover:text-accent</code></td><td>togglet Notiz-Textarea</td></tr>
|
||||||
|
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||||
|
<tr><td>Interlude-Container</td><td><del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> left-accent border via <code>--color-interlude-border</code></td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||||
|
<tr><td>Label „Zwischentext"</td><td><del>text-orange-700</del> → <code>color: var(--color-interlude-label)</code></td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||||
|
<tr><td>Zwischentext-Textarea</td><td><del>border-orange-200 focus:border-orange-400</del> → <code>border-line focus-visible:ring-focus-ring</code></td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
||||||
|
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
||||||
|
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
||||||
|
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
||||||
|
<tr><td>Bibliothek</td><td><del>@dnd-kit/core oder svelte-dnd-action</del> → <code>createBlockDragDrop<JourneyItemView></code> aus <code>$lib/document/transcription/useBlockDragDrop.svelte</code></td><td>kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt</td></tr>
|
||||||
|
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
||||||
|
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN LE-3: INLINE NOTE EDITING ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Screens — Inline-Notiz-Editing</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head">
|
||||||
|
<h3>LE-3 — Notiz-Textarea wird geöffnet</h3>
|
||||||
|
<span class="scr-id">Issue #753 · LE-3</span>
|
||||||
|
</div>
|
||||||
|
<p class="scr-desc">Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.</p>
|
||||||
|
<p class="scr-var"><strong>Inset-Ansicht — kein vollständiger Seiten-Mockup nötig</strong></p>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col" style="width:100%;max-width:560px;">
|
||||||
|
<span class="bp-lbl">Inset — Notiz-Textarea geöffnet (Fokus)</span>
|
||||||
|
<div style="background:#E8E7E2;padding:16px;border-radius:var(--radius-xl);">
|
||||||
|
<!-- Item before (no note) -->
|
||||||
|
<div class="je-item" style="margin-bottom:5px;">
|
||||||
|
<div class="je-drag">
|
||||||
|
<div class="je-drag-dots">
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-num">1</div>
|
||||||
|
<div class="je-body">
|
||||||
|
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||||
|
<div class="je-doc-meta">12. Juli 1938 · Franz → Emma</div>
|
||||||
|
<div class="je-note-add">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
Notiz hinzufügen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item with opened note textarea (focused) -->
|
||||||
|
<div class="je-item">
|
||||||
|
<div class="je-drag">
|
||||||
|
<div class="je-drag-dots">
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-num">2</div>
|
||||||
|
<div class="je-body">
|
||||||
|
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||||
|
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · Franz → Emma</div>
|
||||||
|
<!-- Focused textarea -->
|
||||||
|
<textarea class="je-note-area je-note-editing" style="outline:none;box-shadow:0 0 0 2px rgba(1,40,81,.2);" readonly placeholder="Kuratoren-Notiz für diesen Brief…">|</textarea>
|
||||||
|
<div style="font-size:7px;color:var(--color-text-muted);margin-top:3px;">Wird gespeichert, wenn du das Feld verlässt.</div>
|
||||||
|
<div class="je-note-add" style="color:var(--color-text-muted);margin-top:2px;">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
Notiz entfernen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>impl-ref — LE-3 Inline-Notiz</h4>
|
||||||
|
<table class="at">
|
||||||
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Toggleverhalten</td></tr>
|
||||||
|
<tr><td>Lokaler State</td><td>let noteOpen = item.note !== null and item.note !== ''</td><td>öffnet sich automatisch wenn Notiz bereits vorhanden</td></tr>
|
||||||
|
<tr><td>„Notiz hinzufügen" Klick</td><td>noteOpen = true; tick().then(() => noteTextarea.focus())</td><td>Fokus nach Svelte-Tick um DOM-Update abzuwarten</td></tr>
|
||||||
|
<tr><td>Textarea blur-Handler</td><td>on:blur={() => saveNote(item.id, note)}</td><td>PATCH /api/geschichten/{id}/items/{itemId} mit {note}</td></tr>
|
||||||
|
<tr><td>Leere Notiz on blur</td><td>wenn note.trim() === '' → noteOpen = false; note = null</td><td>verhindert leere Notizen im Backend</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Fokus-Styling</td></tr>
|
||||||
|
<tr><td>Fokus-Ring</td><td>focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white</td><td>sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand</td></tr>
|
||||||
|
<tr><td>Spar-Hint</td><td>text-[9px] text-ink-3 mt-1</td><td>„Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Barrierefreiheit</td></tr>
|
||||||
|
<tr><td>aria-label Textarea</td><td>aria-label="Kuratoren-Notiz für {document.title}"</td><td>spezifisch; Screen-Reader nennt Brief-Kontext</td></tr>
|
||||||
|
<tr><td>aria-expanded Toggle</td><td>aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button</td><td>kommuniziert Expand-State</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN LE-4: MOBILE ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Screens — Mobile Editor</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head">
|
||||||
|
<h3>LE-4 — Mobile Journey-Editor</h3>
|
||||||
|
<span class="scr-id">Issue #753 · LE-4</span>
|
||||||
|
</div>
|
||||||
|
<p class="scr-desc">Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.</p>
|
||||||
|
<p class="scr-var"><strong>Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.</strong></p>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<span class="bp-lbl">Mobile — 320px · mit Einträgen</span>
|
||||||
|
<div class="phone" style="min-height:580px;">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="m-nav">
|
||||||
|
<span class="m-logo">ARCHIV</span>
|
||||||
|
<div class="m-nav-r">
|
||||||
|
<div class="m-av">KR</div>
|
||||||
|
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mob-topbar">
|
||||||
|
<span class="mob-back">←</span>
|
||||||
|
<span class="mob-label">Lesereise bearbeiten</span>
|
||||||
|
<div class="ed-status-pill ed-status-pub" style="font-size:7px;padding:1px 5px;">VERÖFF.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mob-body">
|
||||||
|
<input class="mob-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||||
|
|
||||||
|
<!-- Item 1: Document -->
|
||||||
|
<div class="mob-je-item">
|
||||||
|
<div class="mob-je-drag">
|
||||||
|
<div class="je-drag-dots">
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mob-je-body">
|
||||||
|
<div class="mob-je-title">Brief vom 12. Juli 1938</div>
|
||||||
|
<div class="mob-je-meta">12. Juli 1938 · Franz → Emma</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item 2: Interlude -->
|
||||||
|
<div class="mob-je-item mob-je-interlude">
|
||||||
|
<div class="mob-je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;"></div>
|
||||||
|
<div class="mob-je-body">
|
||||||
|
<div style="font-size:6.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:3px;">Zwischentext</div>
|
||||||
|
<div class="mob-je-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht…</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 6px 0 0;font-size:10px;color:#D4A574;">×</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item 3: Document with note -->
|
||||||
|
<div class="mob-je-item">
|
||||||
|
<div class="mob-je-drag"></div>
|
||||||
|
<div class="mob-je-body">
|
||||||
|
<div class="mob-je-title">Postkarte Aug. 1938</div>
|
||||||
|
<div class="mob-je-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||||
|
<div class="mob-je-note">Diese Karte ist ungewöhnlich kurz für Franz…</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add bar -->
|
||||||
|
<div style="display:flex;gap:5px;padding:4px 0;">
|
||||||
|
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Brief</button>
|
||||||
|
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Zwischentext</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible: Personen -->
|
||||||
|
<div class="mob-collapsible">
|
||||||
|
<div class="mob-coll-hdr">
|
||||||
|
Personen
|
||||||
|
<span class="mob-coll-chevron">›</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Collapsible: Status -->
|
||||||
|
<div class="mob-collapsible">
|
||||||
|
<div class="mob-coll-hdr">
|
||||||
|
Status & Speichern
|
||||||
|
<span class="mob-coll-chevron">›</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mob-savebar">
|
||||||
|
<button class="mob-btn mob-btn-ghost" style="font-size:8px;flex:0 0 auto;padding:7px 10px;">Entwurf</button>
|
||||||
|
<button class="mob-btn mob-btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>impl-ref — LE-4 Mobile</h4>
|
||||||
|
<table class="at">
|
||||||
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Layout-Anpassungen</td></tr>
|
||||||
|
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||||
|
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||||
|
<tr><td>Drag auf Mobile</td><td>Move-Up/Down Buttons statt Drag (44px touch targets)</td><td><del>dnd-kit unterstützt Touch nativ</del> → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons</td></tr>
|
||||||
|
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
||||||
|
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
||||||
|
<tr><td>Savebar Mobile</td><td>flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf"</td><td>Volltext passt nicht auf 320px</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||||
|
<div class="llm">
|
||||||
|
<h2>Implementation Guide — Journey-Editor</h2>
|
||||||
|
|
||||||
|
<h3>Neue Komponente</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Datei</th><th>Typ</th><th>Beschreibung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>src/lib/geschichte/JourneyEditor.svelte</code></td><td>Svelte-Komponente</td><td>Hauptkomponente; Props: <code>geschichte: Geschichte</code></td></tr>
|
||||||
|
<tr><td><code>src/lib/geschichte/JourneyItemRow.svelte</code></td><td>Svelte-Komponente</td><td>Eine Zeile (Dokument oder Interlude); Props: <code>item: JourneyItem, position: number</code>, Events: <code>remove, noteChange</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Edit-Page-Integration</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>GeschichteEditor.svelte</code> erhält ein neues Prop <code>type: GeschichteType</code>.</li>
|
||||||
|
<li>Wenn <code>type === 'JOURNEY'</code>: rendere <code>JourneyEditor</code> statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.</li>
|
||||||
|
<li>Die Savebar-Logik ist in der Edit-Page (<code>+page.svelte</code>) verankert — <code>JourneyEditor</code> gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>API-Calls</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Aktion</th><th>Endpoint</th><th>Body</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Brief hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{documentId: UUID}</code></td></tr>
|
||||||
|
<tr><td>Zwischentext hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{note: string}</code></td></tr>
|
||||||
|
<tr><td>Notiz speichern/bearbeiten</td><td><code>PATCH /api/geschichten/{id}/items/{itemId}</code></td><td><code>{note: string | null}</code></td></tr>
|
||||||
|
<tr><td>Item entfernen</td><td><code>DELETE /api/geschichten/{id}/items/{itemId}</code></td><td>—</td></tr>
|
||||||
|
<tr><td>Reihenfolge speichern</td><td><code>PUT /api/geschichten/{id}/items/reorder</code></td><td><code>[{id: UUID, position: number}]</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Optimistische Updates</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State <em>sofort</em>, der API-Call läuft parallel.</li>
|
||||||
|
<li>Bei Fehler: lokalen State zurückrollen und einen <code>aria-live="polite"</code>-Fehlerhinweis anzeigen.</li>
|
||||||
|
<li>Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>DocumentPicker-Integration</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Der „Brief hinzufügen"-Button öffnet die bestehende <code>DocumentPicker</code>-Komponente (prüfe <code>$lib/document/</code> auf vorhandene Typeahead-Komponenten).</li>
|
||||||
|
<li>Nach Auswahl eines Dokuments: <code>POST /items</code> mit <code>documentId</code>, neues Item wird an das Ende der Liste angehängt und eingeblendet.</li>
|
||||||
|
<li>Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Drag-to-Reorder</h3>
|
||||||
|
<ul>
|
||||||
|
<li><del>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist.</del> → Implementiert mit <code>createBlockDragDrop<JourneyItemView></code> (kein externes Package).</li>
|
||||||
|
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
||||||
|
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Barrierefreiheit</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Items-Liste: <code><ol></code>-Element — kommuniziert die Ordnung an Screenreader.</li>
|
||||||
|
<li>Drag-Handle: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Reihenfolge von '{title}' ändern"</code>.</li>
|
||||||
|
<li>Entfernen-Button: <code>aria-label="'{title}' entfernen"</code>; kein reines ×-Zeichen ohne Label.</li>
|
||||||
|
<li>Notiz-Textarea: <code>aria-label="Kuratoren-Notiz für '{title}'"</code>.</li>
|
||||||
|
<li>Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).</li>
|
||||||
|
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Buttons und Textareas.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Abgrenzung zu GeschichteEditor</h3>
|
||||||
|
<ul>
|
||||||
|
<li>TipTap wird für JOURNEY <em>nicht</em> geladen — kein unnötiger Bundle-Load.</li>
|
||||||
|
<li>Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.</li>
|
||||||
|
<li>Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.</li>
|
||||||
|
<li><code>Geschichte.body</code> dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
728
docs/specs/lesereisen-reader-spec.html
Normal file
728
docs/specs/lesereisen-reader-spec.html
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Lesereisen — Reader-Integration · Familienarchiv</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||||
|
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||||
|
.section{margin-bottom:64px;}
|
||||||
|
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||||
|
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||||
|
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||||
|
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||||
|
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||||
|
.jh-o .jn{color:var(--orange);}
|
||||||
|
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||||
|
.scr{margin-bottom:56px;}
|
||||||
|
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||||
|
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||||
|
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||||
|
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||||
|
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||||
|
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||||
|
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||||
|
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||||
|
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||||
|
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||||
|
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.fa-link.active{color:var(--mint);}
|
||||||
|
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||||
|
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||||
|
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||||
|
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||||
|
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||||
|
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||||
|
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||||
|
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||||
|
|
||||||
|
/* ── impl-ref table ── */
|
||||||
|
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||||
|
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||||
|
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||||
|
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||||
|
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||||
|
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||||
|
.at tr:last-child td{border-bottom:none;}
|
||||||
|
.at td:first-child{color:#7A7A72;}
|
||||||
|
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||||
|
.at td:nth-child(3){color:#5A5A55;}
|
||||||
|
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||||
|
|
||||||
|
/* ── LLM guide ── */
|
||||||
|
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||||
|
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||||
|
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||||
|
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||||
|
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||||
|
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||||
|
.llm li{margin-bottom:4px;}
|
||||||
|
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||||
|
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||||
|
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.llm td{color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
/* ── List row (re-used from reader-journey spec) ── */
|
||||||
|
.g-list-card{background:#fff;border:1px solid #E4E2D7;border-radius:4px;box-shadow:var(--shadow-card);overflow:hidden;}
|
||||||
|
.g-row{display:flex;gap:0;border-bottom:1px solid #F0EFE9;}
|
||||||
|
.g-row:last-child{border-bottom:none;}
|
||||||
|
.g-meta{width:88px;flex-shrink:0;padding:10px 10px 10px 12px;display:flex;flex-direction:column;gap:3px;border-right:1px solid #F0EFE9;}
|
||||||
|
.g-content{padding:10px 14px 10px 12px;flex:1;min-width:0;}
|
||||||
|
.g-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;margin-bottom:3px;}
|
||||||
|
.av-navy{background:#012851;} .av-purple{background:#534AB7;} .av-teal{background:#0E9488;}
|
||||||
|
.g-author{font-size:7px;font-weight:700;color:#1C1C18;line-height:1.3;}
|
||||||
|
.g-date{font-size:6.5px;color:#6B6A63;}
|
||||||
|
.g-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;background:#F5F4EE;border:1px solid #D8D7D0;border-radius:10px;font-size:6px;font-weight:500;color:#1C1C18;margin-top:2px;max-width:76px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.g-title{font-family:Georgia,serif;font-size:11px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||||
|
.g-excerpt{font-size:7.5px;color:#6B6A63;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||||
|
.g-filters{display:flex;gap:5px;align-items:center;padding:8px 12px;background:var(--color-page);border-bottom:1px solid #EDECEA;flex-wrap:wrap;}
|
||||||
|
.g-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:6.5px;font-weight:700;border:1px solid #D8D7D0;color:#6B6A63;background:transparent;}
|
||||||
|
.g-pill.active{background:#012851;color:#fff;border-color:#012851;}
|
||||||
|
.g-page-hdr{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px;}
|
||||||
|
.g-page-title{font-family:Georgia,serif;font-size:16px;font-weight:400;color:#012851;}
|
||||||
|
.g-new-btn{font-size:7px;font-weight:700;padding:4px 10px;border-radius:3px;background:#012851;color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||||
|
|
||||||
|
/* ── Journey badge in list ── */
|
||||||
|
.j-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-top:2px;}
|
||||||
|
|
||||||
|
/* ── Type selector cards ── */
|
||||||
|
.type-selector{display:flex;gap:12px;justify-content:center;padding:20px 24px;flex:1;align-items:center;background:#E8E7E2;}
|
||||||
|
.type-selector-inner{max-width:520px;width:100%;}
|
||||||
|
.type-selector-q{font-family:Georgia,serif;font-size:12px;font-weight:400;color:#6B6A63;text-align:center;margin-bottom:14px;}
|
||||||
|
.type-cards{display:flex;gap:10px;}
|
||||||
|
.type-card{flex:1;border:1px solid #D8D7D0;border-radius:6px;padding:12px 14px;cursor:pointer;background:#fff;display:flex;flex-direction:column;gap:5px;}
|
||||||
|
.type-card.selected{border-color:var(--orange);background:var(--orange-tint);box-shadow:0 0 0 2px rgba(232,134,42,.15);}
|
||||||
|
.type-card-icon{font-size:16px;margin-bottom:2px;}
|
||||||
|
.type-card-title{font-family:Georgia,serif;font-size:11px;font-weight:400;color:var(--navy);}
|
||||||
|
.type-card-desc{font-size:7.5px;color:#6B6A63;line-height:1.55;}
|
||||||
|
.type-card-check{width:14px;height:14px;border-radius:50%;background:var(--orange);display:flex;align-items:center;justify-content:center;margin-top:4px;align-self:flex-end;}
|
||||||
|
.type-card-check svg{width:8px;height:8px;}
|
||||||
|
.type-next-bar{display:flex;justify-content:flex-end;padding:8px 24px;background:#fff;border-top:1px solid #E4E2D7;}
|
||||||
|
.type-next-btn{font-size:8px;font-weight:700;padding:5px 14px;border-radius:3px;background:var(--navy);color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||||
|
|
||||||
|
/* ── Journey reader ── */
|
||||||
|
.jr-article{background:var(--color-page);border-radius:6px;padding:16px 20px;max-width:640px;margin:0 auto;}
|
||||||
|
.jr-back{font-size:7px;color:#6B6A63;margin-bottom:10px;display:flex;align-items:center;gap:2px;}
|
||||||
|
.jr-badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:5px;}
|
||||||
|
.jr-title{font-family:Georgia,serif;font-size:18px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:8px;}
|
||||||
|
.jr-metabar{display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid #EDECEA;margin-bottom:10px;}
|
||||||
|
.jr-metabar-r{margin-left:auto;display:flex;align-items:center;gap:6px;}
|
||||||
|
.jr-edit-btn{font-size:6.5px;font-weight:600;padding:2px 7px;border:1px solid #D8D7D0;border-radius:3px;color:#1C1C18;background:transparent;}
|
||||||
|
.jr-intro{font-family:Georgia,serif;font-size:8.5px;line-height:1.75;color:#6B6A63;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed #EDECEA;}
|
||||||
|
|
||||||
|
/* Journey items in reader */
|
||||||
|
.jr-item{display:flex;gap:7px;margin-bottom:9px;align-items:flex-start;}
|
||||||
|
.jr-num{width:18px;height:18px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||||
|
.jr-card{flex:1;background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:7px 9px;}
|
||||||
|
.jr-card-title{font-family:Georgia,serif;font-size:9px;color:#012851;line-height:1.3;margin-bottom:2px;font-weight:400;}
|
||||||
|
.jr-card-meta{font-size:6.5px;color:#6B6A63;margin-bottom:5px;}
|
||||||
|
.jr-card-link{font-size:7px;font-weight:600;color:#012851;display:flex;align-items:center;gap:2px;}
|
||||||
|
.jr-annotation{margin-top:6px;padding:5px 7px;border-left:2px solid var(--mint);background:#F5F4EE;border-radius:0 3px 3px 0;}
|
||||||
|
.jr-annotation-text{font-size:7.5px;font-style:italic;color:#6B6A63;line-height:1.55;}
|
||||||
|
.jr-interlude{margin:10px 0 10px 25px;padding:7px 9px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 4px 4px 0;}
|
||||||
|
.jr-interlude-text{font-size:8px;font-style:italic;color:#1C1C18;line-height:1.65;}
|
||||||
|
|
||||||
|
/* Mobile list row */
|
||||||
|
.m-row{padding:9px 10px;border-bottom:1px solid #F0EFE9;background:#fff;}
|
||||||
|
.m-row-top{display:flex;align-items:center;gap:5px;margin-bottom:3px;}
|
||||||
|
.m-author-name{font-size:7px;font-weight:700;color:#1C1C18;}
|
||||||
|
.m-date{font-size:6.5px;color:#6B6A63;margin-left:auto;}
|
||||||
|
.m-title{font-family:Georgia,serif;font-size:10px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||||
|
.m-excerpt{font-size:7px;color:#6B6A63;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||||
|
.m-filters{display:flex;gap:4px;padding:6px 10px;background:var(--color-page);border-bottom:1px solid #EDECEA;overflow-x:auto;flex-wrap:nowrap;}
|
||||||
|
.m-filters::-webkit-scrollbar{display:none;}
|
||||||
|
|
||||||
|
/* Mobile journey reader */
|
||||||
|
.mjr-article{background:#fff;border-radius:6px;padding:12px 12px 16px;}
|
||||||
|
.mjr-back{font-size:7px;color:#6B6A63;margin-bottom:7px;display:flex;align-items:center;gap:2px;}
|
||||||
|
.mjr-badge{display:inline-flex;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:4px;}
|
||||||
|
.mjr-title{font-family:Georgia,serif;font-size:14px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:6px;}
|
||||||
|
.mjr-metabar{display:flex;align-items:center;gap:5px;padding-bottom:6px;border-bottom:1px solid #EDECEA;margin-bottom:8px;}
|
||||||
|
.mjr-intro{font-family:Georgia,serif;font-size:8px;line-height:1.7;color:#6B6A63;font-style:italic;margin-bottom:9px;padding-bottom:7px;border-bottom:1px dashed #EDECEA;}
|
||||||
|
.mjr-item{display:flex;gap:5px;margin-bottom:7px;align-items:flex-start;}
|
||||||
|
.mjr-num{width:14px;height:14px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||||
|
.mjr-card{flex:1;background:#F5F4EE;border:1px solid #E4E2D7;border-radius:4px;padding:5px 7px;}
|
||||||
|
.mjr-card-title{font-family:Georgia,serif;font-size:8.5px;color:#012851;line-height:1.3;margin-bottom:1px;}
|
||||||
|
.mjr-card-meta{font-size:6px;color:#6B6A63;margin-bottom:4px;}
|
||||||
|
.mjr-card-link{font-size:6.5px;font-weight:600;color:#012851;}
|
||||||
|
.mjr-interlude{margin:7px 0 7px 19px;padding:5px 7px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 3px 3px 0;}
|
||||||
|
.mjr-interlude-text{font-size:7.5px;font-style:italic;color:#1C1C18;line-height:1.6;}
|
||||||
|
|
||||||
|
/* ── Editor topbar (type selector screen) ── */
|
||||||
|
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||||
|
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||||
|
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||||
|
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||||
|
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||||
|
|
||||||
|
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- ═══ DOC HEADER ═══ -->
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>Lesereisen — Reader-Integration</h1>
|
||||||
|
<p>Typauswahl bei <code>/geschichten/new</code>, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf <code>/geschichten/[id]</code> wenn <code>type === 'JOURNEY'</code>. Bestehende Story-Ansichten bleiben unverändert.</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
Familienarchiv<br/>
|
||||||
|
<span class="pill pill-o">Final Spec</span><br/>
|
||||||
|
2026-06-07 · @leonievoss<br/>
|
||||||
|
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #752</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||||
|
<div class="jh jh-o">
|
||||||
|
<div class="jn">R</div>
|
||||||
|
<div>
|
||||||
|
<h2>Lesereisen — Reader</h2>
|
||||||
|
<p>Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.</p>
|
||||||
|
<div class="fl">/geschichten · /geschichten/new · /geschichten/[id]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ KONZEPT ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Konzept</div>
|
||||||
|
<p class="prose">Eine <em>Lesereise</em> ist eine <code>Geschichte</code> mit <code>type === 'JOURNEY'</code>. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (<code>JourneyItem</code>s mit <code>document_id</code>) und Zwischentexten (<code>JourneyItem</code>s ohne <code>document_id</code>). Das optionale Feld <code>body</code> dient als Einleitung/Preface.</p>
|
||||||
|
<p class="prose">Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf <code>/geschichten/new</code> als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.</p>
|
||||||
|
<p class="prose">Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN LR-0: TYPE SELECTOR ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Screens — Typauswahl</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head">
|
||||||
|
<h3>LR-0 — Typauswahl /geschichten/new</h3>
|
||||||
|
<span class="scr-id">Issue #752 · LR-0</span>
|
||||||
|
</div>
|
||||||
|
<p class="scr-desc">Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (<code>?type=JOURNEY</code>).</p>
|
||||||
|
<p class="scr-var"><strong>Varianten:</strong> Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt</p>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||||
|
<span class="bp-lbl">Desktop — 1040px · Lesereise gewählt</span>
|
||||||
|
<div class="desk" style="min-height:320px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<span class="fa-logo">ARCHIV</span>
|
||||||
|
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||||
|
<span class="fa-link">Dokumente</span>
|
||||||
|
<span class="fa-link">Personen</span>
|
||||||
|
<span class="fa-link active">Geschichten</span>
|
||||||
|
<span class="fa-link">Chronik</span>
|
||||||
|
<div class="fa-nav-r">
|
||||||
|
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ed-topbar">
|
||||||
|
<div class="ed-back">←</div>
|
||||||
|
<div class="ed-title-label">Neue Geschichte</div>
|
||||||
|
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-selector">
|
||||||
|
<div class="type-selector-inner">
|
||||||
|
<div class="type-selector-q">Was möchtest du erstellen?</div>
|
||||||
|
<div class="type-cards">
|
||||||
|
<!-- Story card -->
|
||||||
|
<div class="type-card">
|
||||||
|
<div class="type-card-icon">✍️</div>
|
||||||
|
<div class="type-card-title">Geschichte</div>
|
||||||
|
<div class="type-card-desc">Freier Prosatext über Familienerlebnisse, Erinnerungen oder historische Einordnungen — mit verlinkten Personen und Dokumenten.</div>
|
||||||
|
</div>
|
||||||
|
<!-- Journey card (selected) -->
|
||||||
|
<div class="type-card selected">
|
||||||
|
<div class="type-card-icon">📜</div>
|
||||||
|
<div class="type-card-title">Lesereise</div>
|
||||||
|
<div class="type-card-desc">Geordnete Briefsequenz mit optionalen Kuratoren-Notizen zwischen den Briefen — für chronologische Korrespondenz-Sammlungen.</div>
|
||||||
|
<div class="type-card-check">
|
||||||
|
<svg viewBox="0 0 10 10" fill="none"><path d="M2 5l2.5 2.5L8 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-next-bar">
|
||||||
|
<button class="type-next-btn">
|
||||||
|
Weiter
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>impl-ref — LR-0 Typauswahl</h4>
|
||||||
|
<table class="at">
|
||||||
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Layout</td></tr>
|
||||||
|
<tr><td>Selector area</td><td>flex flex-1 items-center justify-center bg-canvas px-6 py-10</td><td>zentriert, füllt restliche Höhe</td></tr>
|
||||||
|
<tr><td>Frage</td><td>font-serif text-sm text-ink-2 text-center mb-4</td><td></td></tr>
|
||||||
|
<tr><td>Karten-Grid</td><td>flex gap-4</td><td>2 gleich breite Karten; auf Mobile flex-col</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Type-Karte</td></tr>
|
||||||
|
<tr><td>Karte (inaktiv)</td><td>border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface</td><td>focus-visible:ring-2 focus-visible:ring-primary</td></tr>
|
||||||
|
<tr><td>Karte (ausgewählt)</td><td>border-2 border-orange-500 bg-orange-50 shadow-sm</td><td>aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange)</td></tr>
|
||||||
|
<tr><td>Check-Kreis</td><td>w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2</td><td>nur sichtbar wenn ausgewählt</td></tr>
|
||||||
|
<tr><td>Kartentitel</td><td>font-serif text-sm text-ink</td><td></td></tr>
|
||||||
|
<tr><td>Kartenbeschreibung</td><td>text-xs text-ink-3 leading-relaxed mt-1</td><td></td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Navigation</td></tr>
|
||||||
|
<tr><td>Weiter-Button</td><td>rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40</td><td>disabled wenn keine Karte ausgewählt</td></tr>
|
||||||
|
<tr><td>URL-Param</td><td>?type=STORY | ?type=JOURNEY</td><td>per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar</td></tr>
|
||||||
|
<tr><td>Mobile</td><td>flex-col Karten; volle Breite</td><td>kein Scrollbedarf auf 320px</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN LR-1: LIST WITH BADGE ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Screens — Übersichtsliste</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head">
|
||||||
|
<h3>LR-1 — Reise-Badge in /geschichten</h3>
|
||||||
|
<span class="scr-id">Issue #752 · LR-1</span>
|
||||||
|
</div>
|
||||||
|
<p class="scr-desc">Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit <code>type === 'STORY'</code> bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.</p>
|
||||||
|
<p class="scr-var"><strong>Varianten:</strong> Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)</p>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<!-- Desktop -->
|
||||||
|
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||||
|
<span class="bp-lbl">Desktop — 1040px · gemischte Liste</span>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<span class="fa-logo">ARCHIV</span>
|
||||||
|
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||||
|
<span class="fa-link">Dokumente</span>
|
||||||
|
<span class="fa-link">Personen</span>
|
||||||
|
<span class="fa-link active">Geschichten</span>
|
||||||
|
<span class="fa-link">Chronik</span>
|
||||||
|
<div class="fa-nav-r">
|
||||||
|
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#E8E7E2;flex:1;padding:14px 16px;">
|
||||||
|
<div class="g-page-hdr" style="padding:0 0 8px;">
|
||||||
|
<span class="g-page-title">Geschichten</span>
|
||||||
|
<button class="g-new-btn">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
Neue Geschichte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="g-list-card">
|
||||||
|
<div class="g-filters">
|
||||||
|
<span class="g-pill active">Alle</span>
|
||||||
|
<span class="g-pill">Franz Raddatz</span>
|
||||||
|
<span class="g-pill">Emma Müller</span>
|
||||||
|
<span class="g-pill" style="border-style:dashed;color:#6B6A63;">+ Person wählen</span>
|
||||||
|
</div>
|
||||||
|
<!-- Row 1: Story (no badge) -->
|
||||||
|
<div class="g-row">
|
||||||
|
<div class="g-meta">
|
||||||
|
<div class="g-av av-navy">MR</div>
|
||||||
|
<div class="g-author">Maria Raddatz</div>
|
||||||
|
<div class="g-date">14. März 2025</div>
|
||||||
|
<span class="g-chip">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||||
|
Franz Raddatz
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="g-content">
|
||||||
|
<div class="g-title">Der Sommer in Breslau</div>
|
||||||
|
<div class="g-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg, als die Familie noch vollständig zusammen war und niemand ahnte, was kommen würde…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Row 2: Journey (badge!) -->
|
||||||
|
<div class="g-row">
|
||||||
|
<div class="g-meta">
|
||||||
|
<div class="g-av av-purple">KR</div>
|
||||||
|
<div class="g-author">Klaus Raddatz</div>
|
||||||
|
<div class="g-date">15. Mai 2025</div>
|
||||||
|
<span class="g-chip">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||||
|
Franz Raddatz
|
||||||
|
</span>
|
||||||
|
<span class="j-badge">REISE</span>
|
||||||
|
</div>
|
||||||
|
<div class="g-content">
|
||||||
|
<div class="g-title">Briefe aus Breslau 1938–1942</div>
|
||||||
|
<div class="g-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma — von den letzten Friedenssommern bis zum Ende des Krieges.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Row 3: Story -->
|
||||||
|
<div class="g-row">
|
||||||
|
<div class="g-meta">
|
||||||
|
<div class="g-av av-teal">GK</div>
|
||||||
|
<div class="g-author">Gertrud Koch</div>
|
||||||
|
<div class="g-date">18. Okt. 2024</div>
|
||||||
|
<span class="g-chip">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:#534AB7;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:#fff;flex-shrink:0;">EM</span>
|
||||||
|
Emma Müller
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="g-content">
|
||||||
|
<div class="g-title">Die Hochzeit im Krieg</div>
|
||||||
|
<div class="g-excerpt">1943, mitten im Chaos — Emma bestand darauf, dass das Fest stattfand. Ihr Bruder kam auf Fronturlaub, drei Tage nur, aber es reichte…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile -->
|
||||||
|
<div class="prev-col">
|
||||||
|
<span class="bp-lbl">Mobile — 320px</span>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="m-nav">
|
||||||
|
<span class="m-logo">ARCHIV</span>
|
||||||
|
<div class="m-nav-r">
|
||||||
|
<div class="m-av">MR</div>
|
||||||
|
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#E8E7E2;flex:1;display:flex;flex-direction:column;">
|
||||||
|
<div style="padding:8px 10px 4px;">
|
||||||
|
<span style="font-family:Georgia,serif;font-size:13px;color:#012851;">Geschichten</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-filters">
|
||||||
|
<span class="g-pill active" style="font-size:6px;padding:2px 7px;">Alle</span>
|
||||||
|
<span class="g-pill" style="font-size:6px;padding:2px 7px;">Franz Raddatz</span>
|
||||||
|
<span class="g-pill" style="font-size:6px;padding:2px 7px;border-style:dashed;">+ Person…</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;flex:1;">
|
||||||
|
<!-- Story row -->
|
||||||
|
<div class="m-row">
|
||||||
|
<div class="m-row-top">
|
||||||
|
<div class="g-av av-navy" style="width:16px;height:16px;font-size:5.5px;">MR</div>
|
||||||
|
<span class="m-author-name">Maria Raddatz</span>
|
||||||
|
<span class="m-date">14. Mrz. 2025</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-title">Der Sommer in Breslau</div>
|
||||||
|
<div class="m-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg…</div>
|
||||||
|
</div>
|
||||||
|
<!-- Journey row (badge) -->
|
||||||
|
<div class="m-row">
|
||||||
|
<div class="m-row-top">
|
||||||
|
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;">KR</div>
|
||||||
|
<span class="m-author-name">Klaus Raddatz</span>
|
||||||
|
<span class="m-date">15. Mai 2025</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;">
|
||||||
|
<div class="m-title" style="margin-bottom:0;">Briefe aus Breslau 1938–1942</div>
|
||||||
|
<span class="j-badge" style="flex-shrink:0;">REISE</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>impl-ref — LR-1 Journey-Badge in der Liste</h4>
|
||||||
|
<table class="at">
|
||||||
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Badge</td></tr>
|
||||||
|
<tr><td>Journey badge</td><td>inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200</td><td>nur wenn type === 'JOURNEY'</td></tr>
|
||||||
|
<tr><td>Position Desktop</td><td>unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta)</td><td>kein extra Abstand nötig — gap-1 der Flex-Spalte reicht</td></tr>
|
||||||
|
<tr><td>Position Mobile</td><td>inline flex items-center gap-1.5 neben Titel</td><td>Titel + Badge in einem flex-Wrapper; badge shrink-0</td></tr>
|
||||||
|
<tr><td>aria-label</td><td>aria-label="Lesereise"</td><td>Badge ist span, kein interaktives Element</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Bedingte Logik</td></tr>
|
||||||
|
<tr><td>Svelte guard</td><td>{#if geschichte.type === 'JOURNEY'}<span …>REISE</span>{/if}</td><td>kein Badge für STORY</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SCREEN LR-2: JOURNEY READER ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Screens — Journey-Leseansicht</div>
|
||||||
|
|
||||||
|
<div class="scr">
|
||||||
|
<div class="scr-head">
|
||||||
|
<h3>LR-2 — Journey-Detail /geschichten/[id]</h3>
|
||||||
|
<span class="scr-id">Issue #752 · LR-2</span>
|
||||||
|
</div>
|
||||||
|
<p class="scr-desc">Wenn <code>type === 'JOURNEY'</code> ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (<code>body</code>) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.</p>
|
||||||
|
<p class="scr-var"><strong>Varianten:</strong> Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile</p>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<!-- Desktop -->
|
||||||
|
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||||
|
<span class="bp-lbl">Desktop — 1040px · BLOG_WRITER-Ansicht</span>
|
||||||
|
<div class="desk" style="min-height:600px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<span class="fa-logo">ARCHIV</span>
|
||||||
|
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||||
|
<span class="fa-link">Dokumente</span>
|
||||||
|
<span class="fa-link">Personen</span>
|
||||||
|
<span class="fa-link active">Geschichten</span>
|
||||||
|
<span class="fa-link">Chronik</span>
|
||||||
|
<div class="fa-nav-r">
|
||||||
|
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#E8E7E2;flex:1;padding:16px 20px;">
|
||||||
|
<div class="jr-article">
|
||||||
|
<div class="jr-back">
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Zurück zu Geschichten
|
||||||
|
</div>
|
||||||
|
<div class="jr-badge">LESEREISE</div>
|
||||||
|
<div class="jr-title">Briefe aus Breslau 1938–1942</div>
|
||||||
|
<div class="jr-metabar">
|
||||||
|
<div class="g-av av-purple" style="width:20px;height:20px;font-size:6.5px;">KR</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:7.5px;font-weight:700;color:#1C1C18;line-height:1.2;">Klaus Raddatz</div>
|
||||||
|
<div style="font-size:6.5px;color:#6B6A63;">zusammengestellt am 15. Mai 2025</div>
|
||||||
|
</div>
|
||||||
|
<div class="jr-metabar-r">
|
||||||
|
<button class="jr-edit-btn">Bearbeiten</button>
|
||||||
|
<span style="font-size:6.5px;font-weight:600;color:#DC4C3E;">Löschen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Intro -->
|
||||||
|
<div class="jr-intro">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten unbeschwerten Sommerwochen 1938 bis zum Kriegsende. Diese Lesereise folgt den Briefen in chronologischer Reihenfolge.</div>
|
||||||
|
<!-- Item 1: Document, no annotation -->
|
||||||
|
<div class="jr-item">
|
||||||
|
<div class="jr-card">
|
||||||
|
<div class="jr-card-title">Brief vom 12. Juli 1938</div>
|
||||||
|
<div class="jr-card-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||||
|
<div class="jr-card-link">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||||
|
Brief öffnen
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Interlude -->
|
||||||
|
<div class="jr-interlude">
|
||||||
|
<div class="jr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde. Seine Briefe aus dieser Zeit tragen eine Leichtigkeit, die in den späteren Kriegsjahren vollständig verschwindet.</div>
|
||||||
|
</div>
|
||||||
|
<!-- Item 2: Document with annotation -->
|
||||||
|
<div class="jr-item">
|
||||||
|
<div class="jr-card">
|
||||||
|
<div class="jr-card-title">Postkarte aus Breslau, August 1938</div>
|
||||||
|
<div class="jr-card-meta">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||||
|
<div class="jr-card-link">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||||
|
Brief öffnen
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="jr-annotation">
|
||||||
|
<div class="jr-annotation-text">Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten, oder schlicht die Hitze des Augusts?</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Item 3: Document -->
|
||||||
|
<div class="jr-item">
|
||||||
|
<div class="jr-card">
|
||||||
|
<div class="jr-card-title">Brief vom 3. September 1939</div>
|
||||||
|
<div class="jr-card-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||||
|
<div class="jr-card-link">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||||
|
Brief öffnen
|
||||||
|
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile -->
|
||||||
|
<div class="prev-col">
|
||||||
|
<span class="bp-lbl">Mobile — 320px · Leserin</span>
|
||||||
|
<div class="phone" style="min-height:520px;">
|
||||||
|
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div class="m-nav">
|
||||||
|
<span class="m-logo">ARCHIV</span>
|
||||||
|
<div class="m-nav-r">
|
||||||
|
<div class="m-av">MR</div>
|
||||||
|
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#E8E7E2;flex:1;padding:10px;">
|
||||||
|
<div class="mjr-article">
|
||||||
|
<div class="mjr-back">
|
||||||
|
<svg width="6" height="6" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Zurück
|
||||||
|
</div>
|
||||||
|
<div class="mjr-badge">LESEREISE</div>
|
||||||
|
<div class="mjr-title">Briefe aus Breslau 1938–1942</div>
|
||||||
|
<div class="mjr-metabar">
|
||||||
|
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;flex-shrink:0;">KR</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:7px;font-weight:700;color:#1C1C18;">Klaus Raddatz</div>
|
||||||
|
<div style="font-size:6px;color:#6B6A63;">15. Mai 2025</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto;font-size:12px;color:#6B6A63;">···</div>
|
||||||
|
</div>
|
||||||
|
<div style="height:1px;background:#EDECEA;margin-bottom:8px;"></div>
|
||||||
|
<div class="mjr-intro">Der Briefwechsel zwischen Franz und Emma — von 1938 bis Kriegsende.</div>
|
||||||
|
<!-- Item 1 -->
|
||||||
|
<div class="mjr-item">
|
||||||
|
<div class="mjr-card">
|
||||||
|
<div class="mjr-card-title">Brief vom 12. Juli 1938</div>
|
||||||
|
<div class="mjr-card-meta">12. Juli 1938 · Franz → Emma</div>
|
||||||
|
<div class="mjr-card-link">Brief öffnen →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Interlude -->
|
||||||
|
<div class="mjr-interlude">
|
||||||
|
<div class="mjr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</div>
|
||||||
|
</div>
|
||||||
|
<!-- Item 2 -->
|
||||||
|
<div class="mjr-item">
|
||||||
|
<div class="mjr-card">
|
||||||
|
<div class="mjr-card-title">Postkarte Aug. 1938</div>
|
||||||
|
<div class="mjr-card-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||||
|
<div class="mjr-card-link">Brief öffnen →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>impl-ref — LR-2 Journey-Leseansicht</h4>
|
||||||
|
<table class="at">
|
||||||
|
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||||
|
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
|
||||||
|
<tr><td>Artikel-Container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>gleich wie StoryReader (R-2)</td></tr>
|
||||||
|
<tr><td>Artikel-Sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet), gleich wie Story (R-2); BackButton bleibt außerhalb</td></tr>
|
||||||
|
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-journey-tint text-journey border border-journey-border mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||||
|
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
|
||||||
|
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
|
||||||
|
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; <s>auf Mobile im ··· BottomSheet</s> <em>(implementiert: inline in der Metazeile auf allen Breiten)</em></td><td>gleich wie Story</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
|
||||||
|
<tr><td>Intro (body)</td><td>font-serif text-lg text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||||
|
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
|
||||||
|
<tr><td>Dokumentkarte</td><td>bg-surface border border-line rounded-sm p-3</td><td></td></tr>
|
||||||
|
<tr><td>Brieftitel</td><td>font-serif text-base text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||||
|
<tr><td>Briefmeta</td><td>text-sm text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||||
|
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
|
||||||
|
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-brand-mint bg-muted rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||||
|
<tr><td>Annotations-Text</td><td>text-base italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||||
|
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-journey-border bg-journey-tint rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||||
|
<tr><td>Interlude-Text</td><td>text-base italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||||
|
<tr><td>··· Menü</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: kein BottomSheet — Aktionen inline)</em></td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||||
|
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||||
|
<div class="llm">
|
||||||
|
<h2>Implementation Guide — Lesereisen Reader</h2>
|
||||||
|
|
||||||
|
<h3>Geänderte Views und Routen</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>View</th><th>Route</th><th>Änderung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Neue Geschichte</td><td>/geschichten/new</td><td>Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY</td></tr>
|
||||||
|
<tr><td>Geschichten-Liste</td><td>/geschichten</td><td>Journey-Badge in GeschichtenCard wenn type === 'JOURNEY'</td></tr>
|
||||||
|
<tr><td>Geschichte-Detail</td><td>/geschichten/[id]</td><td>Bedingte Verzweigung: JourneyReader | StoryReader</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Neue Komponenten</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>JourneyReader.svelte</code> — rendert Intro + Items-Liste; Props: <code>geschichte: GeschichteDetail</code></li>
|
||||||
|
<li><code>JourneyItemCard.svelte</code> — ein Dokument-Item mit optionaler Annotation; Props: <code>item: JourneyItem, position: number</code></li>
|
||||||
|
<li><code>JourneyInterlude.svelte</code> — ein reiner Text-Interlude; Props: <code>note: string</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Datenmodell (nach #750)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>GeschichteType: 'STORY' | 'JOURNEY'</code></li>
|
||||||
|
<li><code>JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }</code></li>
|
||||||
|
<li><code>Geschichte.items</code> — geordnete Liste (nach <code>position</code> ASC); für STORY leer</li>
|
||||||
|
<li><code>Geschichte.body</code> — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Typauswahl — Implementierungshinweise</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Die Typauswahl ist ein Schritt INNERHALB der <code>/geschichten/new</code>-Route — kein eigener URL, kein <code>goto()</code>. Zustand <code>let selectedType: GeschichteType | null = null</code> in der Komponente.</li>
|
||||||
|
<li>Erst wenn <code>selectedType !== null</code> ist der „Weiter"-Button aktiviert (<code>disabled={!selectedType}</code>).</li>
|
||||||
|
<li>Nach Klick auf „Weiter": wenn <code>selectedType === 'JOURNEY'</code> → <code>goto('/geschichten/new?type=JOURNEY')</code> und zeige den Journey-Editor (aus Issue #753); wenn <code>STORY</code> → bestehender GeschichteEditor (unverändert).</li>
|
||||||
|
<li>Die Karten verwenden <code>role="radio"</code> und <code>aria-checked</code> für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Journey-Badge — Implementierungshinweise</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Badge nur in <code>GeschichtenCard.svelte</code> hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.</li>
|
||||||
|
<li>Text: „REISE" (Kurzform für die Metaspalte); <code>aria-label="Lesereise"</code> für den Badge-Span.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Journey-Reader — Implementierungshinweise</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Items werden bereits geordnet vom Backend geliefert (<code>ORDER BY position ASC</code>). Keine client-seitige Sortierung nötig.</li>
|
||||||
|
<li>Ein Item ist Interlude wenn <code>item.document === null</code>. In diesem Fall: <code>JourneyInterlude</code>-Komponente rendern.</li>
|
||||||
|
<li>Der Intro-Absatz (<code>body</code>) wird als Plaintext gerendert — <em>nicht</em> als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Berechtigungen</h3>
|
||||||
|
<ul>
|
||||||
|
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
|
||||||
|
<li><s>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets)</em></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Barrierefreiheit</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Items-Liste: <code><ol></code> semantisch für die geordnete Briefliste. Interludes sind <code><li></code>-Elemente mit <code>aria-label="Kuratorennotiz"</code>.</li>
|
||||||
|
<li>„Brief öffnen"-Link: beschreibender Text mit Briefdatum im <code>aria-label</code>, z.B. <code>aria-label="Brief vom 12. Juli 1938 öffnen"</code>.</li>
|
||||||
|
<li>Touch-Targets: jede Dokumentkarte hat mindestens 44px Höhe durch den Padding der Karte.</li>
|
||||||
|
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Links.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1212
docs/specs/nl-search-spec.html
Normal file
1212
docs/specs/nl-search-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# spaCy NLP Service — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-06-07
|
||||||
|
**Status:** Prototype
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current NL search uses Ollama (`qwen2.5:7b-instruct-q4_K_M`) to parse free-text queries into structured extractions (person names, dates, role, keywords). Inference takes 5–15 seconds per query, making the feature too slow to be useful compared to filling in the filter UI manually.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a standalone `nlp-service/` prototype that replaces Ollama with spaCy for query parsing. The prototype is scoped to **extraction quality evaluation** — run it locally, curl it with real archive queries, and measure whether spaCy extracts names/dates/keywords well enough to justify a full migration. No Java-side changes in this iteration.
|
||||||
|
|
||||||
|
## Extraction Contract
|
||||||
|
|
||||||
|
The service must produce an output compatible with the existing `OllamaExtraction` Java record:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `personNames` | `string[]` | Names of persons mentioned, left-to-right order |
|
||||||
|
| `personRole` | `"sender"` \| `"receiver"` \| `"any"` | Role of the person(s) in the document |
|
||||||
|
| `dateFrom` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||||
|
| `dateTo` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||||
|
| `keywords` | `string[]` | Content words — fuzzy-matched against tags by Java |
|
||||||
|
| `rawQuery` | `string` | Echo of the input query |
|
||||||
|
|
||||||
|
**Two-person ordering:** `personNames` must be in left-to-right span order. Java maps `[0]` → sender, `[1]` → receiver.
|
||||||
|
|
||||||
|
**`rawQuery` note:** In the current Java code `rawQuery` is set by the caller, not parsed from Ollama. The service echoes the input for convenience; the eventual `RestClientSpacyClient` will set it from the input directly, same as today.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
nlp-service/
|
||||||
|
├── main.py # FastAPI app — /parse and /health endpoints
|
||||||
|
├── extractor.py # NLP pipeline: NER → role → dates → keywords
|
||||||
|
├── models.py # Pydantic request/response types
|
||||||
|
├── requirements.txt
|
||||||
|
├── Dockerfile
|
||||||
|
└── CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Sits alongside `ocr-service/` in the repo. For the prototype it runs standalone (no docker-compose wiring).
|
||||||
|
|
||||||
|
## Extraction Pipeline (`extractor.py`)
|
||||||
|
|
||||||
|
Five steps run in sequence on each query.
|
||||||
|
|
||||||
|
### Step 1 — NER pass
|
||||||
|
|
||||||
|
Run spaCy on the query using the model for the requested language. Collect:
|
||||||
|
- All `PER` spans → candidates for `personNames`
|
||||||
|
- All `DATE` spans → raw text strings for step 3
|
||||||
|
|
||||||
|
### Step 2 — Role detection
|
||||||
|
|
||||||
|
Only relevant when exactly **one** PER entity is found. Walk the dependency tree of the PER span's root token; check if a governing `case` or `prep` token matches the sender or receiver preposition set for the language:
|
||||||
|
|
||||||
|
| Language | Sender prepositions | Receiver prepositions |
|
||||||
|
|---|---|---|
|
||||||
|
| `de` | von, vom | an, nach, für |
|
||||||
|
| `en` | from, by | to, for |
|
||||||
|
| `es` | de, por | para, a |
|
||||||
|
|
||||||
|
- One person + sender preposition → `personRole = "sender"`
|
||||||
|
- One person + receiver preposition → `personRole = "receiver"`
|
||||||
|
- One person + no match / two or more persons → `personRole = "any"`
|
||||||
|
|
||||||
|
Two-person queries always return `"any"` — Java derives direction from position.
|
||||||
|
|
||||||
|
### Step 3 — Date parsing
|
||||||
|
|
||||||
|
For each DATE span, inspect the token immediately before the span to detect range direction:
|
||||||
|
|
||||||
|
| Direction token | Effect |
|
||||||
|
|---|---|
|
||||||
|
| vor / before / antes de | Span → `dateTo` |
|
||||||
|
| nach / after / después de | Span → `dateFrom` |
|
||||||
|
| zwischen…und / between…and / entre…y | Earlier span → `dateFrom`, later → `dateTo` |
|
||||||
|
| No direction token (bare year/date) | Span → both `dateFrom` and `dateTo` set to that year (year-range, Jan 1–Dec 31) |
|
||||||
|
|
||||||
|
`dateparser.parse()` with `PREFER_DAY_OF_MONTH=first` converts the span text to a Python `date`. For `dateTo` results that resolve to a year boundary, set to Dec 31 of that year (mirrors `RestClientOllamaClient.parseDate()` behaviour).
|
||||||
|
|
||||||
|
Output as ISO strings (`YYYY-MM-DD`) or `null`.
|
||||||
|
|
||||||
|
### Step 4 — Keyword extraction
|
||||||
|
|
||||||
|
Collect tokens that satisfy all of:
|
||||||
|
- POS tag is `NOUN` or `PROPN`
|
||||||
|
- Not a stopword
|
||||||
|
- Not inside any NER span (PER or DATE)
|
||||||
|
- Lemma length ≥ 3
|
||||||
|
|
||||||
|
Output as lowercased lemmas. These are fuzzy-matched against the tags table by `NlQueryParserService.resolveTags()` on the Java side — no tag lookup in the Python service.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Briefe aus dem Krieg" → `keywords: ["brief", "krieg"]`
|
||||||
|
- "Texte über Weihnachten" → `keywords: ["text", "weihnachten"]`
|
||||||
|
|
||||||
|
### Step 5 — Assembly
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"personNames": ["Opa Hermann", "Marie"],
|
||||||
|
"personRole": "any",
|
||||||
|
"dateFrom": null,
|
||||||
|
"dateTo": "1920-12-31",
|
||||||
|
"keywords": ["brief"],
|
||||||
|
"rawQuery": "Briefe von Opa Hermann an Marie vor 1920"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `POST /parse`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{ "query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`lang` is a required enum: `"de"` | `"en"` | `"es"`. Unknown values → HTTP 422 (FastAPI validation).
|
||||||
|
|
||||||
|
**Response:** extraction object as above, HTTP 200.
|
||||||
|
|
||||||
|
**Error:** pipeline crash → HTTP 500 `{"detail": "..."}`.
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Returns HTTP 200 `{"status": "ok"}` when all three models are loaded.
|
||||||
|
|
||||||
|
## Language Models
|
||||||
|
|
||||||
|
| `lang` | spaCy model |
|
||||||
|
|---|---|
|
||||||
|
| `de` | `de_core_news_sm` |
|
||||||
|
| `en` | `en_core_web_sm` |
|
||||||
|
| `es` | `es_core_news_sm` |
|
||||||
|
|
||||||
|
All three models are loaded at startup and held in memory. Routing is by the `lang` field on the request.
|
||||||
|
|
||||||
|
## Dockerfile
|
||||||
|
|
||||||
|
Mirrors `ocr-service/` — `python:3.11-slim`, non-root user, models baked into the image:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN python -m spacy download de_core_news_sm \
|
||||||
|
&& python -m spacy download en_core_web_sm \
|
||||||
|
&& python -m spacy download es_core_news_sm
|
||||||
|
COPY . .
|
||||||
|
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1001 nlp \
|
||||||
|
&& chown -R nlp:nlp /app
|
||||||
|
USER nlp
|
||||||
|
EXPOSE 8001
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Image size: ~350 MB. No volume needed — models live in the image layer.
|
||||||
|
|
||||||
|
## Local Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nlp-service
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m spacy download de_core_news_sm en_core_web_sm es_core_news_sm
|
||||||
|
uvicorn main:app --reload --port 8001
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8001/parse \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- **Historical names:** spaCy models are trained on modern news corpora. Unusual 1899–1950 German names may not score as `PER`. Mitigation: the Java `resolveNames()` already does fuzzy matching against the persons table, so partial name extraction is recoverable.
|
||||||
|
- **Role detection:** the preposition sets are a fixed enumeration (~12 tokens across 3 languages). Sentences that express direction without one of these prepositions will fall through to `personRole = "any"`. This is acceptable — `"any"` is the safe default and searches both sender and receiver positions.
|
||||||
|
- **"über Oma" ambiguity:** if spaCy recognises "Oma" as a PER entity it lands in `personNames` (person search); if not, it lands in `keywords` (tag search via Java). Both paths return relevant results. The prototype evaluation will reveal which path dominates for real archive queries.
|
||||||
|
|
||||||
|
## Out of Scope (prototype)
|
||||||
|
|
||||||
|
- docker-compose integration (Ollama replacement)
|
||||||
|
- Java-side changes (`RestClientSpacyClient`, rename `OllamaClient` → `NlParserClient`)
|
||||||
|
- Tag lookup inside the Python service
|
||||||
|
- Automated test suite (pytest fixtures) — evaluation is done by curling the running service
|
||||||
@@ -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,7 +84,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/persons/{id}/confirm": {
|
"/api/geschichten/{id}/items/reorder": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -92,12 +92,16 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
get?: never;
|
||||||
put?: 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;
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch: operations["confirmPerson"];
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/documents/{id}": {
|
"/api/documents/{id}": {
|
||||||
@@ -436,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;
|
||||||
@@ -708,6 +728,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/backfill-titles": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["backfillTitles"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/backfill-file-hashes": {
|
"/api/admin/backfill-file-hashes": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -740,6 +776,22 @@ export interface paths {
|
|||||||
patch: operations["patchFamilyMember"];
|
patch: operations["patchFamilyMember"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{id}/confirm": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["confirmPerson"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/notifications/{id}/read": {
|
"/api/notifications/{id}/read": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -788,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;
|
||||||
@@ -1651,7 +1719,7 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
generation?: number | null;
|
generation?: number;
|
||||||
};
|
};
|
||||||
Person: {
|
Person: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1668,12 +1736,38 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
generation?: number | null;
|
generation?: number;
|
||||||
familyMember: boolean;
|
familyMember: boolean;
|
||||||
sourceRef?: string;
|
sourceRef?: string;
|
||||||
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 */
|
||||||
@@ -1930,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 */
|
||||||
@@ -2148,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;
|
||||||
@@ -2235,25 +2351,6 @@ export interface components {
|
|||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
totalStories: number;
|
totalStories: number;
|
||||||
};
|
};
|
||||||
PersonSummaryDTO: {
|
|
||||||
title?: string;
|
|
||||||
/** Format: uuid */
|
|
||||||
id?: string;
|
|
||||||
displayName?: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
/** Format: int64 */
|
|
||||||
documentCount?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
birthYear?: number;
|
|
||||||
/** Format: int32 */
|
|
||||||
deathYear?: number;
|
|
||||||
alias?: string;
|
|
||||||
notes?: string;
|
|
||||||
personType?: string;
|
|
||||||
familyMember?: boolean;
|
|
||||||
provisional?: boolean;
|
|
||||||
};
|
|
||||||
PersonSearchResult: {
|
PersonSearchResult: {
|
||||||
items: components["schemas"]["PersonSummaryDTO"][];
|
items: components["schemas"]["PersonSummaryDTO"][];
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
@@ -2265,6 +2362,25 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
PersonSummaryDTO: {
|
||||||
|
title?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
id?: string;
|
||||||
|
displayName?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
documentCount?: number;
|
||||||
|
alias?: string;
|
||||||
|
notes?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
birthYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
deathYear?: number;
|
||||||
|
provisional?: boolean;
|
||||||
|
personType?: string;
|
||||||
|
familyMember?: boolean;
|
||||||
|
};
|
||||||
InferredRelationshipWithPersonDTO: {
|
InferredRelationshipWithPersonDTO: {
|
||||||
person: components["schemas"]["PersonNodeDTO"];
|
person: components["schemas"]["PersonNodeDTO"];
|
||||||
label: string;
|
label: string;
|
||||||
@@ -2280,7 +2396,7 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
generation?: number | null;
|
generation?: number;
|
||||||
familyMember: boolean;
|
familyMember: boolean;
|
||||||
};
|
};
|
||||||
InferredRelationshipDTO: {
|
InferredRelationshipDTO: {
|
||||||
@@ -2360,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"][];
|
||||||
@@ -2368,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: {
|
||||||
@@ -2392,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;
|
||||||
@@ -2538,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;
|
||||||
@@ -2828,6 +2963,52 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deletePerson: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
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;
|
||||||
@@ -3184,48 +3365,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
confirmPerson: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"*/*": components["schemas"]["Person"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
deletePerson: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description No Content */
|
|
||||||
204: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
createPerson: {
|
createPerson: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3590,7 +3729,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Geschichte"][];
|
"*/*": components["schemas"]["GeschichteSummary"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -3614,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"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4117,6 +4282,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
backfillTitles: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["BackfillResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
backfillFileHashes: {
|
backfillFileHashes: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4163,6 +4348,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
confirmPerson: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Person"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
markOneRead: {
|
markOneRead: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4248,7 +4455,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Geschichte"];
|
"*/*": components["schemas"]["GeschichteView"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4294,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"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -5282,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());
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user