diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 91e3c250..6dfb81fe 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; +import org.raddatz.familienarchiv.dto.TagOperator; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; @@ -204,11 +205,15 @@ public class DocumentController { @RequestParam(required = false) String tagQ, @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status, @Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort, - @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) { + @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir, + @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) { if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC"); } - return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir)); + // tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive) + // defaults to AND, which matches the frontend default and keeps old clients working. + TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; + return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator)); } // --- TRAINING LABELS --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java index c3d99299..39f66981 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TagController.java @@ -1,23 +1,29 @@ package org.raddatz.familienarchiv.controller; import java.util.List; -import java.util.Map; import java.util.UUID; +import org.raddatz.familienarchiv.dto.MergeTagDTO; +import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; +import org.raddatz.familienarchiv.dto.TagUpdateDTO; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.TagService; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -31,8 +37,8 @@ public class TagController { @PutMapping("/{id}") @RequirePermission(Permission.ADMIN_TAG) - public ResponseEntity updateTag(@PathVariable UUID id, @RequestBody Map payload) { - return ResponseEntity.ok(tagService.update(id, payload.get("name"))); + public ResponseEntity updateTag(@PathVariable UUID id, @RequestBody TagUpdateDTO dto) { + return ResponseEntity.ok(tagService.update(id, dto)); } @DeleteMapping("/{id}") @@ -46,4 +52,22 @@ public class TagController { public List searchTags(@RequestParam(defaultValue = "") String query) { return tagService.search(query); } + + @GetMapping("/tree") + public List getTagTree() { + return tagService.getTagTree(); + } + + @PostMapping("/{id}/merge") + @RequirePermission(Permission.ADMIN_TAG) + public ResponseEntity mergeTag(@PathVariable UUID id, @Valid @RequestBody MergeTagDTO dto) { + return ResponseEntity.ok(tagService.mergeTags(id, dto.targetId())); + } + + @DeleteMapping("/{id}/subtree") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.ADMIN_TAG) + public void deleteSubtree(@PathVariable UUID id) { + tagService.deleteWithDescendants(id); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/MergeTagDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/MergeTagDTO.java new file mode 100644 index 00000000..be57f404 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/MergeTagDTO.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +public record MergeTagDTO(@NotNull UUID targetId) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TagOperator.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagOperator.java new file mode 100644 index 00000000..c3e0f290 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagOperator.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +/** Determines how multiple selected tag filters are combined in a document search. */ +public enum TagOperator { + /** Every tag set must match (default). */ + AND, + /** At least one tag set must match. */ + OR +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java new file mode 100644 index 00000000..4205b199 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagTreeNodeDTO.java @@ -0,0 +1,14 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.List; +import java.util.UUID; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TagTreeNodeDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name, + String color, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount, + List children, + @Schema(description = "Parent tag ID, null for root tags") UUID parentId) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TagUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagUpdateDTO.java new file mode 100644 index 00000000..7b840228 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TagUpdateDTO.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.UUID; + +public record TagUpdateDTO(String name, UUID parentId, String color) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 26aab838..5d10c917 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -78,6 +78,18 @@ public enum ErrorCode { /** A training run is already in progress. 409 */ TRAINING_ALREADY_RUNNING, + // --- Tags --- + /** A tag with the given ID does not exist. 404 */ + TAG_NOT_FOUND, + /** The supplied color token is not in the allowed palette. 400 */ + INVALID_TAG_COLOR, + /** Setting this parent would create a cycle in the tag hierarchy. 400 */ + TAG_CYCLE_DETECTED, + /** Merge source and target are the same tag. 400 */ + TAG_MERGE_SELF, + /** The merge target is a descendant of the source tag. 400 */ + TAG_MERGE_INVALID_TARGET, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java index 5063ffa3..59c173f3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Tag.java @@ -20,4 +20,11 @@ public class Tag { @Column(unique = true, nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String name; + + /** UUID of the parent tag, or null for root-level tags. */ + @Column(name = "parent_id") + private UUID parentId; + + /** Color token name (e.g. "sage"), only set on root-level tags. Null means no color. */ + private String color; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index 4ce5cb63..8e89034d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -4,6 +4,7 @@ import jakarta.persistence.criteria.*; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; import org.raddatz.familienarchiv.model.Document; @@ -54,34 +55,64 @@ public class DocumentSpecifications { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } - // Filtert nach Schlagworten (UND-Verknüpfung, exakter Match) - public static Specification hasTags(List tags) { + /** + * Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik. + * + *

AND (useOr=false): Das Dokument muss mindestens einen Tag aus jedem Set besitzen. + *

OR (useOr=true): Das Dokument muss mindestens einen Tag aus der Vereinigung aller Sets besitzen. + * + *

Jedes Set repräsentiert einen ausgewählten Tag inklusive aller seiner Nachkommen + * (vorausgeweitet durch {@code TagRepository.findDescendantIdsByName}). + */ + public static Specification hasTags(List> tagIdSets, boolean useOr) { return (root, query, cb) -> { - if (tags == null || tags.isEmpty()) + if (tagIdSets == null || tagIdSets.isEmpty()) return null; - List predicates = new ArrayList<>(); - - for (String tagName : tags) { - if (!StringUtils.hasText(tagName)) continue; - - Subquery subquery = query.subquery(Long.class); - Root subRoot = subquery.from(Document.class); - Join subTags = subRoot.join("tags"); - - subquery.select(subRoot.get("id")) - .where( - cb.equal(subRoot.get("id"), root.get("id")), - cb.equal(cb.lower(subTags.get("name")), tagName.trim().toLowerCase()) - ); - - predicates.add(cb.exists(subquery)); + if (!useOr) { + // AND mode: an empty set means the tag resolved to no IDs (doesn't exist) — + // no document can satisfy the condition, so return no results immediately. + boolean hasEmptySet = tagIdSets.stream().anyMatch(s -> s == null || s.isEmpty()); + if (hasEmptySet) return cb.disjunction(); } + List> nonEmpty = tagIdSets.stream() + .filter(s -> s != null && !s.isEmpty()) + .toList(); + if (nonEmpty.isEmpty()) return null; + + if (useOr) { + Set union = new java.util.HashSet<>(); + nonEmpty.forEach(union::addAll); + return documentHasTagIn(root, query, cb, union); + } + + // AND: one EXISTS subquery per set + List predicates = new ArrayList<>(); + for (Set ids : nonEmpty) { + predicates.add(documentHasTagIn(root, query, cb, ids)); + } return cb.and(predicates.toArray(new Predicate[0])); }; } + private static Predicate documentHasTagIn( + Root root, + jakarta.persistence.criteria.CriteriaQuery query, + jakarta.persistence.criteria.CriteriaBuilder cb, + Set tagIds) { + Subquery subquery = query.subquery(UUID.class); + Root subRoot = subquery.from(Document.class); + Join subTags = subRoot.join("tags"); + + subquery.select(subRoot.get("id")) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + subTags.get("id").in(tagIds) + ); + return cb.exists(subquery); + } + // Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche public static Specification hasTagPartial(String tagQ) { return (root, query, cb) -> { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java index d4e58a04..fcc2dffd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java @@ -1,13 +1,126 @@ package org.raddatz.familienarchiv.repository; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; import org.raddatz.familienarchiv.model.Tag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TagRepository extends JpaRepository { + + /** Typed projection for document-count aggregation results. */ + interface TagCount { + UUID getTagId(); + Long getCount(); + } + + Optional findByNameIgnoreCase(String name); + List findByNameContainingIgnoreCase(String name); -} \ No newline at end of file + + /** + * Returns the IDs of all ancestors of the given tag (parent, grandparent, …) + * via a recursive CTE. Used for cycle detection before assigning a new parent. + * Includes a depth guard of 50 levels to prevent runaway queries. + */ + @Query(value = """ + WITH RECURSIVE ancestors AS ( + SELECT parent_id, 0 AS depth + FROM tag + WHERE id = :tagId AND parent_id IS NOT NULL + UNION ALL + SELECT t.parent_id, a.depth + 1 + FROM tag t + JOIN ancestors a ON t.id = a.parent_id + WHERE t.parent_id IS NOT NULL AND a.depth < 50 + ) + SELECT parent_id FROM ancestors + """, nativeQuery = true) + List findAncestorIds(@Param("tagId") UUID tagId); + + /** + * Returns the IDs of the tag with the given name AND all of its descendants + * via a recursive CTE. Used to expand a selected tag to inclusive hierarchy results. + * Includes a depth guard of 50 levels to prevent runaway queries. + */ + @Query(value = """ + WITH RECURSIVE descendants AS ( + SELECT id, 0 AS depth FROM tag WHERE LOWER(name) = LOWER(:name) + UNION ALL + SELECT t.id, d.depth + 1 FROM tag t + JOIN descendants d ON t.parent_id = d.id + WHERE d.depth < 50 + ) + SELECT id FROM descendants + """, nativeQuery = true) + List findDescendantIdsByName(@Param("name") String name); + + /** + * Returns the IDs of the tag with the given ID AND all of its descendants + * via a recursive CTE. Used for merge validation and subtree delete. + * Includes a depth guard of 50 levels to prevent runaway queries. + */ + @Query(value = """ + WITH RECURSIVE descendants AS ( + SELECT id, 0 AS depth FROM tag WHERE id = :tagId + UNION ALL + SELECT t.id, d.depth + 1 FROM tag t + JOIN descendants d ON t.parent_id = d.id + WHERE d.depth < 50 + ) + SELECT id FROM descendants + """, nativeQuery = true) + List findDescendantIds(@Param("tagId") UUID tagId); + + /** + * Reassigns document_tags rows from source to target, skipping rows where + * the target tag is already present (to avoid PK conflicts). + */ + @Modifying(clearAutomatically = true) + @Query(value = """ + UPDATE document_tags + SET tag_id = :targetId + WHERE tag_id = :sourceId + AND NOT EXISTS ( + SELECT 1 FROM document_tags d2 + WHERE d2.document_id = document_tags.document_id + AND d2.tag_id = :targetId + ) + """, nativeQuery = true) + void reassignDocumentTags(@Param("sourceId") UUID sourceId, @Param("targetId") UUID targetId); + + /** + * Removes all document_tags rows for the given tag. + */ + @Modifying(clearAutomatically = true) + @Query(value = "DELETE FROM document_tags WHERE tag_id = :tagId", nativeQuery = true) + void deleteDocumentTagsByTagId(@Param("tagId") UUID tagId); + + /** + * Removes all document_tags rows for the given collection of tag IDs. + * Caller must guard against an empty collection — PostgreSQL rejects IN (). + */ + @Modifying(clearAutomatically = true) + @Query(value = "DELETE FROM document_tags WHERE tag_id IN :ids", nativeQuery = true) + void deleteDocumentTagsByTagIds(@Param("ids") Collection ids); + + /** + * Re-parents all direct children of sourceId to targetId. + */ + @Modifying(clearAutomatically = true) + @Query(value = "UPDATE tag SET parent_id = :targetId WHERE parent_id = :sourceId", nativeQuery = true) + void reparentChildren(@Param("sourceId") UUID sourceId, @Param("targetId") UUID targetId); + + /** + * Returns (tagId, count) pairs for all tags that appear in document_tags. + * Used to populate documentCount in the tag tree without N+1 queries. + */ + @Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true) + List findDocumentCountsPerTag(); +} 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 ab50ab22..2f074e48 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -9,6 +9,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.dto.MatchOffset; import org.raddatz.familienarchiv.dto.SearchMatchData; +import org.raddatz.familienarchiv.dto.TagOperator; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.ScriptType; @@ -293,7 +294,7 @@ public class DocumentService { } // 1. Allgemeine Suche (für das Suchfeld im Frontend) - public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) { + public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) { boolean hasText = StringUtils.hasText(text); List rankedIds = null; @@ -302,12 +303,15 @@ public class DocumentService { if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of()); } + boolean useOrLogic = tagOperator == TagOperator.OR; + List> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); + Specification textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null; Specification spec = Specification.where(textSpec) .and(isBetween(from, to)) .and(hasSender(sender)) .and(hasReceiver(receiver)) - .and(hasTags(tags)) + .and(hasTags(expandedTagSets, useOrLogic)) .and(hasTagPartial(tagQ)) .and(hasStatus(status)); @@ -316,12 +320,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 @@ -334,12 +338,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) { @@ -430,8 +434,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() { @@ -510,6 +516,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 06b8b862..042b7943 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -1,21 +1,41 @@ package org.raddatz.familienarchiv.service; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; 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; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.TagRepository; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.util.StringUtils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor +@Slf4j public class TagService { + // These 10 color tokens are the fixed palette. + // Keep in sync with the --c-tag-* tokens defined in frontend/src/routes/layout.css. + static final Set ALLOWED_TAG_COLORS = Set.of( + "sage", "sienna", "amber", "slate", "violet", + "rose", "cobalt", "moss", "sand", "coral" + ); + private final TagRepository tagRepository; public List search(String query) { @@ -24,7 +44,7 @@ public class TagService { public Tag getById(UUID id) { return tagRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tag nicht gefunden")); + .orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id)); } public Tag findOrCreate(String name) { @@ -34,9 +54,22 @@ public class TagService { } @Transactional - public Tag update(UUID id, String newName) { + public Tag update(UUID id, TagUpdateDTO dto) { Tag tag = getById(id); - tag.setName(newName); + + if (dto.parentId() != null) { + validateNoSelfReference(id, dto.parentId()); + validateNoAncestorCycle(id, dto.parentId()); + getById(dto.parentId()); // ensure parent exists + } + + if (dto.color() != null) { + validateColor(dto.color()); + } + + tag.setName(dto.name()); + tag.setParentId(dto.parentId()); + tag.setColor(dto.color()); return tagRepository.save(tag); } @@ -44,4 +77,152 @@ public class TagService { public void delete(UUID id) { tagRepository.delete(getById(id)); } + + @Transactional + public Tag mergeTags(UUID sourceId, UUID targetId) { + validateNotSelf(sourceId, targetId); + Tag source = getById(sourceId); + Tag target = getById(targetId); + log.info("Merging tag '{}' ({}) into '{}' ({})", source.getName(), sourceId, target.getName(), targetId); + validateNotDescendant(sourceId, targetId); + transferDocuments(sourceId, targetId); + tagRepository.reparentChildren(sourceId, targetId); + tagRepository.deleteById(sourceId); + return target; + } + + @Transactional + public void deleteWithDescendants(UUID id) { + log.info("Deleting subtree rooted at {}", id); + getById(id); + List ids = tagRepository.findDescendantIds(id); + if (!ids.isEmpty()) tagRepository.deleteDocumentTagsByTagIds(ids); + tagRepository.deleteAllById(ids); + log.info("Deleted subtree rooted at {}, {} nodes", id, ids.size()); + } + + /** + * 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. + */ + public List> expandTagNamesToDescendantIdSets(List tagNames) { + if (tagNames == null || tagNames.isEmpty()) return List.of(); + return tagNames.stream() + .filter(StringUtils::hasText) + .map(name -> (Set) new HashSet<>(tagRepository.findDescendantIdsByName(name.trim()))) + .toList(); + } + + /** + * Returns all tags assembled into a tree with document counts per node. + * Uses a single aggregate query to avoid N+1 behaviour. + * NOTE: document counts are global per tag, not scoped to any search filter. + * The tree endpoint is only used for the admin sidebar, so this is intentional. + */ + public List getTagTree() { + List all = tagRepository.findAll(); + Map counts = tagRepository.findDocumentCountsPerTag().stream() + .collect(Collectors.toMap( + TagRepository.TagCount::getTagId, + TagRepository.TagCount::getCount + )); + return buildTree(all, counts); + } + + // ─── private helpers ───────────────────────────────────────────────────── + + private void validateNotSelf(UUID sourceId, UUID targetId) { + if (sourceId.equals(targetId)) { + throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF, + "Source and target must not be the same tag: " + sourceId); + } + } + + private void validateNotDescendant(UUID sourceId, UUID targetId) { + List descendants = tagRepository.findDescendantIds(sourceId); + if (descendants.contains(targetId)) { + throw DomainException.badRequest(ErrorCode.TAG_MERGE_INVALID_TARGET, + "Target " + targetId + " is a descendant of source " + sourceId); + } + } + + private void transferDocuments(UUID sourceId, UUID targetId) { + tagRepository.reassignDocumentTags(sourceId, targetId); + tagRepository.deleteDocumentTagsByTagId(sourceId); + } + + private void validateNoSelfReference(UUID tagId, UUID proposedParentId) { + if (tagId.equals(proposedParentId)) { + throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED, + "A tag cannot be its own parent: " + tagId); + } + } + + private void validateNoAncestorCycle(UUID tagId, UUID proposedParentId) { + // TOCTOU note: concurrent admin writes could both pass this check and create a + // multi-node cycle. This is intentionally not locked because: (a) the endpoint + // requires ADMIN_TAG permission so concurrency is rare, (b) the DB-level + // CHECK (parent_id != id) prevents infinite self-loops as a hard backstop, + // and (c) the window is microseconds. Do NOT add a pessimistic lock here. + List ancestors = tagRepository.findAncestorIds(proposedParentId); + if (ancestors.contains(tagId)) { + throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED, + "Assigning parent " + proposedParentId + " to tag " + tagId + " would create a cycle"); + } + } + + private void validateColor(String color) { + if (!ALLOWED_TAG_COLORS.contains(color)) { + throw DomainException.badRequest(ErrorCode.INVALID_TAG_COLOR, + "Color '" + color + "' is not in the allowed palette"); + } + } + + private List buildTree(List tags, Map counts) { + Map nodeById = new LinkedHashMap<>(); + for (Tag tag : tags) { + int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue(); + nodeById.put(tag.getId(), new TagTreeNodeDTO( + tag.getId(), tag.getName(), tag.getColor(), documentCount, + new ArrayList<>(), tag.getParentId() + )); + } + for (TagTreeNodeDTO node : nodeById.values()) { + if (node.parentId() != null) { + TagTreeNodeDTO parent = nodeById.get(node.parentId()); + if (parent != null) parent.children().add(node); + } + } + return nodeById.values().stream().filter(n -> n.parentId() == null).toList(); + } } diff --git a/backend/src/main/resources/db/migration/V39__add_tag_hierarchy.sql b/backend/src/main/resources/db/migration/V39__add_tag_hierarchy.sql new file mode 100644 index 00000000..330d10df --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__add_tag_hierarchy.sql @@ -0,0 +1,9 @@ +-- Add self-referencing parent FK for tag hierarchy (adjacency list model). +-- ON DELETE SET NULL: deleting a parent promotes its children to root level. +ALTER TABLE tag ADD COLUMN parent_id UUID REFERENCES tag(id) ON DELETE SET NULL; +ALTER TABLE tag ADD CONSTRAINT chk_tag_no_self_reference CHECK (parent_id != id); +CREATE INDEX idx_tag_parent_id ON tag(parent_id); + +-- Optional color token (e.g. "sage", "teal") for root-level tags. +-- Validated against the allowed palette in TagService before save. +ALTER TABLE tag ADD COLUMN color VARCHAR(20); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index c7e2f279..dd73aa91 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -62,7 +62,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_returns200_whenAuthenticated() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search")) @@ -72,13 +72,13 @@ class DocumentControllerTest { @Test @WithMockUser void search_withStatusParam_passesItToService() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) .andExpect(status().isOk()); - verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()); + verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()); } @Test @@ -105,7 +105,7 @@ class DocumentControllerTest { @Test @WithMockUser void search_responseContainsTotalCount() throws Exception { - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(DocumentSearchResult.of(List.of())); mockMvc.perform(get("/api/documents/search")) @@ -126,7 +126,7 @@ class DocumentControllerTest { .build(); var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData( "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); - when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) .thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java index 77c2176f..dbc524ca 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TagControllerTest.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.dto.TagTreeNodeDTO; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.CustomUserDetailsService; @@ -19,10 +20,15 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.UUID; -import static org.mockito.ArgumentMatchers.any; +import org.raddatz.familienarchiv.dto.MergeTagDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; + import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; +import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(TagController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -82,6 +88,107 @@ class TagControllerTest { .andExpect(status().isOk()); } + // ─── GET /api/tags/tree ─────────────────────────────────────────────────── + + @Test + void getTagTree_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/tags/tree")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getTagTree_returns200_withTreeStructure() throws Exception { + UUID parentId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of(), parentId); + TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child), null); + when(tagService.getTagTree()).thenReturn(List.of(parent)); + + mockMvc.perform(get("/api/tags/tree")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Immobilie")) + .andExpect(jsonPath("$[0].color").value("teal")) + .andExpect(jsonPath("$[0].children[0].name").value("Haus")); + } + + // ─── POST /api/tags/{id}/merge ──────────────────────────────────────────── + + @Test + void mergeTag_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception { + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void mergeTag_returns400_whenTargetIdIsNull() throws Exception { + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void mergeTag_returns404_whenSourceTagNotFound() throws Exception { + when(tagService.mergeTags(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found")); + + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void mergeTag_returns200_withTargetTag_onSuccess() throws Exception { + UUID targetId = UUID.randomUUID(); + Tag target = Tag.builder().id(targetId).name("Target").build(); + when(tagService.mergeTags(any(), any())).thenReturn(target); + + mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetId\": \"" + targetId + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(targetId.toString())) + .andExpect(jsonPath("$.name").value("Target")); + } + + // ─── DELETE /api/tags/{id}/subtree ──────────────────────────────────────── + + @Test + void deleteSubtree_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ADMIN_TAG") + void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception { + mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree")) + .andExpect(status().isNoContent()); + } + // ─── DELETE /api/tags/{id} ──────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java index 80f1fd00..8d12a678 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentRepositoryTest.java @@ -7,6 +7,7 @@ import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; @@ -37,6 +38,9 @@ class DocumentRepositoryTest { @Autowired private PersonRepository personRepository; + @Autowired + private TagRepository tagRepository; + @Autowired private AnnotationRepository annotationRepository; @@ -345,6 +349,105 @@ class DocumentRepositoryTest { assertThat(stats.getTranscriptionCount()).isEqualTo(0L); } + // ─── hasTags specification — AND/OR + hierarchy ─────────────────────────── + + @Test + void hasTags_and_findsDocumentThatHasBothTags() { + Tag tagA = tagRepository.save(Tag.builder().name("TagA").build()); + Tag tagB = tagRepository.save(Tag.builder().name("TagB").build()); + Tag tagC = tagRepository.save(Tag.builder().name("TagC").build()); + + Document docAB = documentRepository.save(Document.builder() + .title("DocAB").originalFilename("docab.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagA, tagB))).build()); + documentRepository.save(Document.builder() + .title("DocA").originalFilename("doca.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagA))).build()); + + // AND: must have both TagA and TagB + List setA = tagRepository.findDescendantIdsByName("TagA").stream().toList(); + List setB = tagRepository.findDescendantIdsByName("TagB").stream().toList(); + List setC = tagRepository.findDescendantIdsByName("TagC").stream().toList(); + + var spec = DocumentSpecifications.hasTags( + List.of(new HashSet<>(setA), new HashSet<>(setB)), false); + List results = documentRepository.findAll(spec); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getId()).isEqualTo(docAB.getId()); + } + + @Test + void hasTags_or_findsDocumentThatHasEitherTag() { + Tag tagA = tagRepository.save(Tag.builder().name("OrTagA").build()); + Tag tagB = tagRepository.save(Tag.builder().name("OrTagB").build()); + + Document docA = documentRepository.save(Document.builder() + .title("OrDocA").originalFilename("ordoca.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagA))).build()); + Document docB = documentRepository.save(Document.builder() + .title("OrDocB").originalFilename("ordocb.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(tagB))).build()); + + List setA = tagRepository.findDescendantIdsByName("OrTagA").stream().toList(); + List setB = tagRepository.findDescendantIdsByName("OrTagB").stream().toList(); + + var spec = DocumentSpecifications.hasTags( + List.of(new HashSet<>(setA), new HashSet<>(setB)), true); + List results = documentRepository.findAll(spec); + + assertThat(results).hasSize(2); + assertThat(results).extracting(Document::getId).containsExactlyInAnyOrder(docA.getId(), docB.getId()); + } + + @Test + void hasTags_hierarchySearch_findsDocumentTaggedWithChildWhenSearchingByParent() { + Tag parent = tagRepository.save(Tag.builder().name("HierParent").build()); + Tag child = tagRepository.save(Tag.builder().name("HierChild").parentId(parent.getId()).build()); + + Document docWithChild = documentRepository.save(Document.builder() + .title("DocWithChild").originalFilename("docwithchild.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(child))).build()); + documentRepository.save(Document.builder() + .title("DocWithParent").originalFilename("docwithparent.pdf").status(DocumentStatus.UPLOADED) + .tags(new HashSet<>(Set.of(parent))).build()); + + // Searching by "HierParent" should include descendants (HierChild) + List parentAndDescendants = tagRepository.findDescendantIdsByName("HierParent") + .stream().toList(); + + // Must include both parent and child IDs + assertThat(parentAndDescendants).contains(parent.getId(), child.getId()); + + var spec = DocumentSpecifications.hasTags( + List.of(new HashSet<>(parentAndDescendants)), false); + List results = documentRepository.findAll(spec); + + assertThat(results).hasSize(2); // both doc-with-child and doc-with-parent match + } + + @Test + void findDescendantIdsByName_returnsOnlyMatchingTag_whenNoChildren() { + Tag tag = tagRepository.save(Tag.builder().name("Leaf").build()); + + List ids = tagRepository.findDescendantIdsByName("Leaf") + .stream().toList(); + + assertThat(ids).containsExactly(tag.getId()); + } + + @Test + void findDescendantIdsByName_returnsParentAndAllDescendants() { + Tag grandparent = tagRepository.save(Tag.builder().name("Grandparent").build()); + Tag parent2 = tagRepository.save(Tag.builder().name("ParentNode").parentId(grandparent.getId()).build()); + Tag child2 = tagRepository.save(Tag.builder().name("ChildNode").parentId(parent2.getId()).build()); + + List ids = tagRepository.findDescendantIdsByName("Grandparent") + .stream().toList(); + + assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId()); + } + // ─── seeding helpers ───────────────────────────────────────────────────── private Document uploaded(String title) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java index 80ca4c08..cefe9918 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -15,8 +15,10 @@ import org.springframework.context.annotation.Import; import org.springframework.data.jpa.domain.Specification; import java.time.LocalDate; +import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*; @@ -156,47 +158,57 @@ class DocumentSpecificationsTest { // ─── hasTags ────────────────────────────────────────────────────────────── @Test - void hasTags_returnsAllDocuments_whenTagListIsNull() { - List result = documentRepository.findAll(Specification.where(hasTags(null))); + void hasTags_returnsAllDocuments_whenTagSetListIsNull() { + List result = documentRepository.findAll(Specification.where(hasTags(null, false))); assertThat(result).hasSize(3); } @Test - void hasTags_returnsAllDocuments_whenTagListIsEmpty() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of()))); + void hasTags_returnsAllDocuments_whenTagSetListIsEmpty() { + List result = documentRepository.findAll(Specification.where(hasTags(List.of(), false))); assertThat(result).hasSize(3); } @Test - void hasTags_filtersDocumentsByTag() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie")))); + void hasTags_and_filtersDocumentsByTag() { + Set familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie")); + List result = documentRepository.findAll(Specification.where(hasTags(List.of(familieIds), false))); assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); } @Test - void hasTags_isCaseInsensitive() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of("familie")))); - assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); - } - - @Test - void hasTags_requiresAllTagsToBePresent_andLogic() { - // briefEarly has "Familie" but not "Urlaub" — should be excluded + void hasTags_and_requiresAllTagsToBePresent() { + // briefEarly has "Familie" but not "Urlaub" — AND should return empty + Set familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie")); + Set urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub")); List result = documentRepository.findAll( - Specification.where(hasTags(List.of("Familie", "Urlaub")))); + Specification.where(hasTags(List.of(familieIds, urlaubIds), false))); assertThat(result).isEmpty(); } @Test - void hasTags_skipsEmptyTagNames() { - // An empty string in the tag list should be ignored - List result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie")))); - assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + void hasTags_or_findsDocumentWithEitherTag() { + Set familieIds = new HashSet<>(tagRepository.findDescendantIdsByName("Familie")); + Set urlaubIds = new HashSet<>(tagRepository.findDescendantIdsByName("Urlaub")); + List result = documentRepository.findAll( + Specification.where(hasTags(List.of(familieIds, urlaubIds), true))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief"); + } + + @Test + void hasTags_returnsEmpty_whenTagIdSetIsEmpty() { + // An empty ID set means the requested tag resolved to nothing — no docs can match + List result = documentRepository.findAll( + Specification.where(hasTags(List.of(new HashSet<>()), false))); + assertThat(result).isEmpty(); } @Test void hasTags_returnsEmpty_whenTagDoesNotExist() { - List result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt")))); + // Non-existent tag → findDescendantIdsByName returns empty list → hasTags returns no results + Set unknownIds = new HashSet<>(tagRepository.findDescendantIdsByName("Unbekannt")); + List result = documentRepository.findAll(Specification.where(hasTags(List.of(unknownIds), false))); assertThat(result).isEmpty(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index db5b98a3..78ff9861 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -168,8 +168,63 @@ class MigrationIntegrationTest { assertThat(rows).isEqualTo(1); } + // ─── V39: tag hierarchy — parent_id FK + self-reference check + color ────── + + @Test + void v39_parentId_allowsNull() { + UUID tagId = createTag("TagWithoutParent"); + + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM tag WHERE id = ? AND parent_id IS NULL", Integer.class, tagId); + assertThat(count).isEqualTo(1); + } + + @Test + void v39_selfReferenceCheck_rejectsSelfAsParent() { + UUID tagId = createTag("SelfRef"); + + assertThatThrownBy(() -> + jdbc.update("UPDATE tag SET parent_id = id WHERE id = ?", tagId) + ).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void v39_parentId_acceptsValidParent() { + UUID parent = createTag("Parent"); + UUID child = createTag("Child"); + + int rows = jdbc.update("UPDATE tag SET parent_id = ? WHERE id = ?", parent, child); + assertThat(rows).isEqualTo(1); + } + + @Test + void v39_color_allowsNull() { + UUID tagId = createTag("ColorlessTag"); + + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM tag WHERE id = ? AND color IS NULL", Integer.class, tagId); + assertThat(count).isEqualTo(1); + } + + @Test + void v39_color_storesTokenName() { + UUID tagId = createTag("ColoredTag"); + + int rows = jdbc.update("UPDATE tag SET color = 'sage' WHERE id = ?", tagId); + String stored = jdbc.queryForObject("SELECT color FROM tag WHERE id = ?", String.class, tagId); + + assertThat(rows).isEqualTo(1); + assertThat(stored).isEqualTo("sage"); + } + // ─── helpers ───────────────────────────────────────────────────────────── + private UUID createTag(String name) { + UUID id = UUID.randomUUID(); + jdbc.update("INSERT INTO tag (id, name) VALUES (?, ?)", id, name); + return id; + } + private UUID createDocument() { Document doc = documentRepository.save(Document.builder() .title("Testdokument") diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java index f089635c..49726999 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceSortTest.java @@ -53,7 +53,7 @@ class DocumentServiceSortTest { .thenReturn(List.of(newer, older)); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC"); + "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null); // Expect: date order (newer 1960 first), NOT rank order (older 1940 first) assertThat(result.documents()).hasSize(2); @@ -75,7 +75,7 @@ class DocumentServiceSortTest { .thenReturn(List.of(doc2, doc1)); // unordered from DB DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); // Expect: rank order restored (id1 first) assertThat(result.documents().get(0).getId()).isEqualTo(id1); @@ -94,7 +94,7 @@ class DocumentServiceSortTest { .thenReturn(List.of(doc2, doc1)); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, null, null); + "Brief", null, null, null, null, null, null, null, null, null, null); assertThat(result.documents().get(0).getId()).isEqualTo(id1); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 93fddb8f..2070dd46 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -1204,7 +1204,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) .thenReturn(List.of()); - documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null); + documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); } @@ -1214,7 +1214,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) .thenReturn(List.of()); - documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null); + documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); } @@ -1292,7 +1292,7 @@ class DocumentServiceTest { .thenReturn(List.of(withSender, noSender)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc"); + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); assertThat(result.documents()).hasSize(2); assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender"); @@ -1312,7 +1312,7 @@ class DocumentServiceTest { .thenReturn(List.of(noReceivers, withReceiver)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc"); + null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null); assertThat(result.documents()).extracting(Document::getTitle) .containsExactly("Has Receiver", "No Receivers"); @@ -1334,7 +1334,7 @@ class DocumentServiceTest { .thenReturn(List.of(docNullName, docSmith)); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc"); + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); // null lastName should sort to end (treated as empty), not before "smith" (as "null") assertThat(result.documents()).extracting(Document::getTitle) @@ -1356,7 +1356,7 @@ class DocumentServiceTest { when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); assertThat(result.matchData()).containsKey(docId); SearchMatchData md = result.matchData().get(docId); @@ -1370,7 +1370,7 @@ class DocumentServiceTest { .thenReturn(List.of()); DocumentSearchResult result = documentService.searchDocuments( - null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null); assertThat(result.matchData()).isEmpty(); } @@ -1389,7 +1389,7 @@ class DocumentServiceTest { when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); DocumentSearchResult result = documentService.searchDocuments( - "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null); + "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); SearchMatchData md = result.matchData().get(docId); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); 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 8700e153..f4c564c8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -5,15 +5,20 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.TagUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.TagRepository; -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; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -31,9 +36,9 @@ class TagServiceTest { when(tagRepository.findById(id)).thenReturn(Optional.empty()); assertThatThrownBy(() -> tagService.getById(id)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(404); + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_NOT_FOUND); } @Test @@ -89,20 +94,383 @@ class TagServiceTest { when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); - Tag result = tagService.update(id, "New"); + Tag result = tagService.update(id, new TagUpdateDTO("New", null, null)); assertThat(result.getName()).isEqualTo("New"); } + @Test + void update_savesParentId() { + UUID id = UUID.randomUUID(); + UUID parentId = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Child").build(); + Tag parent = Tag.builder().id(parentId).name("Parent").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.findById(parentId)).thenReturn(Optional.of(parent)); + when(tagRepository.findAncestorIds(parentId)).thenReturn(List.of()); + when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); + + Tag result = tagService.update(id, new TagUpdateDTO("Child", parentId, null)); + + assertThat(result.getParentId()).isEqualTo(parentId); + } + + @Test + void update_savesColor() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Colored").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); + + Tag result = tagService.update(id, new TagUpdateDTO("Colored", null, "sage")); + + assertThat(result.getColor()).isEqualTo("sage"); + } + @Test void update_throwsNotFound_whenTagMissing() { UUID id = UUID.randomUUID(); when(tagRepository.findById(id)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> tagService.update(id, "New")) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(404); + assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("New", null, null))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_NOT_FOUND); + } + + // ─── color validation ───────────────────────────────────────────────────── + + @Test + void update_throwsInvalidTagColor_whenColorNotInAllowedPalette() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Tag").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + + assertThatThrownBy(() -> tagService.update(id, new TagUpdateDTO("Tag", null, "hotpink"))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_TAG_COLOR); + } + + @Test + void update_allowsNullColor() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Tag").color("sage").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.save(tag)).thenAnswer(inv -> inv.getArgument(0)); + + Tag result = tagService.update(id, new TagUpdateDTO("Tag", null, null)); + + assertThat(result.getColor()).isNull(); + } + + // ─── cycle detection ───────────────────────────────────────────────────── + + @Test + void update_throwsCycleDetected_whenTagIsAncestorOfProposedParent() { + UUID tagId = UUID.randomUUID(); + UUID proposedParentId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Tag").build(); + when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); + // tagId appears in the ancestor chain of proposedParentId → cycle + when(tagRepository.findAncestorIds(proposedParentId)).thenReturn(List.of(tagId)); + + assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", proposedParentId, null))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_CYCLE_DETECTED); + } + + @Test + void update_throwsCycleDetected_whenTagIsSameAsProposedParent() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Tag").build(); + when(tagRepository.findById(tagId)).thenReturn(Optional.of(tag)); + + assertThatThrownBy(() -> tagService.update(tagId, new TagUpdateDTO("Tag", tagId, null))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_CYCLE_DETECTED); + } + + // ─── getTagTree ─────────────────────────────────────────────────────────── + + @Test + void getTagTree_returnsEmptyList_whenNoTags() { + when(tagRepository.findAll()).thenReturn(List.of()); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + + assertThat(tagService.getTagTree()).isEmpty(); + } + + @Test + void getTagTree_returnsFlatRootTags_whenNoParentRelationships() { + UUID idA = UUID.randomUUID(); + UUID idB = UUID.randomUUID(); + List tags = List.of( + Tag.builder().id(idA).name("Alpha").build(), + Tag.builder().id(idB).name("Beta").build() + ); + when(tagRepository.findAll()).thenReturn(tags); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + + var tree = tagService.getTagTree(); + + assertThat(tree).hasSize(2); + assertThat(tree).allSatisfy(node -> assertThat(node.children()).isEmpty()); + } + + @Test + void getTagTree_nestsChildrenUnderParent() { + UUID parentId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag parent = Tag.builder().id(parentId).name("Parent").build(); + Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build(); + when(tagRepository.findAll()).thenReturn(List.of(parent, child)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + + var tree = tagService.getTagTree(); + + assertThat(tree).hasSize(1); + assertThat(tree.get(0).id()).isEqualTo(parentId); + assertThat(tree.get(0).children()).hasSize(1); + assertThat(tree.get(0).children().get(0).id()).isEqualTo(childId); + } + + // ─── getTagTree (enriched) ──────────────────────────────────────────────── + + @Test + void getTagTree_populatesParentId_onChildNode() { + UUID parentId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag parent = Tag.builder().id(parentId).name("Parent").build(); + Tag child = Tag.builder().id(childId).name("Child").parentId(parentId).build(); + when(tagRepository.findAll()).thenReturn(List.of(parent, child)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + + var tree = tagService.getTagTree(); + + assertThat(tree.get(0).children().get(0).parentId()).isEqualTo(parentId); + } + + @Test + void getTagTree_populatesDocumentCount_fromAggregateQuery() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().id(tagId).name("Tag").build(); + TagRepository.TagCount countEntry = mock(TagRepository.TagCount.class); + when(countEntry.getTagId()).thenReturn(tagId); + when(countEntry.getCount()).thenReturn(5L); + when(tagRepository.findAll()).thenReturn(List.of(tag)); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(countEntry)); + + var tree = tagService.getTagTree(); + + assertThat(tree.get(0).documentCount()).isEqualTo(5); + } + + @Test + void getTagTree_callsFindDocumentCountsPerTag_exactlyOnce() { + when(tagRepository.findAll()).thenReturn(List.of()); + when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of()); + + tagService.getTagTree(); + + verify(tagRepository, times(1)).findDocumentCountsPerTag(); + } + + // ─── 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"); + } + + // ─── mergeTags ──────────────────────────────────────────────────────────── + + @Test + void mergeTags_throwsBadRequest_whenSelfMerge() { + UUID id = UUID.randomUUID(); + + assertThatThrownBy(() -> tagService.mergeTags(id, id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_MERGE_SELF); + } + + @Test + void mergeTags_throwsNotFound_whenSourceMissing() { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + when(tagRepository.findById(sourceId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_NOT_FOUND); + } + + @Test + void mergeTags_throwsNotFound_whenTargetMissing() { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + Tag source = Tag.builder().id(sourceId).name("Source").build(); + when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source)); + when(tagRepository.findById(targetId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_NOT_FOUND); + } + + @Test + void mergeTags_throwsBadRequest_whenTargetIsDescendantOfSource() { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + Tag source = Tag.builder().id(sourceId).name("Parent").build(); + Tag target = Tag.builder().id(targetId).name("Child").parentId(sourceId).build(); + when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source)); + when(tagRepository.findById(targetId)).thenReturn(Optional.of(target)); + when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId, targetId)); + + assertThatThrownBy(() -> tagService.mergeTags(sourceId, targetId)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_MERGE_INVALID_TARGET); + } + + @Test + void mergeTags_reassignsDocumentsReparentsChildrenAndDeletesSource() { + UUID sourceId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + Tag source = Tag.builder().id(sourceId).name("Source").build(); + Tag target = Tag.builder().id(targetId).name("Target").build(); + when(tagRepository.findById(sourceId)).thenReturn(Optional.of(source)); + when(tagRepository.findById(targetId)).thenReturn(Optional.of(target)); + when(tagRepository.findDescendantIds(sourceId)).thenReturn(List.of(sourceId)); + + Tag result = tagService.mergeTags(sourceId, targetId); + + verify(tagRepository).reassignDocumentTags(sourceId, targetId); + verify(tagRepository).deleteDocumentTagsByTagId(sourceId); + verify(tagRepository).reparentChildren(sourceId, targetId); + verify(tagRepository).deleteById(sourceId); + assertThat(result).isEqualTo(target); + } + + // ─── deleteWithDescendants ──────────────────────────────────────────────── + + @Test + void deleteWithDescendants_throwsNotFound_whenTagMissing() { + UUID id = UUID.randomUUID(); + when(tagRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> tagService.deleteWithDescendants(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_NOT_FOUND); + } + + @Test + void deleteWithDescendants_deletesSubtreeDocTagsAndAllTags() { + UUID id = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Root").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id, childId)); + + tagService.deleteWithDescendants(id); + + verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id, childId)); + verify(tagRepository).deleteAllById(List.of(id, childId)); + } + + @Test + void deleteWithDescendants_whenLeafTag_deletesTagAndItsOwnDocTags() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Leaf").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id)); + + tagService.deleteWithDescendants(id); + + verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id)); + verify(tagRepository).deleteAllById(List.of(id)); + } + + @Test + void deleteWithDescendants_skipsDocTagDeletion_whenDescendantIdsIsEmpty() { + UUID id = UUID.randomUUID(); + Tag tag = Tag.builder().id(id).name("Tag").build(); + when(tagRepository.findById(id)).thenReturn(Optional.of(tag)); + when(tagRepository.findDescendantIds(id)).thenReturn(List.of()); + + tagService.deleteWithDescendants(id); + + verify(tagRepository, never()).deleteDocumentTagsByTagIds(any()); + verify(tagRepository).deleteAllById(List.of()); } // ─── delete ─────────────────────────────────────────────────────────────── @@ -124,8 +492,8 @@ class TagServiceTest { when(tagRepository.findById(id)).thenReturn(Optional.empty()); assertThatThrownBy(() -> tagService.delete(id)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) - .isEqualTo(404); + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TAG_NOT_FOUND); } } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index da516d83..d043357a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -180,7 +180,7 @@ "admin_tags_empty": "Keine Schlagworte vorhanden.", "admin_tags_select_prompt": "Wähle ein Schlagwort aus der Liste.", "admin_tag_edit_heading": "Schlagwort: {name}", - "admin_tag_updated": "Schlagwort umbenannt.", + "admin_tag_updated": "Schlagwort gespeichert.", "admin_unsaved_warning": "Du hast ungespeicherte Änderungen – speichere oder verwerfe, bevor du wechselst.", "admin_btn_collapse_list": "Liste einklappen", "admin_btn_expand_list": "Liste ausklappen", @@ -501,6 +501,8 @@ "error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.", "error_ocr_processing_failed": "Die OCR-Verarbeitung ist fehlgeschlagen.", "error_training_already_running": "Es läuft bereits ein Trainings-Vorgang.", + "error_invalid_tag_color": "Die gewählte Farbe ist ungültig.", + "error_tag_cycle_detected": "Dieses übergeordnete Schlagwort würde einen Kreis erzeugen.", "ocr_script_type_typewriter": "Schreibmaschine", "ocr_script_type_handwriting_latin": "Handschrift (lateinisch)", "ocr_script_type_handwriting_kurrent": "Handschrift (Kurrent/Sütterlin)", @@ -574,5 +576,40 @@ "mission_control_ready_empty_cta": "Jetzt mitmachen", "mission_control_weekly_pulse": "↑ +{count} diese Woche", "mission_control_blocks_progress": "{texted} / {total} Blöcke", - "mission_control_reviewed_pct": "{pct}% geprüft" + "mission_control_reviewed_pct": "{pct}% geprüft", + "error_tag_not_found": "Dieses Schlagwort wurde nicht gefunden.", + "error_tag_merge_self": "Ein Schlagwort kann nicht mit sich selbst zusammengeführt werden.", + "error_tag_merge_invalid_target": "Das Ziel-Schlagwort ist ein Untergeordnetes des Quell-Schlagworts.", + "admin_tag_tree_label": "Schlagwörter", + "admin_tag_collapse_node": "Einklappen", + "admin_tag_expand_node": "Ausklappen", + "admin_tag_parent_placeholder": "Übergeordnetes Schlagwort suchen …", + "admin_tag_inherited_color": "Farbe wird von {parent} vererbt", + "admin_tag_ancestry_label": "Pfad", + "admin_tag_children_label": "Untergeordnete Schlagwörter", + "admin_tag_children_more": "… und {count} weitere", + "admin_tag_merge_heading": "Zusammenführen", + "admin_tag_merge_description": "Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen und dieses danach löschen.", + "admin_tag_merge_btn": "Mit anderem Schlagwort zusammenführen …", + "admin_tag_merge_target_label": "Ziel-Schlagwort", + "admin_tag_merge_preview_docs": "{count} Dokumente", + "admin_tag_merge_preview_children": "{count} Untergeordnete", + "admin_tag_merge_deleted_after": "wird danach gelöscht", + "admin_tag_merge_confirm_btn": "Jetzt zusammenführen", + "admin_tag_merge_step1": "Schritt 1 von 2", + "admin_tag_merge_step2": "Schritt 2 von 2", + "admin_tag_merge_target_placeholder": "Ziel-Schlagwort suchen …", + "admin_tag_merge_success": "Erfolgreich zusammengeführt.", + "admin_tag_delete_impact": "{docs} Dokument(e) · {descendants} Untergeordnete", + "admin_tag_delete_only_this": "Nur dieses Schlagwort löschen", + "admin_tag_delete_only_this_sub": "Untergeordnete werden zu {parent} verschoben", + "admin_tag_delete_only_this_sub_root": "Untergeordnete werden zu Root-Schlagwörtern", + "admin_tag_delete_subtree": "Gesamten Teilbaum löschen", + "admin_tag_delete_subtree_warn": "Löscht auch {count} untergeordnete Schlagwörter", + "admin_tag_delete_subtree_confirm_btn": "Teilbaum löschen", + "admin_tag_delete_confirm_heading": "Gib «{name}» zur Bestätigung ein:", + "filter_operator_and": "UND", + "filter_operator_or": "ODER", + "filter_operator_and_label": "Alle gewählten Schlagworte müssen zutreffen (UND)", + "filter_operator_or_label": "Mindestens ein Schlagwort muss zutreffen (ODER)" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 86c3dd90..5e79d77a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -180,7 +180,7 @@ "admin_tags_empty": "No tags found.", "admin_tags_select_prompt": "Select a tag from the list.", "admin_tag_edit_heading": "Tag: {name}", - "admin_tag_updated": "Tag renamed.", + "admin_tag_updated": "Tag saved.", "admin_unsaved_warning": "You have unsaved changes — save or discard before switching.", "admin_btn_collapse_list": "Collapse list", "admin_btn_expand_list": "Expand list", @@ -501,6 +501,8 @@ "error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.", "error_ocr_processing_failed": "OCR processing failed.", "error_training_already_running": "A training run is already in progress.", + "error_invalid_tag_color": "The chosen color is not valid.", + "error_tag_cycle_detected": "This parent tag would create a cycle.", "ocr_script_type_typewriter": "Typewriter", "ocr_script_type_handwriting_latin": "Handwriting (Latin)", "ocr_script_type_handwriting_kurrent": "Handwriting (Kurrent/Sütterlin)", @@ -574,5 +576,40 @@ "mission_control_ready_empty_cta": "Start contributing", "mission_control_weekly_pulse": "↑ +{count} this week", "mission_control_blocks_progress": "{texted} / {total} blocks", - "mission_control_reviewed_pct": "{pct}% reviewed" + "mission_control_reviewed_pct": "{pct}% reviewed", + "error_tag_not_found": "This tag was not found.", + "error_tag_merge_self": "A tag cannot be merged with itself.", + "error_tag_merge_invalid_target": "The target tag is a descendant of the source tag.", + "admin_tag_tree_label": "Tags", + "admin_tag_collapse_node": "Collapse", + "admin_tag_expand_node": "Expand", + "admin_tag_parent_placeholder": "Search parent tag …", + "admin_tag_inherited_color": "Color inherited from {parent}", + "admin_tag_ancestry_label": "Path", + "admin_tag_children_label": "Child Tags", + "admin_tag_children_more": "… and {count} more", + "admin_tag_merge_heading": "Merge", + "admin_tag_merge_description": "Transfer all documents and child tags to another tag, then delete this one.", + "admin_tag_merge_btn": "Merge with another tag …", + "admin_tag_merge_target_label": "Target Tag", + "admin_tag_merge_preview_docs": "{count} documents", + "admin_tag_merge_preview_children": "{count} children", + "admin_tag_merge_deleted_after": "will be deleted after", + "admin_tag_merge_confirm_btn": "Merge now", + "admin_tag_merge_step1": "Step 1 of 2", + "admin_tag_merge_step2": "Step 2 of 2", + "admin_tag_merge_target_placeholder": "Search target tag …", + "admin_tag_merge_success": "Merged successfully.", + "admin_tag_delete_impact": "{docs} document(s) · {descendants} child tags", + "admin_tag_delete_only_this": "Delete only this tag", + "admin_tag_delete_only_this_sub": "Children will be moved to {parent}", + "admin_tag_delete_only_this_sub_root": "Children will become root tags", + "admin_tag_delete_subtree": "Delete entire subtree", + "admin_tag_delete_subtree_warn": "Also deletes {count} child tags", + "admin_tag_delete_subtree_confirm_btn": "Delete subtree", + "admin_tag_delete_confirm_heading": "Type «{name}» to confirm:", + "filter_operator_and": "AND", + "filter_operator_or": "OR", + "filter_operator_and_label": "All selected tags must match (AND)", + "filter_operator_or_label": "At least one tag must match (OR)" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 07924e14..0b3e078b 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -180,7 +180,7 @@ "admin_tags_empty": "No hay etiquetas.", "admin_tags_select_prompt": "Selecciona una etiqueta de la lista.", "admin_tag_edit_heading": "Etiqueta: {name}", - "admin_tag_updated": "Etiqueta renombrada.", + "admin_tag_updated": "Etiqueta guardada.", "admin_unsaved_warning": "Tienes cambios sin guardar — guarda o descarta antes de cambiar.", "admin_btn_collapse_list": "Contraer lista", "admin_btn_expand_list": "Expandir lista", @@ -501,6 +501,8 @@ "error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.", "error_ocr_processing_failed": "El procesamiento OCR ha fallado.", "error_training_already_running": "Ya hay un proceso de entrenamiento en curso.", + "error_invalid_tag_color": "El color elegido no es válido.", + "error_tag_cycle_detected": "Esta etiqueta padre crearía un ciclo.", "ocr_script_type_typewriter": "Máquina de escribir", "ocr_script_type_handwriting_latin": "Escritura manuscrita (latina)", "ocr_script_type_handwriting_kurrent": "Escritura manuscrita (Kurrent/Sütterlin)", @@ -574,5 +576,40 @@ "mission_control_ready_empty_cta": "Empezar a colaborar", "mission_control_weekly_pulse": "↑ +{count} esta semana", "mission_control_blocks_progress": "{texted} / {total} bloques", - "mission_control_reviewed_pct": "{pct}% revisado" + "mission_control_reviewed_pct": "{pct}% revisado", + "error_tag_not_found": "Esta etiqueta no fue encontrada.", + "error_tag_merge_self": "Una etiqueta no puede fusionarse consigo misma.", + "error_tag_merge_invalid_target": "La etiqueta de destino es descendiente de la etiqueta de origen.", + "admin_tag_tree_label": "Etiquetas", + "admin_tag_collapse_node": "Colapsar", + "admin_tag_expand_node": "Expandir", + "admin_tag_parent_placeholder": "Buscar etiqueta superior …", + "admin_tag_inherited_color": "Color heredado de {parent}", + "admin_tag_ancestry_label": "Ruta", + "admin_tag_children_label": "Etiquetas subordinadas", + "admin_tag_children_more": "… y {count} más", + "admin_tag_merge_heading": "Fusionar", + "admin_tag_merge_description": "Transferir todos los documentos y etiquetas subordinadas a otra etiqueta y luego eliminar esta.", + "admin_tag_merge_btn": "Fusionar con otra etiqueta …", + "admin_tag_merge_target_label": "Etiqueta de destino", + "admin_tag_merge_preview_docs": "{count} documentos", + "admin_tag_merge_preview_children": "{count} subordinados", + "admin_tag_merge_deleted_after": "se eliminará después", + "admin_tag_merge_confirm_btn": "Fusionar ahora", + "admin_tag_merge_step1": "Paso 1 de 2", + "admin_tag_merge_step2": "Paso 2 de 2", + "admin_tag_merge_target_placeholder": "Buscar etiqueta de destino …", + "admin_tag_merge_success": "Fusionado con éxito.", + "admin_tag_delete_impact": "{docs} documento(s) · {descendants} subordinados", + "admin_tag_delete_only_this": "Eliminar solo esta etiqueta", + "admin_tag_delete_only_this_sub": "Las subordinadas se moverán a {parent}", + "admin_tag_delete_only_this_sub_root": "Las subordinadas se convertirán en etiquetas raíz", + "admin_tag_delete_subtree": "Eliminar todo el subárbol", + "admin_tag_delete_subtree_warn": "También elimina {count} etiquetas subordinadas", + "admin_tag_delete_subtree_confirm_btn": "Eliminar subárbol", + "admin_tag_delete_confirm_heading": "Escribe «{name}» para confirmar:", + "filter_operator_and": "Y", + "filter_operator_or": "O", + "filter_operator_and_label": "Todas las etiquetas seleccionadas deben coincidir (Y)", + "filter_operator_or_label": "Al menos una etiqueta debe coincidir (O)" } diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index e51eb7c4..985cdd42 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -3,6 +3,7 @@ import { untrack } from 'svelte'; import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/actions/clickOutside'; +import { createTypeahead } from '$lib/hooks/useTypeahead.svelte'; type Person = components['schemas']['Person']; interface Props { @@ -50,10 +51,23 @@ $effect(() => { } }); -let results: Person[] = $state([]); -let showDropdown = $state(false); -let loading = $state(false); -let debounceTimer: ReturnType; +const typeahead = createTypeahead({ + fetchUrl: async (term) => { + const personId = restrictToCorrespondentsOf; + if (personId) { + const url = + term.length >= 1 + ? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}` + : `/api/persons/${personId}/correspondents`; + const res = await fetch(url); + return res.ok ? await res.json() : []; + } + if (term.length < 1) return []; + const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`); + return res.ok ? await res.json() : []; + }, + debounceMs: 300 +}); function handleInput() { if (value && searchTerm !== initialName) { @@ -61,69 +75,38 @@ function handleInput() { onchange?.(''); } - showDropdown = true; - clearTimeout(debounceTimer); - - debounceTimer = setTimeout(async () => { - const term = untrack(() => searchTerm); - const correspondentsOf = untrack(() => restrictToCorrespondentsOf); - loading = true; - try { - let url: string; - if (correspondentsOf) { - if (term.length >= 1) { - url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`; - } else { - url = `/api/persons/${correspondentsOf}/correspondents`; - } - } else { - if (term.length < 1) { - results = []; - loading = false; - return; - } - url = `/api/persons?q=${encodeURIComponent(term)}`; - } - const res = await fetch(url); - results = res.ok ? await res.json() : []; - } catch (e) { - console.error('Suche fehlgeschlagen', e); - results = []; - } finally { - loading = false; - } - }, 300); + const term = untrack(() => searchTerm); + typeahead.setQuery(term); } function handleFocus() { onfocused?.(); - showDropdown = true; if (restrictToCorrespondentsOf) { const personId = untrack(() => restrictToCorrespondentsOf)!; - loading = true; (async () => { try { const res = await fetch(`/api/persons/${personId}/correspondents`); - results = res.ok ? await res.json() : []; + const persons: Person[] = res.ok ? await res.json() : []; + typeahead.openWith(persons); } catch (e) { console.error('Suche fehlgeschlagen', e); - results = []; - } finally { - loading = false; + typeahead.openWith([]); } })(); + } else { + typeahead.openWith(typeahead.results); } } function selectPerson(person: Person) { value = person.id!; searchTerm = person.displayName; - showDropdown = false; + typeahead.close(); onchange?.(person.id!); } -

(showDropdown = false)}> +
typeahead.close()}>