diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index f4b8f0c9..7826153d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -319,12 +319,12 @@ public class DocumentService { if (sort == DocumentSort.RECEIVER) { List results = documentRepository.findAll(spec); List sorted = sortByFirstReceiver(results, dir); - return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text)); + return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); } if (sort == DocumentSort.SENDER) { List results = documentRepository.findAll(spec); List sorted = sortBySender(results, dir); - return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text)); + return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); } // RELEVANCE: default when text present and no explicit sort given @@ -337,12 +337,12 @@ public class DocumentService { .sorted(Comparator.comparingInt( doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE))) .toList(); - return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text)); + return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text)); } Sort springSort = resolveSort(sort, dir); List results = documentRepository.findAll(spec, springSort); - return DocumentSearchResult.withMatchData(results, enrichWithMatchData(results, text)); + return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text)); } private Sort resolveSort(DocumentSort sort, String dir) { @@ -433,8 +433,10 @@ public class DocumentService { } public Document getDocumentById(UUID id) { - return documentRepository.findById(id) + Document doc = documentRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)); + tagService.resolveEffectiveColors(doc.getTags()); + return doc; } public List getDocumentsWithoutVersions() { @@ -513,6 +515,12 @@ public class DocumentService { // ─── private helpers ────────────────────────────────────────────────────── + private List resolveDocumentTagColors(List docs) { + List allTags = docs.stream().flatMap(d -> d.getTags().stream()).toList(); + tagService.resolveEffectiveColors(allTags); + return docs; + } + private static String stripExtension(String filename) { if (filename == null) return null; int dot = filename.lastIndexOf('.'); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java index a4e47e12..a7031390 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -1,12 +1,14 @@ package org.raddatz.familienarchiv.service; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; import org.raddatz.familienarchiv.dto.TagUpdateDTO; @@ -73,6 +75,36 @@ public class TagService { tagRepository.delete(getById(id)); } + /** + * Sets the effective (inherited) color on child tags that have no color of their own. + * Colors are stored only on root-level tags; children inherit the parent's color. + * Parent tags are batch-loaded in a single query. Safe to call on detached entities. + */ + public void resolveEffectiveColors(Collection tags) { + if (tags == null || tags.isEmpty()) return; + + Set parentIdsNeeded = tags.stream() + .filter(t -> t.getColor() == null && t.getParentId() != null) + .map(Tag::getParentId) + .collect(Collectors.toSet()); + + if (parentIdsNeeded.isEmpty()) return; + + Map parentColors = tagRepository.findAllById(parentIdsNeeded) + .stream() + .filter(p -> p.getColor() != null) + .collect(Collectors.toMap(Tag::getId, Tag::getColor)); + + tags.forEach(tag -> { + if (tag.getColor() == null && tag.getParentId() != null) { + String resolved = parentColors.get(tag.getParentId()); + if (resolved != null) { + tag.setColor(resolved); + } + } + }); + } + /** * 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. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java index 5f42e964..59dbb83c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -14,6 +14,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -233,6 +234,75 @@ class TagServiceTest { assertThat(tree.get(0).children().get(0).id()).isEqualTo(childId); } + // ─── resolveEffectiveColors ─────────────────────────────────────────────── + + @Test + void resolveEffectiveColors_doesNothing_whenCollectionIsEmpty() { + tagService.resolveEffectiveColors(List.of()); + verifyNoInteractions(tagRepository); + } + + @Test + void resolveEffectiveColors_doesNothing_whenAllTagsHaveOwnColor() { + Tag tag = Tag.builder().id(UUID.randomUUID()).name("Root").color("sage").build(); + + tagService.resolveEffectiveColors(List.of(tag)); + + verify(tagRepository, never()).findAllById(any()); + assertThat(tag.getColor()).isEqualTo("sage"); + } + + @Test + void resolveEffectiveColors_doesNothing_whenTagHasNoParentAndNoColor() { + Tag tag = Tag.builder().id(UUID.randomUUID()).name("Root").build(); + + tagService.resolveEffectiveColors(List.of(tag)); + + verify(tagRepository, never()).findAllById(any()); + assertThat(tag.getColor()).isNull(); + } + + @Test + void resolveEffectiveColors_setsParentColor_onChildTagWithNoColor() { + UUID parentId = UUID.randomUUID(); + Tag parent = Tag.builder().id(parentId).name("Parent").color("sage").build(); + Tag child = Tag.builder().id(UUID.randomUUID()).name("Child").parentId(parentId).build(); + when(tagRepository.findAllById(Set.of(parentId))).thenReturn(List.of(parent)); + + tagService.resolveEffectiveColors(List.of(child)); + + assertThat(child.getColor()).isEqualTo("sage"); + } + + @Test + void resolveEffectiveColors_leavesChildUnchanged_whenParentHasNoColor() { + UUID parentId = UUID.randomUUID(); + Tag parent = Tag.builder().id(parentId).name("Parent").build(); + Tag child = Tag.builder().id(UUID.randomUUID()).name("Child").parentId(parentId).build(); + when(tagRepository.findAllById(Set.of(parentId))).thenReturn(List.of(parent)); + + tagService.resolveEffectiveColors(List.of(child)); + + assertThat(child.getColor()).isNull(); + } + + @Test + void resolveEffectiveColors_batchLoadsParents_inOneQuery() { + UUID parentId1 = UUID.randomUUID(); + UUID parentId2 = UUID.randomUUID(); + Tag parent1 = Tag.builder().id(parentId1).name("P1").color("sage").build(); + Tag parent2 = Tag.builder().id(parentId2).name("P2").color("sienna").build(); + Tag child1 = Tag.builder().id(UUID.randomUUID()).name("C1").parentId(parentId1).build(); + Tag child2 = Tag.builder().id(UUID.randomUUID()).name("C2").parentId(parentId2).build(); + when(tagRepository.findAllById(Set.of(parentId1, parentId2))).thenReturn(List.of(parent1, parent2)); + + tagService.resolveEffectiveColors(List.of(child1, child2)); + + verify(tagRepository, times(1)).findAllById(any()); + assertThat(child1.getColor()).isEqualTo("sage"); + assertThat(child2.getColor()).isEqualTo("sienna"); + } + // ─── delete ─────────────────────────────────────────────────────────────── @Test