From 353945c9520b7a5afa48c8cbe4ff50d2583d28e4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 17:18:00 +0200 Subject: [PATCH] fix(journey-item): enforce duplicate-document guard in append() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds JOURNEY_DOCUMENT_ALREADY_ADDED to ErrorCode, an existsByGeschichteIdAndDocumentId() repo method, and a 409 guard in JourneyItemService.append() — the error code was registered on the frontend but never thrown on the backend, allowing concurrent tabs to add the same document twice. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/exception/ErrorCode.java | 2 ++ .../journeyitem/JourneyItemRepository.java | 3 +++ .../journeyitem/JourneyItemService.java | 4 ++++ .../journeyitem/JourneyItemServiceTest.java | 16 ++++++++++++++++ 4 files changed, 25 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 1eaf8a2b..e38f8b0b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -128,6 +128,8 @@ public enum ErrorCode { JOURNEY_ITEM_POSITION_CONFLICT, /** The journey already has the maximum allowed number of items (100). 400 */ JOURNEY_AT_CAPACITY, + /** The document is already present in this journey — duplicate items are not allowed. 409 */ + JOURNEY_DOCUMENT_ALREADY_ADDED, /** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */ GESCHICHTE_TYPE_MISMATCH, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java index a1b3baee..34e7e26e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -30,6 +30,9 @@ public interface JourneyItemRepository extends JpaRepository /** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */ long countByGeschichteId(UUID geschichteId); + /** Dedup guard: true when the document is already linked to this journey. */ + boolean existsByGeschichteIdAndDocumentId(UUID geschichteId, 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() diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java index f189a9d3..2a9b5484 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -69,6 +69,10 @@ public class JourneyItemService { Document doc = null; if (dto.getDocumentId() != null) { + if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) { + throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED, + "Document already in journey: " + dto.getDocumentId()); + } doc = documentService.findSummaryByIdInternal(dto.getDocumentId()); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java index c411a3bb..93808775 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java @@ -288,6 +288,22 @@ class JourneyItemServiceTest { .isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY)); } + @Test + void append_returns409_when_document_already_in_journey() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L); + when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(docId); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED)); + } + @Test void cap_is_COUNT_based_not_MAX_position_based() { // 99 rows with MAX(position)=2000 should still accept the 100th append