feat(timeline): add TimelineEventService with CRUD + view assembly
create/update/delete write methods (@Transactional) + getEvent read (@Transactional(readOnly=true) for the LazyInit guard). Persons resolved via PersonService.getAllById with a distinct-size check; documents via per-id DocumentService.getDocumentById loop; both dedupe-first, fail-closed. RANGE invariant (both directions), title-length guard, YEAR date normalization, and default precision. Audit fields server-set (createdBy+updatedBy on create; only updatedBy on update). Optimistic-lock conflict translated to TIMELINE_EVENT_CONFLICT via saveAndFlush+catch. Views assembled after flush. Per #775 / ADR-040. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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) {
|
||||
validateRangeInvariant(request);
|
||||
validateTitleLength(request);
|
||||
DatePrecision precision = effectivePrecision(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));
|
||||
validateRangeInvariant(request);
|
||||
validateTitleLength(request);
|
||||
applyUpdate(event, request, 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, UUID actorId) {
|
||||
DatePrecision precision = effectivePrecision(request);
|
||||
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
|
||||
if (request.version() != null) {
|
||||
event.setVersion(request.version());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 directions. */
|
||||
private void validateRangeInvariant(TimelineEventRequest request) {
|
||||
boolean isRange = effectivePrecision(request) == 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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Person> resolvePersons(List<UUID> 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<UUID> distinct = new LinkedHashSet<>(ids);
|
||||
List<Person> 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<Document> resolveDocuments(List<UUID> 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<Document> 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<PersonView> persons = event.getPersons().stream()
|
||||
.map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
|
||||
.toList();
|
||||
List<DocumentRef> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user