Merge remote-tracking branch 'origin/main' into feat/issue-837-relationship-edit-dates
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m2s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m8s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
SDD Gate / RTM Check (pull_request) Successful in 16s

# Conflicts:
#	.specify/rtm.md
This commit is contained in:
Marcel
2026-06-14 19:47:26 +02:00
18 changed files with 534 additions and 12 deletions

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.tag;
import java.util.UUID;
/**
* The root-ancestor view of a tag: its id, display name, and color token.
* Colors are stored only on root tags, so {@code color} is the authoritative token
* (one of {@link TagService#ALLOWED_TAG_COLORS}) or {@code null} when the root has none.
* Returned by {@link TagService#resolveRootTags} for read surfaces (the timeline chip,
* later the Thema buckets) that need a tag's theme without the entity graph.
*/
public record RootTag(UUID id, String name, String color) {
}

View File

@@ -175,6 +175,59 @@ public class TagService {
});
}
/**
* Resolves each given tag to its root ancestor, returning a {@link RootTag} (id, name, color
* token) keyed by the input tag's id. A root tag maps to itself; a child is walked to the
* ancestor with no parent via {@link TagRepository#findAncestorIds} (one CTE per distinct
* non-root tag, memoized) plus a single batched {@code findAllById}, so a timeline of many
* letters sharing few tags costs O(distinct tags) queries, never O(letters). The color comes
* from the resolved root's stored token (null when the root has none). Safe on detached tags.
*/
public Map<UUID, RootTag> resolveRootTags(Collection<Tag> tags) {
if (tags == null || tags.isEmpty()) return Map.of();
Map<UUID, Tag> distinct = new LinkedHashMap<>();
for (Tag tag : tags) {
if (tag != null && tag.getId() != null) distinct.putIfAbsent(tag.getId(), tag);
}
Map<UUID, List<UUID>> ancestorIdsByTagId = new HashMap<>();
Set<UUID> idsToLoad = new HashSet<>();
for (Tag tag : distinct.values()) {
if (tag.getParentId() == null) continue;
List<UUID> ancestorIds = tagRepository.findAncestorIds(tag.getId());
ancestorIdsByTagId.put(tag.getId(), ancestorIds);
idsToLoad.addAll(ancestorIds);
}
Map<UUID, Tag> ancestorsById = idsToLoad.isEmpty() ? Map.of()
: tagRepository.findAllById(idsToLoad).stream()
.collect(Collectors.toMap(Tag::getId, t -> t));
Map<UUID, RootTag> result = new HashMap<>();
for (Tag tag : distinct.values()) {
Tag root = resolveRoot(tag, ancestorIdsByTagId.get(tag.getId()), ancestorsById);
result.put(tag.getId(), new RootTag(root.getId(), root.getName(), root.getColor()));
}
return result;
}
private Tag resolveRoot(Tag tag, List<UUID> ancestorIds, Map<UUID, Tag> ancestorsById) {
if (tag.getParentId() == null) return tag;
if (ancestorIds != null) {
for (UUID ancestorId : ancestorIds) {
Tag ancestor = ancestorsById.get(ancestorId);
if (ancestor != null && ancestor.getParentId() == null) return ancestor;
}
}
// No null-parent ancestor surfaced — the parent is orphaned or the chain is deeper than the
// findAncestorIds CTE's depth guard. Fall back to the tag as its own root, but surface it:
// a silently mislabeled root would otherwise be invisible. UUIDs only (no tag names logged).
log.warn("Tag {} has parent {} but no root surfaced from its ancestry; "
+ "treating it as its own root.", tag.getId(), tag.getParentId());
return tag;
}
/**
* For each tag name, returns the set of that tag's ID plus all descendant IDs.
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.

View File

@@ -21,6 +21,13 @@ import java.util.UUID;
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
* an event-type badge for letters.
*
* <p><b>Root-tag fields ({@code rootTagId}/{@code rootTagName}/{@code rootTagColor}):</b> the
* letter's primary root tag — the root ancestor of its alphabetically-first assigned tag (#835).
* All three are {@code null} for non-{@link Kind#LETTER} entries and for letters with no tags;
* {@code rootTagColor} is additionally {@code null} when the resolved root carries no color token.
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
* types stay optional.
*
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
*/
@@ -37,6 +44,9 @@ public record TimelineEntryDTO(
UUID eventId,
UUID documentId,
List<UUID> linkedPersonIds,
DerivedEventType derivedType
DerivedEventType derivedType,
UUID rootTagId,
String rootTagName,
String rootTagColor
) {
}

