diff --git a/backend/pom.xml b/backend/pom.xml
index 30fa0719..b01f2362 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -253,13 +253,6 @@
1.18.1
-
-
- org.openapitools
- jackson-databind-nullable
- 0.2.6
-
-
io.micrometer
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java
index 5c5e0945..43a597d3 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java
@@ -1,15 +1,10 @@
package org.raddatz.familienarchiv.config;
-import com.fasterxml.jackson.databind.Module;
-import org.openapitools.jackson.nullable.JsonNullableModule;
-import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+/**
+ * Jackson customisations. Currently a placeholder — custom modules added here as needed.
+ */
@Configuration
public class JacksonConfig {
-
- @Bean
- public Module jsonNullableModule() {
- return new JsonNullableModule();
- }
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java
index 11468c8d..baf365a4 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java
@@ -1,6 +1,11 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.http.HttpStatus;
@@ -10,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -24,6 +30,7 @@ import java.util.UUID;
public class GeschichteController {
private final GeschichteService geschichteService;
+ private final JourneyItemService journeyItemService;
@GetMapping
public List list(
@@ -60,4 +67,41 @@ public class GeschichteController {
geschichteService.delete(id);
return ResponseEntity.noContent().build();
}
+
+ // ─── JourneyItem CRUD ────────────────────────────────────────────────────
+
+ @PostMapping("/{id}/items")
+ @RequirePermission(Permission.BLOG_WRITE)
+ public ResponseEntity appendItem(
+ @PathVariable UUID id,
+ @RequestBody JourneyItemCreateDTO dto) {
+ JourneyItemView view = journeyItemService.append(id, dto);
+ return ResponseEntity.status(HttpStatus.CREATED).body(view);
+ }
+
+ @PatchMapping("/{id}/items/{itemId}")
+ @RequirePermission(Permission.BLOG_WRITE)
+ public JourneyItemView updateItemNote(
+ @PathVariable UUID id,
+ @PathVariable UUID itemId,
+ @RequestBody JourneyItemUpdateDTO dto) {
+ return journeyItemService.updateNote(id, itemId, dto);
+ }
+
+ @DeleteMapping("/{id}/items/{itemId}")
+ @RequirePermission(Permission.BLOG_WRITE)
+ public ResponseEntity deleteItem(
+ @PathVariable UUID id,
+ @PathVariable UUID itemId) {
+ journeyItemService.delete(id, itemId);
+ return ResponseEntity.noContent().build();
+ }
+
+ @PutMapping("/{id}/items/reorder")
+ @RequirePermission(Permission.BLOG_WRITE)
+ public List reorderItems(
+ @PathVariable UUID id,
+ @RequestBody JourneyReorderDTO dto) {
+ return journeyItemService.reorder(id, dto);
+ }
}
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 6b1923f8..d0835c26 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
@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.openapitools.jackson.nullable.JsonNullable;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
@@ -98,12 +97,13 @@ public class JourneyItemService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
- JsonNullable noteField = dto.getNote();
- if (!noteField.isPresent()) {
+ // null = field absent from JSON → no-op
+ Optional noteField = dto.getNote();
+ if (noteField == null) {
return toView(item);
}
- String note = normalizeNote(noteField.get());
+ String note = normalizeNote(noteField.orElse(null));
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java
index db8c4e6e..1e63ac9c 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java
@@ -1,16 +1,19 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
-import org.openapitools.jackson.nullable.JsonNullable;
+
+import java.util.Optional;
/**
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
- * JsonNullable enables three-way semantics:
- * absent → field not present in JSON → leave unchanged
- * null → {"note": null} → clear the note field
- * string → {"note": "text"} → set the note
+ * Three-way semantics via Optional:
+ * null → field absent from JSON → leave note unchanged
+ * Optional.empty() → {"note": null} → clear the note
+ * Optional.of("x") → {"note": "x"} → set the note
+ *
+ * Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
*/
@Data
public class JourneyItemUpdateDTO {
- private JsonNullable note = JsonNullable.undefined();
+ private Optional note = null;
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java
index 54dc8c85..88320dc2 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java
@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.geschichte;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
-import org.raddatz.familienarchiv.security.SecurityConfig;
+import org.raddatz.familienarchiv.config.JacksonConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
+import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
+import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -32,11 +35,12 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(GeschichteController.class)
-@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
+@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class, JacksonConfig.class})
class GeschichteControllerTest {
@Autowired
@@ -44,11 +48,9 @@ class GeschichteControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
- @MockitoBean
- GeschichteService geschichteService;
-
- @MockitoBean
- CustomUserDetailsService customUserDetailsService;
+ @MockitoBean GeschichteService geschichteService;
+ @MockitoBean JourneyItemService journeyItemService;
+ @MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/geschichten ────────────────────────────────────────────────
@@ -205,8 +207,164 @@ class GeschichteControllerTest {
verify(geschichteService).delete(id);
}
+ // ─── POST /api/geschichten/{id}/items ────────────────────────────────────
+
+ @Test
+ void appendItem_returns401_whenUnauthenticated() throws Exception {
+ mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"note\":\"x\"}"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(authorities = "READ_ALL")
+ void appendItem_returns403_whenLackingBlogWrite() throws Exception {
+ mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"note\":\"x\"}"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "BLOG_WRITE")
+ void appendItem_returns201_withBlogWrite() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID itemId = UUID.randomUUID();
+ when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
+
+ mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"note\":\"Note\"}"))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(itemId.toString()))
+ .andExpect(jsonPath("$.position").value(10));
+ }
+
+ // ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
+
+ @Test
+ @WithMockUser(authorities = "READ_ALL")
+ void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
+ mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
+ UUID.randomUUID(), UUID.randomUUID()).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"note\":\"x\"}"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "BLOG_WRITE")
+ void updateItemNote_returns200_withBlogWrite() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID itemId = UUID.randomUUID();
+ when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
+ .thenReturn(itemViewStub(itemId, 10, "Updated"));
+
+ mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"note\":\"Updated\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.note").value("Updated"));
+ }
+
+ @Test
+ @WithMockUser(authorities = "BLOG_WRITE")
+ void updateItemNote_null_note_deserializes_as_present_null() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID itemId = UUID.randomUUID();
+ when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
+ .thenReturn(itemViewStub(itemId, 10, null));
+
+ // Raw JSON — local objectMapper lacks JsonNullableModule
+ mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"note\": null}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.note").doesNotExist());
+ }
+
+ @Test
+ @WithMockUser(authorities = "BLOG_WRITE")
+ void updateItemNote_returns404_whenItemNotFound() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID itemId = UUID.randomUUID();
+ when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
+ .thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
+
+ mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"note\":\"x\"}"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
+ }
+
+ // ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
+
+ @Test
+ @WithMockUser(authorities = "READ_ALL")
+ void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
+ mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
+ UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "BLOG_WRITE")
+ void deleteItem_returns204_withBlogWrite() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID itemId = UUID.randomUUID();
+
+ mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
+ .andExpect(status().isNoContent());
+
+ verify(journeyItemService).delete(id, itemId);
+ }
+
+ @Test
+ @WithMockUser(authorities = "BLOG_WRITE")
+ void deleteItem_returns404_whenItemNotFound() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID itemId = UUID.randomUUID();
+ org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
+ .when(journeyItemService).delete(id, itemId);
+
+ mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
+ }
+
+ // ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
+
+ @Test
+ @WithMockUser(authorities = "READ_ALL")
+ void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
+ mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"itemIds\":[]}"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "BLOG_WRITE")
+ void reorderItems_returns200_withBlogWrite() throws Exception {
+ UUID id = UUID.randomUUID();
+ UUID itemId = UUID.randomUUID();
+ when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
+
+ mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"itemIds\":[\"" + itemId + "\"]}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].id").value(itemId.toString()));
+ }
+
// ─── helpers ─────────────────────────────────────────────────────────────
+ private JourneyItemView itemViewStub(UUID id, int position, String note) {
+ return new JourneyItemView(id, position, null, note);
+ }
+
private Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
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 9ffb4fc1..a6afe3b6 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
@@ -6,7 +6,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import org.openapitools.jackson.nullable.JsonNullable;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
@@ -310,7 +309,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
- // note is JsonNullable.undefined() by default
+ // note is null by default — absent from JSON, no-op
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
@@ -329,7 +328,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
- dto.setNote(JsonNullable.of(null));
+ dto.setNote(Optional.empty());
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
@@ -346,7 +345,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
- dto.setNote(JsonNullable.of("New note"));
+ dto.setNote(Optional.of("New note"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
@@ -360,7 +359,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
- dto.setNote(JsonNullable.of(null));
+ dto.setNote(Optional.empty());
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
@@ -379,7 +378,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
- dto.setNote(JsonNullable.of("\n \n"));
+ dto.setNote(Optional.of("\n \n"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
@@ -393,7 +392,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
- dto.setNote(JsonNullable.of("x".repeat(5001)));
+ dto.setNote(Optional.of("x".repeat(5001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
@@ -406,7 +405,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
- dto.setNote(JsonNullable.of("text"));
+ dto.setNote(Optional.of("text"));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)