` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
-**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
+**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
---
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 5c6b6d95..bea7cf66 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
@@ -155,6 +155,14 @@ public enum ErrorCode {
/** The merge target is a descendant of the source tag. 400 */
TAG_MERGE_INVALID_TARGET,
+ // --- Timeline (Zeitstrahl) ---
+ /** A timeline event with the given ID does not exist. 404 */
+ TIMELINE_EVENT_NOT_FOUND,
+ /** Optimistic-locking conflict — the timeline event was modified by another curator. 409 */
+ TIMELINE_EVENT_CONFLICT,
+ /** A timeline event title exceeds the maximum length (255 characters — the DB column bound). 400 */
+ TIMELINE_TITLE_TOO_LONG,
+
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,
@@ -162,6 +170,8 @@ public enum ErrorCode {
BATCH_TOO_LARGE,
/** Bulk edit request exceeds the per-request document ID cap. 400 */
BULK_EDIT_TOO_MANY_IDS,
+ /** A concurrent modification was detected (generic optimistic-lock backstop). 409 */
+ CONFLICT,
/** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR,
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
index c56cc576..b6484c86 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
@@ -104,6 +104,30 @@ public class GlobalExceptionHandler {
return "unknown";
}
+ /**
+ * Generic backstop for optimistic-locking conflicts that escape a service-level catch. A
+ * conflict is a 409, not a system fault — so, like {@link #handleDataIntegrityViolation}, it
+ * must NOT fire Sentry and must NOT leak Hibernate internals (CWE-209): the response carries
+ * only the generic {@link ErrorCode#CONFLICT} code and a generic message — no entity id, no
+ * version, no persistent-class name.
+ *
+ * Deliberately code-GENERIC: do NOT {@code switch} on {@code getPersistentClassName()} to map
+ * back to a per-entity code. Unlike {@link #handleDataIntegrityViolation}, which branches on
+ * stable schema constraint NAMES, persistent-class names are not a contract. The precise,
+ * code-carrying path is the service catch (e.g. {@code TIMELINE_EVENT_CONFLICT}); this is only
+ * the net that keeps any current or future write path from regressing to a 500.
+ */
+ @ExceptionHandler(org.springframework.orm.ObjectOptimisticLockingFailureException.class)
+ public ResponseEntity handleOptimisticLock(
+ org.springframework.orm.ObjectOptimisticLockingFailureException ex) {
+ // Log the persistent-class name ONLY (schema metadata, safe for Loki). Never `ex` /
+ // ex.getMessage(): those embed the entity id + version (CWE-209). No Sentry: it's a 409.
+ log.warn("Rejected a write that lost an optimistic-lock race on: {}", ex.getPersistentClassName());
+ return ResponseEntity.status(409)
+ .body(new ErrorResponse(ErrorCode.CONFLICT,
+ "The resource was modified concurrently. Please reload and try again."));
+ }
+
@ExceptionHandler(Exception.class)
public ResponseEntity handleGeneric(Exception ex) {
Sentry.captureException(ex);
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java
new file mode 100644
index 00000000..e3c62e74
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java
@@ -0,0 +1,71 @@
+package org.raddatz.familienarchiv.timeline;
+
+import jakarta.validation.Valid;
+
+import lombok.RequiredArgsConstructor;
+
+import org.raddatz.familienarchiv.security.Permission;
+import org.raddatz.familienarchiv.security.RequirePermission;
+import org.raddatz.familienarchiv.security.SecurityUtils;
+import org.raddatz.familienarchiv.user.UserService;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+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.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/timeline/events")
+@RequiredArgsConstructor
+public class TimelineEventController {
+
+ private final TimelineEventService timelineEventService;
+ private final UserService userService;
+
+ /**
+ * No {@code @RequirePermission} on GET by design: the global {@code anyRequest().authenticated()}
+ * rule is the READ_ALL baseline, consistent with {@code DocumentController.getDocument}. Do not
+ * "fix" the missing annotation.
+ */
+ @GetMapping("/{id}")
+ public TimelineEventView getEvent(@PathVariable UUID id) {
+ return timelineEventService.getEvent(id);
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @RequirePermission(Permission.WRITE_ALL)
+ public TimelineEventView create(@Valid @RequestBody TimelineEventRequest request, Authentication authentication) {
+ return timelineEventService.create(request, requireUserId(authentication));
+ }
+
+ @PutMapping("/{id}")
+ @RequirePermission(Permission.WRITE_ALL)
+ public TimelineEventView update(
+ @PathVariable UUID id,
+ @Valid @RequestBody TimelineEventRequest request,
+ Authentication authentication) {
+ return timelineEventService.update(id, request, requireUserId(authentication));
+ }
+
+ @DeleteMapping("/{id}")
+ @RequirePermission(Permission.WRITE_ALL)
+ public ResponseEntity delete(@PathVariable UUID id) {
+ timelineEventService.delete(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ private UUID requireUserId(Authentication authentication) {
+ return SecurityUtils.requireUserId(authentication, userService);
+ }
+}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRequest.java
new file mode 100644
index 00000000..dd25b3e9
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRequest.java
@@ -0,0 +1,40 @@
+package org.raddatz.familienarchiv.timeline;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+import org.raddatz.familienarchiv.document.DatePrecision;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Flat input DTO for creating/updating a {@link TimelineEvent}. Bean Validation fires at the
+ * controller boundary (via {@code @Valid}) and produces a 400 {@code VALIDATION_ERROR} for the
+ * presence/size constraints below; cross-field rules (the RANGE invariant), date normalization,
+ * id dedupe, and the title-length structured-error guard live in {@code TimelineEventService}.
+ *
+ * {@code createdBy}/{@code updatedBy} are intentionally absent. Authorship is
+ * server-populated from the session principal only — accepting it from the body would be an
+ * authorship-forgery / mass-assignment vector (CWE-639; see ADR-040 §7).
+ *
+ * @param version optional optimistic-lock concurrency token (the {@code @Version} the client last
+ * saw), applied on update only. This is a concurrency token, not
+ * an authorship field, so it is deliberately exempt from the §7 server-only audit rule.
+ * Null on update means "no concurrency check" (last-write-wins). No range validation —
+ * a stale/negative value is simply a mismatch the lock rejects at flush; the lock, not
+ * a validator, is the control.
+ */
+public record TimelineEventRequest(
+ @NotBlank @Size(max = 255) String title,
+ @NotNull EventType type,
+ @NotNull LocalDate eventDate,
+ DatePrecision precision,
+ LocalDate eventDateEnd,
+ @Size(max = 5000) String description,
+ Long version,
+ @Size(max = 50) List personIds,
+ @Size(max = 50) List documentIds
+) {}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
new file mode 100644
index 00000000..e7c0d892
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java
@@ -0,0 +1,247 @@
+package org.raddatz.familienarchiv.timeline;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import org.raddatz.familienarchiv.document.DatePrecision;
+import org.raddatz.familienarchiv.document.Document;
+import org.raddatz.familienarchiv.document.DocumentService;
+import org.raddatz.familienarchiv.exception.DomainException;
+import org.raddatz.familienarchiv.exception.ErrorCode;
+import org.raddatz.familienarchiv.person.Person;
+import org.raddatz.familienarchiv.person.PersonService;
+import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
+import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
+
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Curator CRUD for {@link TimelineEvent}. Persons and documents are resolved through their own
+ * services (never their repositories). All four body-returning operations return a
+ * {@link TimelineEventView} assembled in-transaction — the entity is never serialized (ADR-040 §2).
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class TimelineEventService {
+
+ private static final int MAX_TITLE_LENGTH = 255;
+
+ private final TimelineEventRepository events;
+ private final PersonService personService;
+ private final DocumentService documentService;
+
+ @Transactional
+ public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
+ DatePrecision precision = effectivePrecision(request);
+ validateRangeInvariant(request, precision);
+ validateTitleLength(request);
+
+ TimelineEvent event = TimelineEvent.builder()
+ .title(request.title())
+ .type(request.type())
+ .eventDate(normalizeEventDate(request.eventDate(), precision))
+ .precision(precision)
+ .eventDateEnd(request.eventDateEnd())
+ .description(request.description())
+ .persons(resolvePersons(request.personIds()))
+ .documents(resolveDocuments(request.documentIds()))
+ .createdBy(actorId)
+ .updatedBy(actorId)
+ .build();
+
+ return toView(events.saveAndFlush(event));
+ }
+
+ @Transactional
+ public TimelineEventView update(UUID id, TimelineEventRequest request, UUID actorId) {
+ TimelineEvent event = events.findById(id)
+ .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
+ "Timeline event not found: " + id));
+ requireVersionMatch(request, event);
+ DatePrecision precision = effectivePrecision(request);
+ validateRangeInvariant(request, precision);
+ validateTitleLength(request);
+ applyUpdate(event, request, precision, actorId);
+
+ // saveAndFlush (not save) so the versioned UPDATE …WHERE version=? fires HERE, inside the
+ // try — a bare save() flushes at commit, after this method returns, so the exception would
+ // escape the catch and surface as a 500. Catch the Spring-translated type, not JPA's.
+ try {
+ return toView(events.saveAndFlush(event));
+ } catch (ObjectOptimisticLockingFailureException ex) {
+ throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
+ "Timeline event was modified concurrently: " + id);
+ }
+ }
+
+ @Transactional
+ public void delete(UUID id) {
+ TimelineEvent event = events.findById(id)
+ .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
+ "Timeline event not found: " + id));
+ events.delete(event);
+ }
+
+ /**
+ * View-assembly read. {@code @Transactional(readOnly = true)} is load-bearing, not optional:
+ * the LAZY {@code persons}/{@code documents} collections are traversed during {@link #toView}
+ * assembly, and under {@code open-in-view: false} a closed session there is a
+ * {@code LazyInitializationException} (ADR-022 / {@code getDocumentDetail} precedent).
+ */
+ @Transactional(readOnly = true)
+ public TimelineEventView getEvent(UUID id) {
+ TimelineEvent event = events.findById(id)
+ .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
+ "Timeline event not found: " + id));
+ return toView(event);
+ }
+
+ // --- update mechanics: mutate the managed entity, never reassign collections ---
+
+ private void applyUpdate(TimelineEvent event, TimelineEventRequest request, DatePrecision precision, UUID actorId) {
+ event.setTitle(request.title());
+ event.setType(request.type());
+ event.setEventDate(normalizeEventDate(request.eventDate(), precision));
+ event.setPrecision(precision);
+ event.setEventDateEnd(request.eventDateEnd());
+ event.setDescription(request.description());
+ replaceLinks(event, request);
+ event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes
+ }
+
+ /**
+ * Compares the client's concurrency token against the freshly-loaded version (the Q1
+ * "last-seen version" token). A mismatch means the client edited stale data → 409.
+ *
+ * This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before
+ * flush. Setting {@code @Version} on a managed entity is silently ignored by Hibernate
+ * for the optimistic check: it uses its own loaded-version snapshot for the
+ * {@code UPDATE … WHERE version=?} clause, so a stale token never reaches the DB. The native
+ * {@code @Version} increment still happens on every save, and the {@code saveAndFlush}+catch
+ * below remains the backstop for two transactions flushing concurrently; this guard is what
+ * catches the human-timescale "B submitted a form based on a version A already superseded" case.
+ * A null token means no check (last-write-wins) until #9 always sends it.
+ */
+ private void requireVersionMatch(TimelineEventRequest request, TimelineEvent event) {
+ if (request.version() != null && !request.version().equals(event.getVersion())) {
+ throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
+ "Timeline event was modified concurrently: " + event.getId());
+ }
+ }
+
+ /**
+ * Replaces (set semantics) the link collections. Mutates the existing managed collections —
+ * Hibernate does not track a reassigned reference, and a fresh {@code Set} risks orphan join
+ * rows against the {@code ON DELETE CASCADE} join tables. A null or empty list clears all links.
+ */
+ private void replaceLinks(TimelineEvent event, TimelineEventRequest request) {
+ event.getPersons().clear();
+ event.getPersons().addAll(resolvePersons(request.personIds()));
+ event.getDocuments().clear();
+ event.getDocuments().addAll(resolveDocuments(request.documentIds()));
+ }
+
+ // --- validation / normalization ---
+
+ /**
+ * Mirrors the DB biconditional CHECK chk_timeline_event_range — both presence directions — and
+ * additionally enforces date ordering, which the DB CHECK does NOT: {@code eventDateEnd} may
+ * equal but never precede {@code eventDate}. Without this guard a reversed range (end before
+ * start) persists silently and renders as a negative span. Equal dates are a valid one-day
+ * closed range.
+ */
+ private void validateRangeInvariant(TimelineEventRequest request, DatePrecision precision) {
+ boolean isRange = precision == DatePrecision.RANGE;
+ if (request.eventDateEnd() != null && !isRange) {
+ throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
+ "eventDateEnd is only valid when precision is RANGE");
+ }
+ if (isRange && request.eventDateEnd() == null) {
+ throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
+ "A RANGE event requires a non-null eventDateEnd");
+ }
+ if (isRange && request.eventDateEnd().isBefore(request.eventDate())) {
+ throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
+ "eventDateEnd must not precede eventDate");
+ }
+ }
+
+ /**
+ * Load-bearing only for non-HTTP callers: the DTO {@code @Size(max = 255)} already covers HTTP
+ * callers, but a non-HTTP caller could otherwise push an over-long title to the VARCHAR(255)
+ * column and get a raw {@code DataIntegrityViolationException} → 500. Do not delete as
+ * "duplicate validation".
+ */
+ private void validateTitleLength(TimelineEventRequest request) {
+ if (request.title() != null && request.title().length() > MAX_TITLE_LENGTH) {
+ throw DomainException.badRequest(ErrorCode.TIMELINE_TITLE_TOO_LONG,
+ "Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
+ }
+ }
+
+ private DatePrecision effectivePrecision(TimelineEventRequest request) {
+ return request.precision() != null ? request.precision() : DatePrecision.YEAR;
+ }
+
+ private LocalDate normalizeEventDate(LocalDate eventDate, DatePrecision precision) {
+ return precision == DatePrecision.YEAR ? LocalDate.of(eventDate.getYear(), 1, 1) : eventDate;
+ }
+
+ // --- link resolution (fail-closed, dedupe-first) ---
+
+ private Set resolvePersons(List ids) {
+ if (ids == null || ids.isEmpty()) {
+ return new HashSet<>();
+ }
+ // Dedupe FIRST: [idA, idA] is one link, not a 404. findAllById dedupes too, so compare the
+ // resolved size against the DISTINCT input count — a raw ids.size() compare reports a spurious
+ // mismatch.
+ Set distinct = new LinkedHashSet<>(ids);
+ List resolved = personService.getAllById(new ArrayList<>(distinct));
+ if (resolved.size() != distinct.size()) {
+ throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found");
+ }
+ return new HashSet<>(resolved);
+ }
+
+ private Set resolveDocuments(List ids) {
+ if (ids == null || ids.isEmpty()) {
+ return new HashSet<>();
+ }
+ // Per-id loop on purpose: DocumentService has no batch fetch, and per-id gives free
+ // DOCUMENT_NOT_FOUND 404s. getDocumentById is @Transactional(readOnly = true) and joins this
+ // write tx via Spring's default REQUIRED propagation — do NOT "optimize" into a phantom batch.
+ Set resolved = new HashSet<>();
+ for (UUID documentId : new LinkedHashSet<>(ids)) {
+ resolved.add(documentService.getDocumentById(documentId));
+ }
+ return resolved;
+ }
+
+ // --- view assembly (explicit allow-list; never the raw entity) ---
+
+ private TimelineEventView toView(TimelineEvent event) {
+ List persons = event.getPersons().stream()
+ .map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
+ .toList();
+ List documents = event.getDocuments().stream()
+ .map(d -> new DocumentRef(d.getId(), d.getTitle(), d.getDocumentDate()))
+ .toList();
+ return new TimelineEventView(
+ event.getId(), event.getTitle(), event.getType(), event.getEventDate(),
+ event.getPrecision(), event.getEventDateEnd(), event.getDescription(), event.getVersion(),
+ event.getCreatedBy(), event.getCreatedAt(), event.getUpdatedBy(), event.getUpdatedAt(),
+ persons, documents);
+ }
+}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventView.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventView.java
new file mode 100644
index 00000000..ac67af11
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventView.java
@@ -0,0 +1,59 @@
+package org.raddatz.familienarchiv.timeline;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import org.raddatz.familienarchiv.document.DatePrecision;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Response view for the timeline event endpoints — returned by GET, POST, and PUT alike.
+ * Assembled inside the service transaction (after {@code saveAndFlush} on the write paths, so
+ * {@code version} is non-null) from the managed entity's already-loaded collections. The raw
+ * {@link TimelineEvent} is never serialized: its LAZY {@code persons}/{@code documents}
+ * collections under {@code open-in-view: false} would otherwise 500 (ADR-036/ADR-040 §2), and
+ * splatting the entities would leak curator-internal fields ({@code Person.notes},
+ * {@code provisional}, transcription data) to every READ_ALL reader. The explicit field
+ * allow-list below is that guarantee.
+ */
+public record TimelineEventView(
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDate eventDate,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
+ LocalDate eventDateEnd,
+ String description,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) Long version,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID createdBy,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID updatedBy,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List persons,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List documents
+) {
+ /** Summarised person — exposes only id, firstName, and lastName. Mirrors GeschichteView.PersonView. */
+ public record PersonView(
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
+ String firstName,
+ String lastName
+ ) {}
+
+ /**
+ * Summarised linked document — id, title, and the eager {@code documentDate} only (no lazy
+ * sender/receiver hop, no person-name leak through the document side).
+ *
+ * timeline-local by design; do not promote to {@code document/} — see #775 R7. Reusing
+ * {@code geschichte.journeyitem.DocumentSummary} would force a cross-domain import of a
+ * package-private mapper plus duplicated name-assembly logic; a 3-field local record is the
+ * lower-coupling choice.
+ */
+ public record DocumentRef(
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
+ LocalDate documentDate
+ ) {}
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java
index 324c17a1..ef3028ba 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java
@@ -14,6 +14,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+
+import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;
@@ -103,6 +106,49 @@ class GlobalExceptionHandlerTest {
});
}
+ @Test
+ void handleOptimisticLock_returns409_genericConflict_noSentry_noLeak() {
+ // CWE-209 regression: an optimistic-lock failure escaping a service catch must become a
+ // generic 409, never a 500 + Sentry + Hibernate internals. The generic CONFLICT code keeps
+ // it entity-agnostic — NOT TIMELINE_EVENT_CONFLICT, or every future entity's conflict is
+ // mislabeled a timeline one.
+ UUID entityId = UUID.randomUUID();
+ ObjectOptimisticLockingFailureException ex =
+ new ObjectOptimisticLockingFailureException("com.example.SomeEntity", entityId);
+
+ Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ handlerLogger.addAppender(appender);
+
+ try (MockedStatic sentryMock = mockStatic(Sentry.class)) {
+ ResponseEntity response = handler.handleOptimisticLock(ex);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(409);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().code()).isEqualTo(ErrorCode.CONFLICT);
+ // Body echoes no persistent-class name, no entity id, no version (enumeration aids).
+ assertThat(response.getBody().message())
+ .doesNotContain("SomeEntity")
+ .doesNotContain(entityId.toString());
+
+ // A conflict is not a system fault — no fabricated Sentry alert.
+ sentryMock.verifyNoInteractions();
+ } finally {
+ handlerLogger.detachAppender(appender);
+ }
+
+ assertThat(appender.list)
+ .as("WARN names the persistent class for debuggability")
+ .anySatisfy(e -> {
+ assertThat(e.getLevel()).isEqualTo(Level.WARN);
+ assertThat(e.getFormattedMessage()).contains("com.example.SomeEntity");
+ });
+ assertThat(appender.list)
+ .as("but never the entity id (would leak via getMessage())")
+ .noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains(entityId.toString()));
+ }
+
@Test
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
// Debuggability (DevOps): the WARN must name *which* constraint fired so an
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java
new file mode 100644
index 00000000..3c6442db
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java
@@ -0,0 +1,273 @@
+package org.raddatz.familienarchiv.timeline;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.exception.DomainException;
+import org.raddatz.familienarchiv.exception.ErrorCode;
+import org.raddatz.familienarchiv.security.PermissionAspect;
+import org.raddatz.familienarchiv.security.SecurityConfig;
+import org.raddatz.familienarchiv.user.AppUser;
+import org.raddatz.familienarchiv.user.CustomUserDetailsService;
+import org.raddatz.familienarchiv.user.UserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+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(TimelineEventController.class)
+@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
+class TimelineEventControllerTest {
+
+ @Autowired MockMvc mockMvc;
+
+ @MockitoBean TimelineEventService timelineEventService;
+ @MockitoBean UserService userService;
+ @MockitoBean CustomUserDetailsService customUserDetailsService;
+
+ private static final String VALID_JSON =
+ "{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}";
+
+ /** Default principal resolution for @WithMockUser's "user"; capture tests override with a known id. */
+ @BeforeEach
+ void resolveDefaultPrincipal() {
+ when(userService.findByEmail("user"))
+ .thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
+ }
+
+ private TimelineEventView sampleView() {
+ return new TimelineEventView(
+ UUID.randomUUID(), "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 1, 1),
+ org.raddatz.familienarchiv.document.DatePrecision.YEAR, null, null, 0L,
+ UUID.randomUUID(), LocalDateTime.now(), UUID.randomUUID(), LocalDateTime.now(),
+ List.of(), List.of());
+ }
+
+ // ─── POST /api/timeline/events ───────────────────────────────────────────
+
+ @Test
+ void create_returns401_whenUnauthenticated() throws Exception {
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(authorities = "READ_ALL")
+ void create_returns403_whenOnlyReadAll() throws Exception {
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void create_returns201_andViewBody_whenWriteAll() throws Exception {
+ when(timelineEventService.create(any(), any())).thenReturn(sampleView());
+
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.title").value("Hochzeit"))
+ .andExpect(jsonPath("$.version").value(0));
+ }
+
+ // ─── PUT /api/timeline/events/{id} ───────────────────────────────────────
+
+ @Test
+ void update_returns401_whenUnauthenticated() throws Exception {
+ mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(authorities = "READ_ALL")
+ void update_returns403_whenOnlyReadAll() throws Exception {
+ mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void update_returns200_andViewBody_whenWriteAll() throws Exception {
+ when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
+
+ mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.title").value("Hochzeit"));
+ }
+
+ // ─── DELETE /api/timeline/events/{id} ────────────────────────────────────
+
+ @Test
+ void delete_returns401_whenUnauthenticated() throws Exception {
+ mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(authorities = "READ_ALL")
+ void delete_returns403_whenOnlyReadAll() throws Exception {
+ mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void delete_returns204_whenWriteAll() throws Exception {
+ mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
+ .andExpect(status().isNoContent());
+ }
+
+ // ─── GET /api/timeline/events/{id} ───────────────────────────────────────
+
+ @Test
+ void getEvent_returns401_whenUnauthenticated() throws Exception {
+ mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser
+ void getEvent_returns200_whenAuthenticated() throws Exception {
+ when(timelineEventService.getEvent(any())).thenReturn(sampleView());
+
+ mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.title").value("Hochzeit"));
+ }
+
+ @Test
+ @WithMockUser
+ void getEvent_returns404_whenMissing() throws Exception {
+ when(timelineEventService.getEvent(any()))
+ .thenThrow(DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, "not found"));
+
+ mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
+ .andExpect(status().isNotFound());
+ }
+
+ // ─── service-thrown link errors map to status ────────────────────────────
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void create_returns404_whenServiceThrowsPersonNotFound() throws Exception {
+ when(timelineEventService.create(any(), any()))
+ .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found"));
+
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":[\""
+ + UUID.randomUUID() + "\"]}"))
+ .andExpect(status().isNotFound());
+ }
+
+ // ─── Bean Validation 400s carry code VALIDATION_ERROR (R1) ───────────────
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void create_returns400_VALIDATION_ERROR_whenTitleBlank() throws Exception {
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\" \",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void create_returns400_VALIDATION_ERROR_whenTypeNull() throws Exception {
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"Hochzeit\",\"eventDate\":\"1914-07-28\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void create_returns400_VALIDATION_ERROR_whenDescriptionTooLong() throws Exception {
+ String description = "x".repeat(5001);
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"description\":\""
+ + description + "\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
+ }
+
+ @Test
+ @WithMockUser(authorities = "WRITE_ALL")
+ void create_returns400_VALIDATION_ERROR_whenTooManyPersonIds() throws Exception {
+ String ids = Stream.generate(() -> "\"" + UUID.randomUUID() + "\"").limit(51).collect(Collectors.joining(","));
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":["
+ + ids + "]}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
+ }
+
+ // ─── actorId is the resolved session principal, not a body field (both write paths) ──
+
+ @Test
+ @WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
+ void create_passesResolvedPrincipalIdAsActor_ignoringBodyCreatedBy() throws Exception {
+ UUID principalId = UUID.randomUUID();
+ when(userService.findByEmail("curator@example.com"))
+ .thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
+ when(timelineEventService.create(any(), any())).thenReturn(sampleView());
+
+ // body carries a forged createdBy — it must be ignored, actor comes from the principal
+ mockMvc.perform(post("/api/timeline/events").with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"createdBy\":\""
+ + UUID.randomUUID() + "\"}"))
+ .andExpect(status().isCreated());
+
+ verify(timelineEventService).create(any(), eq(principalId));
+ }
+
+ @Test
+ @WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
+ void update_passesResolvedPrincipalIdAsActor_ignoringBodyUpdatedBy() throws Exception {
+ UUID principalId = UUID.randomUUID();
+ UUID eventId = UUID.randomUUID();
+ when(userService.findByEmail("curator@example.com"))
+ .thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
+ when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
+
+ mockMvc.perform(put("/api/timeline/events/" + eventId).with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"updatedBy\":\""
+ + UUID.randomUUID() + "\"}"))
+ .andExpect(status().isOk());
+
+ verify(timelineEventService).update(eq(eventId), any(), eq(principalId));
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java
new file mode 100644
index 00000000..1ee456cc
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java
@@ -0,0 +1,109 @@
+package org.raddatz.familienarchiv.timeline;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.junit.jupiter.api.Test;
+import org.raddatz.familienarchiv.PostgresContainerConfig;
+import org.raddatz.familienarchiv.document.Document;
+import org.raddatz.familienarchiv.document.DocumentRepository;
+import org.raddatz.familienarchiv.document.DocumentStatus;
+import org.raddatz.familienarchiv.exception.DomainException;
+import org.raddatz.familienarchiv.exception.ErrorCode;
+import org.raddatz.familienarchiv.person.Person;
+import org.raddatz.familienarchiv.person.PersonRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.transaction.annotation.Transactional;
+import software.amazon.awssdk.services.s3.S3Client;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Service-level integration scope the entity/DB tests ({@link TimelineEventTest}) don't reach: the
+ * in-transaction view assembly survives a context clear (the {@code @Transactional(readOnly = true)}
+ * LazyInit guard), the serialized view leaks no curator-internal fields, and the {@code @Version}
+ * optimistic lock engages end-to-end (the Mockito test only proves the catch branch). Real Postgres
+ * (V77 CHECK constraints are Postgres-specific) via {@link PostgresContainerConfig}.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
+@ActiveProfiles("test")
+@Import(PostgresContainerConfig.class)
+@Transactional
+class TimelineEventServiceIntegrationTest {
+
+ @MockitoBean S3Client s3Client;
+ @Autowired TimelineEventService service;
+ @Autowired PersonRepository personRepository;
+ @Autowired DocumentRepository documentRepository;
+ @PersistenceContext EntityManager em;
+
+ // Built locally — the webEnvironment=NONE context has no auto-configured ObjectMapper bean.
+ // findAndRegisterModules() pulls in JavaTimeModule so the view's LocalDate/LocalDateTime serialize.
+ private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
+
+ private TimelineEventRequest request(String title, Long version, List personIds, List documentIds) {
+ return new TimelineEventRequest(title, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, version, personIds, documentIds);
+ }
+
+ @Test
+ void getEvent_after_context_clear_populates_links_and_leaks_no_internal_fields() throws Exception {
+ Person anna = personRepository.save(Person.builder()
+ .firstName("Anna").lastName("Müller").notes("GEHEIM-NOTIZ").provisional(true).build());
+ Document letter = documentRepository.save(Document.builder()
+ .title("Brief an Anna").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
+ UUID eventId = service.create(
+ request("Hochzeit", null, List.of(anna.getId()), List.of(letter.getId())), UUID.randomUUID()).id();
+ em.flush();
+ em.clear();
+
+ // Fresh read — NOT the create return value (that view was assembled while the entity was
+ // managed). Only a separate read after the clear proves the readOnly LazyInit guard.
+ TimelineEventView fresh = service.getEvent(eventId);
+
+ assertThat(fresh.persons()).singleElement().satisfies(p -> {
+ assertThat(p.firstName()).isEqualTo("Anna");
+ assertThat(p.lastName()).isEqualTo("Müller");
+ });
+ assertThat(fresh.documents()).singleElement().satisfies(d ->
+ assertThat(d.title()).isEqualTo("Brief an Anna"));
+
+ // Assert on the SERIALIZED JSON: a getter re-introducing a leaked field later would slip
+ // past a field-level check.
+ String json = objectMapper.writeValueAsString(fresh);
+ assertThat(json)
+ .doesNotContain("GEHEIM-NOTIZ")
+ .doesNotContain("notes")
+ .doesNotContain("provisional")
+ .doesNotContain("password");
+ }
+
+ @Test
+ void concurrent_update_with_stale_version_yields_TIMELINE_EVENT_CONFLICT() {
+ UUID editorA = UUID.randomUUID();
+ UUID editorB = UUID.randomUUID();
+ UUID eventId = service.create(request("Original", null, null, null), editorA).id();
+ em.flush();
+ em.clear();
+
+ // Editor A saves with the version they last saw (0) → succeeds, version advances to 1.
+ service.update(eventId, request("Edit A", 0L, null, null), editorA);
+ em.flush();
+ em.clear();
+
+ // Editor B still holds the stale version 0 → the versioned UPDATE matches no row → 409.
+ assertThatThrownBy(() -> service.update(eventId, request("Edit B", 0L, null, null), editorB))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode())
+ .isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java
new file mode 100644
index 00000000..658a198f
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java
@@ -0,0 +1,453 @@
+package org.raddatz.familienarchiv.timeline;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.raddatz.familienarchiv.document.DatePrecision;
+import org.raddatz.familienarchiv.document.Document;
+import org.raddatz.familienarchiv.document.DocumentService;
+import org.raddatz.familienarchiv.exception.DomainException;
+import org.raddatz.familienarchiv.exception.ErrorCode;
+import org.raddatz.familienarchiv.person.Person;
+import org.raddatz.familienarchiv.person.PersonService;
+
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+
+import java.time.LocalDate;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.AdditionalAnswers.returnsFirstArg;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TimelineEventServiceTest {
+
+ @Mock TimelineEventRepository events;
+ @Mock PersonService personService;
+ @Mock DocumentService documentService;
+ @InjectMocks TimelineEventService service;
+
+ private final UUID actor = UUID.randomUUID();
+ private final UUID secondEditor = UUID.randomUUID();
+
+ /** Mirrors #774's makeEvent defaults so NOT NULL createdBy/updatedBy aren't tripped for the wrong reason. */
+ private TimelineEventRequest baseRequest() {
+ return new TimelineEventRequest(
+ "Hochzeit von Anna und Otto", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, null, null);
+ }
+
+ private Person makePerson(UUID id) {
+ return Person.builder().id(id).firstName("Anna").lastName("Müller").build();
+ }
+
+ private Document makeDocument(UUID id) {
+ return Document.builder().id(id).title("Brief an Anna").documentDate(LocalDate.of(1914, 6, 1)).build();
+ }
+
+ /** A managed, persisted event for update/delete/get paths — version 5, distinct creator. */
+ private TimelineEvent existingEvent(UUID id, UUID creator) {
+ return TimelineEvent.builder()
+ .id(id).title("Original").type(EventType.PERSONAL).eventDate(LocalDate.of(1914, 1, 1))
+ .precision(DatePrecision.YEAR).createdBy(creator).updatedBy(creator).version(5L)
+ .build();
+ }
+
+ /** Stubs saveAndFlush to mimic Hibernate setting version=0 on insert; returns the same managed entity. */
+ private void stubFlushSetsVersion() {
+ when(events.saveAndFlush(any())).thenAnswer(inv -> {
+ TimelineEvent e = inv.getArgument(0);
+ if (e.getVersion() == null) e.setVersion(0L);
+ return e;
+ });
+ }
+
+ private TimelineEvent captureSaved() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(TimelineEvent.class);
+ verify(events).saveAndFlush(captor.capture());
+ return captor.getValue();
+ }
+
+ // --- create ---
+
+ @Test
+ void create_persists_and_sets_createdBy_and_updatedBy_from_actorId() {
+ stubFlushSetsVersion();
+
+ TimelineEventView view = service.create(baseRequest(), actor);
+
+ TimelineEvent persisted = captureSaved();
+ assertThat(persisted.getCreatedBy()).isEqualTo(actor);
+ assertThat(persisted.getUpdatedBy()).isEqualTo(actor);
+ assertThat(view.title()).isEqualTo("Hochzeit von Anna und Otto");
+ assertThat(view.version()).isEqualTo(0L);
+ }
+
+ @Test
+ void create_defaults_precision_to_YEAR_when_omitted() {
+ stubFlushSetsVersion();
+
+ TimelineEventView view = service.create(baseRequest(), actor);
+
+ assertThat(view.precision()).isEqualTo(DatePrecision.YEAR);
+ }
+
+ @Test
+ void create_normalizes_eventDate_to_first_of_january_when_precision_is_YEAR() {
+ stubFlushSetsVersion();
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ DatePrecision.YEAR, null, null, null, null, null);
+
+ TimelineEventView view = service.create(request, actor);
+
+ assertThat(view.eventDate()).isEqualTo(LocalDate.of(1914, 1, 1));
+ }
+
+ @Test
+ void create_with_null_link_lists_yields_empty_collections_no_npe() {
+ stubFlushSetsVersion();
+
+ TimelineEventView view = service.create(baseRequest(), actor);
+
+ assertThat(view.persons()).isEmpty();
+ assertThat(view.documents()).isEmpty();
+ }
+
+ // --- RANGE invariant (both directions are separate tests; each asserts saveAndFlush never called) ---
+
+ @Test
+ void create_rejects_eventDateEnd_when_precision_is_not_RANGE() {
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
+ DatePrecision.YEAR, LocalDate.of(1918, 1, 1), null, null, null, null);
+
+ assertThatThrownBy(() -> service.create(request, actor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void create_rejects_RANGE_with_null_eventDateEnd() {
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
+ DatePrecision.RANGE, null, null, null, null, null);
+
+ assertThatThrownBy(() -> service.create(request, actor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void create_rejects_RANGE_with_eventDateEnd_before_eventDate() {
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
+ DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
+
+ assertThatThrownBy(() -> service.create(request, actor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void create_accepts_RANGE_with_eventDateEnd_equal_to_eventDate() {
+ stubFlushSetsVersion();
+ LocalDate sameDay = LocalDate.of(1914, 7, 28);
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Eintägiges Ereignis", EventType.HISTORICAL, sameDay,
+ DatePrecision.RANGE, sameDay, null, null, null, null); // end == start is a valid closed range
+
+ TimelineEventView view = service.create(request, actor);
+
+ assertThat(view.eventDate()).isEqualTo(sameDay);
+ assertThat(view.eventDateEnd()).isEqualTo(sameDay);
+ }
+
+ // --- title-length service guard ---
+
+ @Test
+ void create_rejects_title_longer_than_255_with_TIMELINE_TITLE_TOO_LONG() {
+ String overLong = "x".repeat(256);
+ TimelineEventRequest request = new TimelineEventRequest(
+ overLong, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, null, null);
+
+ assertThatThrownBy(() -> service.create(request, actor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_TITLE_TOO_LONG);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ // --- link resolution (fail-closed, dedupe-first) ---
+
+ @Test
+ void create_resolves_persons_and_documents() {
+ stubFlushSetsVersion();
+ UUID personId = UUID.randomUUID();
+ UUID docId = UUID.randomUUID();
+ when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
+ when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, List.of(personId), List.of(docId));
+
+ TimelineEventView view = service.create(request, actor);
+
+ assertThat(view.persons()).singleElement().satisfies(p -> assertThat(p.id()).isEqualTo(personId));
+ assertThat(view.documents()).singleElement().satisfies(d -> assertThat(d.id()).isEqualTo(docId));
+ }
+
+ @Test
+ void create_with_duplicate_personIds_resolves_to_single_link_not_404() {
+ stubFlushSetsVersion();
+ UUID personId = UUID.randomUUID();
+ when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, List.of(personId, personId), null);
+
+ TimelineEventView view = service.create(request, actor);
+
+ assertThat(view.persons()).hasSize(1);
+ }
+
+ @Test
+ void create_with_duplicate_documentIds_resolves_to_single_link_not_404() {
+ stubFlushSetsVersion();
+ UUID docId = UUID.randomUUID();
+ when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, null, List.of(docId, docId));
+
+ TimelineEventView view = service.create(request, actor);
+
+ assertThat(view.documents()).hasSize(1);
+ }
+
+ @Test
+ void create_rejects_unknown_personId_with_PERSON_NOT_FOUND_without_saving() {
+ UUID known = UUID.randomUUID();
+ UUID unknown = UUID.randomUUID();
+ when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(known))); // one missing
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, List.of(known, unknown), null);
+
+ assertThatThrownBy(() -> service.create(request, actor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.PERSON_NOT_FOUND);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void create_rejects_unknown_documentId_with_DOCUMENT_NOT_FOUND_without_saving() {
+ UUID unknown = UUID.randomUUID();
+ when(documentService.getDocumentById(unknown))
+ .thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + unknown));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, null, List.of(unknown));
+
+ assertThatThrownBy(() -> service.create(request, actor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ // --- update ---
+
+ @Test
+ void update_replaces_links_preserves_createdBy_and_advances_updatedBy_to_second_editor() {
+ UUID id = UUID.randomUUID();
+ UUID creator = UUID.randomUUID();
+ UUID newPersonId = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, creator);
+ existing.getPersons().add(makePerson(UUID.randomUUID())); // pre-existing link to be replaced
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+ when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
+ when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(newPersonId)));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, List.of(newPersonId), null);
+
+ service.update(id, request, secondEditor);
+
+ assertThat(existing.getCreatedBy()).isEqualTo(creator);
+ assertThat(existing.getUpdatedBy()).isEqualTo(secondEditor);
+ assertThat(existing.getPersons()).singleElement()
+ .satisfies(p -> assertThat(p.getId()).isEqualTo(newPersonId));
+ }
+
+ @Test
+ void update_with_empty_link_lists_clears_all_links() {
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID());
+ existing.getPersons().add(makePerson(UUID.randomUUID()));
+ existing.getDocuments().add(makeDocument(UUID.randomUUID()));
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+ when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, null, List.of(), List.of());
+
+ service.update(id, request, secondEditor);
+
+ assertThat(existing.getPersons()).isEmpty();
+ assertThat(existing.getDocuments()).isEmpty();
+ }
+
+ @Test
+ void update_rejects_RANGE_with_eventDateEnd_before_eventDate_without_saving() {
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID());
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
+ DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
+
+ assertThatThrownBy(() -> service.update(id, request, secondEditor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
+ UUID id = UUID.randomUUID();
+ when(events.findById(id)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.update(id, baseRequest(), actor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
+ }
+
+ // --- version / optimistic lock ---
+
+ // Note: the lock control is an explicit base-version compare (requireVersionMatch), NOT
+ // event.setVersion(clientVersion) — Hibernate silently ignores a manually-set @Version on a
+ // managed entity (proven by TimelineEventServiceIntegrationTest). The saveAndFlush+catch below
+ // is retained as the native backstop for two transactions flushing concurrently.
+
+ @Test
+ void update_with_null_version_skips_the_check_and_saves_last_write_wins() {
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+ when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
+
+ service.update(id, baseRequest(), secondEditor); // baseRequest has null version
+
+ verify(events).saveAndFlush(existing); // no conflict despite an unknown client base
+ }
+
+ @Test
+ void update_with_in_sync_version_succeeds_and_saves() {
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+ when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, 5L, null, null); // matches the loaded version
+
+ TimelineEventView view = service.update(id, request, secondEditor);
+
+ assertThat(view).isNotNull();
+ verify(events).saveAndFlush(existing);
+ }
+
+ @Test
+ void update_with_stale_version_throws_conflict_without_saving() {
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, 2L, null, null); // stale: 2 != 5
+
+ assertThatThrownBy(() -> service.update(id, request, secondEditor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
+ verify(events, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void update_translates_concurrent_flush_lock_failure_to_TIMELINE_EVENT_CONFLICT() {
+ // Native @Version backstop: an in-sync token passes the explicit guard, but a genuinely
+ // concurrent flush makes saveAndFlush throw — it must still surface as a 409, not a 500.
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+ when(events.saveAndFlush(any()))
+ .thenThrow(new ObjectOptimisticLockingFailureException(TimelineEvent.class, id));
+ TimelineEventRequest request = new TimelineEventRequest(
+ "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
+ null, null, null, 5L, null, null); // in-sync, passes the guard
+
+ assertThatThrownBy(() -> service.update(id, request, secondEditor))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
+ }
+
+ // --- delete / getEvent ---
+
+ @Test
+ void delete_removes_existing_event() {
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID());
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+
+ service.delete(id);
+
+ verify(events).delete(existing);
+ }
+
+ @Test
+ void delete_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
+ UUID id = UUID.randomUUID();
+ when(events.findById(id)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.delete(id))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
+ }
+
+ @Test
+ void getEvent_returns_view_for_existing_event() {
+ UUID id = UUID.randomUUID();
+ TimelineEvent existing = existingEvent(id, UUID.randomUUID());
+ when(events.findById(id)).thenReturn(Optional.of(existing));
+
+ TimelineEventView view = service.getEvent(id);
+
+ assertThat(view.id()).isEqualTo(id);
+ assertThat(view.title()).isEqualTo("Original");
+ }
+
+ @Test
+ void getEvent_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
+ UUID id = UUID.randomUUID();
+ when(events.findById(id)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.getEvent(id))
+ .isInstanceOf(DomainException.class)
+ .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index dcc7acd2..e676b42f 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
-| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
+| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. Timeline domain codes: `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG`, plus a generic `CONFLICT` (409 optimistic-lock backstop). |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
diff --git a/docs/adr/041-renovate-runner-setup.md b/docs/adr/041-renovate-runner-setup.md
new file mode 100644
index 00000000..41a17fcd
--- /dev/null
+++ b/docs/adr/041-renovate-runner-setup.md
@@ -0,0 +1,123 @@
+# ADR-041 — Renovate runner stand-up: two-token model, OSV surfacing, digest pinning
+
+**Date:** 2026-06-13
+**Status:** Accepted
+**Issue:** [#818](https://git.raddatz.cloud/marcel/familienarchiv/issues/818)
+
+---
+
+## Context
+
+Issue #817 (esbuild/cookie advisory) revealed that `main` had no early-warning
+mechanism for newly-published advisories. An advisory landed against already-pinned
+versions, turned the `npm audit --audit-level=high --omit=dev` gate red on `main`,
+and then ambushed the next unrelated PR (#774). The author who hit it did not cause
+it and had no warning.
+
+`renovate.json` existed but `renovatebot` had never actually run: there was no
+`.gitea/workflows/renovate.yml` and zero Renovate-authored PRs in the repo's entire
+history. The three `packageRules` (bucket4j / tiptap / privileged-digest) were
+silently inert.
+
+This ADR records the **negative space** — why specific design choices were made,
+so future maintainers do not "tidy up" toward a worse outcome.
+
+---
+
+## Decision
+
+### Why there is no auto-provided `GITEA_TOKEN`
+
+Self-hosted Gitea runners do not auto-inject a `GITEA_TOKEN` equivalent.
+`docs/infrastructure/ci-gitea.md` (and its current line ~251) explicitly states the
+token "must be created manually." No existing workflow in this repo references
+`GITEA_TOKEN` for API calls — only for container registry auth (`docker login`).
+Both `RENOVATE_TOKEN` and `NIGHTLY_AUDIT_TOKEN` must be manually provisioned as
+Gitea secrets by a repository admin.
+
+### Why two tokens, not one
+
+The two jobs have different blast radii on token compromise:
+
+| Token | Scopes | Used by |
+|-------|--------|---------|
+| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | Renovate — must read/write files and open PRs |
+| `NIGHTLY_AUDIT_TOKEN` | `issues` only | Nightly audit — only needs to file a tracking issue |
+
+The nightly job's token appears in step `env:` and is passed to `curl -H`. A leak via
+runner logs, process arguments, or a misconfigured step would expose the token.
+An `issues`-only token cannot push branches, open PRs, or read repository contents —
+the leaked token's blast radius is limited to creating/editing issues.
+
+A single broad token would give any leak path full `contents` + `pull_request` write
+access to the repository. That risk is asymmetric with the upside (one fewer secret).
+
+Both tokens belong to one dedicated bot account (consistent authorship; one identity
+to audit and rotate). **Branch protection on `main` must forbid the bot pushing
+directly**, because a `contents`-scoped token can push to any unprotected branch.
+
+### Why the Renovate action is digest-pinned
+
+`renovatebot/github-action` executes with the `RENOVATE_TOKEN` in scope. That token
+carries `contents` + `pull_request` + `issues` — enough to read files, open PRs, and
+write issues. An unpinned `@v40` tag can be re-pointed by the upstream maintainer
+(or a compromised maintainer account) at any time. A pinned digest (`@`) cannot
+be silently modified; the SHA is immutable. This is the same threat model applied to
+all privileged CI steps in this repo (see the `matchFileNames` rule in `renovate.json`
+for `.gitea/workflows/**`).
+
+Renovate itself will open a PR to bump the digest when a new release ships, which is
+the intended update path.
+
+### Why `osvVulnerabilityAlerts` is the load-bearing detector on Gitea
+
+Renovate's `vulnerabilityAlerts` config key triggers off a *platform* vulnerability
+graph. GitHub exposes the GitHub Advisory Database via its API; **Gitea does not
+expose an equivalent vulnerability graph**. On self-hosted Gitea, `vulnerabilityAlerts`
+is effectively a label carrier — it attaches the configured labels to PRs that
+`osvVulnerabilityAlerts` already detected, but it is not an independent detector.
+
+`osvVulnerabilityAlerts: true` is the load-bearing flag: Renovate queries
+[OSV.dev](https://osv.dev) directly (platform-agnostic). The runner host must be able
+to reach OSV.dev over HTTPS — if egress is filtered, allow `osv.dev:443` or the flag
+silently no-ops.
+
+### Why the root `schedule` does not mute security PRs
+
+`"schedule": ["before 6am on monday"]` in `renovate.json` batches **routine** dependency
+updates (version bumps outside any security context) to a weekly window. This reduces
+noise from routine update PRs while still allowing review before merge.
+
+**Security and vulnerability PRs bypass the schedule by design** — Renovate raises
+them immediately regardless of the schedule window. A future "tidy-up" that removes
+or widens the schedule cannot mute vulnerability alerts; this is worth stating
+explicitly to prevent that misunderstanding.
+
+### Why `lockFileMaintenance` has no `automerge`
+
+`lockFileMaintenance` refreshes transitive pins weekly so the dependency tree drifts
+into fewer advisories over time. It is explicitly set without `automerge: true` because
+a weekly transitive pin refresh can silently break the build if a transitive dep
+introduces a breaking change. These PRs are small and should be reviewed.
+
+### Why there is no entry in `l2-containers.puml`
+
+`docs/architecture/c4/l2-containers.puml` documents long-lived infrastructure
+containers (services that run continuously). Renovate is a scheduled CI job that runs
+on a Gitea Actions runner and exits — it is not a long-lived container. Adding it to
+the container diagram would misrepresent the architecture. This omission is deliberate,
+not an oversight.
+
+---
+
+## Consequences
+
+- Newly-published advisories against our frontend dependencies are surfaced within
+ one day (daily Renovate cron) rather than at the next contributor PR.
+- A nightly `npm audit` job provides an independent signal for dev-dependency advisories
+ that Renovate may not cover via OSV.
+- Two secrets (`RENOVATE_TOKEN`, `NIGHTLY_AUDIT_TOKEN`) must be manually provisioned
+ and rotated annually (or on suspected compromise). See
+ `docs/infrastructure/ci-gitea.md` for the runbook.
+- The bot account must be kept active and branch protection on `main` must forbid
+ it pushing directly. These are operational prerequisites, not code invariants.
diff --git a/docs/infrastructure/ci-gitea.md b/docs/infrastructure/ci-gitea.md
index 6d99f694..871eec78 100644
--- a/docs/infrastructure/ci-gitea.md
+++ b/docs/infrastructure/ci-gitea.md
@@ -462,3 +462,82 @@ jobs:
name: e2e-results
path: frontend/test-results/e2e/
```
+
+---
+
+## Renovate + Nightly Audit — Token Model
+
+> See [ADR-041](../adr/041-renovate-runner-setup.md) for full rationale.
+
+### Two-token model
+
+This repo uses two separate tokens for automated dependency work, both manually
+provisioned as Gitea secrets. There is **no auto-provided `GITEA_TOKEN`** on
+self-hosted Gitea runners — it must be created manually (see §Context Variable Names
+table above).
+
+| Secret | Scopes | Job | Reason |
+|--------|--------|-----|--------|
+| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | `renovate.yml` | Renovate needs to read/write files and open PRs |
+| `NIGHTLY_AUDIT_TOKEN` | `issues` only | `nightly.yml` → `npm-audit` job | Only needs to file a tracking issue; an `issues`-only token cannot push branches or read contents — limits blast radius on leak |
+
+Both tokens belong to a single dedicated bot account. **Branch protection on `main`
+must forbid the bot pushing directly**, because a `contents`-scoped token can push
+to any unprotected branch.
+
+### PAT rotation cadence
+
+Rotate both tokens:
+- **Annually** (calendar reminder)
+- **Immediately** on suspected compromise (runner log leak, accidental `set -x`, etc.)
+
+Tokens are stored exclusively as Gitea secrets. Never commit them to `.env` files or
+log them. The nightly audit step passes its token via `env:` at the step level, reads
+it as `$NIGHTLY_AUDIT_TOKEN` in the shell, and never runs the API `curl` under
+`set -x`.
+
+### OSV vs platform alerts on Gitea
+
+Renovate's `vulnerabilityAlerts` config key requires a *platform* vulnerability graph.
+**Gitea does not expose one** — it has no equivalent to the GitHub Advisory Database
+API. On this runner, `vulnerabilityAlerts` is a label carrier only: it attaches
+`security` and `P1-high` labels to PRs that `osvVulnerabilityAlerts` already raised.
+
+`osvVulnerabilityAlerts: true` is the load-bearing detector. Renovate queries
+[OSV.dev](https://osv.dev) directly, which works regardless of platform. The runner
+host must be able to reach `osv.dev:443`. If egress is filtered and OSV.dev is
+unreachable, the flag silently no-ops — verify egress when standing up the runner.
+
+### Nightly audit vs PR gate (divergence)
+
+| Gate | Command | Dev deps | When |
+|------|---------|----------|------|
+| PR gate (`ci.yml`) | `npm audit --audit-level=high --omit=dev` | ❌ excluded | Every PR |
+| Nightly audit (`nightly.yml`) | `npm audit --audit-level=high` | ✅ included | Nightly + `workflow_dispatch` |
+
+The nightly job is **deliberately broader** — it catches dev-tooling advisories
+(esbuild, Vite, vitest, etc.) that the PR gate ignores. A red nightly audit job does
+**not** mean the PR gate is broken; the two signals are independent.
+
+### Runbook: nightly-opened tracking issue
+
+When the `npm-audit` job opens or updates the tracking issue
+"Nightly npm audit: high-severity advisory":
+
+1. **Triage severity.** Check the advisory page (link in the issue body). Is it
+ exploitable in production? A dev-only dep (e.g. esbuild, prettier) has no
+ production attack surface — treat as low urgency.
+
+2. **Pin or upgrade.** If a non-breaking upgrade is available, update
+ `frontend/package.json` and regenerate the lockfile. Open a PR.
+
+3. **Override if justified.** If the advisory does not apply (dev-only dep, no
+ exploitable path), add an `npm audit` override in `package.json`:
+ ```json
+ "overrides": { "esbuild": ">=0.25.4" }
+ ```
+ Document the rationale in the PR body. See #817 for the reference decision tree.
+
+4. **Close the tracking issue** once the advisory is resolved or overridden and the
+ nightly job runs clean (verify via the `✅ npm audit clean` heartbeat in the job
+ summary).
diff --git a/docs/infrastructure/self-hosted-catalogue.md b/docs/infrastructure/self-hosted-catalogue.md
index fc9a1c61..c197db0c 100644
--- a/docs/infrastructure/self-hosted-catalogue.md
+++ b/docs/infrastructure/self-hosted-catalogue.md
@@ -151,7 +151,7 @@ receivers:
name: Renovate
on:
schedule:
- - cron: '0 3 * * 1' # every Monday at 3am
+ - cron: '0 3 * * *' # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day
workflow_dispatch:
jobs:
@@ -160,32 +160,58 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Run Renovate
- uses: renovatebot/github-action@v40
+ # Pin by digest — this action holds contents+pull_request+issues token;
+ # an unpinned tag is a supply-chain risk. Update digest + renovate-version
+ # together when Renovate publishes a new release.
+ uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
with:
configurationFile: renovate.json
- token: ${{ secrets.GITEA_TOKEN }}
- renovate-version: latest
+ token: ${{ secrets.RENOVATE_TOKEN }}
+ renovate-version: "46.1.15"
+ env:
+ RENOVATE_PLATFORM: gitea
+ RENOVATE_ENDPOINT: https://gitea.example.com # replace with your Gitea URL
+ RENOVATE_REPOSITORIES: '["org/repo"]' # replace with your repo slug
+ LOG_LEVEL: info
```
+> **Token:** `RENOVATE_TOKEN` must be a PAT on a dedicated bot account with scopes
+> `contents` + `pull_request` + `issues`. **Do not reuse** `GITEA_TOKEN` — that variable
+> is not auto-provided on self-hosted Gitea runners and must be manually created anyway;
+> using a single broad token violates least-privilege. See ADR-041.
+
### Renovate Configuration
+The `renovate.json` in the repo root carries only dependency rules — platform and
+endpoint config is injected via `env:` in the workflow above. Keep the two concerns
+separate so the config file remains portable.
+
```json
-// renovate.json
{
- "platform": "gitea",
- "endpoint": "https://gitea.example.com",
- "repositories": ["org/familienarchiv"],
- "automerge": true,
- "automergeType": "pr",
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "osvVulnerabilityAlerts": true,
+ "dependencyDashboard": true,
+ "schedule": ["before 6am on monday"],
+ "vulnerabilityAlerts": {
+ "labels": ["security", "P1-high"]
+ },
+ "lockFileMaintenance": {
+ "enabled": true,
+ "schedule": ["before 6am on monday"]
+ },
"packageRules": [
{
- "matchUpdateTypes": ["patch"],
- "automerge": true
+ "matchPackageNames": ["com.example:my-dep"],
+ "automerge": true,
+ "matchUpdateTypes": ["patch"]
}
]
}
```
+> **Do not add `automerge: true` at the root.** Security and digest-bump PRs should
+> always be reviewed manually. Per-rule `automerge` on patch-level routine deps is fine.
+
---
## Secrets Management -- age + git-crypt
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 6fae284d..c306a1a3 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
"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).",
+ "error_timeline_event_not_found": "Zeitleistenereignis nicht gefunden.",
+ "error_timeline_event_conflict": "Dieses Ereignis wurde zwischenzeitlich geändert. Bitte neu laden.",
+ "error_timeline_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
+ "error_conflict": "Der Datensatz wurde zwischenzeitlich geändert. Bitte neu laden.",
"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."
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 0c51cafe..9f3bb8e0 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
"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).",
+ "error_timeline_event_not_found": "Timeline event not found.",
+ "error_timeline_event_conflict": "This event was changed in the meantime. Please reload.",
+ "error_timeline_title_too_long": "The title is too long (maximum 255 characters).",
+ "error_conflict": "The record was changed in the meantime. Please reload.",
"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."
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index d1695d15..d4d8544d 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
"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).",
+ "error_timeline_event_not_found": "Evento de la línea de tiempo no encontrado.",
+ "error_timeline_event_conflict": "Este evento se modificó mientras tanto. Vuelve a cargar.",
+ "error_timeline_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
+ "error_conflict": "El registro se modificó mientras tanto. Vuelve a cargar.",
"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."
diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts
index 44151d3d..f275c9f2 100644
--- a/frontend/src/lib/generated/api.ts
+++ b/frontend/src/lib/generated/api.ts
@@ -52,6 +52,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/timeline/events/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["getEvent"];
+ put: operations["update"];
+ post?: never;
+ delete: operations["delete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/tags/{id}": {
parameters: {
query?: never;
@@ -232,6 +248,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/timeline/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations["create"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/tags/{id}/merge": {
parameters: {
query?: never;
@@ -433,7 +465,7 @@ export interface paths {
};
get: operations["list"];
put?: never;
- post: operations["create"];
+ post: operations["create_1"];
delete?: never;
options?: never;
head?: never;
@@ -834,10 +866,10 @@ export interface paths {
get: operations["getById"];
put?: never;
post?: never;
- delete: operations["delete"];
+ delete: operations["delete_1"];
options?: never;
head?: never;
- patch: operations["update"];
+ patch: operations["update_1"];
trace?: never;
};
"/api/geschichten/{id}/items/{itemId}": {
@@ -1691,6 +1723,61 @@ export interface components {
notifyOnReply?: boolean;
notifyOnMention?: boolean;
};
+ TimelineEventRequest: {
+ title: string;
+ /** @enum {string} */
+ type: "PERSONAL" | "HISTORICAL";
+ /** Format: date */
+ eventDate: string;
+ /** @enum {string} */
+ precision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
+ /** Format: date */
+ eventDateEnd?: string;
+ description?: string;
+ /** Format: int64 */
+ version?: number;
+ personIds?: string[];
+ documentIds?: string[];
+ };
+ DocumentRef: {
+ /** Format: uuid */
+ id: string;
+ title: string;
+ /** Format: date */
+ documentDate?: string;
+ };
+ PersonView: {
+ /** Format: uuid */
+ id: string;
+ firstName?: string;
+ lastName?: string;
+ };
+ TimelineEventView: {
+ /** Format: uuid */
+ id: string;
+ title: string;
+ /** @enum {string} */
+ type: "PERSONAL" | "HISTORICAL";
+ /** Format: date */
+ eventDate: string;
+ /** @enum {string} */
+ precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
+ /** Format: date */
+ eventDateEnd?: string;
+ description?: string;
+ /** Format: int64 */
+ version: number;
+ /** Format: uuid */
+ createdBy: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: uuid */
+ updatedBy: string;
+ /** Format: date-time */
+ updatedAt: string;
+ persons: components["schemas"]["PersonView"][];
+ documents: components["schemas"]["DocumentRef"][];
+ };
TagUpdateDTO: {
name?: string;
/** Format: uuid */
@@ -2060,12 +2147,6 @@ export interface components {
/** Format: date-time */
updatedAt: string;
};
- PersonView: {
- /** Format: uuid */
- id: string;
- firstName?: string;
- lastName?: string;
- };
JourneyItemCreateDTO: {
/** Format: uuid */
documentId?: string;
@@ -2498,10 +2579,10 @@ export interface components {
/** Format: int32 */
number?: number;
sort?: components["schemas"]["SortObject"];
- /** Format: int32 */
- numberOfElements?: number;
first?: boolean;
last?: boolean;
+ /** Format: int32 */
+ numberOfElements?: number;
empty?: boolean;
};
PageableObject: {
@@ -2885,6 +2966,74 @@ export interface operations {
};
};
};
+ getEvent: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["TimelineEventView"];
+ };
+ };
+ };
+ };
+ update: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["TimelineEventRequest"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["TimelineEventView"];
+ };
+ };
+ };
+ };
+ delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
updateTag: {
parameters: {
query?: never;
@@ -3325,6 +3474,30 @@ export interface operations {
};
};
};
+ create: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["TimelineEventRequest"];
+ };
+ };
+ responses: {
+ /** @description Created */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "*/*": components["schemas"]["TimelineEventView"];
+ };
+ };
+ };
+ };
mergeTag: {
parameters: {
query?: never;
@@ -3754,7 +3927,7 @@ export interface operations {
};
};
};
- create: {
+ create_1: {
parameters: {
query?: never;
header?: never;
@@ -4480,7 +4653,7 @@ export interface operations {
};
};
};
- delete: {
+ delete_1: {
parameters: {
query?: never;
header?: never;
@@ -4500,7 +4673,7 @@ export interface operations {
};
};
};
- update: {
+ update_1: {
parameters: {
query?: never;
header?: never;
diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts
index 344fc08c..9bd6fd66 100644
--- a/frontend/src/lib/shared/errors.ts
+++ b/frontend/src/lib/shared/errors.ts
@@ -56,6 +56,9 @@ export type ErrorCode =
| 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG'
+ | 'TIMELINE_EVENT_NOT_FOUND'
+ | 'TIMELINE_EVENT_CONFLICT'
+ | 'TIMELINE_TITLE_TOO_LONG'
| 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS'
@@ -66,6 +69,7 @@ export type ErrorCode =
| 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS'
+ | 'CONFLICT'
| 'INTERNAL_ERROR';
export interface BackendError {
@@ -194,6 +198,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_geschichte_title_too_long();
case 'GESCHICHTE_INTRO_TOO_LONG':
return m.error_geschichte_intro_too_long();
+ case 'TIMELINE_EVENT_NOT_FOUND':
+ return m.error_timeline_event_not_found();
+ case 'TIMELINE_EVENT_CONFLICT':
+ return m.error_timeline_event_conflict();
+ case 'TIMELINE_TITLE_TOO_LONG':
+ return m.error_timeline_title_too_long();
case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials();
case 'SESSION_EXPIRED':
@@ -214,6 +224,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_batch_too_large();
case 'BULK_EDIT_TOO_MANY_IDS':
return m.error_bulk_edit_too_many_ids();
+ case 'CONFLICT':
+ return m.error_conflict();
default:
return m.error_internal_error();
}
diff --git a/renovate.json b/renovate.json
index 2b4af645..bae03932 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,5 +1,15 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "osvVulnerabilityAlerts": true,
+ "dependencyDashboard": true,
+ "schedule": ["before 6am on monday"],
+ "vulnerabilityAlerts": {
+ "labels": ["security", "P1-high"]
+ },
+ "lockFileMaintenance": {
+ "enabled": true,
+ "schedule": ["before 6am on monday"]
+ },
"packageRules": [
{
"description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.",
@@ -9,13 +19,13 @@
"matchUpdateTypes": ["patch"]
},
{
- "matchPackagePatterns": ["^@tiptap/"],
+ "matchPackageNames": ["/^@tiptap/"],
"groupName": "tiptap",
"automerge": false
},
{
"description": "Digest bumps for images used in privileged CI steps (--privileged --pid=host) must be reviewed manually — a compromised image has root-equivalent host access. Covers .gitea/actions/** too: the reload-caddy alpine digest now lives in a composite action (#603).",
- "matchPaths": [".gitea/workflows/**", ".gitea/actions/**"],
+ "matchFileNames": [".gitea/workflows/**", ".gitea/actions/**"],
"matchUpdateTypes": ["digest"],
"automerge": false,
"reviewersFromCodeOwners": false