View File

@@ -266,7 +266,8 @@ public class TimelineEventService {
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
p.getBirthDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.BIRTH))
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
null, null, null))
.toList();
}
@@ -277,7 +278,8 @@ public class TimelineEventService {
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
p.getDeathDate(), null,
p.getDisplayName(), EventType.PERSONAL,
null, null, List.of(p.getId()), DerivedEventType.DEATH))
null, null, List.of(p.getId()), DerivedEventType.DEATH,
null, null, null))
.toList();
}
@@ -301,7 +303,8 @@ public class TimelineEventService {
title, EventType.PERSONAL,
null, null,
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
DerivedEventType.MARRIAGE));
DerivedEventType.MARRIAGE,
null, null, null));
}
}
return result;

View File

@@ -10,12 +10,16 @@ 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.tag.RootTag;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -52,6 +56,7 @@ public class TimelineService {
private final TimelineEventService timelineEventService;
private final DocumentService documentService;
private final PersonService personService;
private final TagService tagService;
/**
* Assembles the timeline for the given filter. All filters are ANDed.
@@ -95,11 +100,15 @@ public class TimelineService {
}
// ── letters ─────────────────────────────────────────────────────────
List<Document> docs = fetchDocuments(filter.personId());
for (Document doc : docs) {
List<Document> letters = new ArrayList<>();
for (Document doc : fetchDocuments(filter.personId())) {
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
entries.add(mapDocument(doc));
letters.add(doc);
}
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
for (Document doc : letters) {
entries.add(mapDocument(doc, rootByDocId));
}
return bucket(entries);
@@ -217,11 +226,15 @@ public class TimelineService {
ev.getId(),
null,
personIds,
null,
null,
null,
null
);
}
private TimelineEntryDTO mapDocument(Document doc) {
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
RootTag root = rootByDocId.get(doc.getId());
return new TimelineEntryDTO(
Kind.LETTER,
doc.getMetaDatePrecision(),
@@ -235,10 +248,47 @@ public class TimelineService {
null,
doc.getId(),
List.of(),
null
null,
root == null ? null : root.id(),
root == null ? null : root.name(),
root == null ? null : root.color()
);
}
/**
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
* so the {@code min()} scan over a letter's tag set runs exactly once here (not again at map
* time), and {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag.
*/
private Map<UUID, RootTag> resolveLetterRootTags(List<Document> letters) {
Map<UUID, Tag> primaryByDocId = new LinkedHashMap<>();
for (Document doc : letters) {
Tag primary = primaryTag(doc);
if (primary != null) primaryByDocId.put(doc.getId(), primary);
}
if (primaryByDocId.isEmpty()) return Map.of();
Map<UUID, RootTag> rootByTagId =
tagService.resolveRootTags(new ArrayList<>(primaryByDocId.values()));
Map<UUID, RootTag> rootByDocId = new HashMap<>();
primaryByDocId.forEach((docId, primary) -> {
RootTag root = rootByTagId.get(primary.getId());
if (root != null) rootByDocId.put(docId, root);
});
return rootByDocId;
}
/** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */
private static Tag primaryTag(Document doc) {
Set<Tag> tags = doc.getTags();
if (tags == null || tags.isEmpty()) return null;
return tags.stream()
.filter(t -> t.getName() != null)
.min(Comparator.comparing(Tag::getName))
.orElse(null);
}
private String resolveSenderName(Document doc) {
if (doc.getSender() != null) return doc.getSender().getDisplayName();
String text = doc.getSenderText();

View File

@@ -0,0 +1,61 @@
package org.raddatz.familienarchiv.tag;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Real-Postgres proof that {@link TagService#resolveRootTags} walks a persisted tag chain to its
* true root through the recursive-CTE {@link TagRepository#findAncestorIds}. The CTE cannot run on
* H2, so this uses {@code postgres:16-alpine} via Testcontainers. Exhaustive case coverage lives in
* {@link TagServiceTest} (mocked); this pins the DB-dependent ancestry walk (issue #835, REQ-003/004).
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TagServiceIntegrationTest {
@Autowired private TagRepository tagRepository;
private TagService tagService;
@BeforeEach
void setUp() {
tagService = new TagService(tagRepository);
}
private Tag tag(String name, String color, UUID parentId) {
return tagRepository.save(Tag.builder().name(name).color(color).parentId(parentId).build());
}
@Test
void resolveRootTags_walksPersistedChainToRoot_withRootColor() {
// leaf → mid → root resolves to the root's (id, name, color) via the real recursive CTE.
Tag root = tag("Krieg", "sienna", null);
Tag mid = tag("Feldpost", null, root.getId());
Tag leaf = tag("Briefe von der Front", null, mid.getId());
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(leaf));
assertThat(result.get(leaf.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
}
@Test
void resolveRootTags_returnsRootItself_forPersistedRoot() {
Tag root = tag("Weihnachten", "amber", null);
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Weihnachten", "amber"));
}
}

View File

@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -450,6 +451,74 @@ class TagServiceTest {
assertThat(child2.getColor()).isEqualTo("sienna");
}
// ─── resolveRootTags ───────────────────────────────────────────────────────
@Test
void resolveRootTags_returnsTagItself_whenTagIsRoot() {
// REQ-003/004: a root tag (no parent) is its own primary root — no ancestry walk, no load.
Tag root = Tag.builder().id(UUID.randomUUID()).name("Krieg").color("sienna").build();
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
verify(tagRepository, never()).findAncestorIds(any());
verify(tagRepository, never()).findAllById(any());
}
@Test
void resolveRootTags_walksChildToRoot_withRootColor() {
// REQ-003/004: a nested child resolves to its root's id/name/color via one CTE + one batch.
UUID rootId = UUID.randomUUID();
UUID midId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
Tag mid = Tag.builder().id(midId).name("Feldpost").parentId(rootId).build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(midId).build();
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(midId, rootId));
when(tagRepository.findAllById(Set.of(midId, rootId))).thenReturn(List.of(mid, rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
}
@Test
void resolveRootTags_memoizesPerDistinctTag_noNPlusOne() {
// REQ-004: two letters sharing one tag id ⇒ a single findAncestorIds + a single batch load.
UUID rootId = UUID.randomUUID();
UUID childId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
Tag childA = Tag.builder().id(childId).name("Front").parentId(rootId).build();
Tag childB = Tag.builder().id(childId).name("Front").parentId(rootId).build();
when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId));
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(childA, childB));
assertThat(result.get(childId)).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
verify(tagRepository, times(1)).findAncestorIds(childId);
verify(tagRepository, times(1)).findAllById(any());
}
@Test
void resolveRootTags_returnsNullColor_whenRootHasNoColor() {
// REQ-007: a colorless root yields RootTag.color() == null (frontend renders a neutral chip).
UUID rootId = UUID.randomUUID();
Tag rootTag = Tag.builder().id(rootId).name("Allgemein").build();
Tag child = Tag.builder().id(UUID.randomUUID()).name("Notiz").parentId(rootId).build();
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(rootId));
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Allgemein", null));
}
@Test
void resolveRootTags_returnsEmptyMap_forEmptyInput() {
assertThat(tagService.resolveRootTags(List.of())).isEmpty();
verify(tagRepository, never()).findAncestorIds(any());
}
// ─── mergeTags ────────────────────────────────────────────────────────────
@Test

View File

@@ -11,13 +11,19 @@ import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.tag.RootTag;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagService;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@@ -27,6 +33,7 @@ class TimelineServiceTest {
@Mock TimelineEventService timelineEventService;
@Mock DocumentService documentService;
@Mock PersonService personService;
@Mock TagService tagService;
@InjectMocks TimelineService timelineService;
@@ -61,9 +68,11 @@ class TimelineServiceTest {
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null);
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
null, null, null);
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null);
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
null, null, null);
var sorted = List.of(e2, e1).stream()
.sorted(TimelineService.WITHIN_BAND_ORDER)
@@ -423,13 +432,98 @@ class TimelineServiceTest {
// ─── Helpers ─────────────────────────────────────────────────────────────
// ─── root-tag chip enrichment (#835) ─────────────────────────────────────
@Test
void letter_with_tags_carries_its_primary_root_tag() {
// REQ-003/006: the primary tag is the root ancestor of the alphabetically-first
// assigned tag ("Briefe von der Front" < "Zeitung"), resolved to root "Krieg".
UUID kriegId = UUID.randomUUID();
Tag front = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(kriegId).build();
Tag zeitung = Tag.builder().id(UUID.randomUUID()).name("Zeitung").build();
Document doc = docWithTags(LocalDate.of(1916, 5, 1), DatePrecision.MONTH, front, zeitung);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
when(tagService.resolveRootTags(anyList()))
.thenReturn(Map.of(front.getId(), new RootTag(kriegId, "Krieg", "sienna")));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.rootTagId()).isEqualTo(kriegId);
assertThat(entry.rootTagName()).isEqualTo("Krieg");
assertThat(entry.rootTagColor()).isEqualTo("sienna");
}
@Test
void untagged_letter_has_no_root_tag_fields() {
// REQ-005: a letter with no tags carries null id/name/color — and never hits TagService.
Document doc = docWithDate(LocalDate.of(1909, 3, 1), DatePrecision.MONTH);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.rootTagId()).isNull();
assertThat(entry.rootTagName()).isNull();
assertThat(entry.rootTagColor()).isNull();
verify(tagService, never()).resolveRootTags(anyList());
}
@Test
void letter_primary_root_without_color_yields_null_color() {
// REQ-007: a colorless root → rootTagColor null, id+name still present (neutral chip).
UUID rootId = UUID.randomUUID();
Tag allgemein = Tag.builder().id(rootId).name("Allgemein").build();
Document doc = docWithTags(LocalDate.of(1910, 2, 1), DatePrecision.MONTH, allgemein);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
when(tagService.resolveRootTags(anyList()))
.thenReturn(Map.of(rootId, new RootTag(rootId, "Allgemein", null)));
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
assertThat(entry.rootTagId()).isEqualTo(rootId);
assertThat(entry.rootTagName()).isEqualTo("Allgemein");
assertThat(entry.rootTagColor()).isNull();
}
@Test
void root_tags_resolved_in_a_single_batched_pass() {
// REQ-004: many letters → exactly one resolveRootTags call (no per-letter N+1).
UUID kriegId = UUID.randomUUID();
Tag krieg = Tag.builder().id(kriegId).name("Krieg").color("sienna").build();
Tag weihnachten = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").color("amber").build();
Document a = docWithTags(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, krieg);
Document b = docWithTags(LocalDate.of(1916, 12, 1), DatePrecision.MONTH, weihnachten);
Document c = docWithTags(LocalDate.of(1917, 1, 1), DatePrecision.YEAR, krieg);
when(eventRepository.findAll()).thenReturn(List.of());
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
when(documentService.getAllForTimeline()).thenReturn(List.of(a, b, c));
when(tagService.resolveRootTags(anyList())).thenReturn(Map.of(
kriegId, new RootTag(kriegId, "Krieg", "sienna"),
weihnachten.getId(), new RootTag(weihnachten.getId(), "Weihnachten", "amber")));
timelineService.assemble(noFilters());
verify(tagService, times(1)).resolveRootTags(anyList());
}
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
assertThat(result.years()).hasSize(1);
return result.years().get(0).entries().get(0);
}
private static TimelineFilter noFilters() {
return new TimelineFilter(null, null, null, null, null);
}
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
date, null, title, null, null, UUID.randomUUID(), List.of(), null);
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
null, null, null);
}
private static Document docWithDate(LocalDate date, DatePrecision precision) {
@@ -437,6 +531,12 @@ class TimelineServiceTest {
.metaDatePrecision(precision).documentDate(date).build();
}
private static Document docWithTags(LocalDate date, DatePrecision precision, Tag... tags) {
return Document.builder().id(UUID.randomUUID()).title("Brief")
.metaDatePrecision(precision).documentDate(date)
.tags(new HashSet<>(Set.of(tags))).build();
}
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
return Document.builder().id(UUID.randomUUID()).title(title)
.metaDatePrecision(precision).documentDate(date).build();