feat(geschichte): JourneyItem CRUD API — append, updateNote, delete, reorder (#751) #788

Merged
marcel merged 41 commits from feat/issue-751-journey-item-crud-api into feat/issue-750-lesereisen-data-model 2026-06-08 22:15:12 +02:00
8 changed files with 47 additions and 6 deletions
Showing only changes of commit 4eb6abd920 - Show all commits

View File

@@ -78,7 +78,14 @@ public class GlobalExceptionHandler {
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex));
String constraint = constraintNameOf(ex);
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
if ("uq_journey_items_geschichte_position".equals(constraint)) {
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
return ResponseEntity.status(409)
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
"A position conflict was detected — another request modified this journey simultaneously"));
}
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
}

View File

@@ -1,6 +1,5 @@
package org.raddatz.familienarchiv.geschichte;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.person.Person;

View File

@@ -13,8 +13,6 @@ import java.util.UUID;
@Repository
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
List<JourneyItem> findAllByGeschichteId(UUID geschichteId);
/** Returns items ordered by position ASC for the read-model assembly path. */
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);

View File

@@ -138,6 +138,11 @@ public class JourneyItemService {
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Duplicate item IDs in reorder request");
}
if (!existingIds.equals(new HashSet<>(requestedIds))) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Requested item IDs do not match the journey's existing items");

View File

@@ -359,6 +359,22 @@ class GeschichteControllerTest {
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
}
// ─── error mapping ───────────────────────────────────────────────────────
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns409_on_position_conflict() throws Exception {
UUID id = UUID.randomUUID();
when(journeyItemService.append(eq(id), any()))
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private JourneyItemView itemViewStub(UUID id, int position, String note) {

View File

@@ -96,7 +96,7 @@ class JourneyItemIntegrationTest {
geschichteRepository.deleteById(geschichteId);
em.flush();
assertThat(journeyItemRepository.findAllByGeschichteId(geschichteId)).isEmpty();
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
}
@Test

View File

@@ -448,6 +448,20 @@ class JourneyItemServiceTest {
// ─── reorder ─────────────────────────────────────────────────────────────
@Test
void reorder_returns400_when_itemIds_contain_duplicates() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id1)); // duplicate
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_itemId_belongs_to_different_journey() {
UUID foreignId = UUID.randomUUID();

View File

@@ -376,8 +376,10 @@ package "Supporting" {
--
geschichte_id : UUID <<FK>>
document_id : UUID <<FK>>
position : INTEGER NOT NULL
position : INTEGER NOT NULL CHECK (position > 0)
note : TEXT
==
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
}
}