fix(#221): resolve inherited color on child tags in document responses
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / Backend Unit Tests (push) Failing after 2m46s

Colors are stored only on root-level tags. DocumentService now calls
TagService.resolveEffectiveColors() before returning search results and
single-document responses, so child tags carry their parent's color when
serialised to JSON. Parent tags are batch-loaded in a single query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 19:28:21 +02:00
parent 171f06da22
commit 06fd5ae2da
3 changed files with 115 additions and 5 deletions

View File

@@ -319,12 +319,12 @@ public class DocumentService {
if (sort == DocumentSort.RECEIVER) { if (sort == DocumentSort.RECEIVER) {
List<Document> results = documentRepository.findAll(spec); List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortByFirstReceiver(results, dir); List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text)); return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
} }
if (sort == DocumentSort.SENDER) { if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec); List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortBySender(results, dir); List<Document> 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 // RELEVANCE: default when text present and no explicit sort given
@@ -337,12 +337,12 @@ public class DocumentService {
.sorted(Comparator.comparingInt( .sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE))) doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList(); .toList();
return DocumentSearchResult.withMatchData(sorted, enrichWithMatchData(sorted, text)); return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
} }
Sort springSort = resolveSort(sort, dir); Sort springSort = resolveSort(sort, dir);
List<Document> results = documentRepository.findAll(spec, springSort); List<Document> 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) { private Sort resolveSort(DocumentSort sort, String dir) {
@@ -433,8 +433,10 @@ public class DocumentService {
} }
public Document getDocumentById(UUID id) { 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)); .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
tagService.resolveEffectiveColors(doc.getTags());
return doc;
} }
public List<Document> getDocumentsWithoutVersions() { public List<Document> getDocumentsWithoutVersions() {
@@ -513,6 +515,12 @@ public class DocumentService {
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private List<Document> resolveDocumentTagColors(List<Document> docs) {
List<Tag> allTags = docs.stream().flatMap(d -> d.getTags().stream()).toList();
tagService.resolveEffectiveColors(allTags);
return docs;
}
private static String stripExtension(String filename) { private static String stripExtension(String filename) {
if (filename == null) return null; if (filename == null) return null;
int dot = filename.lastIndexOf('.'); int dot = filename.lastIndexOf('.');

View File

@@ -1,12 +1,14 @@
package org.raddatz.familienarchiv.service; package org.raddatz.familienarchiv.service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; import org.raddatz.familienarchiv.dto.TagTreeNodeDTO;
import org.raddatz.familienarchiv.dto.TagUpdateDTO; import org.raddatz.familienarchiv.dto.TagUpdateDTO;
@@ -73,6 +75,36 @@ public class TagService {
tagRepository.delete(getById(id)); 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<Tag> tags) {
if (tags == null || tags.isEmpty()) return;
Set<UUID> parentIdsNeeded = tags.stream()
.filter(t -> t.getColor() == null && t.getParentId() != null)
.map(Tag::getParentId)
.collect(Collectors.toSet());
if (parentIdsNeeded.isEmpty()) return;
Map<UUID, String> 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. * 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. * Used by DocumentService to expand selected filter tags before applying AND/OR logic.

View File

@@ -14,6 +14,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -233,6 +234,75 @@ class TagServiceTest {
assertThat(tree.get(0).children().get(0).id()).isEqualTo(childId); 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 ─────────────────────────────────────────────────────────────── // ─── delete ───────────────────────────────────────────────────────────────
@Test @Test