Files
familienarchiv/docs/adr/035-optional-string-three-way-patch-semantics.md
marcel 0780c09bb4
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
feat(geschichte): JourneyItem CRUD API — append, updateNote, delete, reorder (#751) (#788)
## Summary

Implements the backend JourneyItem CRUD API on top of the data model from #750, building towards the full Lesereisen feature (#751).

**Completed in this PR:**
- `jackson-databind-nullable` 0.2.6 + `JacksonConfig` (`@Bean Module`) for three-way PATCH semantics (`JsonNullable`)
- `AuditKind`: `JOURNEY_ITEM_ADDED`, `JOURNEY_ITEM_REMOVED`, `JOURNEY_ITEMS_REORDERED` (last is rollup-eligible)
- `ErrorCode`: `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT`
- V73 migration: `UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED` + `CHECK (position > 0)` on `journey_items`
- `JourneyItemConstraintsTest`: verifies deferrable flag via `pg_constraint` query; position check; duplicate position rejection (3 passing tests)
- Read models: `DocumentSummary`, `JourneyItemView`, `GeschichteView` (with `AuthorView` to prevent AppUser email leak)
- `DocumentService.getSummaryById` — lean lookup without tag-color resolution
- `JourneyItemRepository`: extended with `findByGeschichteIdOrderByPosition`, `findByIdAndGeschichteId` (IDOR-safe), `findIdsByGeschichteId`, `findMaxPositionByGeschichteId`, `countByGeschichteId`
- DTOs: `JourneyItemCreateDTO`, `JourneyItemUpdateDTO` (`JsonNullable<String> note`), `JourneyReorderDTO`

**Still in progress (WIP):**
- `JourneyItemService` — `append`, `updateNote`, `delete`, `reorder`, `toSummary`, `toView` (Task 6)
- `GeschichteService.getById` → returns `GeschichteView` (Task 7)
- New endpoints on `GeschichteController` + slice tests (Task 8)
- Frontend error codes + i18n + `npm run generate:api` (Task 9)

## Commits

- `0b177247` feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support
- `408ae334` feat(audit,error): add JourneyItem AuditKind values and ErrorCodes
- `7b06c3ad` feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items
- `160ca1c3` feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models
- `2ad5c36e` feat(geschichte): extend JourneyItemRepository and add item DTOs

## Test plan

- [ ] `./mvnw test -Dtest=JourneyItemConstraintsTest` — all 3 constraint tests pass
- [ ] `./mvnw clean package -DskipTests` — builds clean after remaining tasks are merged
- [ ] Frontend: `npm run generate:api` after Task 9 endpoint additions

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #788
2026-06-08 22:15:10 +02:00

2.0 KiB

ADR-035 — Optional<String> for three-way PATCH semantics

Status: Accepted
Date: 2026-06-08
Issue: #751 (JourneyItem CRUD API)

Context

The PATCH /api/geschichten/{id}/items/{itemId} endpoint must distinguish three cases for the note field:

JSON body Intended meaning
{"note": "text"} Set note to "text"
{"note": null} Clear the note
{} (absent) Leave note unchanged

The standard library for this on Jackson 2.x is jackson-databind-nullable (JsonNullable<T> from org.openapitools). However, that library targets com.fasterxml.jackson.* (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses tools.jackson.* (Jackson 3.x). The module fails to register and throws at startup.

Decision

Use Optional<String> with Java's default field initializer (= null) to encode the three states:

@Data
public class JourneyItemUpdateDTO {
    private Optional<String> note = null;  // Java default — absent = no-op
}
Java value JSON wire Semantics
null (default) field absent no-op
Optional.empty() {"note": null} clear
Optional.of("x") {"note": "x"} set

Jackson 3.x natively maps a JSON null to Optional.empty() and leaves absent fields at their Java default. No custom module is needed.

Consequences

  • No external dependency for PATCH semantics — simpler pom.xml.
  • The DTO field type is Optional<String>, not String — service code must null-check the field first (if (noteField == null) return;) and then call .orElse(null) to unwrap.
  • This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
  • jackson-databind-nullable is removed from pom.xml; JacksonConfig.java is kept as a placeholder for future custom modules.