Compare commits
11 Commits
feat/issue
...
74124dcc5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74124dcc5d | ||
|
|
f7bc3ccfc1 | ||
|
|
194340c716 | ||
|
|
39ef5f2d83 | ||
|
|
7199575a11 | ||
|
|
6cd500ed8f | ||
|
|
1cc302b289 | ||
|
|
bc89426063 | ||
|
|
ee728e3522 | ||
|
|
52920a5aba | ||
|
|
1e3f76cb44 |
@@ -154,9 +154,9 @@ Schedule monthly automated restore tests. If the restore fails, the backup is wo
|
||||
```
|
||||
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
|
||||
|
||||
3. **Upgrading hardware before profiling**
|
||||
3. **Upgrading VPS tier before profiling**
|
||||
```
|
||||
# "The app feels slow" → order more RAM / a faster CPU
|
||||
# "The app feels slow" → upgrade from CX32 to CX42
|
||||
# Actual cause: unindexed query scanning 100k rows
|
||||
```
|
||||
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
|
||||
@@ -404,8 +404,8 @@ Hetzner Object Storage (S3-compatible, replaces MinIO in prod)
|
||||
Prometheus + Loki + Alertmanager
|
||||
```
|
||||
|
||||
### Monthly Cost: ~6 EUR (excl. server)
|
||||
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||
### Monthly Cost: ~23 EUR
|
||||
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||
|
||||
### Reference Documentation
|
||||
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`
|
||||
|
||||
19
.env.example
19
.env.example
@@ -72,25 +72,6 @@ VITE_SENTRY_DSN=
|
||||
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# NL search — Ollama LLM inference
|
||||
# Leave APP_OLLAMA_BASE_URL empty to disable NL search (safe default for CX32 / CI).
|
||||
# Set to http://ollama:11434 to enable. Requires CX42 (16 GB RAM) to run alongside OCR.
|
||||
APP_OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
# CPU limit: 4.0 is safe on both CX32 (4 vCPUs) and CX42 (8 vCPUs).
|
||||
# Raise to 7.5 on CX42 for full throughput.
|
||||
OLLAMA_CPU_LIMIT=4.0
|
||||
|
||||
# Memory limit: requires CX42 (16 GB) to run alongside OCR.
|
||||
# Reduce or set APP_OLLAMA_BASE_URL= on smaller hosts.
|
||||
OLLAMA_MEM_LIMIT=8g
|
||||
|
||||
# Ollama API key — set on the Ollama service to restrict inference API access on archiv-net.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# NOTE: Empirically verified that OLLAMA_API_KEY is NOT enforced in Ollama 0.6.5 or 0.30.6 (ADR-028 §7).
|
||||
# archiv-net network isolation is the only effective access control. Retained for forward compatibility.
|
||||
OLLAMA_API_KEY=
|
||||
|
||||
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||
# APP_BASE_URL=https://your-domain.example.com
|
||||
# MAIL_HOST=smtp.example.com
|
||||
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -86,8 +86,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ FileService (S3/MinIO)
|
||||
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── geschichte/ Geschichte (story) domain
|
||||
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||
├── notification/ Notification domain + SseEmitterRegistry
|
||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||
@@ -106,15 +105,13 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
|
||||
### Domain Model
|
||||
|
||||
| Entity | Table | Key relationships |
|
||||
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
| `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` |
|
||||
| Entity | Table | Key relationships |
|
||||
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
@@ -155,7 +152,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
||||
|
||||
### DTOs
|
||||
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **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.
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||
|
||||
@@ -163,7 +160,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
|
||||
**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).
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -271,7 +268,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ # FileService (S3/MinIO)
|
||||
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── geschichte/ # Geschichte (story) domain
|
||||
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||
├── notification/ # Notification domain + SseEmitterRegistry
|
||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||
|
||||
@@ -28,18 +28,4 @@ Authorization: Basic Gast_User gast
|
||||
###Groups
|
||||
#GET
|
||||
GET http://localhost:8080/api/admin/tags
|
||||
Authorization: Basic admin admin123
|
||||
|
||||
### One-time backfill: re-sync already-stale auto-titles (#726)
|
||||
# RUNBOOK: a one-shot ADMIN maintenance call, NOT part of normal operation. Run it ONCE
|
||||
# after deploying #726 to clean the existing backlog of stale titles (e.g. a title still
|
||||
# showing "2028" after the date was corrected to "1928"). It is synchronous and idempotent
|
||||
# — a second run returns {"count": 0} and writes nothing. Hit the backend DIRECTLY on
|
||||
# port 8080 (NOT through the SvelteKit proxy) so the sweep can't trip the proxy timeout.
|
||||
# Returns {"count": <documents rewritten>}.
|
||||
POST http://localhost:8080/api/admin/backfill-titles
|
||||
Authorization: Basic admin admin123
|
||||
|
||||
### NEGATIV-TEST: ein Nicht-Admin darf den Backfill NICHT auslösen -> 403 Forbidden
|
||||
POST http://localhost:8080/api/admin/backfill-titles
|
||||
Authorization: Basic Gast_User gast
|
||||
Authorization: Basic admin admin123
|
||||
@@ -41,27 +41,6 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</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>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
@@ -158,12 +137,6 @@
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-jetty12</artifactId>
|
||||
<version>3.9.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Excel Bearbeitung (Apache POI) -->
|
||||
<dependency>
|
||||
|
||||
@@ -50,25 +50,10 @@ public enum AuditKind {
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED,
|
||||
|
||||
// --- Reading Journeys (Lesereisen) ---
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||
JOURNEY_ITEM_ADDED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_REMOVED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_NOTE_UPDATED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||
JOURNEY_ITEMS_REORDERED;
|
||||
LOGIN_RATE_LIMITED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||
JOURNEY_ITEMS_REORDERED
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,13 +36,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.list")
|
||||
Page<Document> findAll(Pageable pageable);
|
||||
|
||||
// Loader for the relevance fast path: list-item enrichment reads tags after the
|
||||
// repository call returns, so the fetch shape must match the spec-based findAll
|
||||
// overloads above. Plain findAllById carries no entity graph and must not feed
|
||||
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
|
||||
@EntityGraph("Document.list")
|
||||
List<Document> findByIdIn(Collection<UUID> ids);
|
||||
|
||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||
@@ -64,7 +57,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.full")
|
||||
List<Document> findByReceiversId(UUID receiverId);
|
||||
|
||||
|
||||
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||
List<Document> findByTags_Id(UUID tagId);
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
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.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -70,7 +68,6 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentTitleFactory documentTitleFactory;
|
||||
private final PersonService personService;
|
||||
private final FileService fileService;
|
||||
private final TagService tagService;
|
||||
@@ -382,14 +379,8 @@ public class DocumentService {
|
||||
|
||||
DocumentStatus statusBefore = doc.getStatus();
|
||||
|
||||
// Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state
|
||||
// BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision
|
||||
// skips nulls, so the old state must be read first. The submitted title is the catalog
|
||||
// auto-title iff it equals this; only then does it follow date/location forward.
|
||||
String autoTitleBefore = documentTitleFactory.build(doc);
|
||||
|
||||
// 1. Einfache Felder Update
|
||||
doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
|
||||
doc.setTitle(dto.getTitle());
|
||||
doc.setDocumentDate(dto.getDocumentDate());
|
||||
applyDatePrecision(doc, dto);
|
||||
validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
|
||||
@@ -433,11 +424,7 @@ public class DocumentService {
|
||||
doc.setScriptType(dto.getScriptType());
|
||||
}
|
||||
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde).
|
||||
// NB (#726): this reassigns originalFilename to the uploaded file's name. The title's index
|
||||
// segment is originalFilename, so after a replace the stored title no longer matches
|
||||
// build(currentState) and the row is treated as manual — neither save-time nor backfill
|
||||
// rewrites it. Accepted fail-safe (ADR-031), and autoTitleBefore was already captured above.
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
||||
if (fileReplaced) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
@@ -466,68 +453,22 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides the title to persist on an edit (#726). The submitted title is the catalog
|
||||
* auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
|
||||
* comparison with no heuristic, relying on the edit form round-tripping the stored title
|
||||
* verbatim when untouched. A machine title is rebuilt from the new state so a corrected
|
||||
* date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank
|
||||
* submission is never persisted (title is always present) — it falls back to the rebuilt
|
||||
* auto-title, which always carries at least the index.
|
||||
*/
|
||||
private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) {
|
||||
if (submitted == null || submitted.isBlank()) {
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
if (!Objects.equals(submitted, autoBefore)) {
|
||||
return submitted;
|
||||
}
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* The document state the regenerated title is built from. It is composed from the SAME
|
||||
* resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
|
||||
* DTO (a null value clears the field), precision/end/raw resolved skip-null via
|
||||
* {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
|
||||
* the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
|
||||
* is never touched by a metadata edit.
|
||||
*/
|
||||
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
|
||||
return Document.builder()
|
||||
.originalFilename(doc.getOriginalFilename())
|
||||
.documentDate(dto.getDocumentDate())
|
||||
.location(dto.getLocation())
|
||||
.metaDatePrecision(effectivePrecision(doc, dto))
|
||||
.metaDateEnd(effectiveMetaDateEnd(doc, dto))
|
||||
.metaDateRaw(effectiveMetaDateRaw(doc, dto))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
|
||||
* so the stored value is kept rather than overwritten with null — which would fabricate a
|
||||
* precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
|
||||
* the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
|
||||
* the stored value back when the DTO omits a field is a harmless no-op).
|
||||
* Applies the three date-precision fields only when the DTO carries them.
|
||||
* A null field means "not submitted" — overwriting the stored value with null
|
||||
* would fabricate a precision the user never chose, the exact dishonesty #666
|
||||
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
||||
* an unrelated edit (e.g. a location typo) is saved.
|
||||
*/
|
||||
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||
doc.setMetaDatePrecision(effectivePrecision(doc, dto));
|
||||
doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
|
||||
doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
|
||||
}
|
||||
|
||||
// Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
|
||||
// projectedState (the title projection) — the single rule keeps them from diverging (#726).
|
||||
private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
|
||||
}
|
||||
|
||||
private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
|
||||
}
|
||||
|
||||
private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
|
||||
if (dto.getMetaDatePrecision() != null) {
|
||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
||||
}
|
||||
if (dto.getMetaDateEnd() != null) {
|
||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
||||
}
|
||||
if (dto.getMetaDateRaw() != null) {
|
||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,14 +792,14 @@ public class DocumentService {
|
||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
|
||||
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
|
||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||
List<UUID> pageIds = new ArrayList<>();
|
||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||
pageIds.add(ftsPage.hits().get(i).id());
|
||||
}
|
||||
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||
@@ -1006,28 +947,6 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||
*
|
||||
* <p><strong>Security contract — read before calling:</strong>
|
||||
* <ol>
|
||||
* <li>This method intentionally bypasses per-document scope checks and
|
||||
* tag-colour resolution. It must only be invoked after
|
||||
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||
* the controller layer, guaranteeing the caller is an authenticated
|
||||
* author.</li>
|
||||
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||
* JOURNEY-type check that fires before this call — so the method is never
|
||||
* reached for STORY-type Geschichten.</li>
|
||||
* </ol>
|
||||
* Under the current single-tenant model every authenticated author shares the
|
||||
* same document scope, so skipping per-document scope checks is safe.
|
||||
*/
|
||||
public Document findSummaryByIdInternal(UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a document for the detail view, additionally flagging whether it has any
|
||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||
@@ -1057,28 +976,6 @@ public class DocumentService {
|
||||
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() {
|
||||
return documentRepository.countByMetadataCompleteFalse();
|
||||
}
|
||||
@@ -1113,43 +1010,6 @@ public class DocumentService {
|
||||
tagService.delete(tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time cleanup of already-stale auto-titles (#726, FR-003). For every document whose
|
||||
* stored title passes the {@link DocumentTitleBackfillMatcher} overwrite heuristic, rebuilds
|
||||
* the title from the row's current state and persists it only when it actually changed.
|
||||
* Idempotent: a second run rebuilds the same value and saves nothing. Hand-written prose is
|
||||
* left untouched.
|
||||
*
|
||||
* <p>Saves via {@code documentRepository.save} directly — it must NOT route through
|
||||
* {@link #updateDocument} (which versions every write), following the {@link #backfillFileHashes}
|
||||
* precedent: a mechanical rename must not snapshot the whole corpus into {@code document_versions}.
|
||||
*
|
||||
* @return the number of documents whose title was rewritten
|
||||
*/
|
||||
@Transactional
|
||||
public int backfillTitles() {
|
||||
List<Document> docs = documentRepository.findAll();
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
for (Document doc : docs) {
|
||||
if (!DocumentTitleBackfillMatcher.isOverwritable(
|
||||
doc.getTitle(), doc.getOriginalFilename(), doc.getLocation())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
String rebuilt = documentTitleFactory.build(doc);
|
||||
if (rebuilt.equals(doc.getTitle())) {
|
||||
skipped++; // already correct — keep idempotent, no write
|
||||
continue;
|
||||
}
|
||||
doc.setTitle(rebuilt);
|
||||
documentRepository.save(doc); // direct save, no recordVersion (mechanical rename)
|
||||
updated++;
|
||||
}
|
||||
log.info("Title backfill complete: scanned={} updated={} skipped={}", docs.size(), updated, skipped);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int backfillFileHashes() {
|
||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Heuristic overwrite test for the one-time title backfill (#726, FR-004): decides whether a
|
||||
* STORED title is a machine-generated auto-title (and so may be rebuilt from the row's current
|
||||
* state) versus hand-written prose (left untouched). Used ONLY by the backfill — save-time
|
||||
* regeneration uses an exact old-vs-new comparison instead, with no heuristic.
|
||||
*
|
||||
* <p>A stored title is overwritable iff, after stripping the literal {@code index} prefix:
|
||||
* <ol>
|
||||
* <li>it is exactly {@code {index}}, or</li>
|
||||
* <li>{@code {index} – {dateLabel}} with an optional trailing {@code – {location}} segment
|
||||
* (any location — a present, valid date label is itself strong evidence of a machine
|
||||
* title), or</li>
|
||||
* <li>{@code {index} – {location}} where the segment equals the document's current location
|
||||
* (no date label, so the segment must match the known location to be distinguished from
|
||||
* prose).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Security: the {@code index} is compared <em>literally</em> via {@link String#startsWith}
|
||||
* (never compiled into a regex) because {@code originalFilename} is user-controlled and may carry
|
||||
* regex metacharacters — an unquoted pattern would be a ReDoS / regex-injection vector
|
||||
* (CWE-1333 / CWE-625). The date-label sub-patterns use only bounded, non-nested quantifiers over
|
||||
* short tokens, so there is no catastrophic backtracking. Fail-closed: any null/blank index or
|
||||
* structural surprise returns {@code false}.
|
||||
*/
|
||||
final class DocumentTitleBackfillMatcher {
|
||||
|
||||
private static final String SEPARATOR = " – ";
|
||||
|
||||
// German month tokens derived from the SAME Locale.GERMAN formatters DocumentTitleFormatter
|
||||
// uses, so the matcher's accepted spellings cannot drift from what the factory emits (full
|
||||
// names "Januar"…"Dezember"; abbreviations "Jan."…"Dez." — note May/June/July/März carry no
|
||||
// period). Pattern.quote each so a "." in an abbreviation is literal, never a wildcard.
|
||||
private static final String FULL_MONTH = monthAlternation("MMMM");
|
||||
private static final String ABBR_MONTH = monthAlternation("MMM");
|
||||
private static final String SEASON = "(?:Frühling|Sommer|Herbst|Winter)";
|
||||
private static final String YEAR = "\\d{1,4}";
|
||||
private static final String DAY_NUM = "\\d{1,2}";
|
||||
|
||||
// One complete date label, anchored, optionally followed by a free-form trailing location
|
||||
// segment. Only bounded/non-nested quantifiers over short tokens plus a single trailing
|
||||
// ".+" → linear, no catastrophic backtracking (FR-004 ReDoS guard).
|
||||
private static final Pattern DATE_LABEL_WITH_OPTIONAL_LOCATION = Pattern.compile(
|
||||
"^(?:" + String.join("|",
|
||||
YEAR, // 1916
|
||||
"ca\\. " + YEAR, // ca. 1920
|
||||
FULL_MONTH + " " + YEAR, // Juni 1916
|
||||
DAY_NUM + "\\. " + FULL_MONTH + " " + YEAR, // 24. Dezember 1943
|
||||
SEASON + " " + YEAR, // Sommer 1916
|
||||
"Datum unbekannt",
|
||||
DAY_NUM + "\\.–" + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10.–11. Jan. 1917
|
||||
DAY_NUM + "\\. " + ABBR_MONTH + " – " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Jan. – 2. Feb. 1917
|
||||
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR + " – " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Dez. 1916 – 2. Jan. 1917
|
||||
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10. Jan. 1917 (range end == start)
|
||||
"ab " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR) // ab 10. Jan. 1917
|
||||
+ ")(?: – .+)?$");
|
||||
|
||||
private DocumentTitleBackfillMatcher() {
|
||||
}
|
||||
|
||||
static boolean isOverwritable(String title, String index, String location) {
|
||||
if (title == null || index == null || index.isBlank()) {
|
||||
return false; // fail closed
|
||||
}
|
||||
if (!title.startsWith(index)) {
|
||||
return false; // index is matched LITERALLY, never as a regex
|
||||
}
|
||||
String tail = title.substring(index.length());
|
||||
if (tail.isEmpty()) {
|
||||
return true; // exactly {index}
|
||||
}
|
||||
if (!tail.startsWith(SEPARATOR)) {
|
||||
return false;
|
||||
}
|
||||
String body = tail.substring(SEPARATOR.length());
|
||||
if (DATE_LABEL_WITH_OPTIONAL_LOCATION.matcher(body).matches()) {
|
||||
return true; // {dateLabel} (+ optional trailing location)
|
||||
}
|
||||
// No date label: the lone segment must equal the document's current location to be
|
||||
// distinguished from hand-written prose.
|
||||
return location != null && !location.isBlank() && body.equals(location);
|
||||
}
|
||||
|
||||
private static String monthAlternation(String pattern) {
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, Locale.GERMAN);
|
||||
Set<String> tokens = new LinkedHashSet<>();
|
||||
for (int month = 1; month <= 12; month++) {
|
||||
tokens.add(formatter.format(LocalDate.of(2000, month, 15)));
|
||||
}
|
||||
return tokens.stream().map(Pattern::quote).collect(Collectors.joining("|", "(?:", ")"));
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Single source of truth for the auto-generated document title
|
||||
* {@code {index} – {dateLabel} – {location}}.
|
||||
*
|
||||
* <p>The {@code document} package owns this formula; {@code importing} consumes it
|
||||
* (see ADR for issue #726). The leading {@code index} is the document's
|
||||
* {@code originalFilename}; the date label is the honest German label produced by
|
||||
* {@link DocumentTitleFormatter} (the Java half of the #666 date-label split); the
|
||||
* trailing location is the {@code meta_location} verbatim, omitted when blank.
|
||||
*/
|
||||
@Component
|
||||
public class DocumentTitleFactory {
|
||||
|
||||
static final String SEPARATOR = " – ";
|
||||
|
||||
/**
|
||||
* Composes the auto-title from the document's current state. The date segment is
|
||||
* dropped for UNKNOWN precision or a null date (the honest "no date" case); the
|
||||
* location segment is dropped when blank.
|
||||
*/
|
||||
public String build(Document doc) {
|
||||
// originalFilename is NOT NULL in production; guard only so a synthetic/partial entity
|
||||
// never trips StringBuilder(null) with an opaque NPE.
|
||||
StringBuilder title = new StringBuilder(doc.getOriginalFilename() == null ? "" : doc.getOriginalFilename());
|
||||
if (doc.getDocumentDate() != null && doc.getMetaDatePrecision() != DatePrecision.UNKNOWN) {
|
||||
title.append(SEPARATOR).append(DocumentTitleFormatter.formatTitleDate(
|
||||
doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
||||
doc.getMetaDateEnd(), doc.getMetaDateRaw()));
|
||||
}
|
||||
if (doc.getLocation() != null && !doc.getLocation().isBlank()) {
|
||||
title.append(SEPARATOR).append(doc.getLocation());
|
||||
}
|
||||
return title.toString();
|
||||
}
|
||||
}
|
||||
@@ -78,8 +78,4 @@ public class DomainException extends RuntimeException {
|
||||
public static DomainException tooManyRequests(ErrorCode code, String message, long 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,20 +122,6 @@ public enum ErrorCode {
|
||||
// --- Geschichten (Stories) ---
|
||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||
GESCHICHTE_NOT_FOUND,
|
||||
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||
JOURNEY_ITEM_NOT_FOUND,
|
||||
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||
JOURNEY_AT_CAPACITY,
|
||||
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
|
||||
GESCHICHTE_TYPE_MISMATCH,
|
||||
/** 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,
|
||||
|
||||
// --- Tags ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
|
||||
@@ -78,14 +78,7 @@ public class GlobalExceptionHandler {
|
||||
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
|
||||
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
|
||||
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
|
||||
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"));
|
||||
}
|
||||
log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex));
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -42,12 +40,6 @@ public class Geschichte {
|
||||
@Builder.Default
|
||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private GeschichteType type = GeschichteType.STORY;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "author_id")
|
||||
private AppUser author;
|
||||
@@ -59,18 +51,12 @@ public class Geschichte {
|
||||
@Builder.Default
|
||||
private Set<Person> persons = new HashSet<>();
|
||||
|
||||
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
|
||||
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
|
||||
// explicitly initialized inside the service transaction. getById() is
|
||||
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
|
||||
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
|
||||
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
|
||||
// fetch-joined List here will throw MultipleBagFetchException at boot.
|
||||
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
|
||||
fetch = FetchType.LAZY)
|
||||
@OrderBy("position ASC")
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(name = "geschichten_documents",
|
||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
||||
@Builder.Default
|
||||
private List<JourneyItem> items = new ArrayList<>();
|
||||
private Set<Document> documents = new HashSet<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(updatable = false)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -16,7 +14,6 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -31,10 +28,9 @@ import java.util.UUID;
|
||||
public class GeschichteController {
|
||||
|
||||
private final GeschichteService geschichteService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
@GetMapping
|
||||
public List<GeschichteSummary> list(
|
||||
public List<Geschichte> list(
|
||||
@RequestParam(required = false) GeschichteStatus status,
|
||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||
@RequestParam(required = false) UUID documentId,
|
||||
@@ -47,20 +43,20 @@ public class GeschichteController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public GeschichteView getById(@PathVariable UUID id) {
|
||||
return geschichteService.getView(id);
|
||||
public Geschichte getById(@PathVariable UUID id) {
|
||||
return geschichteService.getById(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
return geschichteService.update(id, dto);
|
||||
}
|
||||
|
||||
@@ -70,45 +66,4 @@ public class GeschichteController {
|
||||
geschichteService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{id}/items")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<JourneyItemView> appendItem(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyItemCreateDTO dto) {
|
||||
JourneyItemView view = journeyItemService.append(id, dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public JourneyItemView updateItemNote(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId,
|
||||
@RequestBody JourneyItemUpdateDTO dto) {
|
||||
return journeyItemService.updateNote(id, itemId, dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Void> deleteItem(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId) {
|
||||
journeyItemService.delete(id, itemId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/items/reorder")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
@Operation(
|
||||
summary = "Reorder journey items",
|
||||
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||
)
|
||||
public List<JourneyItemView> reorderItems(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyReorderDTO dto) {
|
||||
return journeyItemService.reorder(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,47 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||
|
||||
/**
|
||||
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
|
||||
*
|
||||
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
|
||||
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
|
||||
*
|
||||
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
|
||||
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
|
||||
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
|
||||
FROM Geschichte g
|
||||
WHERE g.status = :effectiveStatus
|
||||
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||
AND (:personCount = 0 OR
|
||||
(SELECT COUNT(DISTINCT p.id)
|
||||
FROM Geschichte g2 JOIN g2.persons p
|
||||
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||
AND (:documentId IS NULL OR
|
||||
EXISTS (SELECT 1 FROM JourneyItem ji
|
||||
WHERE ji.geschichte = g AND ji.document.id = :documentId))
|
||||
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||
""")
|
||||
List<GeschichteSummary> findSummaries(
|
||||
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||
@Param("authorId") UUID authorId,
|
||||
@Param("personIds") Collection<UUID> personIds,
|
||||
@Param("personCount") long personCount,
|
||||
@Param("documentId") UUID documentId);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,28 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -36,7 +41,6 @@ public class GeschichteService {
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final UserService userService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
/**
|
||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||
@@ -56,7 +60,6 @@ public class GeschichteService {
|
||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Geschichte getById(UUID id) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
@@ -69,57 +72,24 @@ public class GeschichteService {
|
||||
return g;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GeschichteView getView(UUID id) {
|
||||
Geschichte g = getById(id);
|
||||
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||
return toView(g, items);
|
||||
}
|
||||
|
||||
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
|
||||
AppUser author = g.getAuthor();
|
||||
GeschichteView.AuthorView authorView = null;
|
||||
if (author != null) {
|
||||
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
|
||||
if (displayName.isBlank()) displayName = "[Unbekannt]";
|
||||
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
|
||||
}
|
||||
Set<GeschichteView.PersonView> personViews = new HashSet<>();
|
||||
for (Person p : g.getPersons()) {
|
||||
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
|
||||
}
|
||||
return new GeschichteView(
|
||||
g.getId(), g.getTitle(), g.getBody(),
|
||||
g.getStatus(), g.getType(),
|
||||
authorView, personViews,
|
||||
items,
|
||||
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||
* must be associated with every person id supplied. An empty or null list applies no
|
||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||
*
|
||||
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||
* LazyInitializationException on the non-transactional list path.
|
||||
*/
|
||||
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
|
||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||
|
||||
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
|
||||
: personIds;
|
||||
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||
|
||||
return geschichteRepository
|
||||
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||
Specification<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAuthor(authorId),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
);
|
||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
||||
.stream()
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
@@ -127,57 +97,46 @@ public class GeschichteService {
|
||||
|
||||
// ─── Write API ───────────────────────────────────────────────────────────
|
||||
|
||||
// Write methods return GeschichteView, never the entity: Jackson serializes after
|
||||
// the transaction closed, where the lazy items collection is a dead proxy.
|
||||
// The view is assembled in-transaction, so no force-init tricks are needed.
|
||||
|
||||
@Transactional
|
||||
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
||||
requireTitle(dto.getTitle());
|
||||
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
|
||||
Geschichte g = Geschichte.builder()
|
||||
.title(dto.getTitle().trim())
|
||||
.body(bodyForType(type, dto.getBody()))
|
||||
.body(sanitize(dto.getBody()))
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(type)
|
||||
.author(currentUser())
|
||||
.persons(resolvePersons(dto.getPersonIds()))
|
||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
||||
.build();
|
||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||
g.setPublishedAt(LocalDateTime.now());
|
||||
}
|
||||
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());
|
||||
return geschichteRepository.save(g);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||
if (dto.getType() != null && dto.getType() != g.getType()) {
|
||||
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
|
||||
"The type of a Geschichte cannot be changed after creation");
|
||||
}
|
||||
if (dto.getTitle() != null) {
|
||||
requireTitle(dto.getTitle());
|
||||
g.setTitle(dto.getTitle().trim());
|
||||
}
|
||||
if (dto.getBody() != null) {
|
||||
g.setBody(bodyForType(g.getType(), dto.getBody()));
|
||||
g.setBody(sanitize(dto.getBody()));
|
||||
}
|
||||
if (dto.getPersonIds() != null) {
|
||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||
}
|
||||
if (dto.getDocumentIds() != null) {
|
||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
||||
}
|
||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||
applyStatusTransition(g, dto.getStatus());
|
||||
}
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
return toView(saved, journeyItemService.getItems(id));
|
||||
return geschichteRepository.save(g);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -207,16 +166,6 @@ public class GeschichteService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return type == GeschichteType.JOURNEY ? body : sanitize(body);
|
||||
}
|
||||
|
||||
private String sanitize(String body) {
|
||||
if (body == null) return null;
|
||||
return BODY_SANITIZER.sanitize(body);
|
||||
@@ -227,6 +176,15 @@ public class GeschichteService {
|
||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
||||
}
|
||||
|
||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
||||
Set<Document> out = new LinkedHashSet<>();
|
||||
for (UUID id : ids) {
|
||||
out.add(documentService.getDocumentById(id));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
|
||||
@@ -6,6 +6,9 @@ import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
import jakarta.persistence.criteria.Subquery;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
@@ -45,7 +48,12 @@ public final class GeschichteSpecifications {
|
||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||
}
|
||||
|
||||
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
|
||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||
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}.
|
||||
@@ -76,4 +84,14 @@ public final class GeschichteSpecifications {
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static Subquery<UUID> documentSubquery(
|
||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
||||
Join<Geschichte, Document> documents = subRoot.join("documents");
|
||||
sub.select(subRoot.get("id"))
|
||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
||||
cb.equal(documents.get("id"), documentId));
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
public enum GeschichteType {
|
||||
STORY,
|
||||
JOURNEY
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -15,6 +16,6 @@ public class GeschichteUpdateDTO {
|
||||
private String title;
|
||||
private String body;
|
||||
private GeschichteStatus status;
|
||||
private GeschichteType type;
|
||||
private List<UUID> personIds;
|
||||
private List<UUID> documentIds;
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface JourneyItemRepository extends JpaRepository<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);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.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,
|
||||
"Journey not found: " + geschichteId));
|
||||
|
||||
if (g.getType() != GeschichteType.JOURNEY) {
|
||||
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_MISMATCH,
|
||||
"Journey items can only be added to a JOURNEY-type Geschichte");
|
||||
}
|
||||
|
||||
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||
if (count >= MAX_ITEMS) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||
"Journey has reached the maximum of 100 items");
|
||||
}
|
||||
|
||||
String note = normalizeNote(dto.getNote());
|
||||
|
||||
if (dto.getDocumentId() == null && note == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"At least one of documentId or note must be provided");
|
||||
}
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.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) {
|
||||
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,
|
||||
"Journey 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 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());
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -75,7 +74,6 @@ public class DocumentImporter {
|
||||
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
||||
|
||||
private final DocumentService documentService;
|
||||
private final DocumentTitleFactory documentTitleFactory;
|
||||
private final PersonService personService;
|
||||
private final TagService tagService;
|
||||
private final S3Client s3Client;
|
||||
@@ -183,7 +181,7 @@ public class DocumentImporter {
|
||||
applyAttribution(doc, row);
|
||||
applyDates(doc, row);
|
||||
applyAuthoritativeAssociations(doc, row);
|
||||
applyFileMetadata(doc, s3Key, contentType, status);
|
||||
applyFileMetadata(doc, s3Key, contentType, status, index);
|
||||
applyComputedFlags(doc);
|
||||
return doc;
|
||||
}
|
||||
@@ -219,15 +217,14 @@ public class DocumentImporter {
|
||||
attachTag(doc, row.get("tags"));
|
||||
}
|
||||
|
||||
// S3 key, content type, status, and the index-derived title. The title formula lives in
|
||||
// the document package's DocumentTitleFactory (single source of truth, #726); by this point
|
||||
// applyDates has populated the date/location and originalFilename carries the index.
|
||||
// S3 key, content type, status, and the index-derived title.
|
||||
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
||||
DocumentStatus status) {
|
||||
DocumentStatus status, String index) {
|
||||
doc.setStatus(status);
|
||||
doc.setFilePath(s3Key);
|
||||
doc.setContentType(contentType);
|
||||
doc.setTitle(documentTitleFactory.build(doc));
|
||||
doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
||||
doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation()));
|
||||
}
|
||||
|
||||
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
||||
@@ -238,6 +235,20 @@ public class DocumentImporter {
|
||||
|| !doc.getReceivers().isEmpty());
|
||||
}
|
||||
|
||||
// The title carries the date at the HONEST precision (never a fabricated day) via the
|
||||
// shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating.
|
||||
private static String buildTitle(String index, LocalDate date, DatePrecision precision,
|
||||
LocalDate end, String raw, String location) {
|
||||
StringBuilder title = new StringBuilder(index);
|
||||
if (date != null && precision != DatePrecision.UNKNOWN) {
|
||||
title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw));
|
||||
}
|
||||
if (location != null && !location.isBlank()) {
|
||||
title.append(" – ").append(location);
|
||||
}
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||
|
||||
private Person resolveSender(String slug, String rawName) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -1,13 +0,0 @@
|
||||
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,8 +19,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
"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(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||
List<Person> searchByName(@Param("query") String query);
|
||||
|
||||
@@ -30,36 +29,14 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
|
||||
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
|
||||
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
|
||||
// add a unique(lower(alias)) constraint — see ADR-033.
|
||||
Optional<Person> findByAlias(String alias);
|
||||
|
||||
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
|
||||
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
|
||||
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
|
||||
List<Person> findAllByAliasIgnoreCase(String alias);
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
||||
Optional<Person> findBySourceRef(String sourceRef);
|
||||
|
||||
// Exact-case first+last name match — the first step of filename-based sender resolution.
|
||||
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
|
||||
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
|
||||
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
|
||||
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
|
||||
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
|
||||
@Param("lastName") String lastName);
|
||||
|
||||
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
|
||||
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
|
||||
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
|
||||
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
|
||||
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
|
||||
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
|
||||
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
|
||||
@Param("lastName") String lastName);
|
||||
// Exact first+last name match, used for filename-based sender lookup
|
||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||
|
||||
// --- PersonSummaryDTO with document count ---
|
||||
|
||||
@@ -212,15 +189,18 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||
|
||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
||||
// clearAutomatically + flushAutomatically keep the L1 cache from desyncing: these bulk
|
||||
// updates run beneath Hibernate, and mergePersons follows them with a deleteById whose
|
||||
// ON DELETE CASCADE (V71) also fires beneath the session.
|
||||
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Modifying
|
||||
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
||||
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
||||
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
|
||||
// delete cannot orphan a documents.sender_id FK (the column is nullable).
|
||||
@Modifying
|
||||
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
|
||||
void reassignSenderToNull(@Param("source") UUID source);
|
||||
|
||||
@Modifying
|
||||
@Query(value = """
|
||||
INSERT INTO document_receivers (document_id, person_id)
|
||||
SELECT document_id, :target FROM document_receivers
|
||||
@@ -230,4 +210,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
)
|
||||
""", nativeQuery = true)
|
||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
||||
}
|
||||
|
||||
@Modifying
|
||||
@Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true)
|
||||
void deleteReceiverReferences(@Param("source") UUID source);
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@@ -30,20 +23,11 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
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 PersonNameAliasRepository aliasRepository;
|
||||
|
||||
@@ -84,13 +68,15 @@ public class PersonService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a person used by triage. Referential integrity is enforced by the database
|
||||
* (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention
|
||||
* rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes.
|
||||
* Hard-deletes a person used by triage. Detaches the person from any documents they
|
||||
* sent (nulls sender_id) and from any received-document references first, so the delete
|
||||
* cannot orphan an FK and fail with a 500.
|
||||
*/
|
||||
@Transactional
|
||||
public void deletePerson(UUID id) {
|
||||
getById(id);
|
||||
personRepository.reassignSenderToNull(id);
|
||||
personRepository.deleteReceiverReferences(id);
|
||||
personRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@@ -114,96 +100,6 @@ public class PersonService {
|
||||
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() {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
@@ -216,19 +112,7 @@ public class PersonService {
|
||||
}
|
||||
|
||||
public Optional<Person> findByName(String firstName, String lastName) {
|
||||
// Same scope as findOrCreateByAlias (#731): a case-collision resolves without throwing;
|
||||
// two byte-identical same-case persons are an out-of-scope data anomaly the exact
|
||||
// Optional below would surface as the opaque INTERNAL_ERROR, not a wrong sender.
|
||||
Optional<Person> exact = personRepository.findByFirstNameAndLastName(firstName, lastName);
|
||||
if (exact.isPresent()) return exact;
|
||||
List<Person> caseInsensitive =
|
||||
personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
// Deliberate divergence from findOrCreateByAlias: an ambiguous filename leaves the sender
|
||||
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
|
||||
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
|
||||
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
|
||||
// lowest-id fallback. See ADR-033.
|
||||
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
|
||||
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
|
||||
@@ -243,45 +127,32 @@ public class PersonService {
|
||||
PersonType type = PersonTypeClassifier.classify(alias);
|
||||
if (type == PersonType.SKIP) return null;
|
||||
|
||||
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
|
||||
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
|
||||
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
|
||||
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
|
||||
// are a true data anomaly out of scope here; the exact Optional below would surface that
|
||||
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
|
||||
Optional<Person> exact = personRepository.findByAlias(alias);
|
||||
if (exact.isPresent()) return exact.get(); // exact-case wins
|
||||
List<Person> caseInsensitive = personRepository.findAllByAliasIgnoreCase(alias);
|
||||
if (!caseInsensitive.isEmpty()) {
|
||||
return caseInsensitive.stream().min(Comparator.comparing(Person::getId)).orElseThrow(); // deterministic tie-break — list is non-empty, never throws
|
||||
}
|
||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.lastName(alias)
|
||||
.personType(type)
|
||||
.build());
|
||||
}
|
||||
|
||||
// Create-when-absent: institution/group keep the full label in lastName; a person name
|
||||
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
|
||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||
return personRepository.save(Person.builder()
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
Person person = personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.lastName(alias)
|
||||
.personType(type)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
}
|
||||
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
Person person = personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
if (split.maidenName() != null) {
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(split.maidenName())
|
||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||
.sortOrder(nextSortOrder)
|
||||
.build());
|
||||
}
|
||||
return person;
|
||||
if (split.maidenName() != null) {
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(split.maidenName())
|
||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||
.sortOrder(nextSortOrder)
|
||||
.build());
|
||||
}
|
||||
return person;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,12 +295,6 @@ public class PersonService {
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the source person into the target, then deletes the source. Sender references move
|
||||
* to the target; receiver references the target lacks are inserted. The source's leftover
|
||||
* receiver join rows are not deleted explicitly — they cascade-drop via V71's
|
||||
* {@code ON DELETE CASCADE} on {@code document_receivers.person_id} when the source is deleted.
|
||||
*/
|
||||
@Transactional
|
||||
public void mergePersons(UUID sourceId, UUID targetId) {
|
||||
if (sourceId.equals(targetId)) {
|
||||
@@ -446,7 +311,9 @@ public class PersonService {
|
||||
// Add target as receiver where source is receiver but target is not yet
|
||||
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
||||
|
||||
// Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE.
|
||||
// Remove all remaining source receiver references (duplicates already handled)
|
||||
personRepository.deleteReceiverReferences(sourceId);
|
||||
|
||||
personRepository.deleteById(sourceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
||||
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `findByName(String firstName, String lastName)` | document | Typeahead search |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
|
||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||
| `count()` | dashboard | Total person count for stats |
|
||||
|
||||
@@ -20,14 +20,7 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
}
|
||||
|
||||
|
||||
// Tag-name resolution (see TagService.findOrCreate). Names that collide case-insensitively across
|
||||
// the canonical tree are VALID — a parent and its same-named lowercase child (e.g. "Geburt" /
|
||||
// "Geburt/geburt") are distinct nodes with their own source_ref and document attachments. So
|
||||
// resolution must be exact-case first, then a non-throwing list for the case-insensitive fallback.
|
||||
// Do NOT add a unique(lower(name)) constraint — it would reject these legitimate rows. See #730.
|
||||
Optional<Tag> findByName(String name);
|
||||
|
||||
List<Tag> findAllByNameIgnoreCase(String name);
|
||||
Optional<Tag> findByNameIgnoreCase(String name);
|
||||
|
||||
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
|
||||
Optional<Tag> findBySourceRef(String sourceRef);
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.tag;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -46,10 +45,6 @@ public class TagService {
|
||||
return enrichWithRelatives(matched);
|
||||
}
|
||||
|
||||
public List<Tag> findByNameContaining(String fragment) {
|
||||
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||
}
|
||||
|
||||
public Tag getById(UUID id) {
|
||||
return tagRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||
@@ -60,21 +55,10 @@ public class TagService {
|
||||
return tagRepository.findBySourceRef(sourceRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a tag name to a single tag, creating one when absent. Never throws on case-insensitive
|
||||
* collisions: names that differ only by case are valid distinct nodes in the canonical tree (a
|
||||
* parent and its same-named lowercase child), so resolution prefers an exact-case match, then
|
||||
* falls back to the lowest-id case-insensitive match, then creates. See #730.
|
||||
*/
|
||||
public Tag findOrCreate(String name) {
|
||||
String cleanName = name.trim();
|
||||
Optional<Tag> exact = tagRepository.findByName(cleanName);
|
||||
if (exact.isPresent()) return exact.get(); // exact-case wins (edit round-trip replays the stored name)
|
||||
List<Tag> caseInsensitive = tagRepository.findAllByNameIgnoreCase(cleanName);
|
||||
if (!caseInsensitive.isEmpty()) {
|
||||
return caseInsensitive.stream().min(Comparator.comparing(Tag::getId)).orElseThrow(); // deterministic tie-break by id — list is non-empty, never throws
|
||||
}
|
||||
return tagRepository.save(Tag.builder().name(cleanName).build()); // create-when-absent (orphan tag: null sourceRef/parentId)
|
||||
return tagRepository.findByNameIgnoreCase(cleanName)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,12 +51,6 @@ public class AdminController {
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-titles")
|
||||
public ResponseEntity<BackfillResult> backfillTitles() {
|
||||
int count = documentService.backfillTitles();
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/generate-thumbnails")
|
||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
||||
thumbnailBackfillService.runBackfillAsync();
|
||||
|
||||
@@ -11,4 +11,3 @@ springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
-- Move person-delete referential integrity from application code into the database (#684).
|
||||
--
|
||||
-- Before this migration, PersonService.deletePerson nulled documents.sender_id and removed
|
||||
-- document_receivers rows in Java before deleting the person, because the two V1 FKs into
|
||||
-- persons had no ON DELETE behaviour. Any other delete path (a future endpoint, a manual
|
||||
-- psql, a batch job) could still orphan rows or 500. This migration makes the database the
|
||||
-- single source of truth so a person delete is safe from every path.
|
||||
--
|
||||
-- Cascade boundary: the cascade stays STRICTLY at the join/reference layer and NEVER reaches
|
||||
-- documents rows — a cascade into documents would destroy historical letters. sender_id is
|
||||
-- SET NULL (documents.senderText preserves the raw textual attribution); the receiver join
|
||||
-- row and the @-mention sidecar row are dropped.
|
||||
--
|
||||
-- No NOT VALID + VALIDATE two-step: these tables are small (thousands of rows → sub-second
|
||||
-- ACCESS EXCLUSIVE lock). Do NOT copy this drop-and-recreate pattern onto a large table.
|
||||
--
|
||||
-- Not audit-logged: a DB ON DELETE cascade runs below AuditService — a known, accepted trade.
|
||||
-- The person-delete action itself is still logged at the service layer.
|
||||
|
||||
-- documents.sender_id → ON DELETE SET NULL (deleted sender clears the link; the document survives).
|
||||
ALTER TABLE public.documents
|
||||
DROP CONSTRAINT fkl5xhww7es3b4um01vmly4y18m,
|
||||
ADD CONSTRAINT fkl5xhww7es3b4um01vmly4y18m
|
||||
FOREIGN KEY (sender_id) REFERENCES public.persons(id) ON DELETE SET NULL;
|
||||
|
||||
-- document_receivers.person_id → ON DELETE CASCADE (drop the join row), the symmetric
|
||||
-- completion of V14, which added the same to the document_id side of this table.
|
||||
ALTER TABLE public.document_receivers
|
||||
DROP CONSTRAINT fkcg7r68qvosqricx1betgrlt7s,
|
||||
ADD CONSTRAINT fkcg7r68qvosqricx1betgrlt7s
|
||||
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||
|
||||
-- Soft reference fix: transcription_block_mentioned_persons.person_id was a UUID with no FK
|
||||
-- (V56), so deleting a person left dangling mention rows. Give it a real FK with CASCADE.
|
||||
-- This reverses V56's deliberate "no FK on person_id" choice — that comment is now historical
|
||||
-- but is intentionally left untouched, because editing an already-applied migration changes its
|
||||
-- Flyway checksum and would fail validateOnMigrate in prod. ADR-032 is the authoritative record.
|
||||
-- Clean up pre-existing orphans first — production likely holds dangling rows because the old
|
||||
-- deletePerson never cleaned mention rows, and the ADD CONSTRAINT validation scan fails on them.
|
||||
-- A DO block with RAISE NOTICE surfaces the purge count: Flyway runs each statement via JDBC
|
||||
-- and discards a trailing SELECT's result set, so a "SELECT count(*)" would log nothing.
|
||||
DO $$
|
||||
DECLARE removed int;
|
||||
BEGIN
|
||||
DELETE FROM transcription_block_mentioned_persons m
|
||||
WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);
|
||||
GET DIAGNOSTICS removed = ROW_COUNT;
|
||||
RAISE NOTICE 'V71 orphaned_mention_rows_removed=%', removed;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.transcription_block_mentioned_persons
|
||||
ADD CONSTRAINT fk_tbmp_person
|
||||
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||
@@ -1,73 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,21 +0,0 @@
|
||||
-- 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).
|
||||
|
||||
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);
|
||||
@@ -131,28 +131,6 @@ class DocumentLazyLoadingTest {
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
|
||||
// q + default sort + no other filters → the relevance fast path
|
||||
// (relevanceSortedPageFromSql), which loads documents by id outside any
|
||||
// transaction and must still deliver an initialized tags collection.
|
||||
Person sender = savedPerson("Hans", "FtSender");
|
||||
Tag tag = savedTag("FtTag");
|
||||
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||
|
||||
SearchFilters textOnly = new SearchFilters(
|
||||
"Walter", null, null, null, null, null, null, null, null, false);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
textOnly, null, "DESC", PageRequest.of(0, 10));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(1);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> i.tags().size()))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||
Person sender = savedPerson("Hans", "SsSender");
|
||||
|
||||
@@ -624,88 +624,4 @@ class DocumentRepositoryTest {
|
||||
.reviewed(reviewed)
|
||||
.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();
|
||||
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findByIdIn(any()))
|
||||
when(documentRepository.findAllById(any()))
|
||||
.thenReturn(List.of(doc(id1)));
|
||||
|
||||
documentService.searchDocuments(
|
||||
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
|
||||
List<Object[]> ftsRows = new ArrayList<>();
|
||||
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
|
||||
@@ -5,7 +5,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
@@ -75,9 +74,6 @@ class DocumentServiceTest {
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||
// shared composition rather than a stub — the #726 single source of truth.
|
||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -232,216 +228,6 @@ class DocumentServiceTest {
|
||||
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
|
||||
}
|
||||
|
||||
// ─── updateDocument save-time auto-title regeneration (#726) ──────────────
|
||||
//
|
||||
// Exact old-vs-new comparison: the title is the catalog auto-title iff the submitted
|
||||
// title equals what the factory builds from the CURRENTLY-persisted state. The edit form
|
||||
// round-trips the stored title verbatim when untouched, so an equal submission means the
|
||||
// user did not type over it. makeStored() seeds index/date/precision/location and sets the
|
||||
// stored title to the matching auto-title, mirroring a freshly-imported row.
|
||||
|
||||
private Document makeStored(String index, LocalDate date, DatePrecision precision, String location) {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename(index)
|
||||
.documentDate(date)
|
||||
.metaDatePrecision(precision)
|
||||
.location(location)
|
||||
.receivers(new HashSet<>())
|
||||
.tags(new HashSet<>())
|
||||
.build();
|
||||
doc.setTitle(documentTitleFactory.build(doc));
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** A DTO that round-trips the stored auto-title untouched, with new date/precision/location. */
|
||||
private static DocumentUpdateDTO editDto(String submittedTitle, LocalDate date,
|
||||
DatePrecision precision, String location) {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(submittedTitle);
|
||||
dto.setDocumentDate(date);
|
||||
dto.setMetaDatePrecision(precision);
|
||||
dto.setLocation(location);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Document runUpdate(Document stored, DocumentUpdateDTO dto) throws Exception {
|
||||
when(documentRepository.findById(stored.getId())).thenReturn(Optional.of(stored));
|
||||
when(documentRepository.save(any())).thenReturn(stored);
|
||||
documentService.updateDocument(stored.getId(), dto, null, null);
|
||||
return stored;
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesAutoTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// title untouched ("C-0029 – 2028 – Berlin"), date corrected to 1928
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_keepsHandWrittenTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
stored.setTitle("C-0029 – Brief an Mutter"); // hand-written, ≠ auto-title
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – Brief an Mutter", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Brief an Mutter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_freshlyTypedTitleWins_overRegeneration() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// user changed the date AND typed a new title in the same save
|
||||
DocumentUpdateDTO dto = editDto("Geburtsanzeige", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("Geburtsanzeige");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesWithNewDateAndLocation() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "München");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – München");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dropsTrailingLocationSegment_whenLocationCleared() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// location cleared (null), title untouched
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratedTitle_doesNotContainOldDate() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).doesNotContain("2028");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_relabelsOnPrecisionChange_yearToDay() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
// stored auto-title "C-0029 – 1928"; set a full day at DAY precision
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 15), DatePrecision.DAY, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 15. Januar 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_populatesTitle_whenDateAddedToUnknownRow() throws Exception {
|
||||
Document stored = makeStored("C-0029", null, DatePrecision.UNKNOWN, null);
|
||||
// stored auto-title is just "C-0029"; add a 1928 YEAR date
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_roundTripsSeasonLabel() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
stored.setMetaDateRaw("Frühling 1943");
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
dto.setMetaDateRaw("Frühling 1943");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_carriesStoredPrecisionAndRaw_whenDtoOmitsThem() throws Exception {
|
||||
// Only the year changes; precision/end/raw are omitted from the DTO, so projectedState
|
||||
// must carry them from the entity (exercises the skip-null effective* resolvers).
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
stored.setMetaDateRaw("Frühling 1943");
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1944, 4, 1), null, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1944");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_roundTripsRangeLabel_atSaveTime() throws Exception {
|
||||
Document stored = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename("C-0029")
|
||||
.documentDate(LocalDate.of(1917, 1, 10))
|
||||
.metaDatePrecision(DatePrecision.RANGE)
|
||||
.metaDateEnd(LocalDate.of(1917, 1, 11))
|
||||
.receivers(new HashSet<>())
|
||||
.tags(new HashSet<>())
|
||||
.build();
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – 10.–11. Jan. 1917"
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(stored.getTitle());
|
||||
dto.setDocumentDate(LocalDate.of(1918, 1, 10));
|
||||
dto.setMetaDatePrecision(DatePrecision.RANGE);
|
||||
dto.setMetaDateEnd(LocalDate.of(1918, 1, 11));
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 10.–11. Jan. 1918");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto("", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_treatsFileReplacedDoc_asManual() throws Exception {
|
||||
// originalFilename was reassigned by an earlier file-replace, so the stored title (built
|
||||
// at import from the old index) no longer matches build(currentState) → treated as manual.
|
||||
Document stored = makeStored("scan_2024.pdf", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stored.setTitle("C-0029 – 1928 – Berlin"); // legacy import title, ≠ build("scan_2024.pdf"…)
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – 1928 – Berlin", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_idempotent_whenNothingChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
String before = stored.getTitle();
|
||||
DocumentUpdateDTO dto = editDto(before, LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo(before);
|
||||
}
|
||||
|
||||
// ─── updateDocument date-range validation (#678) ──────────────────────────
|
||||
|
||||
/** Builds a stored doc ready for an updateDocument call (collections initialised). */
|
||||
@@ -695,59 +481,6 @@ class DocumentServiceTest {
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── backfillTitles — one-time stale-title cleanup (#726, FR-003) ─────────
|
||||
|
||||
@Test
|
||||
void backfillTitles_rewritesStaleAutoTitle_andCountsIt() {
|
||||
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stale.setTitle("C-0029 – 2028 – Berlin"); // stale stored title (date typo never fixed)
|
||||
when(documentRepository.findAll()).thenReturn(List.of(stale));
|
||||
when(documentRepository.save(any())).thenReturn(stale);
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isEqualTo(1);
|
||||
assertThat(stale.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
verify(documentRepository).save(stale);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_skipsProse() {
|
||||
Document prose = makeStored("C-0030", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
prose.setTitle("C-0030 – Brief an Mutter");
|
||||
when(documentRepository.findAll()).thenReturn(List.of(prose));
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isZero();
|
||||
assertThat(prose.getTitle()).isEqualTo("C-0030 – Brief an Mutter");
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_isIdempotent_forAlreadyCorrectTitle() {
|
||||
Document fresh = makeStored("C-0031", LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||
// title already equals build(current state) → nothing to do
|
||||
when(documentRepository.findAll()).thenReturn(List.of(fresh));
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isZero();
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_neverRecordsVersions() {
|
||||
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stale.setTitle("C-0029 – 2028 – Berlin");
|
||||
when(documentRepository.findAll()).thenReturn(List.of(stale));
|
||||
when(documentRepository.save(any())).thenReturn(stale);
|
||||
|
||||
documentService.backfillTitles();
|
||||
|
||||
verify(documentVersionService, never()).recordVersion(any());
|
||||
}
|
||||
|
||||
// ─── thumbnail dispatch ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -2166,7 +1899,7 @@ class DocumentServiceTest {
|
||||
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
||||
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
@@ -2202,7 +1935,7 @@ class DocumentServiceTest {
|
||||
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
||||
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
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.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end backfill against a real Postgres (#726, FR-003). H2 is unusable here — the
|
||||
* {@code title} column is NOT NULL and the title-sync semantics depend on that — so this pins the
|
||||
* behaviour on {@code postgres:16-alpine}: a stale auto-title is rewritten, the sweep is
|
||||
* idempotent, prose is left alone, and the mechanical rename writes no {@code document_versions}
|
||||
* rows. Permission enforcement (401/403) is covered faster by the {@code @WebMvcTest} slice in
|
||||
* {@code AdminControllerTest}.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class DocumentTitleBackfillIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired DocumentVersionRepository documentVersionRepository;
|
||||
|
||||
private Document persist(String index, String title, LocalDate date, DatePrecision precision, String location) {
|
||||
return documentRepository.save(Document.builder()
|
||||
.originalFilename(index)
|
||||
.title(title)
|
||||
.documentDate(date)
|
||||
.metaDatePrecision(precision)
|
||||
.location(location)
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_rewritesStaleAutoTitle() {
|
||||
Document stale = persist("C-0029", "C-0029 – 2028 – Berlin",
|
||||
LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isEqualTo(1); // exactly the one stale row seeded (clean test DB)
|
||||
assertThat(documentRepository.findById(stale.getId()).orElseThrow().getTitle())
|
||||
.isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_isIdempotent_secondRunChangesNothing() {
|
||||
persist("C-0029", "C-0029 – 2028 – Berlin", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
documentService.backfillTitles();
|
||||
int secondRun = documentService.backfillTitles();
|
||||
|
||||
assertThat(secondRun).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_skipsProse() {
|
||||
Document prose = persist("C-0030", "C-0030 – Brief an Mutter",
|
||||
LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
documentService.backfillTitles();
|
||||
|
||||
assertThat(documentRepository.findById(prose.getId()).orElseThrow().getTitle())
|
||||
.isEqualTo("C-0030 – Brief an Mutter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_addsNoDocumentVersionRows() {
|
||||
persist("C-0029", "C-0029 – 2028 – Berlin", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
long versionsBefore = documentVersionRepository.count();
|
||||
|
||||
documentService.backfillTitles();
|
||||
|
||||
assertThat(documentVersionRepository.count()).isEqualTo(versionsBefore);
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The backfill overwrite heuristic (FR-004) in isolation — every emittable date-label form is
|
||||
* recognised, prose is left alone, and a regex-metacharacter index is matched literally without
|
||||
* hanging. The exact label spellings mirror {@code docs/date-label-fixtures.json}.
|
||||
*/
|
||||
class DocumentTitleBackfillMatcherTest {
|
||||
|
||||
private static boolean overwritable(String title, String location) {
|
||||
return DocumentTitleBackfillMatcher.isOverwritable(title, "C-0029", location);
|
||||
}
|
||||
|
||||
// ─── each date-label form (index + form) is overwritable ──────────────────
|
||||
|
||||
@Test
|
||||
void year_form() {
|
||||
assertThat(overwritable("C-0029 – 1916", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void approx_form() {
|
||||
assertThat(overwritable("C-0029 – ca. 1920", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void month_form() {
|
||||
assertThat(overwritable("C-0029 – Juni 1916", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void day_form() {
|
||||
assertThat(overwritable("C-0029 – 24. Dezember 1943", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void season_form() {
|
||||
assertThat(overwritable("C-0029 – Sommer 1916", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_label_form() {
|
||||
assertThat(overwritable("C-0029 – Datum unbekannt", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_same_month_form() {
|
||||
assertThat(overwritable("C-0029 – 10.–11. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_cross_month_form() {
|
||||
assertThat(overwritable("C-0029 – 30. Jan. – 2. Feb. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_cross_year_form() {
|
||||
assertThat(overwritable("C-0029 – 30. Dez. 1916 – 2. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_single_day_form() {
|
||||
assertThat(overwritable("C-0029 – 10. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_open_form() {
|
||||
assertThat(overwritable("C-0029 – ab 10. Jan. 1917", null)).isTrue();
|
||||
}
|
||||
|
||||
// ─── date label + trailing location (any location) ────────────────────────
|
||||
|
||||
@Test
|
||||
void date_form_with_trailing_location() {
|
||||
assertThat(overwritable("C-0029 – 1916 – Berlin", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void range_with_internal_separator_plus_trailing_location() {
|
||||
// The range label itself contains " – "; the trailing " – Berlin" must still be peeled.
|
||||
assertThat(overwritable("C-0029 – 30. Jan. – 2. Feb. 1917 – Berlin", null)).isTrue();
|
||||
}
|
||||
|
||||
// ─── index-only and index+location cases ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void exactly_index() {
|
||||
assertThat(overwritable("C-0029", null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_plus_location_equal_to_current() {
|
||||
assertThat(overwritable("C-0029 – Berlin", "Berlin")).isTrue();
|
||||
}
|
||||
|
||||
// ─── prose is left untouched ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void prose_segment_not_matching_location_is_skipped() {
|
||||
assertThat(overwritable("C-0029 – Brief an Mutter", "Berlin")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void location_only_segment_is_skipped_when_no_current_location() {
|
||||
// No date label, and the doc has no location to compare against → cannot prove machine.
|
||||
assertThat(overwritable("C-0029 – Berlin", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void title_not_starting_with_index_is_skipped() {
|
||||
assertThat(overwritable("Ganz anderer Titel", null)).isFalse();
|
||||
}
|
||||
|
||||
// ─── near-miss: shapes that look almost machine-built but are not ──────────
|
||||
|
||||
@Test
|
||||
void ascii_hyphen_instead_of_en_dash_separator_is_skipped() {
|
||||
// The separator is " – " (en dash); a plain " - " is not the machine separator.
|
||||
assertThat(overwritable("C-0029 - 1916", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void date_label_without_separator_before_trailing_text_is_skipped() {
|
||||
// "1916 Berlin" is not a date label and is not joined by " – "; prose, not machine.
|
||||
assertThat(overwritable("C-0029 – 1916 Berlin", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void year_with_trailing_letters_is_not_a_year_label() {
|
||||
assertThat(overwritable("C-0029 – 1916er Brief", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_immediately_followed_by_text_without_separator_is_skipped() {
|
||||
assertThat(overwritable("C-0029x – 1916", null)).isFalse();
|
||||
}
|
||||
|
||||
// ─── fail-closed guards ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void null_title_is_not_overwritable() {
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(null, "C-0029", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void null_index_is_not_overwritable() {
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable("C-0029 – 1916", null, null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blank_index_is_not_overwritable() {
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(" – 1916", " ", null)).isFalse();
|
||||
}
|
||||
|
||||
// ─── ReDoS / regex-metacharacter index is matched literally and terminates ─
|
||||
|
||||
@Test
|
||||
@Timeout(value = 5, unit = TimeUnit.SECONDS)
|
||||
void index_with_regex_metacharacters_is_matched_literally_and_terminates() {
|
||||
String hostileIndex = "C-0029(.*).pdf";
|
||||
// Literal prefix → matches; trailing date label → overwritable. Must not hang.
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(
|
||||
hostileIndex + " – 1916", hostileIndex, null)).isTrue();
|
||||
// A title that does NOT start with the literal hostile index is skipped, also fast.
|
||||
assertThat(DocumentTitleBackfillMatcher.isOverwritable(
|
||||
"C-0029 – 1916", hostileIndex, null)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The auto-title composition {@code {index} – {dateLabel} – {location}} in isolation.
|
||||
* The honest date-label forms themselves are pinned by {@link DocumentTitleFormatterTest}
|
||||
* against the shared #666 fixture; here we assert only how the factory composes the
|
||||
* three segments and which segments it omits.
|
||||
*/
|
||||
class DocumentTitleFactoryTest {
|
||||
|
||||
private final DocumentTitleFactory factory = new DocumentTitleFactory();
|
||||
|
||||
private static Document.DocumentBuilder doc(String index) {
|
||||
return Document.builder()
|
||||
.originalFilename(index)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_only_when_no_date_and_no_location() {
|
||||
assertThat(factory.build(doc("C-0029").build())).isEqualTo("C-0029");
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_and_year_date() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void index_date_and_location() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.location("Berlin")
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void location_without_date_attaches_directly_to_index() {
|
||||
Document d = doc("C-0029").location("Berlin").build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_precision_omits_the_date_segment() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blank_location_is_omitted() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.location(" ")
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bare_document_with_null_index_builds_empty_string_not_npe() {
|
||||
// originalFilename is NOT NULL in production; the guard keeps a synthetic/partial entity
|
||||
// from tripping StringBuilder(null) with an opaque NPE.
|
||||
assertThat(factory.build(Document.builder().build())).isEqualTo("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void day_precision_renders_the_full_german_label() {
|
||||
Document d = doc("C-0029")
|
||||
.documentDate(LocalDate.of(1928, 1, 15))
|
||||
.metaDatePrecision(DatePrecision.DAY)
|
||||
.build();
|
||||
assertThat(factory.build(d)).isEqualTo("C-0029 – 15. Januar 1928");
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* #730 — tag-name resolution against a real Postgres. A mocked repo can't prove the two things that
|
||||
* actually break: that {@code findAllByNameIgnoreCase} folds case the way Postgres {@code LOWER()}
|
||||
* does (critical for umlauts like {@code ü}), and that saving a document tagged with a case-colliding
|
||||
* tag no longer throws {@code NonUniqueResultException}. H2 folds case differently, so this pins the
|
||||
* behaviour on {@code postgres:16-alpine}. The four-branch resolution logic itself is covered faster
|
||||
* by the mocked {@code TagServiceTest}.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class TagCaseCollisionIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
@Autowired TagService tagService;
|
||||
|
||||
private Tag persistTag(String name, String sourceRef, UUID parentId) {
|
||||
return tagRepository.save(Tag.builder().name(name).sourceRef(sourceRef).parentId(parentId).build());
|
||||
}
|
||||
|
||||
private Document persistDocTaggedWith(Tag tag) {
|
||||
return documentRepository.save(Document.builder()
|
||||
.originalFilename("C-7301")
|
||||
.title("Weihnachtsbrief")
|
||||
.documentDate(LocalDate.of(1928, 1, 1))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.tags(new HashSet<>(Set.of(tag)))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_succeedsAndKeepsExactChildTag_whenTaggedWithCaseCollidingChild() throws Exception {
|
||||
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
|
||||
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
|
||||
Document doc = persistDocTaggedWith(child);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Weihnachtsbrief");
|
||||
dto.setDocumentDate(LocalDate.of(1930, 1, 1)); // change the date — the field that 500'd on staging
|
||||
dto.setMetaDatePrecision(DatePrecision.YEAR);
|
||||
dto.setTags("weihnachten"); // the edit form round-trips the stored child name
|
||||
|
||||
assertThatCode(() -> documentService.updateDocument(doc.getId(), dto, null, null))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
|
||||
assertThat(tags).hasSize(1);
|
||||
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId()); // child kept, not the parent
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_resolvesUmlautCollisionDeterministically_withoutThrow() {
|
||||
// The regression catcher: a plain-ASCII pair would stay green even if Postgres folded ü wrongly.
|
||||
Tag parent = persistTag("Glückwünsche", "Glückwünsche", null);
|
||||
Tag child = persistTag("glückwünsche", "Glückwünsche/glückwünsche", parent.getId());
|
||||
|
||||
// Proof that real Postgres LOWER() folds the umlaut so both rows match case-insensitively.
|
||||
// Query with the UPPERCASE form findOrCreate actually passes — folding LOWER('GLÜCKWÜNSCHE')
|
||||
// against LOWER(name) is the exact step under test; a lowercase probe wouldn't exercise it.
|
||||
assertThat(tagRepository.findAllByNameIgnoreCase("GLÜCKWÜNSCHE")).hasSize(2);
|
||||
|
||||
// No exact-case "GLÜCKWÜNSCHE" row exists → resolution falls through to the case-insensitive
|
||||
// branch with two candidates and must pick the lowest id deterministically, never throwing.
|
||||
UUID expected = List.of(parent, child).stream().min(Comparator.comparing(Tag::getId)).orElseThrow().getId();
|
||||
Tag first = tagService.findOrCreate("GLÜCKWÜNSCHE");
|
||||
Tag second = tagService.findOrCreate("GLÜCKWÜNSCHE");
|
||||
|
||||
assertThat(first.getId()).isEqualTo(expected);
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkEdit_resolvesCaseCollidingTagThroughFindOrCreate_withoutThrow() {
|
||||
// Bulk-edit shares resolveTags → findOrCreate; this guards a future refactor that bypasses it.
|
||||
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
|
||||
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.originalFilename("C-7302")
|
||||
.title("Brief")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
|
||||
DocumentBulkEditDTO dto = new DocumentBulkEditDTO();
|
||||
dto.setTagNames(List.of("weihnachten"));
|
||||
|
||||
assertThatCode(() -> documentService.applyBulkEditToDocument(doc.getId(), dto, null))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
|
||||
assertThat(tags).hasSize(1);
|
||||
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId());
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import org.raddatz.familienarchiv.document.annotation.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.transcription.PersonMention;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
@@ -32,7 +30,6 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
@Autowired TranscriptionBlockRepository blockRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AnnotationRepository annotationRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
private UUID documentId;
|
||||
@@ -58,9 +55,8 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
|
||||
@Test
|
||||
void mentionedPersons_roundTripsTwoEntries() {
|
||||
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||
UUID auguste = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||
UUID hermann = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||
UUID auguste = UUID.randomUUID();
|
||||
UUID hermann = UUID.randomUUID();
|
||||
|
||||
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId)
|
||||
@@ -101,9 +97,8 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
|
||||
@Test
|
||||
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||
UUID augusteId = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||
UUID hermannId = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||
UUID augusteId = UUID.randomUUID();
|
||||
UUID hermannId = UUID.randomUUID();
|
||||
|
||||
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId).documentId(documentId)
|
||||
|
||||
@@ -12,7 +12,6 @@ import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -38,30 +37,6 @@ class GlobalExceptionHandlerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeak() {
|
||||
// #731: before the fix, a case-colliding alias/name made Hibernate throw
|
||||
// NonUniqueResultException → IncorrectResultSizeDataAccessException, which has no
|
||||
// dedicated handler and falls through to handleGeneric. The fix removes the throw, but
|
||||
// this pins the handler: a stray one must stay opaque — no Hibernate class name, no SQL,
|
||||
// no "2 results were returned" row count reaching the client (CWE-209).
|
||||
IncorrectResultSizeDataAccessException ex = new IncorrectResultSizeDataAccessException(
|
||||
"query did not return a unique result: 2 results were returned", 1, 2);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(500);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||
assertThat(response.getBody().message())
|
||||
.isEqualTo("An unexpected error occurred")
|
||||
.doesNotContain("results were returned")
|
||||
.doesNotContain("NonUnique")
|
||||
.doesNotContain("IncorrectResultSize");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() {
|
||||
// A DataIntegrityViolationException carries the constraint name + SQL in its message;
|
||||
|
||||
@@ -2,13 +2,15 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -19,25 +21,22 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.nullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(GeschichteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -48,9 +47,11 @@ class GeschichteControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean GeschichteService geschichteService;
|
||||
@MockitoBean JourneyItemService journeyItemService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
@MockitoBean
|
||||
GeschichteService geschichteService;
|
||||
|
||||
@MockitoBean
|
||||
CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@@ -64,7 +65,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_returns200_forReader() throws Exception {
|
||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of(summaryStub("Story A")));
|
||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -106,7 +107,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns200_whenFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
|
||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
.andExpect(status().isOk())
|
||||
@@ -118,7 +119,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getView(id))
|
||||
when(geschichteService.getById(id))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
@@ -150,7 +151,7 @@ class GeschichteControllerTest {
|
||||
void create_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
|
||||
.thenReturn(draft(id, "New"));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("New");
|
||||
@@ -178,7 +179,7 @@ class GeschichteControllerTest {
|
||||
void update_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
|
||||
.thenReturn(published(id, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -207,202 +208,31 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).delete(id);
|
||||
}
|
||||
|
||||
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void appendItem_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Note\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(itemId.toString()))
|
||||
.andExpect(jsonPath("$.position").value(10));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Updated\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value("Updated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, null));
|
||||
|
||||
// Raw JSON — local objectMapper lacks JsonNullableModule
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\": null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(journeyItemService).delete(id, itemId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
|
||||
.when(journeyItemService).delete(id, itemId);
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void reorderItems_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
|
||||
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
|
||||
}
|
||||
|
||||
// ─── error mapping ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns409_on_position_conflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||
return new JourneyItemView(id, position, null, note);
|
||||
private Geschichte published(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.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 GeschichteView viewStub(UUID id, String title) {
|
||||
return viewStub(id, title, GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
|
||||
return new GeschichteView(id, title, "<p>x</p>",
|
||||
status, GeschichteType.STORY,
|
||||
null, new HashSet<>(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||
private GeschichteSummary summaryStub(String title) {
|
||||
return new GeschichteSummary() {
|
||||
public UUID getId() { return UUID.randomUUID(); }
|
||||
public String getTitle() { return title; }
|
||||
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
|
||||
public GeschichteType getType() { return GeschichteType.STORY; }
|
||||
public AuthorSummary getAuthor() { return null; }
|
||||
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
|
||||
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
|
||||
public String getBody() { return null; }
|
||||
};
|
||||
private Geschichte draft(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,12 +8,9 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteView;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -42,7 +39,6 @@ class GeschichteServiceIntegrationTest {
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteService geschichteService;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@@ -80,11 +76,11 @@ class GeschichteServiceIntegrationTest {
|
||||
+ "<script>alert('xss')</script>");
|
||||
dto.setPersonIds(List.of(franz.getId()));
|
||||
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.body())
|
||||
assertThat(created.getId()).isNotNull();
|
||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.getBody())
|
||||
.contains("<strong>jeden Sonntag</strong>")
|
||||
.doesNotContain("<script>");
|
||||
|
||||
@@ -93,7 +89,7 @@ class GeschichteServiceIntegrationTest {
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||
|
||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||
UUID draftId = created.id();
|
||||
UUID draftId = created.getId();
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||
.hasMessageContaining("not found");
|
||||
|
||||
@@ -101,17 +97,16 @@ class GeschichteServiceIntegrationTest {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.publishedAt()).isNotNull();
|
||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
||||
|
||||
// Reader can now see and fetch it
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||
Geschichte fetched = geschichteService.getById(draftId);
|
||||
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
|
||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||
|
||||
// Delete as writer; join rows go with it
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
@@ -142,17 +137,17 @@ class GeschichteServiceIntegrationTest {
|
||||
|
||||
// No filter → all three
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.extracting(Geschichte::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// Single filter (Anna) → all three
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.extracting(Geschichte::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.extracting(Geschichte::getId)
|
||||
.containsExactly(storyAB);
|
||||
|
||||
// AND: Bertha AND Carl → none (no story has both)
|
||||
@@ -179,7 +174,7 @@ class GeschichteServiceIntegrationTest {
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
@@ -190,7 +185,7 @@ class GeschichteServiceIntegrationTest {
|
||||
dto.setBody("<p>body</p>");
|
||||
dto.setPersonIds(personIds);
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
return geschichteService.create(dto).id();
|
||||
return geschichteService.create(dto).getId();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
|
||||
@@ -7,22 +7,26 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -33,10 +37,7 @@ import java.util.stream.Collectors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -44,13 +45,17 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteServiceTest {
|
||||
|
||||
@Mock GeschichteRepository geschichteRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock UserService userService;
|
||||
@Mock JourneyItemService journeyItemService;
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
@Mock
|
||||
PersonService personService;
|
||||
@Mock
|
||||
DocumentService documentService;
|
||||
@Mock
|
||||
UserService userService;
|
||||
|
||||
@InjectMocks GeschichteService geschichteService;
|
||||
@InjectMocks
|
||||
GeschichteService geschichteService;
|
||||
|
||||
AppUser writer;
|
||||
AppUser reader;
|
||||
@@ -91,8 +96,7 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(result).isSameAs(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -104,8 +108,7 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(result).isSameAs(published);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,175 +123,79 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ─── getView ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
||||
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
|
||||
|
||||
GeschichteView view = geschichteService.getView(id);
|
||||
|
||||
assertThat(view.id()).isEqualTo(id);
|
||||
assertThat(view.items()).containsExactly(item);
|
||||
verify(journeyItemService).getItems(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_throws_NOT_FOUND_when_id_unknown() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.getView(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_uses_firstName_lastName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("author@test")
|
||||
.firstName("Hans").lastName("Raddatz").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("anon@test").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_email_is_not_in_author_view() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("secret@test")
|
||||
.firstName("Max").lastName("M").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
// AuthorView exposes only id + displayName — no email field at all
|
||||
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
|
||||
assertThat(result.author().displayName()).doesNotContain("secret@test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_persons_are_mapped_to_PersonView() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID personId = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setPersons(new HashSet<>(List.of(
|
||||
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
|
||||
)));
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.persons()).hasSize(1);
|
||||
GeschichteView.PersonView pv = result.persons().iterator().next();
|
||||
assertThat(pv.id()).isEqualTo(personId);
|
||||
assertThat(pv.firstName()).isEqualTo("Franz");
|
||||
assertThat(pv.lastName()).isEqualTo("Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_items_are_passed_through() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
||||
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
||||
// through the spec-aware repository method.
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteSummary s1 = mock(GeschichteSummary.class);
|
||||
GeschichteSummary s2 = mock(GeschichteSummary.class);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(s1, s2));
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
||||
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
assertThat(out).hasSize(2);
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(personId), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
|
||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(a, b), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_passes_documentId_to_repository_as_journey_item_filter() {
|
||||
void list_filters_by_documentId() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), documentId, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_when_caller_passes_huge_value() {
|
||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(mock(GeschichteSummary.class)));
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
|
||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||
}
|
||||
@@ -306,11 +213,11 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("My Story");
|
||||
dto.setBody("<p>plain text</p>");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
assertThat(saved.author().id()).isEqualTo(writer.getId());
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -324,9 +231,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("XSS attempt");
|
||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.body())
|
||||
assertThat(saved.getBody())
|
||||
.contains("<p>safe</p>")
|
||||
.doesNotContain("<script>")
|
||||
.doesNotContain("onerror")
|
||||
@@ -345,9 +252,9 @@ class GeschichteServiceTest {
|
||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
||||
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.body())
|
||||
assertThat(saved.getBody())
|
||||
.contains("<h2>Heading</h2>")
|
||||
.contains("<strong>bold</strong>")
|
||||
.contains("<em>italic</em>")
|
||||
@@ -370,9 +277,28 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("Linked");
|
||||
dto.setPersonIds(List.of(personId));
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
|
||||
assertThat(saved.getPersons()).containsExactly(person);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_resolves_documentIds_via_DocumentService() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).build();
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Linked doc");
|
||||
dto.setDocumentIds(List.of(docId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getDocuments()).containsExactly(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -389,94 +315,6 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_preserves_JOURNEY_type_from_dto() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Journey");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_defaults_to_STORY_when_type_is_null() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Story");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
// The journey intro is plain text: JourneyReader renders it via Svelte text
|
||||
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
|
||||
// would corrupt real content ("Müller & Söhne" → "Müller & Söhne") and
|
||||
// re-encode cumulatively on every editor round-trip.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("Müller & Söhne, Temperatur < 0");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.JOURNEY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("Temperatur < 0 & Schnee");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_still_sanitizes_STORY_body() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
// ─── update ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -492,10 +330,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.publishedAt()).isNotNull();
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.getPublishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -511,10 +349,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.DRAFT);
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -528,46 +366,9 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
|
||||
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");
|
||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -625,7 +426,7 @@ class GeschichteServiceTest {
|
||||
.body("<p>body</p>")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.persons(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -637,7 +438,7 @@ class GeschichteServiceTest {
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||
.persons(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class JourneyItemIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("journey-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = documentRepository.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurity() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
var authorities = java.util.Arrays.stream(permissions)
|
||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||
.toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||
}
|
||||
|
||||
// ─── @OrderBy ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void items_are_returned_in_position_order_regardless_of_insertion_order() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
|
||||
// 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();
|
||||
|
||||
// Delete document — ON DELETE SET NULL fires at DB level
|
||||
documentRepository.deleteById(doc.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("still here");
|
||||
}
|
||||
|
||||
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
|
||||
|
||||
@Test
|
||||
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem empty = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).build();
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
journeyItemRepository.save(empty);
|
||||
journeyItemRepository.flush();
|
||||
}).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
|
||||
|
||||
@Test
|
||||
void append_persists_item_at_position_10() {
|
||||
// Arrange: authenticate as a user with BLOG_WRITE
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("First stop");
|
||||
|
||||
// Act
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item exists in DB at position 10
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.note()).isEqualTo("First stop");
|
||||
List<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);
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
}
|
||||
@@ -1,745 +0,0 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JourneyItemServiceTest {
|
||||
|
||||
@Mock JourneyItemRepository journeyItemRepository;
|
||||
@Mock GeschichteQueryService geschichteQueryService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock UserService userService;
|
||||
|
||||
@InjectMocks JourneyItemService journeyItemService;
|
||||
|
||||
UUID geschichteId = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setupAuth() {
|
||||
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
|
||||
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
|
||||
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("test@test.de", null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
// ─── toSummary — name composition ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void toSummary_uses_linked_person_firstName_lastName() {
|
||||
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
|
||||
Document doc = makeDoc(docId, sender, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_falls_back_to_senderText_when_no_person() {
|
||||
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Familie Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(0);
|
||||
assertThat(summary.receiverName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
|
||||
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
|
||||
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
|
||||
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(2);
|
||||
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_SEASON_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.SEASON);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_APPROX_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.APPROX);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
|
||||
}
|
||||
|
||||
// ─── append ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void append_to_empty_journey_starts_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.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_returns409_on_non_JOURNEY_type() {
|
||||
Geschichte story = Geschichte.builder()
|
||||
.id(geschichteId)
|
||||
.title("Story")
|
||||
.type(GeschichteType.STORY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY() {
|
||||
// Arrange: mock geschichteQueryService.findById() to return a STORY-type Geschichte
|
||||
UUID storyId = UUID.randomUUID();
|
||||
Geschichte story = Geschichte.builder()
|
||||
.id(storyId)
|
||||
.type(GeschichteType.STORY)
|
||||
.build();
|
||||
when(geschichteQueryService.findById(storyId)).thenReturn(Optional.of(story));
|
||||
|
||||
// Act + Assert: calling append throws GESCHICHTE_TYPE_MISMATCH
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
assertThatThrownBy(() -> journeyItemService.append(storyId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
// Verify: document service was never touched — type guard fired first
|
||||
verifyNoInteractions(documentService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns404_when_documentId_does_not_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(documentService.findSummaryByIdInternal(docId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_100_items_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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 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_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 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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -60,10 +59,8 @@ class DocumentImporterTest {
|
||||
// override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
|
||||
lenient().when(fileStreamOpener.open(any(File.class)))
|
||||
.thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
|
||||
// Real factory (pure, dependency-free) so the title-content assertions below exercise
|
||||
// the shared composition rather than a stub — the #726 single source of truth.
|
||||
importer = new DocumentImporter(documentService, new DocumentTitleFactory(), personService,
|
||||
tagService, s3Client, thumbnailAsyncRunner, fileStreamOpener);
|
||||
importer = new DocumentImporter(documentService, personService, tagService, s3Client,
|
||||
thumbnailAsyncRunner, fileStreamOpener);
|
||||
ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -21,7 +21,6 @@ import jakarta.persistence.PersistenceContext;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -121,60 +120,37 @@ class PersonRepositoryTest {
|
||||
.containsExactly("Anna", "Clara");
|
||||
}
|
||||
|
||||
// ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───
|
||||
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByAlias_returnsExactCaseMatchOnly() {
|
||||
void findByAliasIgnoreCase_returnsMatchingPerson() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
|
||||
|
||||
assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
|
||||
assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
|
||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getFirstName()).isEqualTo("Karl");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||
assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).isEmpty();
|
||||
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
|
||||
|
||||
assertThat(found).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByAliasIgnoreCase_foldsUmlautCase_inRealPostgres() {
|
||||
// Proves Postgres LOWER() folds ü the same way for both rows — a plain-ASCII probe would
|
||||
// stay green even if umlaut folding regressed. Both case-colliding aliases must match.
|
||||
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
|
||||
assertThat(personRepository.findAllByAliasIgnoreCase("MÜLLER")).hasSize(2);
|
||||
}
|
||||
|
||||
// ─── findByFirstNameAndLastName (exact) / findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase ───
|
||||
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
|
||||
|
||||
@Test
|
||||
void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
|
||||
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
|
||||
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
|
||||
|
||||
assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
|
||||
assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
|
||||
}
|
||||
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
|
||||
"maria", "raddatz");
|
||||
|
||||
@Test
|
||||
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
|
||||
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||
|
||||
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
|
||||
.hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
|
||||
// Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
|
||||
// NULL and pull in the institution/last-name-only row as a "sender". Proven on real
|
||||
// Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
|
||||
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||
|
||||
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
|
||||
.isEmpty();
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getFirstName()).isEqualTo("Maria");
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ───────────────────────────────────────────────────
|
||||
@@ -390,6 +366,30 @@ class PersonRepositoryTest {
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
|
||||
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document doc1 = documentRepository.save(Document.builder()
|
||||
.title("Brief 1").originalFilename("b1.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||
Document doc2 = documentRepository.save(Document.builder()
|
||||
.title("Brief 2").originalFilename("b2.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||
|
||||
personRepository.deleteReceiverReferences(toDelete.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── searchByName with aliases ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -428,67 +428,6 @@ class PersonRepositoryTest {
|
||||
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 ────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -768,146 +707,4 @@ class PersonRepositoryTest {
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getGeneration()).isNull();
|
||||
}
|
||||
|
||||
// ─── #684: ON DELETE integrity enforced at the database layer ──────────────
|
||||
// A raw deleteById (bypassing PersonService) must keep referential integrity:
|
||||
// documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the
|
||||
// transcription_block_mentioned_persons soft reference → CASCADE. These run against
|
||||
// real Postgres because the FK ON DELETE behaviour never fires on H2.
|
||||
|
||||
@Test
|
||||
void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() {
|
||||
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build());
|
||||
|
||||
Document sent = documentRepository.save(Document.builder()
|
||||
.title("Gesendet").originalFilename("sent.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(target).build());
|
||||
Document received = documentRepository.save(Document.builder()
|
||||
.title("Empfangen").originalFilename("received.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||
.receivers(Set.of(target)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||
|
||||
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
|
||||
assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL
|
||||
|
||||
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
|
||||
assertThat(reloadedReceived.getReceivers())
|
||||
.noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row
|
||||
|
||||
// Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference
|
||||
// layer — both documents themselves survive. Guards against a future migration turning
|
||||
// documents.sender_id SET NULL into CASCADE and destroying historical letters.
|
||||
assertThat(documentRepository.findById(sent.getId())).isPresent();
|
||||
assertThat(documentRepository.findById(received.getId())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() {
|
||||
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||
.receivers(Set.of(target, coReceiver)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||
.containsExactly(coReceiver.getId()); // co-receiver untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() {
|
||||
// AC-8: the trickier same-document interaction the cross-document cases don't exercise.
|
||||
Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Selbstbrief").originalFilename("self.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(target)
|
||||
.receivers(Set.of(target, coReceiver)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
assertThat(reloaded.getSender()).isNull();
|
||||
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||
.containsExactly(coReceiver.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() {
|
||||
// AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives
|
||||
// in transcription_blocks.text and must stay visible as plain text after the person goes.
|
||||
Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build());
|
||||
Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build());
|
||||
entityManager.flush();
|
||||
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) "
|
||||
+ "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')")
|
||||
.setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)")
|
||||
.setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId())
|
||||
.setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate();
|
||||
// Two mention rows on the same block: the deleted person and an innocent bystander.
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||
+ "VALUES (?1, ?2, ?3)")
|
||||
.setParameter(1, blockId).setParameter(2, mentioned.getId())
|
||||
.setParameter(3, "Auguste Raddatz").executeUpdate();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||
+ "VALUES (?1, ?2, ?3)")
|
||||
.setParameter(1, blockId).setParameter(2, survivor.getId())
|
||||
.setParameter(3, "Clara Cram").executeUpdate();
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(mentioned.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Number mentionRows = (Number) entityManager.createNativeQuery(
|
||||
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||
.setParameter(1, mentioned.getId()).getSingleResult();
|
||||
assertThat(mentionRows.longValue()).isZero();
|
||||
|
||||
// The cascade is scoped to the deleted person — the bystander's mention row is untouched.
|
||||
Number survivorRows = (Number) entityManager.createNativeQuery(
|
||||
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||
.setParameter(1, survivor.getId()).getSingleResult();
|
||||
assertThat(survivorRows.longValue()).isEqualTo(1);
|
||||
|
||||
String text = (String) entityManager.createNativeQuery(
|
||||
"SELECT text FROM transcription_blocks WHERE id = ?1")
|
||||
.setParameter(1, blockId).getSingleResult();
|
||||
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
@@ -17,13 +16,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -37,7 +33,6 @@ class PersonServiceIntegrationTest {
|
||||
@Autowired PersonService personService;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired DocumentService documentService;
|
||||
|
||||
@PersistenceContext EntityManager entityManager;
|
||||
|
||||
@@ -80,93 +75,6 @@ class PersonServiceIntegrationTest {
|
||||
assertThat(result.getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
// ─── #731: case-colliding alias resolution against real Postgres ───────────
|
||||
// The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
|
||||
// plain-ASCII test would stay green while umlaut aliases regressed.
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
|
||||
Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();
|
||||
|
||||
// No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
|
||||
// candidates and must pick the lowest id, never throwing NonUniqueResultException.
|
||||
Person resolved = personService.findOrCreateByAlias("MÜLLER");
|
||||
|
||||
assertThat(resolved.getId()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
|
||||
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
|
||||
Person first = personService.findOrCreateByAlias("MÜLLER");
|
||||
Person second = personService.findOrCreateByAlias("MÜLLER");
|
||||
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
}
|
||||
|
||||
// ─── #731: filename-based sender resolution against real Postgres ──────────
|
||||
|
||||
@Test
|
||||
void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
|
||||
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNotNull();
|
||||
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
|
||||
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
|
||||
// Filename folds to "hans müller"; the only stored person is "Hans Müller".
|
||||
Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNotNull();
|
||||
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
|
||||
// Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
|
||||
// neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
|
||||
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
|
||||
// A last-name-only filename never resolves to a sender (the parser yields no parsed name).
|
||||
personRepository.save(Person.builder().lastName("Müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_Müller.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
|
||||
// Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
|
||||
// and pick up the last-name-only row.
|
||||
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||
|
||||
assertThat(personService.findByName(null, "Müller")).isEmpty();
|
||||
}
|
||||
|
||||
private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
|
||||
return documentService.storeDocument(file, null);
|
||||
}
|
||||
|
||||
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
|
||||
|
||||
@Test
|
||||
@@ -272,9 +180,9 @@ class PersonServiceIntegrationTest {
|
||||
@Test
|
||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||
// cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE
|
||||
// constraints null the sender_id FK and drop the receiver join row, so there is no FK
|
||||
// orphan and the documents themselves survive.
|
||||
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
|
||||
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
|
||||
// the documents themselves survive.
|
||||
Person target = personRepository.save(Person.builder()
|
||||
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||
Person bystander = personRepository.save(Person.builder()
|
||||
@@ -288,16 +196,16 @@ class PersonServiceIntegrationTest {
|
||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
||||
|
||||
// Persist the fixture and detach everything so the delete operates on the database
|
||||
// directly without the persistence context holding stale references.
|
||||
// Persist the fixture and detach everything so the native @Modifying deletes operate on
|
||||
// the database directly without the persistence context holding stale references that
|
||||
// would re-flush a now-deleted person as a transient association.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personService.deletePerson(target.getId());
|
||||
|
||||
// The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1
|
||||
// cache so the asserting reads observe the post-delete database state, not stale
|
||||
// managed entities still holding the dropped sender/receiver associations.
|
||||
// Native @Modifying queries bypass the persistence context — clear it so the asserting
|
||||
// reads observe the post-delete database state, not stale managed entities.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
@@ -312,38 +220,4 @@ class PersonServiceIntegrationTest {
|
||||
// The other person and the documents themselves survive the delete.
|
||||
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() {
|
||||
// AC-7: merging a source who is sender of A and receiver of B into a target leaves the
|
||||
// target as sender of A and receiver of B, drops the source's leftover receiver row via
|
||||
// V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact.
|
||||
Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build());
|
||||
Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document docA = documentRepository.save(Document.builder()
|
||||
.title("Von Anna").originalFilename("a.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(source).build());
|
||||
Document docB = documentRepository.save(Document.builder()
|
||||
.title("An Anna").originalFilename("b.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||
.receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personService.mergePersons(source.getId(), target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(personRepository.findById(source.getId())).isEmpty();
|
||||
|
||||
Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow();
|
||||
assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId());
|
||||
|
||||
Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow();
|
||||
assertThat(reloadedB.getReceivers()).extracting(Person::getId)
|
||||
.containsExactlyInAnyOrder(target.getId(), coReceiver.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -148,11 +147,9 @@ class PersonServiceTest {
|
||||
|
||||
personService.deletePerson(id);
|
||||
|
||||
// Integrity is enforced by V71's ON DELETE constraints — the service only checks
|
||||
// existence then deletes; it no longer detaches sender/receiver references itself.
|
||||
verify(personRepository).findById(id);
|
||||
verify(personRepository).reassignSenderToNull(id);
|
||||
verify(personRepository).deleteReceiverReferences(id);
|
||||
verify(personRepository).deleteById(id);
|
||||
verifyNoMoreInteractions(personRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -375,57 +372,14 @@ class PersonServiceTest {
|
||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling() {
|
||||
String alias = "müller";
|
||||
Person exact = Person.builder().id(UUID.randomUUID()).alias("müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
|
||||
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
|
||||
String alias = "Walter de Gruyter";
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(exact);
|
||||
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsExactCaseMatch_evenWhenMultipleSiblingsCollide() {
|
||||
String alias = "Müller";
|
||||
Person exact = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(exact);
|
||||
// exact-case short-circuits — the case-insensitive siblings are never consulted.
|
||||
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||
String alias = "müller";
|
||||
Person only = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(only));
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(result).isEqualTo(only);
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
|
||||
String alias = "müller";
|
||||
Person lower = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).alias("Müller").build();
|
||||
Person higher = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).alias("müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(higher, lower)); // unordered
|
||||
|
||||
Person first = personService.findOrCreateByAlias(alias);
|
||||
Person second = personService.findOrCreateByAlias(alias);
|
||||
|
||||
assertThat(first.getId()).isEqualTo(lower.getId()); // lowest id wins
|
||||
assertThat(second.getId()).isEqualTo(first.getId()); // same result every call — never throws
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(personRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@@ -433,8 +387,7 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
@@ -447,8 +400,7 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
|
||||
String alias = "Clara Cram geb. de Gruyter";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
|
||||
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
@@ -470,8 +422,7 @@ class PersonServiceTest {
|
||||
@Test
|
||||
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
|
||||
String alias = "Arthur Collignon GmbH";
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
@@ -488,8 +439,7 @@ class PersonServiceTest {
|
||||
@Test
|
||||
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
|
||||
String alias = "Geschwister de Gruyter";
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
@@ -507,8 +457,7 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_noAlias_whenNoGeb() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenReturn(saved);
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
@@ -520,54 +469,11 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_trimsInput() {
|
||||
String alias = " Clara Cram ";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
|
||||
when(personRepository.findByAlias("Clara Cram")).thenReturn(Optional.of(saved));
|
||||
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
|
||||
verify(personRepository).findByAlias("Clara Cram");
|
||||
}
|
||||
|
||||
// ─── findByName (filename-based sender resolution) ────────────────────────
|
||||
|
||||
@Test
|
||||
void findByName_returnsExactCaseMatch_overCaseInsensitiveSibling() {
|
||||
Person exact = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personRepository.findByFirstNameAndLastName("Hans", "Müller")).thenReturn(Optional.of(exact));
|
||||
|
||||
assertThat(personService.findByName("Hans", "Müller")).contains(exact);
|
||||
verify(personRepository, never()).findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||
Person only = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
|
||||
.thenReturn(List.of(only));
|
||||
|
||||
assertThat(personService.findByName("hans", "müller")).contains(only);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_bailsToEmpty_whenTwoOrMoreCaseInsensitiveMatches() {
|
||||
Person a = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
Person b = Person.builder().id(UUID.randomUUID()).firstName("hans").lastName("müller").build();
|
||||
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
|
||||
.thenReturn(List.of(a, b));
|
||||
|
||||
// Ambiguous sender → unset, never an arbitrary guess (provenance correctness over a
|
||||
// confidently-wrong pre-fill). This is the deliberate divergence from the alias path.
|
||||
assertThat(personService.findByName("hans", "müller")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_returnsEmpty_whenFirstNameNullFoldsToNoMatch() {
|
||||
when(personRepository.findByFirstNameAndLastName(null, "Müller")).thenReturn(Optional.empty());
|
||||
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
|
||||
.thenReturn(List.of());
|
||||
|
||||
assertThat(personService.findByName(null, "Müller")).isEmpty();
|
||||
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
|
||||
}
|
||||
|
||||
// ─── updatePerson (notes) ────────────────────────────────────────────────
|
||||
@@ -794,14 +700,10 @@ class PersonServiceTest {
|
||||
|
||||
personService.mergePersons(sourceId, targetId);
|
||||
|
||||
verify(personRepository).findById(sourceId);
|
||||
verify(personRepository).findById(targetId);
|
||||
verify(personRepository).reassignSender(sourceId, targetId);
|
||||
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
||||
verify(personRepository).deleteById(sourceId);
|
||||
// The source's leftover receiver rows cascade-drop via V71's ON DELETE CASCADE on
|
||||
// deleteById — merge no longer deletes them explicitly.
|
||||
verifyNoMoreInteractions(personRepository);
|
||||
}
|
||||
|
||||
// ─── getAliases ─────────────────────────────────────────────────────────
|
||||
@@ -898,165 +800,4 @@ class PersonServiceTest {
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,68 +53,20 @@ class TagServiceTest {
|
||||
// ─── findOrCreate ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreate_exactCaseWins_overCaseInsensitiveSibling() {
|
||||
// "Geburt" (parent) and "geburt" (child) both exist; the edit round-trip replays the stored
|
||||
// name "geburt", which must bind to the exact-case row, not the parent.
|
||||
Tag exact = Tag.builder().id(UUID.randomUUID()).name("geburt").build();
|
||||
when(tagRepository.findByName("geburt")).thenReturn(Optional.of(exact));
|
||||
void findOrCreate_returnsExisting_whenNameFound() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Familie")).thenReturn(Optional.of(existing));
|
||||
|
||||
Tag result = tagService.findOrCreate("geburt");
|
||||
Tag result = tagService.findOrCreate("Familie");
|
||||
|
||||
assertThat(result).isEqualTo(exact);
|
||||
assertThat(result).isEqualTo(existing);
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_exactCaseWins_evenWhenItsIdIsNotTheLowest() {
|
||||
// Adversarial guard: exact-case must short-circuit BEFORE the lowest-id rule. Here the exact row
|
||||
// has the higher id, so a naive "always pick lowest id across all CI matches" would pick wrong.
|
||||
Tag exactHigherId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000009")).name("geburt").build();
|
||||
when(tagRepository.findByName("geburt")).thenReturn(Optional.of(exactHigherId));
|
||||
|
||||
Tag result = tagService.findOrCreate("geburt");
|
||||
|
||||
assertThat(result).isEqualTo(exactHigherId);
|
||||
verify(tagRepository, never()).findAllByNameIgnoreCase(any()); // exact-case wins without consulting the CI list
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||
// Stored name is "Weihnachten"; a save replays "weihnachten" (no exact-case row) → bind to the
|
||||
// single case-insensitive match rather than creating a duplicate.
|
||||
Tag stored = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").build();
|
||||
when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(stored));
|
||||
|
||||
Tag result = tagService.findOrCreate("weihnachten");
|
||||
|
||||
assertThat(result).isEqualTo(stored);
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
|
||||
// Two rows collide case-insensitively and neither equals the query exactly. Resolution must be
|
||||
// deterministic (lowest id) and never throw — proven by calling twice and getting the same id.
|
||||
Tag lowerId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).name("Reisepläne").build();
|
||||
Tag higherId = Tag.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).name("reisepläne").build();
|
||||
when(tagRepository.findByName("REISEPLÄNE")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("REISEPLÄNE")).thenReturn(List.of(higherId, lowerId));
|
||||
|
||||
Tag first = tagService.findOrCreate("REISEPLÄNE");
|
||||
Tag second = tagService.findOrCreate("REISEPLÄNE");
|
||||
|
||||
assertThat(first.getId()).isEqualTo(lowerId.getId());
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
verify(tagRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_createsOrphanTag_whenNameAbsent() {
|
||||
void findOrCreate_createsNew_whenNameNotFound() {
|
||||
Tag saved = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||
when(tagRepository.findByName("Krieg")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("Krieg")).thenReturn(List.of());
|
||||
when(tagRepository.findByNameIgnoreCase("Krieg")).thenReturn(Optional.empty());
|
||||
when(tagRepository.save(any())).thenReturn(saved);
|
||||
|
||||
Tag result = tagService.findOrCreate("Krieg");
|
||||
@@ -124,15 +76,13 @@ class TagServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_trimsWhitespace_thenLandsOnCaseInsensitiveChild() {
|
||||
Tag child = Tag.builder().id(UUID.randomUUID()).name("weihnachten").build();
|
||||
when(tagRepository.findByName("weihnachten")).thenReturn(Optional.empty());
|
||||
when(tagRepository.findAllByNameIgnoreCase("weihnachten")).thenReturn(List.of(child));
|
||||
void findOrCreate_trimsWhitespaceBeforeLookup() {
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Urlaub").build();
|
||||
when(tagRepository.findByNameIgnoreCase("Urlaub")).thenReturn(Optional.of(existing));
|
||||
|
||||
Tag result = tagService.findOrCreate(" weihnachten ");
|
||||
tagService.findOrCreate(" Urlaub ");
|
||||
|
||||
assertThat(result).isEqualTo(child);
|
||||
verify(tagRepository).findByName("weihnachten");
|
||||
verify(tagRepository).findByNameIgnoreCase("Urlaub");
|
||||
}
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────
|
||||
@@ -666,17 +616,4 @@ class TagServiceTest {
|
||||
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,31 +132,6 @@ class AdminControllerTest {
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/backfill-titles (#726) ────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillTitles_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-titles").with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillTitles_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-titles").with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void backfillTitles_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.backfillTitles()).thenReturn(7);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-titles").with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(7));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/generate-thumbnails ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
|
||||
|
||||
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
||||
|
||||
**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (ordered stops in a JOURNEY-type Geschichte). Two subtypes: `STORY` (prose) and `JOURNEY` (curated document sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL).
|
||||
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
|
||||
|
||||
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
||||
|
||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`. |
|
||||
| `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). |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
|
||||
@@ -52,12 +52,11 @@ The OCR service requires significant RAM for model loading. The dev compose sets
|
||||
|
||||
| Production target | RAM | Recommended OCR limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
|
||||
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
|
||||
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
||||
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
| Hetzner CX42 | 16 GB | 12 GB | Recommended for OCR-enabled production |
|
||||
| Hetzner CX32 | 8 GB | 6 GB | Accept reduced batch sizes and slower throughput |
|
||||
| Hetzner CX22 | 4 GB | — | Disable the 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.
|
||||
A CX32 cannot honour the default `mem_limit: 12g` — set the `OCR_MEM_LIMIT=6g` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow) before deploying on a CX32. The prod compose interpolates this var with a 12g default.
|
||||
|
||||
### Dev vs production differences
|
||||
|
||||
@@ -141,7 +140,7 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
|
||||
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
|
||||
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
|
||||
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on servers with 8 GB RAM; leave unset (12g default) on servers with ≥ 16 GB RAM | `12g` (prod compose default) | — | — |
|
||||
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
|
||||
| `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` | — | — |
|
||||
|
||||
@@ -265,7 +264,6 @@ git.raddatz.cloud A <server IP>
|
||||
|
||||
### 3.4 First deploy
|
||||
|
||||
|
||||
```bash
|
||||
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
|
||||
# Expected: docker compose up -d --wait succeeds for archiv-staging, then
|
||||
|
||||
@@ -45,9 +45,6 @@ _See also [TranscriptionBlock](#transcriptionblock-transcriptionblock)._
|
||||
|
||||
**raw attribution** (`Document.senderText`, `Document.receiverText`, `Document.metaDateRaw`) — the original spreadsheet cell text for a document's sender, receiver, and date, preserved verbatim even after a `Person` or normalized date is linked. It keeps provenance intact and enables an "as written in the original" view.
|
||||
|
||||
**auto-generated title** (`DocumentTitleFactory`) — a `Document` title composed by the formula `{index} – {dateLabel} – {location}` (index = `originalFilename`; date label honest at the row's precision; location omitted when blank). On edit, an unchanged auto-title follows a corrected date/location forward (exact old-vs-new match in `DocumentService.updateDocument`); a hand-written title is kept verbatim. `POST /api/admin/backfill-titles` rewrites already-stale ones in one sweep using a grammar heuristic (`DocumentTitleBackfillMatcher`).
|
||||
_Not to be confused with a hand-written title_ — only a title that still equals what the factory builds is treated as machine-generated and rewritten; prose is left untouched.
|
||||
|
||||
**DocumentVersion** (`DocumentVersion`) — an append-only snapshot of a `Document`'s metadata at a point in time. Append-only by convention; no consumer-facing create or update endpoint exists. The entity uses Lombok `@Data` (which generates setters), so immutability is enforced by application convention, not at the Java level.
|
||||
|
||||
**Tag** (`Tag`) — a hierarchical category that can be applied to `Document`s. Tags are self-referencing via a `parent_id` foreign key, forming a tree structure.
|
||||
@@ -149,20 +146,7 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
||||
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
|
||||
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a single stop in a *Lesereise* (`Geschichte` with `type=JOURNEY`). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per journey). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`).
|
||||
|
||||
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -178,8 +162,6 @@ _Not to be confused with a document item's optional note_ — a document item's
|
||||
|
||||
**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
|
||||
|
||||
@@ -35,7 +35,7 @@ Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (alrea
|
||||
|
||||
**Harder:**
|
||||
- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload).
|
||||
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on memory-constrained hosts. A busy backfill alongside OCR can approach the 3 GB heap on an 8 GB server — acceptable but not comfortable. The current production server (64 GB) has ample headroom. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
|
||||
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
|
||||
|
||||
### Operational caveats (intentional)
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ The `/tmp` tmpfs remains at 512 MB and continues to serve training-ZIP extractio
|
||||
## Alternatives considered
|
||||
|
||||
**Approach B — Enlarge `/tmp` to 4 GB**
|
||||
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on servers with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
|
||||
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on CX32 hosts with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
|
||||
|
||||
**Approach C — Both TMPDIR redirect and enlarged /tmp**
|
||||
Belt-and-suspenders: Approach A + 1 GB tmpfs. Discarded in favour of the cleaner Approach A. The defence-in-depth benefit does not outweigh the extra compose churn; the 512 MB cap on `/tmp` is intentional.
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# ADR-031 — The document title is a shared `document`-package factory, re-synced by an exact match on save and a grammar heuristic on a one-time backfill
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Accepted
|
||||
**Issue:** #726 (auto-sync document titles with date/location: save-time + one-time backfill)
|
||||
**Milestone:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
A document title was a string built **once**, at import time, by a private
|
||||
`DocumentImporter.buildTitle()` composing `{index} – {dateLabel} – {location}` (index =
|
||||
`originalFilename`, date label honest at the row's precision via `DocumentTitleFormatter`,
|
||||
location verbatim). Nothing rebuilt it afterwards. When an archivist later corrected a date
|
||||
or location in the edit form, the title kept its stale value (e.g. it still read `2028`
|
||||
after the date was fixed to `1928`), because the edit form round-trips the stored title
|
||||
verbatim and `updateDocument` simply re-persisted it.
|
||||
|
||||
Two distinct problems live here:
|
||||
|
||||
1. **Going forward**, an edit to date/location must flow into a title that was machine-built
|
||||
— but must never overwrite a title a human wrote.
|
||||
2. **The existing backlog** of already-stale titles must be cleaned once. For these rows the
|
||||
pre-edit state is gone, so there is no exact value to compare against.
|
||||
|
||||
The composition formula also existed only inside `importing`, which is the wrong owner: a
|
||||
title is a `document` concern, and three call sites (import, save-time, backfill) must share
|
||||
one rule or they will drift.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. One formula, owned by the `document` package (`DocumentTitleFactory`)
|
||||
|
||||
Extract the composition into `DocumentTitleFactory` (a `@Component` in the `document`
|
||||
package) with `build(Document)`. `DocumentImporter` (package `importing`) now consumes it.
|
||||
`DocumentTitleFormatter` moves into `document` alongside the factory (it stays
|
||||
package-private; `importing` reaches the formula only through the factory). The direction is
|
||||
deliberate: `document` owns the rule, `importing` depends on it — not the reverse. The
|
||||
German date *label* remains the deliberate Java/TS dual implementation pinned by
|
||||
`docs/date-label-fixtures.json` (#666); this ADR touches the **composition** only and does
|
||||
not collapse the frontend `formatDocumentDate`.
|
||||
|
||||
### 2. Save-time regeneration is an EXACT match, not a heuristic
|
||||
|
||||
In `DocumentService.updateDocument` only (bulk edit is out of scope), capture
|
||||
`autoTitleBefore = titleFactory.build(doc)` from the **currently-persisted** state *before*
|
||||
any setter runs. Then:
|
||||
|
||||
- if the **submitted** title equals `autoTitleBefore`, it was the machine value → rebuild
|
||||
from the new state;
|
||||
- otherwise keep the submitted title verbatim (hand-written or freshly typed).
|
||||
|
||||
This is an exact old-vs-new comparison — no false positives, no false negatives — relying on
|
||||
the edit form round-tripping an untouched title verbatim. `projectedState` mirrors the
|
||||
existing setter asymmetry exactly: `documentDate`/`location` overwrite unconditionally (a
|
||||
null clears them), while precision/end/raw are taken from the DTO only when non-null and
|
||||
otherwise kept from the entity. A blank submission is never persisted (the title is always
|
||||
present) — it falls back to the rebuilt auto-title, which always carries at least the index.
|
||||
|
||||
### 3. The one-time backlog cleanup is a grammar heuristic, behind an ADMIN endpoint
|
||||
|
||||
`POST /api/admin/backfill-titles` (synchronous, under `AdminController`'s class-level
|
||||
`@RequirePermission(Permission.ADMIN)`) sweeps every document and, for each whose stored
|
||||
title passes the overwrite test, rebuilds it via the factory. Because the pre-edit state is
|
||||
gone, the test (`DocumentTitleBackfillMatcher`, used **only** here) is a grammar heuristic:
|
||||
after stripping the **literal** index prefix, the remainder must be exactly the index, a
|
||||
known date-label form (+ an optional trailing location), or a lone segment equal to the
|
||||
document's current location. Prose is left untouched; anything malformed fails closed.
|
||||
|
||||
The backfill saves via `documentRepository.save` directly and **never** routes through
|
||||
`updateDocument` — following the `backfillFileHashes` precedent — so a mechanical rename does
|
||||
not snapshot the whole corpus into `document_versions`. It is idempotent (a second run
|
||||
rewrites nothing) and logs one SLF4J-parameterized `scanned/updated/skipped` line; the
|
||||
response is `BackfillResult(count)`.
|
||||
|
||||
### 4. Edit-form feedback (FR-005)
|
||||
|
||||
A localized helper line (de/en/es) under the title input explains that the title is built
|
||||
from date/place and that a hand-edit is preserved, wired via `aria-describedby` and shown
|
||||
only on the single-document edit form. A live preview was considered and declined.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The three call sites can never diverge — there is exactly one formula
|
||||
(`NFR-MAINT-001`). Save-time cost is a string build + compare; the backfill is one
|
||||
synchronous transactional sweep over a low-thousands corpus.
|
||||
- Security: the index is compared **literally** (`String.startsWith` / `Pattern.quote`)
|
||||
because `originalFilename` is user-controlled and may carry regex metacharacters — an
|
||||
unquoted pattern would be a ReDoS / regex-injection vector (CWE-1333 / CWE-625). The
|
||||
date-label sub-patterns use only bounded, non-nested quantifiers.
|
||||
- **File-replaced documents are treated as manual, by design.** The index is
|
||||
`originalFilename`, which `updateDocument` reassigns to the uploaded file's name on a
|
||||
file-replace. After a replace the stored title no longer matches `build(currentState)`, so
|
||||
neither save-time nor backfill rewrites it. This is the accepted fail-safe of overloading
|
||||
`originalFilename` rather than adding a dedicated `catalogIndex` column.
|
||||
- The save-time heuristic risk is zero (exact match); the backfill heuristic can, by its
|
||||
documented FR-004 rule, treat `{index} – {valid date label} – {anything}` as machine-built
|
||||
and rewrite the trailing segment. This is the accepted trade for cleaning the backlog
|
||||
without the lost pre-edit state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A dedicated `catalogIndex` column** instead of overloading `originalFilename` — rejected;
|
||||
it adds a migration and a second source of truth for the index for no current benefit, and
|
||||
the file-replace fail-safe is acceptable.
|
||||
- **A heuristic at save-time too** (instead of the exact match) — rejected; the stored title
|
||||
is available pre-edit, so an exact comparison is strictly better (no false positives).
|
||||
- **A live title preview in the edit form** — rejected (FR-005); a static helper line is
|
||||
calmer for the 60+ audience and avoids a second client-side mirror of the formula.
|
||||
- **Collapsing the frontend `formatDocumentDate` into the backend** — out of scope; the
|
||||
Java/TS date-label split is the deliberate #666 design, pinned by a shared fixture.
|
||||
@@ -1,64 +0,0 @@
|
||||
# ADR-032 — Person-delete referential integrity lives in the database, and the cascade never reaches `documents`
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Issue:** #684 (move person-delete FK detach to database-level `ON DELETE`)
|
||||
**Milestone:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Deleting a `Person` had to detach the two FKs into `persons` that lacked any `ON DELETE`
|
||||
behaviour: `documents.sender_id` and `document_receivers.person_id` (both from V1).
|
||||
`PersonService.deletePerson` and `mergePersons` did this in Java — nulling the sender and
|
||||
deleting receiver join rows before `deleteById` — so the integrity guarantee lived in
|
||||
application code. Any other delete path (a future endpoint, a manual `psql`, a batch job)
|
||||
could still orphan rows or fail with an FK-violation 500.
|
||||
|
||||
A related soft reference made it worse: `transcription_block_mentioned_persons.person_id`
|
||||
was a UUID column with **no FK** (V56, a deliberate "no FK" choice), so a person delete left
|
||||
dangling `@`-mention rows. The literal `@DisplayName` lives in `transcription_blocks.text`,
|
||||
so only the *link* was ever at stake — not the visible name.
|
||||
|
||||
## Decision
|
||||
|
||||
Move person-delete integrity into the database (migration V71) and thin the service to a
|
||||
plain `deleteById`:
|
||||
|
||||
- `documents.sender_id` → `ON DELETE SET NULL` (`documents.senderText` preserves the raw
|
||||
textual attribution, so nulling the link loses no historical record).
|
||||
- `document_receivers.person_id` → `ON DELETE CASCADE` (the symmetric completion of V14,
|
||||
which gave the `document_id` side the same).
|
||||
- `transcription_block_mentioned_persons.person_id` → a real FK with `ON DELETE CASCADE`,
|
||||
reversing V56's "no FK" decision. The read renderer already degrades a `@DisplayName` with
|
||||
no sidecar row to plain escaped text, so removing the link is invisible to the reader.
|
||||
|
||||
**Cascade-boundary invariant:** the cascade stays strictly at the join/reference layer and
|
||||
**never reaches `documents` rows** — a cascade into `documents` would destroy historical
|
||||
letters. This is pinned by a non-negotiable document-survival assertion in
|
||||
`PersonRepositoryTest`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A person delete is safe from every path, not just `PersonService`. The service and merge
|
||||
stay thin (`deleteById` + the cascade); `reassignSenderToNull` and `deleteReceiverReferences`
|
||||
are deleted.
|
||||
- This *fixes* the pre-existing dead-link-on-deleted-person case — it is not a purely
|
||||
invisible refactor. Note the read renderer strips the `@` prefix when it emits a live
|
||||
mention link, but the degraded (deleted-person) path leaves the literal `@Name` in the
|
||||
block text as-is — the reader sees `@Auguste Raddatz` as plain text, never a dead link.
|
||||
- DB cascades run below `AuditService`, so the row-level cleanup is intentionally not
|
||||
audit-logged; the person-delete action itself is still logged at the service layer.
|
||||
- The V71 FK validation requires cleaning pre-existing orphan mention rows first; the
|
||||
migration does this in a `DO` block that logs the purge count via `RAISE NOTICE`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep integrity in Java** — rejected; it only protects the one code path and re-breaks the
|
||||
moment a second delete path appears.
|
||||
- **Cascade `documents.sender_id`** — rejected; it would delete historical letters when a
|
||||
sender is removed. `SET NULL` keeps the letter and its `senderText`.
|
||||
- **Leave the mention sidecar FK-less (honour V56)** — rejected; the "no FK" rationale was
|
||||
stale, the name survives in the block text regardless, and the FK removes the orphan-row
|
||||
class of bug.
|
||||
@@ -1,148 +0,0 @@
|
||||
# ADR-033 — Tag-name resolution tolerates case-collisions: exact-case first, then a deterministic lowest-id fallback, and never a `unique(lower(name))` constraint
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Issue:** #730 (document with a case-colliding tag cannot be saved — `findByNameIgnoreCase` `NonUniqueResultException`)
|
||||
**Milestone:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
`TagService.findOrCreate(name)` is the single point that turns a tag **name** into a `Tag`
|
||||
row. The document edit form, bulk-edit, and the upload batch all round-trip tag **names**
|
||||
(the edit form sends `tags.map(t => t.name).join(',')`) and re-resolve them on **every**
|
||||
save through `resolveTags → findOrCreate`. The old implementation resolved with
|
||||
`tagRepository.findByNameIgnoreCase(name)`, a derived query returning `Optional<Tag>`.
|
||||
|
||||
That signature encodes an invariant the data does **not** hold: that a name is globally
|
||||
unique case-insensitively. The canonical tag tree legitimately contains names that differ
|
||||
only by case — a parent container and its same-named lowercase **child** (`Geburt` /
|
||||
`Geburt/geburt`, `Weihnachten` / `Weihnachten/weihnachten`, …), or two siblings
|
||||
(`Reise/Reisepläne` / `Reise/reisepläne`). Each is a distinct node with its own
|
||||
`source_ref` (the stable identity, per ADR-025) and its own document attachments — **not** an
|
||||
accidental duplicate. When two rows matched case-insensitively, Hibernate threw
|
||||
`NonUniqueResultException` → `IncorrectResultSizeDataAccessException` → a generic HTTP 500.
|
||||
|
||||
The effect was severe and opaque: every document carrying one of ~10 colliding tags (≈180
|
||||
document-tag attachments on staging) became **un-editable** — any field change failed on save
|
||||
because the whole tag set is re-resolved — and the user saw only "an unexpected error", with
|
||||
no hint that a tag was the cause.
|
||||
|
||||
This is a **lookup** problem, not a data problem: the collisions are valid canonical nodes
|
||||
and must be preserved.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Resolution is exact-case first, then a non-throwing deterministic fallback
|
||||
|
||||
`findOrCreate` resolves in three ordered steps and never throws on a collision:
|
||||
|
||||
1. `findByName(cleanName)` — **exact-case** derived query. If present, return it. The edit
|
||||
round-trip replays the stored name verbatim, so the exact-case row is the right binding
|
||||
(typing the bare child name `weihnachten` binds to the child; `Weihnachten` binds to the
|
||||
parent container).
|
||||
2. else `findAllByNameIgnoreCase(cleanName)` — the **plural** case-insensitive list. If
|
||||
non-empty, return the element with the **lowest `id`** (`min(comparing(Tag::getId))`).
|
||||
3. else create the tag (an orphan: null `sourceRef`/`parentId`).
|
||||
|
||||
The two repository methods are deliberately **two distinctly-named methods** — Spring Data
|
||||
cannot disambiguate an `Optional<Tag>` from a `List<Tag>` derived query by return type alone.
|
||||
The throwing `Optional<Tag> findByNameIgnoreCase` is **deleted** so the non-unique-throwing
|
||||
call cannot be reintroduced; `findOrCreate` was its only production caller.
|
||||
|
||||
### 2. The tie-break is `id`, and it is load-bearing
|
||||
|
||||
`id` is a stable, always-present, unique column, so "lowest id" is a total, deterministic
|
||||
order over the candidates: the same name resolves to the same row on every call, forever,
|
||||
without throwing. This matters only in the free-text authoring path (step 2), where no
|
||||
exact-case row exists yet two case-folding rows do.
|
||||
|
||||
### 3. No `unique(lower(name))` constraint — and a load-bearing comment says so
|
||||
|
||||
A global case-insensitive uniqueness constraint is **wrong**: it would reject the legitimate
|
||||
parent/child canonical nodes. It would also **fail to apply** against the existing rows,
|
||||
turning a code-only deploy into a failed Flyway migration that blocks startup. A comment at
|
||||
both `findOrCreate` and the repository methods records this so the constraint is not "helpfully"
|
||||
added later.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Code-only, zero migration, fully reversible** (roll back the JAR). No tag data is touched;
|
||||
the colliding rows stay exactly as the canonical importer produced them.
|
||||
- One change fixes all three write paths — single-document edit, bulk-edit, and upload batch —
|
||||
because they all funnel through `resolveTags → findOrCreate`, which stays the single source
|
||||
of truth (resolution logic is **not** hoisted into `DocumentService`).
|
||||
- **Free-text tag semantics under collision are accepted as-is** (issue #730, option A): the
|
||||
bare word `weihnachten` binds to the deep child and `Weihnachten` to the parent container.
|
||||
Correct for the edit round-trip and acceptable for authoring; making the typeahead show the
|
||||
tree path so an author can tell a container from its same-named child is a separate
|
||||
follow-up.
|
||||
- The wire response stays opaque: after the fix this path no longer throws
|
||||
`IncorrectResultSizeDataAccessException`, and `GlobalExceptionHandler`'s generic handler maps
|
||||
any stray one to `INTERNAL_ERROR` with no Hibernate/SQL leak — so no dedicated handler was
|
||||
added.
|
||||
- **The sibling Person path is fixed the same way — see the Person extension below (#731).**
|
||||
- Postgres `LOWER()` folding of umlauts (`ü`/`ä`) is the actual correctness hinge of the
|
||||
fallback and cannot be proven by a mocked repo, so it is pinned by a Testcontainers
|
||||
`postgres:16-alpine` test on a `Glückwünsche`/`glückwünsche` pair; a plain-ASCII test would
|
||||
stay green while the bug reappeared for umlaut tags.
|
||||
|
||||
## Person extension (#731)
|
||||
|
||||
The Person domain carried the same latent throw on **two** user-influenced lookup surfaces, and
|
||||
is fixed with the same exact-case-first, non-throwing pattern — but with a deliberately
|
||||
**different fallback per surface**, because the two paths have different consequences.
|
||||
|
||||
- **Alias path — `PersonService.findOrCreateByAlias` — deterministic lowest-id (mirrors tag).**
|
||||
`findByAliasIgnoreCase` (`Optional`) is replaced by `findByAlias` (exact) → `findAllByAliasIgnoreCase`
|
||||
(plural, lowest id) → the existing create-when-absent branch (INSTITUTION/GROUP and the
|
||||
maiden-name alias are preserved verbatim). There is no human in the importer loop and the path
|
||||
creates-on-absent anyway, so a deterministic guess is the right behaviour — exactly like tags.
|
||||
|
||||
- **Name/sender path — `PersonService.findByName` — bail to null on ambiguity (the new wrinkle).**
|
||||
Used only by `DocumentService.storeDocument` to resolve the upload **sender** from the parsed
|
||||
filename. `findByFirstNameIgnoreCaseAndLastNameIgnoreCase` (`Optional`) is replaced by
|
||||
`findByFirstNameAndLastName` (exact) → `findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase`
|
||||
(plural). Resolution returns the exact-case match, else the single case-insensitive match, else
|
||||
— on **two or more** matches — **empty**. The sender is left unset rather than guessing.
|
||||
|
||||
**Why this diverges from the alias (and tag) decision:** the archive's value is correct
|
||||
provenance. A confidently-wrong pre-filled `Hans Müller` is worse than an empty field, because a
|
||||
senior reviewer will not re-check a value that is already filled in, whereas an empty sender
|
||||
routes the document into the "needs completion" state (`metadataComplete=false`) for a human to
|
||||
assign. The load-bearing comment at `findByName` records this so a future "consistency cleanup"
|
||||
does not reintroduce the confidently-wrong-sender bug by switching it to lowest-id.
|
||||
|
||||
- **Fail-closed on a null first name.** A parsed filename can lack a first name. The two new name
|
||||
methods use explicit HQL equality (`= :firstName`) rather than a derived
|
||||
`…IgnoreCase` query, because Spring Data folds a null derived-query argument to `first_name IS
|
||||
NULL` — which would silently widen the match and pull a last-name-only / institution row in as a
|
||||
"sender" (a quiet provenance-integrity defect). With HQL equality a null binds as `= NULL`,
|
||||
which never matches, so a null first name resolves to **no sender**. This is pinned by a
|
||||
real-Postgres repository test.
|
||||
|
||||
- **Scope — "ambiguous" is case-insensitive only.** Both exact-case lookups (`findByAlias`,
|
||||
`findByFirstNameAndLastName`) return `Optional`, so two **byte-identical same-case** rows would
|
||||
still throw `NonUniqueResultException`. That is a true data anomaly, deliberately out of scope
|
||||
(it is not a case-collision), and it surfaces as the opaque `INTERNAL_ERROR` — never a silently
|
||||
wrong row — so it is no worse than any other unexpected error and needs no extra handling here.
|
||||
|
||||
- **Same stance as tags otherwise:** no `unique(lower(alias))` / `unique(lower(name))` constraint
|
||||
(collisions are valid human labels; `source_ref` is the stable identity per ADR-025), no
|
||||
merge/dedupe, code-only and reversible, and no shared `resolveExactThenCi(...)` helper — the
|
||||
two Person paths have different fallbacks, so the exact→CI→fallback logic is inlined at each
|
||||
with its load-bearing comment (KISS).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A `unique(lower(name))` index** — rejected: the collisions are valid canonical nodes, and
|
||||
the migration would fail against the existing data and block startup.
|
||||
- **Merging/deduping the colliding tags** — rejected: each has a distinct `source_ref`, tree
|
||||
position, and real document attachments; they are not duplicates.
|
||||
- **Round-tripping tag IDs instead of names** so resolution can't be ambiguous at all — the
|
||||
cleaner long-term shape (removes the name-as-key smell), but a larger change with frontend
|
||||
surface; deferred to #732. The lookup fix here is the minimal correct unblock.
|
||||
- **Hoisting resolution into `DocumentService.resolveTags`** — rejected: it would duplicate the
|
||||
rule across the edit, bulk-edit, and import paths and let them drift; `findOrCreate` stays
|
||||
the one owner.
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,65 +0,0 @@
|
||||
# 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`.
|
||||
@@ -9,12 +9,10 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
|
||||
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(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(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
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, ollama, "NL query parsing for natural-language search", "HTTP / REST (internal)")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -18,7 +18,7 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
}
|
||||
|
||||
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), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend management port 8081 (/actuator/prometheus), 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(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.")
|
||||
@@ -45,7 +45,6 @@ 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(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
|
||||
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
|
||||
|
||||
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||
|
||||
@@ -9,17 +9,15 @@ ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED). Hosts the one-shot maintenance backfills (versions, file-hashes, titles) — synchronous, ADMIN-only.")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. On update, regenerates an unchanged auto-title from the new date/location (exact old-vs-new match, #726); exposes backfillTitles() to clean already-stale titles in one sweep. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
|
||||
Component(importOrch, "CanonicalImportOrchestrator", "Spring Service — @Async", "Runs the four canonical loaders in an explicit dependency DAG (TagTree → PersonRegister → PersonTree → Document). Smoke-checks all four artifacts before starting, owns the IDLE/RUNNING/DONE/FAILED state machine, fails closed on a malformed artifact.")
|
||||
Component(tagTreeLoader, "TagTreeImporter", "Spring Component", "Upserts the tag hierarchy from canonical-tag-tree.xlsx via TagService (by canonical tag_path).")
|
||||
Component(personRegLoader, "PersonRegisterImporter", "Spring Component", "Upserts register persons from canonical-persons.xlsx via PersonService (by normalizer person_id).")
|
||||
Component(personTreeLoader, "PersonTreeImporter", "Spring Component", "Upserts tree persons + relationships from canonical-persons-tree.json via PersonService and RelationshipService.")
|
||||
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds the title via DocumentTitleFactory, keeps the S3 upload + thumbnail plumbing, and resolves each PDF by index (importDir/<index>.pdf) guarded by strict index validation + canonical-path containment + %PDF magic-byte check (no recursive walk).")
|
||||
Component(titleFactory, "DocumentTitleFactory", "Spring Component", "Single source of truth for the auto-title {index} – {dateLabel} – {location} (#726). The document package owns this formula; importer, save-time regeneration, and the backfill all build through it so they never diverge.")
|
||||
Component(titleFmt, "DocumentTitleFormatter", "Pure helper (document pkg)", "Formats the date label at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).")
|
||||
Component(titleMatcher, "DocumentTitleBackfillMatcher", "Pure helper", "Backfill-only heuristic deciding whether a STORED title is machine-generated (overwritable) vs hand-written prose. Index matched literally (no regex injection / ReDoS); fail-closed.")
|
||||
Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds an honest precision-aware title via DocumentTitleFormatter, keeps the S3 upload + thumbnail plumbing, and resolves each PDF by index (importDir/<index>.pdf) guarded by strict index validation + canonical-path containment + %PDF magic-byte check (no recursive walk).")
|
||||
Component(titleFmt, "DocumentTitleFormatter", "Pure helper", "Formats the date label baked into an import title at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).")
|
||||
Component(sheetReader, "CanonicalSheetReader", "POI helper", "Maps a canonical .xlsx by header name (no positional indices), splits pipe-delimited list columns, fails closed (IMPORT_ARTIFACT_INVALID) on a missing required header.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
@@ -46,11 +44,7 @@ Rel(importOrch, docLoader, "4. Loads documents")
|
||||
Rel(tagTreeLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(personRegLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(docLoader, sheetReader, "Reads canonical .xlsx")
|
||||
Rel(docLoader, titleFactory, "Builds the auto-title")
|
||||
Rel(docSvc, titleFactory, "Regenerates auto-title (save-time + backfill)")
|
||||
Rel(docSvc, titleMatcher, "Backfill overwrite test")
|
||||
Rel(titleFactory, titleFmt, "Formats the honest date label")
|
||||
Rel(adminCtrl, docSvc, "backfillTitles() / backfillFileHashes()")
|
||||
Rel(docLoader, titleFmt, "Builds honest title date")
|
||||
Rel(tagTreeLoader, tagSvc, "Upserts tags by source_ref")
|
||||
Rel(personRegLoader, personSvc, "Upserts persons by source_ref")
|
||||
Rel(personTreeLoader, personSvc, "Upserts persons by source_ref")
|
||||
|
||||
@@ -16,10 +16,8 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories (STORY) and reading journeys (JOURNEY). Returns GeschichteSummary projections for list; full Geschichte with JourneyItems for detail. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.")
|
||||
Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Enforces JOURNEY-type guard on append.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
@@ -40,10 +38,6 @@ Rel(notifCtrl, notifSvc, "Delegates to")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to")
|
||||
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
|
||||
Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type")
|
||||
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
|
||||
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
|
||||
@@ -10,11 +10,6 @@ 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(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(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(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.")
|
||||
@@ -30,12 +25,6 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
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(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(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
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(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar); 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(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
@@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@startuml db-orm
|
||||
' Schema source: Flyway V1–V72 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V72 (2026-06-08)
|
||||
' Schema source: Flyway V1–V69 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V69 (2026-05-27)
|
||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||
|
||||
hide circle
|
||||
@@ -260,7 +260,7 @@ package "Transcription" {
|
||||
|
||||
entity transcription_block_mentioned_persons {
|
||||
block_id : UUID <<FK>>
|
||||
person_id : UUID NOT NULL <<FK>>
|
||||
person_id : UUID NOT NULL
|
||||
--
|
||||
display_name : VARCHAR(200) NOT NULL
|
||||
}
|
||||
@@ -359,7 +359,6 @@ package "Supporting" {
|
||||
title : VARCHAR(255) NOT NULL
|
||||
body : TEXT
|
||||
status : VARCHAR(32) NOT NULL
|
||||
type : VARCHAR(32) NOT NULL
|
||||
author_id : UUID <<FK>>
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
updated_at : TIMESTAMP NOT NULL
|
||||
@@ -371,16 +370,9 @@ package "Supporting" {
|
||||
person_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity journey_items {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
entity geschichten_documents {
|
||||
geschichte_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
position : INTEGER NOT NULL CHECK (position > 0)
|
||||
note : TEXT CHECK (length <= 2000)
|
||||
==
|
||||
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,9 +386,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
||||
invite_token_group_ids }o--|| user_groups : group_id
|
||||
|
||||
' Document relationships
|
||||
documents }o--o| persons : sender_id (ON DELETE SET NULL)
|
||||
documents }o--o| persons : sender_id
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
document_receivers }o--|| persons : person_id
|
||||
document_tags }o--|| documents : document_id
|
||||
document_tags }o--|| tag : tag_id
|
||||
document_versions }o--|| documents : document_id
|
||||
@@ -428,7 +420,6 @@ transcription_blocks }o--o| app_users : updated_by
|
||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||
transcription_block_versions }o--o| app_users : changed_by
|
||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||
transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
|
||||
' OCR relationships
|
||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||
@@ -444,7 +435,7 @@ audit_log }o--o| documents : document_id
|
||||
geschichten }o--o| app_users : author_id
|
||||
geschichten_persons }o--|| geschichten : geschichte_id
|
||||
geschichten_persons }o--|| persons : person_id
|
||||
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -66,7 +66,7 @@ package "Supporting" {
|
||||
entity audit_log
|
||||
entity geschichten
|
||||
entity geschichten_persons
|
||||
entity journey_items
|
||||
entity geschichten_documents
|
||||
}
|
||||
|
||||
' Auth relationships
|
||||
@@ -79,9 +79,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
||||
invite_token_group_ids }o--|| user_groups : group_id
|
||||
|
||||
' Document relationships
|
||||
documents }o--o| persons : sender_id (ON DELETE SET NULL)
|
||||
documents }o--o| persons : sender_id
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
document_receivers }o--|| persons : person_id
|
||||
document_tags }o--|| documents : document_id
|
||||
document_tags }o--|| tag : tag_id
|
||||
document_versions }o--|| documents : document_id
|
||||
@@ -113,7 +113,6 @@ transcription_blocks }o--o| app_users : updated_by
|
||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||
transcription_block_versions }o--o| app_users : changed_by
|
||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||
transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
|
||||
' OCR relationships
|
||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||
@@ -129,8 +128,7 @@ audit_log }o--o| documents : document_id
|
||||
geschichten }o--o| app_users : author_id
|
||||
geschichten_persons }o--|| geschichten : geschichte_id
|
||||
geschichten_persons }o--|| persons : person_id
|
||||
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -20,19 +20,24 @@ The observability stack (Prometheus, Loki, Grafana, Tempo, GlitchTip) ships as a
|
||||
|
||||
---
|
||||
|
||||
## Server Sizing
|
||||
## VPS Sizing Recommendations
|
||||
|
||||
### Current Production Server: Hetzner Dedicated (Serverbörse)
|
||||
### Recommended: Hetzner CX32
|
||||
|
||||
**Specs**: Intel Core i7-6700 (4C/8T, 3.4 GHz), 64 GB RAM · acquired via Hetzner server auction
|
||||
**Specs**: 4 vCPU, 8 GB RAM, 80 GB SSD · **Cost**: 17 EUR/mo
|
||||
|
||||
Comfortably handles the full application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy, full observability stack) with headroom to spare. The 64 GB RAM means OCR, Ollama inference, and the observability stack can all run concurrently without memory pressure.
|
||||
Sufficient for the application stack (Postgres, MinIO, OCR with `mem_limit: 12g`, backend, frontend, Caddy) on a CX32 today. Once the observability stack lands (Prometheus/Loki/Grafana/Alertmanager add ~2 GB) consider a CX42.
|
||||
|
||||
### When to Reconsider Hardware
|
||||
### When to Upgrade: Hetzner CX42
|
||||
|
||||
- CPU is Skylake (2015) — single-threaded performance is the likely bottleneck before RAM
|
||||
- Profile with Grafana dashboards before concluding hardware is the constraint
|
||||
- Most perceived performance issues are application bugs (unindexed queries, N+1 loads), not resource limits
|
||||
**Specs**: 8 vCPU, 16 GB RAM · **Cost**: 29 EUR/mo
|
||||
|
||||
Upgrade when:
|
||||
- Observability stack adds memory pressure (Loki + Grafana with >30 days retention)
|
||||
- OCR throughput needs scaling beyond a single-node Surya/Kraken setup
|
||||
- Real user load profiled in Grafana shows response-time degradation
|
||||
|
||||
Never upgrade the VPS tier before profiling — most perceived performance issues are application bugs, not resource constraints.
|
||||
|
||||
---
|
||||
|
||||
@@ -40,11 +45,12 @@ Comfortably handles the full application stack (Postgres, MinIO, OCR with `mem_l
|
||||
|
||||
| Service | Cost |
|
||||
|---|---|
|
||||
| Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM) | see invoice |
|
||||
| Hetzner CX32 VPS | 17.00 EUR |
|
||||
| Hetzner DNS | 0.00 EUR |
|
||||
| Hetzner SMTP relay | ~1.00 EUR |
|
||||
| **Total** | **~18 EUR/mo** |
|
||||
|
||||
MinIO data lives on the server disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
|
||||
MinIO data lives on the VPS disk (no Object Storage line item yet). The Hetzner OBS migration would add ~5 EUR/mo at ~200 GB.
|
||||
|
||||
Equivalent SaaS stack: 200–300 EUR/mo.
|
||||
|
||||
|
||||
@@ -424,13 +424,13 @@
|
||||
<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 class="grp"><td colspan="3">Listenzeile</td></tr>
|
||||
<tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr>
|
||||
<tr><td>Meta column</td><td>w-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>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>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-sm font-semibold text-ink</td><td></td></tr>
|
||||
<tr><td>Date</td><td>font-sans text-sm text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
||||
<tr><td>Author name</td><td>font-sans text-xs font-semibold text-ink</td><td></td></tr>
|
||||
<tr><td>Date</td><td>font-sans text-xs text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
||||
<tr><td>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-lg text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
||||
<tr><td>Excerpt</td><td>font-sans text-sm text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
||||
<tr><td>Story title</td><td>font-serif text-[15px] text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
||||
<tr><td>Excerpt</td><td>font-sans text-xs text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
||||
<tr class="grp"><td colspan="3">Filter</td></tr>
|
||||
<tr><td>Filter pill (inaktiv)</td><td>rounded-full border border-line px-3 py-1 text-xs font-semibold text-ink-2 hover:bg-muted</td><td>aria-pressed="false"</td></tr>
|
||||
<tr><td>Filter pill (aktiv)</td><td>rounded-full bg-primary text-primary-fg px-3 py-1 text-xs font-semibold</td><td>aria-pressed="true"</td></tr>
|
||||
@@ -640,8 +640,7 @@
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Artikel-Container</td></tr>
|
||||
<tr><td>Article container</td><td>max-w-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>Article container</td><td>max-w-3xl mx-auto px-4 py-10</td><td>zentriert, volle Breite auf Mobile</td></tr>
|
||||
<tr><td>Story title</td><td>font-family:var(--font-display);font-size:clamp(22px,4vw,32px);color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
||||
<tr><td>Back button</td><td><BackButton /> aus $lib/components/BackButton.svelte</td><td>history.back(); nicht <a href></td></tr>
|
||||
<tr class="grp"><td colspan="3">Metazeile</td></tr>
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,728 +0,0 @@
|
||||
<!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; auf Mobile im ··· BottomSheet</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>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</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>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
||||
# 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
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Auto-title sync, full-stack happy path (#726). A document whose stored title equals its
|
||||
* machine-generated auto-title must follow a date correction forward on save; a hand-edit would
|
||||
* be kept. The exhaustive permutations live in the backend unit/integration suites — this is the
|
||||
* single end-to-end pass, and it also asserts the FR-005 helper line is present on the edit form.
|
||||
*/
|
||||
test.describe('Document auto-title sync (#726)', () => {
|
||||
test('editing the date rebuilds the auto-title, and the edit form explains it', async ({
|
||||
page
|
||||
}) => {
|
||||
// 1. Create a document with no date/location, so its stored title == its auto-title
|
||||
// (originalFilename only). createDocument derives originalFilename from the title.
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByLabel('Titel').fill('E2E Auto-Titel Sync');
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
const detailUrl = page.url();
|
||||
|
||||
// 2. The edit form carries the FR-005 helper explaining the auto-generated title.
|
||||
await page.goto(`${detailUrl}/edit`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await expect(page.locator('#title-help')).toBeVisible();
|
||||
|
||||
// 3. Add a YEAR-precision date WITHOUT touching the title, then save.
|
||||
await page.locator('#documentDate').fill('15.01.1928');
|
||||
await page.locator('#metaDatePrecision').selectOption('YEAR');
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
|
||||
// 4. The detail page shows the regenerated title carrying the new year.
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
await expect(page.getByRole('heading', { name: /E2E Auto-Titel Sync.*1928/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user