|
|
|
|
@@ -0,0 +1,260 @@
|
|
|
|
|
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.springframework.stereotype.Service;
|
|
|
|
|
|
|
|
|
|
import java.util.ArrayList;
|
|
|
|
|
import java.util.Comparator;
|
|
|
|
|
import java.util.LinkedHashMap;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.Set;
|
|
|
|
|
import java.util.TreeMap;
|
|
|
|
|
import java.util.UUID;
|
|
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
|
|
|
|
|
* derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
|
|
|
|
|
*
|
|
|
|
|
* <p>Cross-domain data is reached exclusively through domain services (PersonService,
|
|
|
|
|
* DocumentService). The only repository injected directly is {@link TimelineEventRepository}
|
|
|
|
|
* (same domain — constitution §1.3).
|
|
|
|
|
*/
|
|
|
|
|
@Service
|
|
|
|
|
@RequiredArgsConstructor
|
|
|
|
|
@Slf4j
|
|
|
|
|
public class TimelineService {
|
|
|
|
|
|
|
|
|
|
/** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
|
|
|
|
|
static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
|
|
|
|
|
Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
|
|
|
|
|
.thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
|
|
|
|
|
.thenComparing(e -> e.title() != null ? e.title() : "")
|
|
|
|
|
.thenComparing(e -> {
|
|
|
|
|
if (e.eventId() != null) return e.eventId().toString();
|
|
|
|
|
if (e.documentId() != null) return e.documentId().toString();
|
|
|
|
|
return "";
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
private final TimelineEventRepository eventRepository;
|
|
|
|
|
private final TimelineEventService timelineEventService;
|
|
|
|
|
private final DocumentService documentService;
|
|
|
|
|
private final PersonService personService;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Assembles the timeline for the given filter. All filters are ANDed.
|
|
|
|
|
* Throws {@link DomainException} (bad request) when fromYear > toYear.
|
|
|
|
|
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
|
|
|
|
|
*/
|
|
|
|
|
public TimelineDTO assemble(TimelineFilter filter) {
|
|
|
|
|
if (filter.fromYear() != null && filter.toYear() != null
|
|
|
|
|
&& filter.fromYear() > filter.toYear()) {
|
|
|
|
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
|
|
|
|
"toYear must not be before fromYear");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve generation person IDs once — used across all three layers
|
|
|
|
|
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
|
|
|
|
|
|
|
|
|
// ── curated events ───────────────────────────────────────────────────
|
|
|
|
|
List<TimelineEntryDTO> entries = new ArrayList<>();
|
|
|
|
|
for (TimelineEvent ev : eventRepository.findAll()) {
|
|
|
|
|
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
|
|
|
|
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
|
|
|
|
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
|
|
|
|
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
|
|
|
|
|
entries.add(mapEvent(ev));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── derived events ───────────────────────────────────────────────────
|
|
|
|
|
for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
|
|
|
|
|
if (!passesTypeFilter(derived.type(), filter.type())) continue;
|
|
|
|
|
if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
|
|
|
|
|
if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
|
|
|
|
|
if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
|
|
|
|
|
entries.add(derived);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── letters ─────────────────────────────────────────────────────────
|
|
|
|
|
List<Document> docs = fetchDocuments(filter.personId());
|
|
|
|
|
for (Document doc : docs) {
|
|
|
|
|
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
|
|
|
|
|
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
|
|
|
|
|
entries.add(mapDocument(doc));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bucket(entries);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Bucketing ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
|
|
|
|
|
Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
|
|
|
|
|
for (TimelineEntryDTO e : entries) {
|
|
|
|
|
if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
|
|
|
|
|
map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
|
|
|
|
|
}
|
|
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
|
|
|
|
|
List<TimelineEntryDTO> undated = entries.stream()
|
|
|
|
|
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
|
|
|
|
|
.sorted(WITHIN_BAND_ORDER)
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
|
|
|
|
|
List<TimelineYearDTO> years = byYear.entrySet().stream()
|
|
|
|
|
.map(e -> new TimelineYearDTO(e.getKey(),
|
|
|
|
|
e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
return new TimelineDTO(years, undated);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Document fetch (global vs personId path) ────────────────────────────
|
|
|
|
|
|
|
|
|
|
private List<Document> fetchDocuments(UUID personId) {
|
|
|
|
|
if (personId == null) {
|
|
|
|
|
return documentService.getAllForTimeline();
|
|
|
|
|
}
|
|
|
|
|
// personId path: validate existence, then union sender+receiver (dedup by id)
|
|
|
|
|
personService.getById(personId);
|
|
|
|
|
Map<UUID, Document> seen = new LinkedHashMap<>();
|
|
|
|
|
for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
|
|
|
|
|
for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
|
|
|
|
|
return new ArrayList<>(seen.values());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Filter predicates ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private boolean passesTypeFilter(EventType entryType, EventType filterType) {
|
|
|
|
|
return filterType == null || filterType == entryType;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
|
|
|
|
|
if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
|
|
|
|
|
int year = date.getYear();
|
|
|
|
|
if (filter.fromYear() != null && year < filter.fromYear()) return false;
|
|
|
|
|
if (filter.toYear() != null && year > filter.toYear()) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
|
|
|
|
|
if (personId == null) return true;
|
|
|
|
|
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
|
|
|
|
|
if (personId == null) return true;
|
|
|
|
|
return linkedIds != null && linkedIds.contains(personId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
|
|
|
|
|
if (generation == null) return null;
|
|
|
|
|
return personService.getPersonsByGeneration(generation).stream()
|
|
|
|
|
.map(Person::getId)
|
|
|
|
|
.collect(Collectors.toSet());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
|
|
|
|
|
if (genPersonIds == null) return true;
|
|
|
|
|
if (persons == null || persons.isEmpty()) return false;
|
|
|
|
|
return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
|
|
|
|
|
if (genPersonIds == null) return true;
|
|
|
|
|
if (linkedIds == null || linkedIds.isEmpty()) return false;
|
|
|
|
|
return linkedIds.stream().anyMatch(genPersonIds::contains);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
|
|
|
|
|
if (genPersonIds == null) return true;
|
|
|
|
|
Person sender = doc.getSender();
|
|
|
|
|
if (sender != null && genPersonIds.contains(sender.getId())) return true;
|
|
|
|
|
Set<Person> receivers = doc.getReceivers();
|
|
|
|
|
if (receivers != null) {
|
|
|
|
|
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Mapping ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
|
|
|
|
|
List<UUID> personIds = ev.getPersons() == null ? List.of()
|
|
|
|
|
: ev.getPersons().stream().map(Person::getId).toList();
|
|
|
|
|
return new TimelineEntryDTO(
|
|
|
|
|
Kind.EVENT,
|
|
|
|
|
ev.getPrecision(),
|
|
|
|
|
false,
|
|
|
|
|
"",
|
|
|
|
|
"",
|
|
|
|
|
ev.getEventDate(),
|
|
|
|
|
ev.getEventDateEnd(),
|
|
|
|
|
ev.getTitle(),
|
|
|
|
|
ev.getType(),
|
|
|
|
|
ev.getId(),
|
|
|
|
|
null,
|
|
|
|
|
personIds,
|
|
|
|
|
null
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private TimelineEntryDTO mapDocument(Document doc) {
|
|
|
|
|
return new TimelineEntryDTO(
|
|
|
|
|
Kind.LETTER,
|
|
|
|
|
doc.getMetaDatePrecision(),
|
|
|
|
|
false,
|
|
|
|
|
resolveSenderName(doc),
|
|
|
|
|
resolveReceiverName(doc),
|
|
|
|
|
doc.getDocumentDate(),
|
|
|
|
|
null,
|
|
|
|
|
doc.getTitle(),
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
doc.getId(),
|
|
|
|
|
List.of(),
|
|
|
|
|
null
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String resolveSenderName(Document doc) {
|
|
|
|
|
if (doc.getSender() != null) return doc.getSender().getDisplayName();
|
|
|
|
|
String text = doc.getSenderText();
|
|
|
|
|
return (text != null && !text.isBlank()) ? text : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String resolveReceiverName(Document doc) {
|
|
|
|
|
Set<Person> receivers = doc.getReceivers();
|
|
|
|
|
if (receivers != null && !receivers.isEmpty()) {
|
|
|
|
|
return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
|
|
|
|
|
}
|
|
|
|
|
String text = doc.getReceiverText();
|
|
|
|
|
return (text != null && !text.isBlank()) ? text : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int precisionRank(DatePrecision precision) {
|
|
|
|
|
if (precision == null) return 0;
|
|
|
|
|
return switch (precision) {
|
|
|
|
|
case DAY -> 5;
|
|
|
|
|
case MONTH -> 4;
|
|
|
|
|
case SEASON -> 3;
|
|
|
|
|
case YEAR -> 2;
|
|
|
|
|
case APPROX -> 1;
|
|
|
|
|
default -> 0;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|