Compare commits

..

17 Commits

Author SHA1 Message Date
Marcel
eefc67bd81 docs(adr): ADR-038 — domain event drives note-less journey-item cleanup on document delete (#805)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m49s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 6m43s
CI / fail2ban Regex (pull_request) Successful in 58s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 17:55:38 +02:00
Marcel
44869d64f7 fix(geschichte): delete note-less journey items before document delete to prevent chk constraint 500 (#805)
Publishes DocumentDeletingEvent from DocumentService.deleteDocument before
deleteById; JourneyItemDocumentDeleteListener handles it synchronously so
note-less items are gone before ON DELETE SET NULL fires on note-carrying rows.
Plain @EventListener chosen over AFTER_COMMIT (fires too late) and @Async
(breaks rollback atomicity) — see ADR-038. Adds DOCUMENT_DELETED to AuditKind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 17:54:54 +02:00
Marcel
7ca6492fc0 test(geschichte): rename journey items integration test — drop misleading end_to_end suffix (#795)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 6m9s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Successful in 5m28s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:15:24 +02:00
Marcel
9ea21f60ea test(geschichte): replace waitForDebounce sleep with retrying locator waits (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:14:16 +02:00
Marcel
a6184fa121 test(geschichte): add 403, catch-path, and CSRF header coverage for StoryDocumentPanel (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:12:43 +02:00
Marcel
232721214d refactor(geschichte): CSS.escape in focus queries, announceTimer cleanup, warning icon (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:11:12 +02:00
Marcel
d91bedbaaf fix(geschichte): restore focus to item remove button after failed DELETE rollback (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:09:26 +02:00
Marcel
8e4810d5da chore(i18n): delete dead keys geschichte_editor_dokumente_heading/hint (zero usages) (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:07:33 +02:00
Marcel
05652a18ee docs(adr): ADR-037 — journey_items serves both Geschichte subtypes (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:06:39 +02:00
Marcel
6b2dd2f259 docs(glossary): expand JourneyItem and Geschichte STORY definitions — both subtypes (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:05:42 +02:00
Marcel
f43df6082d docs(architecture): update JourneyItemService C4 description — no type guard, both subtypes (#795)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:04:49 +02:00
Marcel
2121d8469f docs(geschichte): add StoryDocumentPanel to component inventory + C4 diagram (#795)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m9s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m23s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:41:11 +02:00
Marcel
e8437b79d1 feat(geschichte): wire StoryDocumentPanel into the story editor sidebar (#795)
GeschichteSidebar gains optional geschichteId/items props and renders the
panel only when geschichteId is set. GeschichteEditor derives both from
its existing GeschichteView prop — null on /geschichten/new, so the panel
stays hidden there (create-then-edit, same as journeys). JourneyEditor's
sidebar call site is untouched, so journeys never show the panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:39:48 +02:00
Marcel
4f0a660cb8 feat(geschichte): StoryDocumentPanel — sidebar document management for stories (#795)
Sidebar-section-styled panel (p-4 card, mobile <details> accordion, no
inner scroll clamp) that lists a story's journey items in position order.
Add is pessimistic via POST /items; remove is optimistic with snapshot
rollback via DELETE /items/{id}; both through csrfFetch. Already-linked
documents are unselectable in the reused DocumentPickerDropdown (visible
label wired via inputId). Document-less items (ON DELETE SET NULL)
render as removable placeholder rows. 409 capacity/duplicate map to
story-worded messages, everything else through getErrorMessage(). Add/
remove are announced in a polite live region and focus moves to the
previous row's remove button (picker input when the list empties).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:35:32 +02:00
Marcel
48f2c67ffc feat(i18n): story document panel keys in de/en/es (#795)
Panel header, hint, empty state, picker label + placeholder, deleted-
document placeholder, remove-button label, live-region announcements,
and the story-worded capacity/duplicate errors (the generic
JOURNEY_AT_CAPACITY / JOURNEY_DOCUMENT_ALREADY_ADDED messages say
"Lesereise" — the wrong frame inside a STORY panel).

Side effect: the JSON round-trip collapsed three pre-existing duplicate
keys per locale (identical values, last-wins either way) —
error_geschichte_title_too_long, error_geschichte_intro_too_long,
person_unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:25:32 +02:00
Marcel
4961c74a01 feat(document-picker): optional inputId prop for external label wiring (#795)
The input always carries an id (generated doc-picker-input-{uid} default,
mirroring the listbox id scheme). When a caller passes inputId it wires a
visible <label for> — the aria-label fallback is dropped then so the
visible label wins. JourneyAddBar stays untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:23:45 +02:00
Marcel
d0fc8ce995 feat(geschichte): allow journey items on STORY-type Geschichten (#795)
Delete the JOURNEY-only type guard in JourneyItemService.append() so the
existing item endpoints serve both Geschichte types. GeschichteType has
exactly two constants, so an allowlist replacement would be unreachable
dead code. Fix the not-found messages that claimed "Journey", and remove
the now-orphaned GESCHICHTE_TYPE_MISMATCH error code end to end
(ErrorCode, errors.ts union + mapping, i18n keys in de/en/es).

Tests: three STORY append unit tests written red against the guard, plus
end-to-end STORY coverage (append+retrieve, V72-style position-gap rows
incl. removal, dangling document-deleted item). The two STORY-rejection
tests die with the guard — no third enum value exists to feed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:21:33 +02:00
30 changed files with 1539 additions and 96 deletions

View File

@@ -52,6 +52,11 @@ public enum AuditKind {
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
LOGIN_RATE_LIMITED,
// --- Documents ---
/** Payload: {@code {"documentId": "uuid"}} */
DOCUMENT_DELETED,
// --- Reading Journeys (Lesereisen) ---
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */

View File

@@ -0,0 +1,11 @@
package org.raddatz.familienarchiv.document;
import java.util.UUID;
/**
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
* before documentRepository.deleteById fires. Listeners run synchronously in the
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
* see ADR-038.
*/
public record DocumentDeletingEvent(UUID documentId) {}

View File

@@ -80,6 +80,7 @@ public class DocumentService {
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AuditLogQueryService auditLogQueryService;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
private final org.springframework.context.ApplicationEventPublisher eventPublisher;
public record StoreResult(Document document, boolean isNew) {}
@@ -1101,7 +1102,9 @@ public class DocumentService {
if (!documentRepository.existsById(id)) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
}
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
documentRepository.deleteById(id);
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, null, id, null);
}
@Transactional

View File

@@ -130,8 +130,6 @@ public enum ErrorCode {
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 */

View File

@@ -0,0 +1,30 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
class JourneyItemDocumentDeleteListener {
private final JourneyItemRepository journeyItemRepository;
/**
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
* See ADR-038. DocumentService cannot call JourneyItemService directly because
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
*/
@EventListener
void onDocumentDeleting(DocumentDeletingEvent event) {
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
if (deleted > 0) {
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
}
}
}

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -43,6 +44,19 @@ public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID>
boolean existsByGeschichteIdAndDocumentId(
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
/**
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
* assertion never reads a stale entity.
*/
@Modifying(clearAutomatically = true)
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
/**
* 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()

View File

@@ -11,7 +11,6 @@ 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;
@@ -44,12 +43,7 @@ public class JourneyItemService {
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");
}
"Geschichte not found: " + geschichteId));
long count = journeyItemRepository.countByGeschichteId(geschichteId);
if (count >= MAX_ITEMS) {
@@ -163,7 +157,7 @@ public class JourneyItemService {
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
if (!geschichteQueryService.existsById(geschichteId)) {
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Journey not found: " + geschichteId);
"Geschichte not found: " + geschichteId);
}
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();

View File

@@ -0,0 +1,259 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemDocumentDeleteTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditService auditService;
@MockitoSpyBean
DocumentRepository documentRepository;
@PersistenceContext
EntityManager em;
@Autowired DocumentService documentService;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository docRepo;
@Autowired AppUserRepository appUserRepository;
@Autowired JdbcTemplate jdbcTemplate;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("delete-test-writer@test")
.password("hash")
.build());
doc = docRepo.save(Document.builder()
.title("Testbrief")
.originalFilename("testbrief.pdf")
.status(DocumentStatus.UPLOADED)
.build());
journey = geschichteRepository.save(Geschichte.builder()
.title("Eine Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
@AfterEach
void cleanup() {
SecurityContextHolder.clearContext();
reset(documentRepository);
journeyItemRepository.deleteAll();
docRepo.deleteAll();
geschichteRepository.deleteAll();
appUserRepository.deleteAll();
}
// ─── AC-1: headline ───────────────────────────────────────────────────────
@Test
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId());
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
assertThat(docRepo.findById(doc.getId())).isEmpty();
}
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
@Test
void deleting_document_preserves_note_carrying_item_as_placeholder() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
em.clear();
documentService.deleteDocument(doc.getId());
em.clear();
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("curator context");
}
// ─── AC-3: note-only item untouched ───────────────────────────────────────
@Test
void deleting_document_does_not_affect_note_only_item() {
JourneyItem noteOnly = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
em.clear();
documentService.deleteDocument(doc.getId());
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
assertThat(reloaded.getDocumentId()).isNull();
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
}
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
@Test
void deleting_document_applies_independently_per_referencing_item() {
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
JourneyItem noteLess = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
JourneyItem noteCarrying = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
em.clear();
documentService.deleteDocument(doc.getId());
em.clear();
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("Begleittext");
}
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
@Test
void listener_deletes_roll_back_when_document_delete_fails() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
doThrow(new RuntimeException("simulated failure"))
.when(documentRepository).deleteById(any());
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId()))
.isInstanceOf(RuntimeException.class);
em.clear();
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
}
// ─── AC-6: empty-string note boundary ────────────────────────────────────
@Test
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
// uq_journey_items_geschichte_document prevents two items with the same
// (geschichte_id, document_id) in one journey — use two separate journeys.
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise für AC-6")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
UUID emptyNoteItemId = UUID.randomUUID();
UUID whitespaceNoteItemId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
em.clear();
documentService.deleteDocument(doc.getId());
em.clear();
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
assertThat(whitespaceItem.getDocumentId()).isNull();
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
}
// ─── Idempotency / no-collateral ──────────────────────────────────────────
@Test
void deleting_document_in_zero_journeys_returns_no_collateral() {
Document unlinked = docRepo.save(Document.builder()
.title("Unverknüpfter Brief")
.originalFilename("other.pdf")
.status(DocumentStatus.UPLOADED)
.build());
JourneyItem unrelated = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
em.clear();
documentService.deleteDocument(unlinked.getId());
em.clear();
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
assertThat(journeyItemRepository.count()).isEqualTo(1);
}
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
@Test
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId());
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), any(), eq(doc.getId()), any());
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
}
}

View File

@@ -6,8 +6,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
@@ -42,12 +44,16 @@ class JourneyItemIntegrationTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditService auditService;
@PersistenceContext
EntityManager em;
@Autowired GeschichteRepository geschichteRepository;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired JourneyItemService journeyItemService;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@Autowired AppUserRepository appUserRepository;
@@ -212,8 +218,9 @@ class JourneyItemIntegrationTest {
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());
// Route through service so the DocumentDeletingEvent fires and the listener
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
documentService.deleteDocument(doc.getId());
em.flush();
em.clear();
@@ -284,6 +291,97 @@ class JourneyItemIntegrationTest {
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
}
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
@Test
void story_type_can_hold_journey_items_through_service() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).id()).isEqualTo(appended.id());
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
}
@Test
void v72_migrated_story_items_keep_position_order_and_are_removable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
Document docB = documentRepository.save(Document.builder()
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
Document docC = documentRepository.save(Document.builder()
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
// V72 inserted journey_items rows directly with position gaps — mirror that
// by writing through the repository instead of the service.
JourneyItem first = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
JourneyItem second = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
JourneyItem third = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::position)
.containsExactly(10, 20, 30);
journeyItemService.delete(story.getId(), second.getId());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::id)
.containsExactly(first.getId(), third.getId());
}
@Test
void story_item_with_deleted_document_survives_and_remains_deletable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
// SET NULL clears document_id — a note-less item would block the
// document delete at the DB instead.
dto.setNote("Begleittext");
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
documentService.deleteDocument(doc.getId());
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).document()).isNull();
journeyItemService.delete(story.getId(), appended.id());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
}
private Geschichte savedStory() {
return geschichteRepository.save(Geschichte.builder()
.title("Eine Geschichte")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.STORY)
.build());
}
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
@Test

View File

@@ -39,7 +39,6 @@ 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)
@@ -233,45 +232,6 @@ class JourneyItemServiceTest {
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);
@@ -320,6 +280,57 @@ class JourneyItemServiceTest {
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_to_STORY_type_creates_journey_item() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
Document doc = makeDoc(docId, null, List.of(), null, null);
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
assertThat(view.document().id()).isEqualTo(docId);
}
@Test
void append_to_STORY_type_respects_capacity_cap() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void append_to_STORY_type_rejects_duplicate_document() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void cap_is_COUNT_based_not_MAX_position_based() {
// 99 rows with MAX(position)=2000 should still accept the 100th append
@@ -729,6 +740,15 @@ class JourneyItemServiceTest {
.build();
}
private Geschichte story(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Story")
.type(GeschichteType.STORY)
.status(GeschichteStatus.DRAFT)
.build();
}
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
return JourneyItem.builder()
.id(id)

View File

@@ -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 and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (document attachments / editorial notes shared by both subtypes — no application-level type guard). Two subtypes: `STORY` (prose + attached documents) and `JOURNEY` (ordered curated sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL). See ADR-037.
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).

View File

@@ -149,9 +149,9 @@ _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.
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s and attaching documents via `journey_items`) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a 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`).
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a document attachment or editorial note belonging to a `Geschichte` of either subtype. JOURNEY-type Geschichten use items for their ordered reading sequence; STORY-type Geschichten use items to attach referenced documents (no type guard is enforced at the application layer — both subtypes share this table). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per Geschichte). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`). See ADR-037.
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.

View File

@@ -0,0 +1,78 @@
# ADR-037 — `journey_items` serves both STORY and JOURNEY Geschichte subtypes
**Status:** Accepted
**Date:** 2026-06-11
**Issue:** #795 (restore document management for STORY-type Geschichten), PR #804 review
## Context
V72 added the `journey_items` table as the backing store for Lesereisen (JOURNEY-type
Geschichten). At the same time, the previous `geschichten_documents` join table was
dropped (#753) and the restoration of a STORY-level document attachment mechanism was
deferred to a future issue.
`JourneyItemService.append()` contained an application-level type guard that rejected
`append()` calls on STORY-type Geschichten with `GESCHICHTE_TYPE_MISMATCH`. This guard
was the only place where the STORY restriction was encoded — the database schema never
enforced it (no CHECK constraint, no partial index on `type='JOURNEY'`).
When #795 restored document attachment for STORY-type Geschichten, the type guard was
the only obstacle. Two implementation paths were considered:
1. Keep an allowlist (`if type not in (JOURNEY, STORY) throw ...`) — dead code today
because `GeschichteType` is a two-constant enum; the branch can never be reached and
would fail the JaCoCo branch-coverage gate.
2. Delete the guard entirely — the schema never encoded the restriction; deleting dead
application logic rather than replacing it with more dead logic.
Path 2 was chosen.
## Decision
`journey_items` is the document-attachment mechanism for **both** `STORY` and `JOURNEY`
subtypes. No application-level type guard governs which subtype may hold items. The only
behavioral difference between the two subtypes' use of items is at the UI layer:
- JOURNEY: items form an ordered reading sequence rendered as a *Lesereise*.
- STORY: items are a set of attached reference documents rendered as a sidebar panel.
Both subtypes share the same capacity cap (100 items), dedup index, position semantics,
and DEFERRABLE constraint — enforced at the database layer, not re-implemented per subtype.
The `GESCHICHTE_TYPE_MISMATCH` error code was removed end-to-end (backend enum,
frontend `ErrorCode` type + `getErrorMessage()` case, all three locale files).
`GESCHICHTE_TYPE_IMMUTABLE` is unrelated and was left intact.
## Naming asymmetry (intentional)
The error codes `JOURNEY_AT_CAPACITY` and `JOURNEY_DOCUMENT_ALREADY_ADDED` carry
journey-flavored names. Renaming them would ripple through `ErrorCode.java`, `errors.ts`,
and three locale files for zero behavior change. `StoryDocumentPanel` remaps these two
codes to story-worded user messages at the presentation layer — the asymmetry is a
documented decision, not an accident.
## Alternatives rejected
- **Separate `story_documents` join table for STORY** — creates two nearly-identical
schemas for the same concept (document attachment with dedup and ordering), doubles the
migration surface, and splits the capacity/dedup logic. Rejected as unnecessary
duplication.
- **Allowlist type guard (`if type not in (JOURNEY, STORY)`)** — unreachable dead code
under a two-constant enum; fails the JaCoCo branch gate. Rejected.
- **Per-subtype application validation** — the schema never encoded the restriction; an
application-only rule with no schema backing is the weakest kind of invariant and was
removed when the product decision reversed it.
## Consequences
- `JourneyItemService.append()` accepts items for any `Geschichte`, regardless of subtype.
The 100-item cap and dedup constraint apply to all.
- GLOSSARY.md and ARCHITECTURE.md updated to reflect that `JourneyItem` is not
JOURNEY-specific.
- The `l3-backend-3g-supporting.puml` C4 diagram updated: type-guard language removed,
`geschQuerySvc` rel label reads "Checks Geschichte existence" (not "and type").
- `StoryDocumentPanel.svelte` is the STORY-side consumer; `JourneyEditor.svelte` is the
JOURNEY-side consumer. Neither is aware of the other.
- Known pre-existing constraint conflict: `ON DELETE SET NULL` on `journey_items.document_id`
combined with `chk_journey_item_not_empty` causes a DB-level 500 when a document linked
via a note-less item is deleted. Pre-existing; tracked in follow-up issue.

View File

@@ -0,0 +1,118 @@
# ADR-038 — Domain event drives note-less journey-item cleanup on document delete
**Status:** Accepted
**Date:** 2026-06-11
**Issue:** #805 (P1 — deleting a document linked via a note-less journey_item 500s at DB constraint)
## Context
Two constraints in V72 encode contradictory rules for a journey item that has a
`document_id` but no `note`:
- **`fk_journey_items_document``ON DELETE SET NULL`** — when a document is deleted,
Postgres nulls out `document_id`.
- **`chk_journey_item_not_empty`** — requires at least one of `document_id` or `note`
to be non-null.
A note-less item (`document_id` set, `note IS NULL`) satisfies the CHECK while the
document exists. Deleting the document causes Postgres to attempt `SET NULL`, which
would leave both columns null — a direct CHECK violation. Postgres aborts the
transaction with a 500 that bypasses `GlobalExceptionHandler`.
The natural fix — delete note-less items inside `DocumentService.deleteDocument` before
`deleteById` runs — cannot call `JourneyItemService` directly: `JourneyItemService`
already injects `DocumentService`, and Spring Framework 7 (used by Spring Boot 4)
**fully prohibits constructor-injection cycles**. The application will not start if such
a cycle is introduced.
## Decision
`DocumentService.deleteDocument` publishes a **`DocumentDeletingEvent`** (plain record,
payload: `documentId` UUID only) via `ApplicationEventPublisher` **before**
`documentRepository.deleteById`. A dedicated `@Component`
`JourneyItemDocumentDeleteListener` in the `geschichte.journeyitem` package consumes
this event and calls `journeyItemRepository.deleteNoteLessByDocumentId(documentId)`
directly — bypassing `JourneyItemService` to avoid re-introducing the cycle and to
suppress the per-item `JOURNEY_ITEM_REMOVED` audit emission (see audit decision below).
### Load-bearing listener-phase choice: plain `@EventListener`
The listener is annotated with `@EventListener` (not
`@TransactionalEventListener(AFTER_COMMIT)`, not `@Async`). **This choice is
load-bearing:**
- **`AFTER_COMMIT` would break the fix entirely.** `AFTER_COMMIT` fires *after* the
surrounding transaction has committed. By that point, `documentRepository.deleteById`
has already executed and Postgres has already tried `ON DELETE SET NULL` — the
constraint violation fires before the listener ever runs.
- **`@Async` would break rollback atomicity (AC-5).** An async listener runs on a
separate thread in its own transaction. If `deleteDocument` subsequently rolls back
(e.g. due to an unrelated failure), the listener's deletes are in a committed async
transaction and cannot be undone.
- **Plain `@EventListener` runs synchronously in the publisher's thread and
transaction.** The listener's JPQL delete and the `deleteById` are a single atomic
unit: if either fails, both roll back together.
### Repository method
```java
@Modifying(clearAutomatically = true)
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
```
`i.document.id` (the real association path) is used instead of `i.documentId`: the
transient `getDocumentId()` getter on `JourneyItem` makes Spring Data unable to resolve
a derived query path (same trap documented at `JourneyItemRepository:33-44`).
`clearAutomatically = true` invalidates the L1 cache so subsequent reads in the same
session do not return stale entities.
The predicate `(note IS NULL OR note = '')` covers the `note = ''` edge case that the
service layer can never produce (normalizeNote converts blank strings to null), but that
may exist via raw SQL inserts or legacy data. Whitespace-only notes (`note = ' '`)
do not match and are preserved as note-carrying placeholders.
### Audit decision
The listener calls the repository directly rather than routing through
`JourneyItemService.delete`. This deliberately bypasses the `JOURNEY_ITEM_REMOVED`
audit emission: a document used in multiple journeys would otherwise produce N audit
rows for a single user action. The `DOCUMENT_DELETED` entry written by `deleteDocument`
is the sole audit record for the operation.
### Boundary: documents must not depend on journey
The event direction is `document → journey`, never the reverse. `DocumentService`
publishes events it knows nothing about the consumers of; `JourneyItemService`'s
dependency on `DocumentService` is unchanged and remains the only cross-domain
reference. This direction is the prerequisite for the cycle constraint to hold.
## Alternatives rejected
- **DB trigger on `journey_items`** — trigger logic is opaque to Java developers,
invisible to code review, and not covered by the JPA test harness.
- **RESTRICT instead of SET NULL** — breaks the existing note-carrying placeholder
UX: deleting a document with a note-carrying journey item would 409 instead of
preserving the item as a placeholder.
- **Relax `chk_journey_item_not_empty`** — the constraint enforces a real invariant
(every item must have at least document or note). Removing it would allow empty rows.
- **`@Lazy` on the `JourneyItemService → DocumentService` injection** — Spring Boot 4 /
Spring Framework 7 prohibits constructor-injection cycles regardless of `@Lazy`.
- **Make `DocumentService` call `JourneyItemService`** — introduces the prohibited
cycle. Rejected at design time.
## Consequences
- **No schema change** — no new Flyway migration, no `db-orm.puml` /
`db-relationships.puml` update.
- This is the **first custom domain event** in the codebase. No prior
`ApplicationEventPublisher` usage existed in `main/`. New cross-domain cleanup that
cannot use direct service calls should follow this pattern.
- All tests that delete documents and then assert journey-item state **must route
through `DocumentService.deleteDocument`**, not `documentRepository.deleteById`.
The existing `JourneyItemIntegrationTest` tests that covered the note-carrying
placeholder UX have been updated accordingly.
- The `DOCUMENT_DELETED` `AuditKind` was added as part of this fix to give AC-7's
audit assertion a positive check (absence-only assertions pass vacuously if all
auditing regresses).

View File

@@ -19,7 +19,7 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
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(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Serves both STORY and JOURNEY subtypes.")
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
}
@@ -41,7 +41,7 @@ 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(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence")
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
Rel(auditSvc, db, "Writes audit_log", "JDBC")

View File

@@ -12,7 +12,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
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(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(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.")

View File

@@ -1028,7 +1028,6 @@
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
"error_geschichte_type_mismatch": "Diese Geschichte ist keine Lesereise Reise-Einträge sind hier nicht erlaubt.",
"journey_item_document_deleted": "[Dokument gelöscht]",
"geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte",
@@ -1068,8 +1067,17 @@
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"geschichte_editor_personen_heading": "Personen",
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
"geschichte_editor_dokumente_heading": "Dokumente",
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
"geschichte_documents_heading": "Briefe & Dokumente",
"geschichte_documents_hint": "Welche Dokumente gehören zu dieser Geschichte?",
"geschichte_documents_empty": "Noch keine Dokumente verknüpft. Suche unten nach einem Brief, um ihn dieser Geschichte hinzuzufügen.",
"geschichte_documents_picker_label": "Dokument hinzufügen",
"geschichte_documents_picker_placeholder": "Brief oder Dokument suchen…",
"geschichte_documents_deleted_placeholder": "Dokument wurde gelöscht",
"geschichte_documents_remove_label": "Dokument entfernen: {title}",
"geschichte_documents_capacity": "Diese Geschichte hat bereits die maximale Anzahl von Dokumenten (100) erreicht.",
"geschichte_documents_duplicate": "Dieses Dokument ist bereits mit der Geschichte verknüpft.",
"geschichte_documents_added_announce": "Hinzugefügt: {title}",
"geschichte_documents_removed_announce": "Entfernt: {title}",
"geschichte_editor_search_person": "Person suchen…",
"geschichte_editor_search_document": "Dokument suchen…",
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
@@ -1215,9 +1223,6 @@
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"person_unknown": "[Unbekannt]",
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"person_unknown": "[Unbekannt]",
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
}

View File

@@ -1028,7 +1028,6 @@
"error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
"error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.",
"error_geschichte_type_mismatch": "This story is not a reading journey — journey items are not allowed here.",
"journey_item_document_deleted": "[Document deleted]",
"geschichten_index_title": "Stories",
"geschichten_new_button": "New story",
@@ -1068,8 +1067,17 @@
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
"geschichte_editor_personen_heading": "People",
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
"geschichte_editor_dokumente_heading": "Documents",
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
"geschichte_documents_heading": "Letters & documents",
"geschichte_documents_hint": "Which documents belong to this story?",
"geschichte_documents_empty": "No documents linked yet. Search below for a letter to add it to this story.",
"geschichte_documents_picker_label": "Add document",
"geschichte_documents_picker_placeholder": "Search for a letter or document…",
"geschichte_documents_deleted_placeholder": "Document was deleted",
"geschichte_documents_remove_label": "Remove document: {title}",
"geschichte_documents_capacity": "This story has already reached the maximum of 100 documents.",
"geschichte_documents_duplicate": "This document is already linked to the story.",
"geschichte_documents_added_announce": "Added: {title}",
"geschichte_documents_removed_announce": "Removed: {title}",
"geschichte_editor_search_person": "Search person…",
"geschichte_editor_search_document": "Search document…",
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
@@ -1215,9 +1223,6 @@
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
"person_unknown": "[Unknown]",
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
"person_unknown": "[Unknown]",
"error_journey_document_already_added": "This letter is already included in the reading journey.",
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
}

View File

@@ -1028,7 +1028,6 @@
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
"error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.",
"error_geschichte_type_mismatch": "Esta historia no es un viaje de lectura — los elementos de viaje no están permitidos aquí.",
"journey_item_document_deleted": "[Documento eliminado]",
"geschichten_index_title": "Historias",
"geschichten_new_button": "Nueva historia",
@@ -1068,8 +1067,17 @@
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
"geschichte_editor_personen_heading": "Personas",
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
"geschichte_editor_dokumente_heading": "Documentos",
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
"geschichte_documents_heading": "Cartas y documentos",
"geschichte_documents_hint": "¿Qué documentos pertenecen a esta historia?",
"geschichte_documents_empty": "Aún no hay documentos vinculados. Busca abajo una carta para añadirla a esta historia.",
"geschichte_documents_picker_label": "Añadir documento",
"geschichte_documents_picker_placeholder": "Buscar una carta o documento…",
"geschichte_documents_deleted_placeholder": "El documento fue eliminado",
"geschichte_documents_remove_label": "Quitar documento: {title}",
"geschichte_documents_capacity": "Esta historia ya ha alcanzado el número máximo de documentos (100).",
"geschichte_documents_duplicate": "Este documento ya está vinculado a la historia.",
"geschichte_documents_added_announce": "Añadido: {title}",
"geschichte_documents_removed_announce": "Quitado: {title}",
"geschichte_editor_search_person": "Buscar persona…",
"geschichte_editor_search_document": "Buscar documento…",
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
@@ -1215,9 +1223,6 @@
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
"person_unknown": "[Desconocido]",
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
"person_unknown": "[Desconocido]",
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
}

View File

@@ -10,17 +10,21 @@ import {
interface Props {
alreadyAddedIds?: Set<string>;
placeholder?: string;
/** Set when a visible <label for> is wired externally — replaces the aria-label fallback. */
inputId?: string;
onSelect: (doc: DocumentOption) => void;
}
let {
alreadyAddedIds = new Set(),
placeholder = m.journey_add_document(),
inputId = undefined,
onSelect
}: Props = $props();
const uid = $props.id();
const listboxId = `doc-picker-listbox-${uid}`;
const resolvedInputId = $derived(inputId ?? `doc-picker-input-${uid}`);
const picker = createDocumentTypeahead();
@@ -82,7 +86,8 @@ function handleKeydown(e: KeyboardEvent) {
type="text"
role="combobox"
autocomplete="off"
aria-label={placeholder}
id={resolvedInputId}
aria-label={inputId ? undefined : placeholder}
aria-expanded={picker.isOpen}
aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
? listboxId

View File

@@ -241,3 +241,21 @@ describe('DocumentPickerDropdown — ARIA listbox integrity', () => {
});
});
});
describe('DocumentPickerDropdown — external label wiring (#795)', () => {
it('renders a generated default id on the input and keeps the aria-label fallback', async () => {
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox').element() as HTMLInputElement;
expect(input.id).toMatch(/^doc-picker-input-/);
expect(input.getAttribute('aria-label')).not.toBeNull();
});
it('uses the provided inputId and drops the aria-label so an external label wins', async () => {
render(DocumentPickerDropdown, { onSelect: vi.fn(), inputId: 'story-doc-picker' });
const input = page.getByRole('combobox').element() as HTMLInputElement;
expect(input.id).toBe('story-doc-picker');
expect(input.getAttribute('aria-label')).toBeNull();
});
});

View File

@@ -234,7 +234,12 @@ function exec(action: () => void) {
</div>
<!-- Sidebar -->
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
<GeschichteSidebar
status={status}
bind:selectedPersons={selectedPersons}
geschichteId={geschichte?.id}
items={geschichte?.items ?? []}
/>
</div>
<!-- Save bar -->

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from './GeschichteEditor.svelte';
const personFactory = (id: string, displayName: string) => ({
@@ -171,3 +172,36 @@ describe('GeschichteEditor — onSubmit payload', () => {
expect(payload.personIds).toEqual(['p1']);
});
});
describe('GeschichteEditor — story document panel (#795)', () => {
it('shows the document panel with the story items when editing an existing story', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({
items: [
{
id: 'i1',
position: 10,
document: {
id: 'd1',
title: 'Brief von Eugenie',
datePrecision: 'DAY' as const,
receiverCount: 0
}
}
]
}),
onSubmit: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
.toBeInTheDocument();
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
});
it('hides the document panel when no geschichte is set (creation flow)', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
});
});

View File

@@ -1,14 +1,26 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import type { PersonOption } from '$lib/person/personOption';
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
status: 'DRAFT' | 'PUBLISHED';
selectedPersons: PersonOption[];
/** When set, the story document panel is rendered (STORY edit only). */
geschichteId?: string;
items?: JourneyItemView[];
}
let { status, selectedPersons = $bindable() }: Props = $props();
let {
status,
selectedPersons = $bindable(),
geschichteId = undefined,
items = []
}: Props = $props();
const isDraft = $derived(status === 'DRAFT');
</script>
@@ -62,4 +74,9 @@ const isDraft = $derived(status === 'DRAFT');
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
</details>
<!-- Documents section — STORY edit only; journeys manage items in the editor column -->
{#if geschichteId}
<StoryDocumentPanel geschichteId={geschichteId} items={items} />
{/if}
</aside>

View File

@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import GeschichteSidebar from './GeschichteSidebar.svelte';
const item = {
id: 'i1',
position: 10,
document: {
id: 'd1',
title: 'Brief von Eugenie',
datePrecision: 'DAY' as const,
receiverCount: 0
}
};
afterEach(() => cleanup());
describe('GeschichteSidebar — document panel contract (#795)', () => {
it('renders the document panel when geschichteId and items are provided', async () => {
render(GeschichteSidebar, {
status: 'DRAFT',
selectedPersons: [],
geschichteId: 'g1',
items: [item]
});
await expect
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
.toBeInTheDocument();
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
});
it('does not render the document panel without geschichteId', async () => {
render(GeschichteSidebar, { status: 'DRAFT', selectedPersons: [] });
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
});
});

View File

@@ -4,7 +4,7 @@ UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-te
## What this domain owns
Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `StoryDocumentPanel.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
Utilities: `utils.ts`.
## What this domain does NOT own
@@ -15,19 +15,20 @@ Utilities: `utils.ts`.
## Key components
| Component | Used in | Notes |
| -------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` |
| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `<details>` mobile collapsibles with 44px touch targets |
| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish |
| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
| `GeschichteListRow.svelte` | `/geschichten` (list) | Editorial list row: meta column (avatar, author, date, REISE badge), title + excerpt content column |
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Card per document item: title, meta line (date · von X an Y), "Brief öffnen →" link, mint-border note |
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Left-accent interlude box between letters (mode-aware tokens); `aria-label="Kuratorennotiz"` |
| Component | Used in | Notes |
| --------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` |
| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `<details>` mobile collapsibles with 44px touch targets |
| `StoryDocumentPanel.svelte` | `GeschichteSidebar` (STORY edit only) | Sidebar document list for stories: picker add (POST), optimistic remove (DELETE), deleted-doc placeholders |
| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish |
| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
| `GeschichteListRow.svelte` | `/geschichten` (list) | Editorial list row: meta column (avatar, author, date, REISE badge), title + excerpt content column |
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Card per document item: title, meta line (date · von X an Y), "Brief öffnen →" link, mint-border note |
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Left-accent interlude box between letters (mode-aware tokens); `aria-label="Kuratorennotiz"` |
## utils.ts

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
import { getErrorMessage } from '$lib/shared/errors';
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
geschichteId: string;
items?: JourneyItemView[];
}
let { geschichteId, items: initialItems = [] }: Props = $props();
const uid = $props.id();
const pickerInputId = `story-doc-picker-${uid}`;
// Initial-state snapshot — the panel owns the list after mount and updates
// it from API responses; the parent re-mounts to reset (same contract as
// GeschichteEditor/JourneyEditor).
// svelte-ignore state_referenced_locally
let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position));
let errorMessage = $state('');
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;
let sectionEl: HTMLElement | null = $state(null);
$effect(() => () => {
if (announceTimer) clearTimeout(announceTimer);
});
const alreadyAddedIds = $derived(
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
);
function announce(message: string) {
liveAnnounce = message;
if (announceTimer) clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
liveAnnounce = '';
announceTimer = null;
}, 500);
}
function itemTitle(item: JourneyItemView): string {
return item.document?.title ?? m.geschichte_documents_deleted_placeholder();
}
/** Maps a failed mutation to a user-facing message — story wording for the
* two journey-flavored 409s, whose generic messages say "Lesereise". */
async function failureMessage(res: Response): Promise<string> {
const code = (await res.json().catch(() => ({})))?.code;
if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity();
if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate();
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
}
/** Pessimistic append — the list updates only with the server's response. */
async function handleAdd(doc: DocumentOption) {
errorMessage = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: doc.id })
});
if (!res.ok) {
errorMessage = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) }));
} catch (e) {
console.error('Story document add failed', e);
errorMessage = m.journey_mutation_error_reload();
}
}
/** The removed row's button leaves the DOM — without this, focus drops to
* <body> and a keyboard user is teleported to page top. */
async function moveFocusAfterRemove(removedIdx: number) {
await tick();
if (items.length === 0) {
sectionEl?.querySelector<HTMLElement>(`#${pickerInputId}`)?.focus();
return;
}
const target = items[Math.max(removedIdx - 1, 0)];
sectionEl
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(target.id)}"] [data-remove-btn]`)
?.focus();
}
/** Optimistic removal with snapshot-and-rollback. */
async function handleRemove(item: JourneyItemView) {
const idx = items.findIndex((i) => i.id === item.id);
const prev = [...items];
errorMessage = '';
items = items.filter((i) => i.id !== item.id);
await moveFocusAfterRemove(idx);
try {
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, {
method: 'DELETE'
});
if (!res.ok) {
items = prev;
await tick();
sectionEl
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
?.focus();
errorMessage = await failureMessage(res);
return;
}
announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) }));
} catch (e) {
console.error('Story document remove failed', e);
items = prev;
await tick();
sectionEl
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
?.focus();
errorMessage = m.journey_mutation_error_reload();
}
}
</script>
<!-- Screen-reader live region for add/remove confirmations -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_documents_heading()}
</summary>
<section bind:this={sectionEl} class="rounded border border-line bg-surface p-4 shadow-sm">
<h2
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_documents_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_hint()}</p>
{#if errorMessage}
<p
role="alert"
class="mb-3 flex items-start gap-2 rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
>
<svg
class="mt-0.5 h-4 w-4 flex-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
{errorMessage}
</p>
{/if}
{#if items.length === 0}
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_empty()}</p>
{:else}
<ul class="m-0 mb-3 flex list-none flex-col p-0">
{#each items as item (item.id)}
<li
data-item-id={item.id}
class="flex items-center justify-between gap-2 border-b border-line/60 last:border-b-0"
>
{#if item.document}
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{item.document.title}
</span>
{:else}
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-3 italic">
{m.geschichte_documents_deleted_placeholder()}
</span>
{/if}
<button
type="button"
data-remove-btn
onclick={() => handleRemove(item)}
aria-label={m.geschichte_documents_remove_label({ title: itemTitle(item) })}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
{/each}
</ul>
{/if}
<label for={pickerInputId} class="mb-1 block font-sans text-xs font-medium text-ink-2">
{m.geschichte_documents_picker_label()}
</label>
<DocumentPickerDropdown
inputId={pickerInputId}
alreadyAddedIds={alreadyAddedIds}
placeholder={m.geschichte_documents_picker_placeholder()}
onSelect={handleAdd}
/>
</section>
</details>

View File

@@ -0,0 +1,446 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
const docSummary = (id: string, title: string) => ({
id,
title,
datePrecision: 'DAY' as const,
receiverCount: 0
});
const makeItem = (
id: string,
position: number,
document?: ReturnType<typeof docSummary>,
note?: string
) => ({ id, position, document, note });
/** DocumentListItem fixture as returned by the picker search endpoint. */
const makeSearchResultItem = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
type MutationResponse = { ok: boolean; status?: number; body?: object };
/**
* Routes the picker's GET search to `searchItems` and every mutation
* (POST/DELETE) to `mutation` — the panel talks to both endpoints.
*/
function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const method = (init?.method ?? 'GET').toUpperCase();
if (method === 'GET') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ items: searchItems })
});
}
return Promise.resolve({
ok: mutation.ok,
status: mutation.status ?? (mutation.ok ? 200 : 500),
json: () => Promise.resolve(mutation.body ?? {})
});
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
geschichteId: 'g1',
items: [],
...overrides
});
async function addViaPicker(title: RegExp) {
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await expect.element(page.getByText(title)).toBeInTheDocument();
await userEvent.click(page.getByText(title));
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('StoryDocumentPanel — rendering', () => {
it('renders linked documents sorted by position', async () => {
render(
StoryDocumentPanel,
defaultProps({
items: [
makeItem('i3', 30, docSummary('d3', 'Dritter Brief')),
makeItem('i1', 10, docSummary('d1', 'Erster Brief'))
]
})
);
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
expect(rows[0]).toContain('Erster Brief');
expect(rows[1]).toContain('Dritter Brief');
});
it('shows the empty state when no items are linked', async () => {
render(StoryDocumentPanel, defaultProps());
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
});
it('renders a deleted-document item as placeholder row that is still removable', async () => {
render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] }));
await expect
.element(page.getByText(m.geschichte_documents_deleted_placeholder()))
.toBeInTheDocument();
await expect
.element(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({
title: m.geschichte_documents_deleted_placeholder()
})
})
)
.toBeInTheDocument();
});
it('wires a visible label to the picker input', async () => {
render(StoryDocumentPanel, defaultProps());
const input = page.getByRole('combobox').element() as HTMLInputElement;
const label = document.querySelector(`label[for="${input.id}"]`);
expect(label?.textContent).toContain(m.geschichte_documents_picker_label());
});
});
describe('StoryDocumentPanel — add', () => {
it('POSTs to the items endpoint and appends the created item', async () => {
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: true,
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post?.[0]).toBe('/api/geschichten/g1/items');
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' });
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
});
it('marks an already-linked document as not selectable in the dropdown', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await expect.element(page.getByRole('option')).toBeInTheDocument();
const option = document.querySelector('[role="listbox"] [role="option"]');
expect(option?.getAttribute('aria-disabled')).toBe('true');
});
it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: false,
status: 409,
body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' }
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.geschichte_documents_duplicate());
});
it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: false,
status: 409,
body: { code: 'JOURNEY_AT_CAPACITY' }
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.geschichte_documents_capacity());
});
it('announces a successful add via the polite live region', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: true,
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion?.textContent).toBe(
m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' })
);
});
it('routes a 403 response through getErrorMessage on POST', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: false,
status: 403,
body: { code: 'FORBIDDEN' }
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const alertText = page.getByRole('alert').element().textContent ?? '';
expect(alertText).not.toBe('');
expect(alertText).not.toContain('FORBIDDEN');
});
it('shows the generic reload message when POST throws a network error', async () => {
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if ((init?.method ?? 'GET').toUpperCase() === 'GET') {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({ items: [makeSearchResultItem('d1', 'Brief von Eugenie')] })
});
}
return Promise.reject(new Error('Network error'));
})
);
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.journey_mutation_error_reload());
});
it('attaches X-XSRF-TOKEN header from cookie on POST', async () => {
document.cookie = 'XSRF-TOKEN=test-csrf-token';
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
ok: true,
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
});
render(StoryDocumentPanel, defaultProps());
await addViaPicker(/Brief von Eugenie/i);
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
const headers = post?.[1]?.headers as Headers;
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
});
});
describe('StoryDocumentPanel — remove', () => {
it('DELETEs the item endpoint and removes the row', async () => {
const fetchMock = stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
expect(del?.[0]).toBe('/api/geschichten/g1/items/i1');
expect(document.querySelectorAll('li').length).toBe(0);
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
});
it('restores the row and shows an error when the DELETE fails', async () => {
stubFetch([], { ok: false, status: 500, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
});
it('moves focus to the previous row remove button instead of dropping to body', async () => {
stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({
items: [
makeItem('i1', 10, docSummary('d1', 'Erster Brief')),
makeItem('i2', 20, docSummary('d2', 'Zweiter Brief'))
]
})
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' })
})
);
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement?.getAttribute('aria-label')).toBe(
m.geschichte_documents_remove_label({ title: 'Erster Brief' })
);
});
it('moves focus to the picker input when the last item is removed', async () => {
stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const input = page.getByRole('combobox').element();
expect(document.activeElement).toBe(input);
});
it('announces a successful remove via the polite live region', async () => {
stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion?.textContent).toBe(
m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' })
);
});
it('returns focus to the item remove button when DELETE fails with !res.ok', async () => {
stubFetch([], { ok: false, status: 500, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
expect(document.activeElement?.getAttribute('aria-label')).toBe(
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
);
});
it('returns focus to the item remove button when DELETE throws a network error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('Network error')))
);
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
expect(document.activeElement?.getAttribute('aria-label')).toBe(
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
);
});
it('shows the generic reload message when DELETE throws a network error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('Network error')))
);
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.journey_mutation_error_reload());
});
it('attaches X-XSRF-TOKEN header from cookie on DELETE', async () => {
document.cookie = 'XSRF-TOKEN=test-csrf-token';
const fetchMock = stubFetch([], { ok: true, body: {} });
render(
StoryDocumentPanel,
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
);
await userEvent.click(
page.getByRole('button', {
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
})
);
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
const headers = del?.[1]?.headers as Headers;
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
});
});

View File

@@ -51,7 +51,6 @@ export type ErrorCode =
| 'JOURNEY_AT_CAPACITY'
| 'JOURNEY_NOTE_TOO_LONG'
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
| 'GESCHICHTE_TYPE_MISMATCH'
| 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG'
@@ -183,8 +182,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_journey_note_too_long();
case 'JOURNEY_DOCUMENT_ALREADY_ADDED':
return m.error_journey_document_already_added();
case 'GESCHICHTE_TYPE_MISMATCH':
return m.error_geschichte_type_mismatch();
case 'GESCHICHTE_TYPE_IMMUTABLE':
return m.error_geschichte_type_immutable();
case 'GESCHICHTE_TITLE_TOO_LONG':

View File

@@ -0,0 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { m } from '$lib/paraglide/messages.js';
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import StoryCreate from './StoryCreate.svelte';
afterEach(() => cleanup());
describe('StoryCreate — document panel guard (#795)', () => {
it('renders without the document panel — documents attach after the first save', async () => {
render(StoryCreate, { initialPersons: [] });
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
});
});