Compare commits
17 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eefc67bd81 | ||
|
|
44869d64f7 | ||
|
|
7ca6492fc0 | ||
|
|
9ea21f60ea | ||
|
|
a6184fa121 | ||
|
|
232721214d | ||
|
|
d91bedbaaf | ||
|
|
8e4810d5da | ||
|
|
05652a18ee | ||
|
|
6b2dd2f259 | ||
|
|
f43df6082d | ||
|
|
2121d8469f | ||
|
|
e8437b79d1 | ||
|
|
4f0a660cb8 | ||
|
|
48f2c67ffc | ||
|
|
4961c74a01 | ||
|
|
d0fc8ce995 |
@@ -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) */
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# ADR-037 — `journey_items` serves both STORY and JOURNEY Geschichte subtypes
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-11
|
||||
**Issue:** #795 (restore document management for STORY-type Geschichten), PR #804 review
|
||||
|
||||
## Context
|
||||
|
||||
V72 added the `journey_items` table as the backing store for Lesereisen (JOURNEY-type
|
||||
Geschichten). At the same time, the previous `geschichten_documents` join table was
|
||||
dropped (#753) and the restoration of a STORY-level document attachment mechanism was
|
||||
deferred to a future issue.
|
||||
|
||||
`JourneyItemService.append()` contained an application-level type guard that rejected
|
||||
`append()` calls on STORY-type Geschichten with `GESCHICHTE_TYPE_MISMATCH`. This guard
|
||||
was the only place where the STORY restriction was encoded — the database schema never
|
||||
enforced it (no CHECK constraint, no partial index on `type='JOURNEY'`).
|
||||
|
||||
When #795 restored document attachment for STORY-type Geschichten, the type guard was
|
||||
the only obstacle. Two implementation paths were considered:
|
||||
|
||||
1. Keep an allowlist (`if type not in (JOURNEY, STORY) throw ...`) — dead code today
|
||||
because `GeschichteType` is a two-constant enum; the branch can never be reached and
|
||||
would fail the JaCoCo branch-coverage gate.
|
||||
2. Delete the guard entirely — the schema never encoded the restriction; deleting dead
|
||||
application logic rather than replacing it with more dead logic.
|
||||
|
||||
Path 2 was chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
`journey_items` is the document-attachment mechanism for **both** `STORY` and `JOURNEY`
|
||||
subtypes. No application-level type guard governs which subtype may hold items. The only
|
||||
behavioral difference between the two subtypes' use of items is at the UI layer:
|
||||
|
||||
- JOURNEY: items form an ordered reading sequence rendered as a *Lesereise*.
|
||||
- STORY: items are a set of attached reference documents rendered as a sidebar panel.
|
||||
|
||||
Both subtypes share the same capacity cap (100 items), dedup index, position semantics,
|
||||
and DEFERRABLE constraint — enforced at the database layer, not re-implemented per subtype.
|
||||
|
||||
The `GESCHICHTE_TYPE_MISMATCH` error code was removed end-to-end (backend enum,
|
||||
frontend `ErrorCode` type + `getErrorMessage()` case, all three locale files).
|
||||
`GESCHICHTE_TYPE_IMMUTABLE` is unrelated and was left intact.
|
||||
|
||||
## Naming asymmetry (intentional)
|
||||
|
||||
The error codes `JOURNEY_AT_CAPACITY` and `JOURNEY_DOCUMENT_ALREADY_ADDED` carry
|
||||
journey-flavored names. Renaming them would ripple through `ErrorCode.java`, `errors.ts`,
|
||||
and three locale files for zero behavior change. `StoryDocumentPanel` remaps these two
|
||||
codes to story-worded user messages at the presentation layer — the asymmetry is a
|
||||
documented decision, not an accident.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **Separate `story_documents` join table for STORY** — creates two nearly-identical
|
||||
schemas for the same concept (document attachment with dedup and ordering), doubles the
|
||||
migration surface, and splits the capacity/dedup logic. Rejected as unnecessary
|
||||
duplication.
|
||||
- **Allowlist type guard (`if type not in (JOURNEY, STORY)`)** — unreachable dead code
|
||||
under a two-constant enum; fails the JaCoCo branch gate. Rejected.
|
||||
- **Per-subtype application validation** — the schema never encoded the restriction; an
|
||||
application-only rule with no schema backing is the weakest kind of invariant and was
|
||||
removed when the product decision reversed it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `JourneyItemService.append()` accepts items for any `Geschichte`, regardless of subtype.
|
||||
The 100-item cap and dedup constraint apply to all.
|
||||
- GLOSSARY.md and ARCHITECTURE.md updated to reflect that `JourneyItem` is not
|
||||
JOURNEY-specific.
|
||||
- The `l3-backend-3g-supporting.puml` C4 diagram updated: type-guard language removed,
|
||||
`geschQuerySvc` rel label reads "Checks Geschichte existence" (not "and type").
|
||||
- `StoryDocumentPanel.svelte` is the STORY-side consumer; `JourneyEditor.svelte` is the
|
||||
JOURNEY-side consumer. Neither is aware of the other.
|
||||
- Known pre-existing constraint conflict: `ON DELETE SET NULL` on `journey_items.document_id`
|
||||
combined with `chk_journey_item_not_empty` causes a DB-level 500 when a document linked
|
||||
via a note-less item is deleted. Pre-existing; tracked in follow-up issue.
|
||||
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# ADR-038 — Domain event drives note-less journey-item cleanup on document delete
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-11
|
||||
**Issue:** #805 (P1 — deleting a document linked via a note-less journey_item 500s at DB constraint)
|
||||
|
||||
## Context
|
||||
|
||||
Two constraints in V72 encode contradictory rules for a journey item that has a
|
||||
`document_id` but no `note`:
|
||||
|
||||
- **`fk_journey_items_document` → `ON DELETE SET NULL`** — when a document is deleted,
|
||||
Postgres nulls out `document_id`.
|
||||
- **`chk_journey_item_not_empty`** — requires at least one of `document_id` or `note`
|
||||
to be non-null.
|
||||
|
||||
A note-less item (`document_id` set, `note IS NULL`) satisfies the CHECK while the
|
||||
document exists. Deleting the document causes Postgres to attempt `SET NULL`, which
|
||||
would leave both columns null — a direct CHECK violation. Postgres aborts the
|
||||
transaction with a 500 that bypasses `GlobalExceptionHandler`.
|
||||
|
||||
The natural fix — delete note-less items inside `DocumentService.deleteDocument` before
|
||||
`deleteById` runs — cannot call `JourneyItemService` directly: `JourneyItemService`
|
||||
already injects `DocumentService`, and Spring Framework 7 (used by Spring Boot 4)
|
||||
**fully prohibits constructor-injection cycles**. The application will not start if such
|
||||
a cycle is introduced.
|
||||
|
||||
## Decision
|
||||
|
||||
`DocumentService.deleteDocument` publishes a **`DocumentDeletingEvent`** (plain record,
|
||||
payload: `documentId` UUID only) via `ApplicationEventPublisher` **before**
|
||||
`documentRepository.deleteById`. A dedicated `@Component`
|
||||
`JourneyItemDocumentDeleteListener` in the `geschichte.journeyitem` package consumes
|
||||
this event and calls `journeyItemRepository.deleteNoteLessByDocumentId(documentId)`
|
||||
directly — bypassing `JourneyItemService` to avoid re-introducing the cycle and to
|
||||
suppress the per-item `JOURNEY_ITEM_REMOVED` audit emission (see audit decision below).
|
||||
|
||||
### Load-bearing listener-phase choice: plain `@EventListener`
|
||||
|
||||
The listener is annotated with `@EventListener` (not
|
||||
`@TransactionalEventListener(AFTER_COMMIT)`, not `@Async`). **This choice is
|
||||
load-bearing:**
|
||||
|
||||
- **`AFTER_COMMIT` would break the fix entirely.** `AFTER_COMMIT` fires *after* the
|
||||
surrounding transaction has committed. By that point, `documentRepository.deleteById`
|
||||
has already executed and Postgres has already tried `ON DELETE SET NULL` — the
|
||||
constraint violation fires before the listener ever runs.
|
||||
- **`@Async` would break rollback atomicity (AC-5).** An async listener runs on a
|
||||
separate thread in its own transaction. If `deleteDocument` subsequently rolls back
|
||||
(e.g. due to an unrelated failure), the listener's deletes are in a committed async
|
||||
transaction and cannot be undone.
|
||||
- **Plain `@EventListener` runs synchronously in the publisher's thread and
|
||||
transaction.** The listener's JPQL delete and the `deleteById` are a single atomic
|
||||
unit: if either fails, both roll back together.
|
||||
|
||||
### Repository method
|
||||
|
||||
```java
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||
```
|
||||
|
||||
`i.document.id` (the real association path) is used instead of `i.documentId`: the
|
||||
transient `getDocumentId()` getter on `JourneyItem` makes Spring Data unable to resolve
|
||||
a derived query path (same trap documented at `JourneyItemRepository:33-44`).
|
||||
|
||||
`clearAutomatically = true` invalidates the L1 cache so subsequent reads in the same
|
||||
session do not return stale entities.
|
||||
|
||||
The predicate `(note IS NULL OR note = '')` covers the `note = ''` edge case that the
|
||||
service layer can never produce (normalizeNote converts blank strings to null), but that
|
||||
may exist via raw SQL inserts or legacy data. Whitespace-only notes (`note = ' '`)
|
||||
do not match and are preserved as note-carrying placeholders.
|
||||
|
||||
### Audit decision
|
||||
|
||||
The listener calls the repository directly rather than routing through
|
||||
`JourneyItemService.delete`. This deliberately bypasses the `JOURNEY_ITEM_REMOVED`
|
||||
audit emission: a document used in multiple journeys would otherwise produce N audit
|
||||
rows for a single user action. The `DOCUMENT_DELETED` entry written by `deleteDocument`
|
||||
is the sole audit record for the operation.
|
||||
|
||||
### Boundary: documents must not depend on journey
|
||||
|
||||
The event direction is `document → journey`, never the reverse. `DocumentService`
|
||||
publishes events it knows nothing about the consumers of; `JourneyItemService`'s
|
||||
dependency on `DocumentService` is unchanged and remains the only cross-domain
|
||||
reference. This direction is the prerequisite for the cycle constraint to hold.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **DB trigger on `journey_items`** — trigger logic is opaque to Java developers,
|
||||
invisible to code review, and not covered by the JPA test harness.
|
||||
- **RESTRICT instead of SET NULL** — breaks the existing note-carrying placeholder
|
||||
UX: deleting a document with a note-carrying journey item would 409 instead of
|
||||
preserving the item as a placeholder.
|
||||
- **Relax `chk_journey_item_not_empty`** — the constraint enforces a real invariant
|
||||
(every item must have at least document or note). Removing it would allow empty rows.
|
||||
- **`@Lazy` on the `JourneyItemService → DocumentService` injection** — Spring Boot 4 /
|
||||
Spring Framework 7 prohibits constructor-injection cycles regardless of `@Lazy`.
|
||||
- **Make `DocumentService` call `JourneyItemService`** — introduces the prohibited
|
||||
cycle. Rejected at design time.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **No schema change** — no new Flyway migration, no `db-orm.puml` /
|
||||
`db-relationships.puml` update.
|
||||
- This is the **first custom domain event** in the codebase. No prior
|
||||
`ApplicationEventPublisher` usage existed in `main/`. New cross-domain cleanup that
|
||||
cannot use direct service calls should follow this pattern.
|
||||
- All tests that delete documents and then assert journey-item state **must route
|
||||
through `DocumentService.deleteDocument`**, not `documentRepository.deleteById`.
|
||||
The existing `JourneyItemIntegrationTest` tests that covered the note-carrying
|
||||
placeholder UX have been updated accordingly.
|
||||
- The `DOCUMENT_DELETED` `AuditKind` was added as part of this fix to give AC-7's
|
||||
audit assertion a positive check (absence-only assertions pass vacuously if all
|
||||
auditing regresses).
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts
Normal file
40
frontend/src/lib/geschichte/GeschichteSidebar.svelte.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||
|
||||
const item = {
|
||||
id: 'i1',
|
||||
position: 10,
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief von Eugenie',
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GeschichteSidebar — document panel contract (#795)', () => {
|
||||
it('renders the document panel when geschichteId and items are provided', async () => {
|
||||
render(GeschichteSidebar, {
|
||||
status: 'DRAFT',
|
||||
selectedPersons: [],
|
||||
geschichteId: 'g1',
|
||||
items: [item]
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { name: m.geschichte_documents_heading() }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Brief von Eugenie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the document panel without geschichteId', async () => {
|
||||
render(GeschichteSidebar, { status: 'DRAFT', selectedPersons: [] });
|
||||
|
||||
expect(document.body.textContent).not.toContain(m.geschichte_documents_heading());
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
221
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
221
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichteId: string;
|
||||
items?: JourneyItemView[];
|
||||
}
|
||||
|
||||
let { geschichteId, items: initialItems = [] }: Props = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const pickerInputId = `story-doc-picker-${uid}`;
|
||||
|
||||
// Initial-state snapshot — the panel owns the list after mount and updates
|
||||
// it from API responses; the parent re-mounts to reset (same contract as
|
||||
// GeschichteEditor/JourneyEditor).
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position));
|
||||
let errorMessage = $state('');
|
||||
let liveAnnounce = $state('');
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sectionEl: HTMLElement | null = $state(null);
|
||||
|
||||
$effect(() => () => {
|
||||
if (announceTimer) clearTimeout(announceTimer);
|
||||
});
|
||||
|
||||
const alreadyAddedIds = $derived(
|
||||
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||
);
|
||||
|
||||
function announce(message: string) {
|
||||
liveAnnounce = message;
|
||||
if (announceTimer) clearTimeout(announceTimer);
|
||||
announceTimer = setTimeout(() => {
|
||||
liveAnnounce = '';
|
||||
announceTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function itemTitle(item: JourneyItemView): string {
|
||||
return item.document?.title ?? m.geschichte_documents_deleted_placeholder();
|
||||
}
|
||||
|
||||
/** Maps a failed mutation to a user-facing message — story wording for the
|
||||
* two journey-flavored 409s, whose generic messages say "Lesereise". */
|
||||
async function failureMessage(res: Response): Promise<string> {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity();
|
||||
if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate();
|
||||
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
|
||||
}
|
||||
|
||||
/** Pessimistic append — the list updates only with the server's response. */
|
||||
async function handleAdd(doc: DocumentOption) {
|
||||
errorMessage = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId: doc.id })
|
||||
});
|
||||
if (!res.ok) {
|
||||
errorMessage = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) }));
|
||||
} catch (e) {
|
||||
console.error('Story document add failed', e);
|
||||
errorMessage = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** The removed row's button leaves the DOM — without this, focus drops to
|
||||
* <body> and a keyboard user is teleported to page top. */
|
||||
async function moveFocusAfterRemove(removedIdx: number) {
|
||||
await tick();
|
||||
if (items.length === 0) {
|
||||
sectionEl?.querySelector<HTMLElement>(`#${pickerInputId}`)?.focus();
|
||||
return;
|
||||
}
|
||||
const target = items[Math.max(removedIdx - 1, 0)];
|
||||
sectionEl
|
||||
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(target.id)}"] [data-remove-btn]`)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
/** Optimistic removal with snapshot-and-rollback. */
|
||||
async function handleRemove(item: JourneyItemView) {
|
||||
const idx = items.findIndex((i) => i.id === item.id);
|
||||
const prev = [...items];
|
||||
errorMessage = '';
|
||||
items = items.filter((i) => i.id !== item.id);
|
||||
await moveFocusAfterRemove(idx);
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
items = prev;
|
||||
await tick();
|
||||
sectionEl
|
||||
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
|
||||
?.focus();
|
||||
errorMessage = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) }));
|
||||
} catch (e) {
|
||||
console.error('Story document remove failed', e);
|
||||
items = prev;
|
||||
await tick();
|
||||
sectionEl
|
||||
?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(item.id)}"] [data-remove-btn]`)
|
||||
?.focus();
|
||||
errorMessage = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Screen-reader live region for add/remove confirmations -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||
|
||||
<details open class="sm:contents">
|
||||
<summary
|
||||
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||
>
|
||||
{m.geschichte_documents_heading()}
|
||||
</summary>
|
||||
<section bind:this={sectionEl} class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2
|
||||
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||
>
|
||||
{m.geschichte_documents_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_hint()}</p>
|
||||
|
||||
{#if errorMessage}
|
||||
<p
|
||||
role="alert"
|
||||
class="mb-3 flex items-start gap-2 rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_empty()}</p>
|
||||
{:else}
|
||||
<ul class="m-0 mb-3 flex list-none flex-col p-0">
|
||||
{#each items as item (item.id)}
|
||||
<li
|
||||
data-item-id={item.id}
|
||||
class="flex items-center justify-between gap-2 border-b border-line/60 last:border-b-0"
|
||||
>
|
||||
{#if item.document}
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||
{item.document.title}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-3 italic">
|
||||
{m.geschichte_documents_deleted_placeholder()}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
data-remove-btn
|
||||
onclick={() => handleRemove(item)}
|
||||
aria-label={m.geschichte_documents_remove_label({ title: itemTitle(item) })}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<label for={pickerInputId} class="mb-1 block font-sans text-xs font-medium text-ink-2">
|
||||
{m.geschichte_documents_picker_label()}
|
||||
</label>
|
||||
<DocumentPickerDropdown
|
||||
inputId={pickerInputId}
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
placeholder={m.geschichte_documents_picker_placeholder()}
|
||||
onSelect={handleAdd}
|
||||
/>
|
||||
</section>
|
||||
</details>
|
||||
446
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
446
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import StoryDocumentPanel from './StoryDocumentPanel.svelte';
|
||||
|
||||
const docSummary = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
datePrecision: 'DAY' as const,
|
||||
receiverCount: 0
|
||||
});
|
||||
|
||||
const makeItem = (
|
||||
id: string,
|
||||
position: number,
|
||||
document?: ReturnType<typeof docSummary>,
|
||||
note?: string
|
||||
) => ({ id, position, document, note });
|
||||
|
||||
/** DocumentListItem fixture as returned by the picker search endpoint. */
|
||||
const makeSearchResultItem = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: '1880-01-01',
|
||||
metaDatePrecision: 'DAY',
|
||||
originalFilename: 'brief.pdf',
|
||||
receivers: [],
|
||||
tags: [],
|
||||
completionPercentage: 0,
|
||||
contributors: [],
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
});
|
||||
|
||||
type MutationResponse = { ok: boolean; status?: number; body?: object };
|
||||
|
||||
/**
|
||||
* Routes the picker's GET search to `searchItems` and every mutation
|
||||
* (POST/DELETE) to `mutation` — the panel talks to both endpoints.
|
||||
*/
|
||||
function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: searchItems })
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: mutation.ok,
|
||||
status: mutation.status ?? (mutation.ok ? 200 : 500),
|
||||
json: () => Promise.resolve(mutation.body ?? {})
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
geschichteId: 'g1',
|
||||
items: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
async function addViaPicker(title: RegExp) {
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await expect.element(page.getByText(title)).toBeInTheDocument();
|
||||
await userEvent.click(page.getByText(title));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('StoryDocumentPanel — rendering', () => {
|
||||
it('renders linked documents sorted by position', async () => {
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({
|
||||
items: [
|
||||
makeItem('i3', 30, docSummary('d3', 'Dritter Brief')),
|
||||
makeItem('i1', 10, docSummary('d1', 'Erster Brief'))
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||
expect(rows[0]).toContain('Erster Brief');
|
||||
expect(rows[1]).toContain('Dritter Brief');
|
||||
});
|
||||
|
||||
it('shows the empty state when no items are linked', async () => {
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a deleted-document item as placeholder row that is still removable', async () => {
|
||||
render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] }));
|
||||
|
||||
await expect
|
||||
.element(page.getByText(m.geschichte_documents_deleted_placeholder()))
|
||||
.toBeInTheDocument();
|
||||
await expect
|
||||
.element(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({
|
||||
title: m.geschichte_documents_deleted_placeholder()
|
||||
})
|
||||
})
|
||||
)
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wires a visible label to the picker input', async () => {
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
const input = page.getByRole('combobox').element() as HTMLInputElement;
|
||||
const label = document.querySelector(`label[for="${input.id}"]`);
|
||||
expect(label?.textContent).toContain(m.geschichte_documents_picker_label());
|
||||
});
|
||||
});
|
||||
|
||||
describe('StoryDocumentPanel — add', () => {
|
||||
it('POSTs to the items endpoint and appends the created item', async () => {
|
||||
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: true,
|
||||
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||
expect(post?.[0]).toBe('/api/geschichten/g1/items');
|
||||
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' });
|
||||
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks an already-linked document as not selectable in the dropdown', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await expect.element(page.getByRole('option')).toBeInTheDocument();
|
||||
|
||||
const option = document.querySelector('[role="listbox"] [role="option"]');
|
||||
expect(option?.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' }
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.geschichte_documents_duplicate());
|
||||
});
|
||||
|
||||
it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: { code: 'JOURNEY_AT_CAPACITY' }
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.geschichte_documents_capacity());
|
||||
});
|
||||
|
||||
it('announces a successful add via the polite live region', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: true,
|
||||
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect(liveRegion?.textContent).toBe(
|
||||
m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a 403 response through getErrorMessage on POST', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: false,
|
||||
status: 403,
|
||||
body: { code: 'FORBIDDEN' }
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const alertText = page.getByRole('alert').element().textContent ?? '';
|
||||
expect(alertText).not.toBe('');
|
||||
expect(alertText).not.toContain('FORBIDDEN');
|
||||
});
|
||||
|
||||
it('shows the generic reload message when POST throws a network error', async () => {
|
||||
stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if ((init?.method ?? 'GET').toUpperCase() === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ items: [makeSearchResultItem('d1', 'Brief von Eugenie')] })
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('Network error'));
|
||||
})
|
||||
);
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.journey_mutation_error_reload());
|
||||
});
|
||||
|
||||
it('attaches X-XSRF-TOKEN header from cookie on POST', async () => {
|
||||
document.cookie = 'XSRF-TOKEN=test-csrf-token';
|
||||
const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], {
|
||||
ok: true,
|
||||
body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))
|
||||
});
|
||||
render(StoryDocumentPanel, defaultProps());
|
||||
|
||||
await addViaPicker(/Brief von Eugenie/i);
|
||||
|
||||
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||
const headers = post?.[1]?.headers as Headers;
|
||||
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
|
||||
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
|
||||
});
|
||||
});
|
||||
|
||||
describe('StoryDocumentPanel — remove', () => {
|
||||
it('DELETEs the item endpoint and removes the row', async () => {
|
||||
const fetchMock = stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
||||
expect(del?.[0]).toBe('/api/geschichten/g1/items/i1');
|
||||
expect(document.querySelectorAll('li').length).toBe(0);
|
||||
await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores the row and shows an error when the DELETE fails', async () => {
|
||||
stubFetch([], { ok: false, status: 500, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? '');
|
||||
expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true);
|
||||
});
|
||||
|
||||
it('moves focus to the previous row remove button instead of dropping to body', async () => {
|
||||
stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({
|
||||
items: [
|
||||
makeItem('i1', 10, docSummary('d1', 'Erster Brief')),
|
||||
makeItem('i2', 20, docSummary('d2', 'Zweiter Brief'))
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||
m.geschichte_documents_remove_label({ title: 'Erster Brief' })
|
||||
);
|
||||
});
|
||||
|
||||
it('moves focus to the picker input when the last item is removed', async () => {
|
||||
stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const input = page.getByRole('combobox').element();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it('announces a successful remove via the polite live region', async () => {
|
||||
stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect(liveRegion?.textContent).toBe(
|
||||
m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns focus to the item remove button when DELETE fails with !res.ok', async () => {
|
||||
stubFetch([], { ok: false, status: 500, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns focus to the item remove button when DELETE throws a network error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||
);
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.activeElement?.getAttribute('aria-label')).toBe(
|
||||
m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the generic reload message when DELETE throws a network error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.reject(new Error('Network error')))
|
||||
);
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('alert'))
|
||||
.toHaveTextContent(m.journey_mutation_error_reload());
|
||||
});
|
||||
|
||||
it('attaches X-XSRF-TOKEN header from cookie on DELETE', async () => {
|
||||
document.cookie = 'XSRF-TOKEN=test-csrf-token';
|
||||
const fetchMock = stubFetch([], { ok: true, body: {} });
|
||||
render(
|
||||
StoryDocumentPanel,
|
||||
defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] })
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
page.getByRole('button', {
|
||||
name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' })
|
||||
})
|
||||
);
|
||||
|
||||
const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
||||
const headers = del?.[1]?.headers as Headers;
|
||||
expect(headers.get('X-XSRF-TOKEN')).toBe('test-csrf-token');
|
||||
document.cookie = 'XSRF-TOKEN=; Max-Age=0';
|
||||
});
|
||||
});
|
||||
@@ -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':
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user