feat(#221): tag entity hierarchy fields, service, repository, controller

- Tag entity: add parentId (UUID FK) and color (String) fields
- TagUpdateDTO and TagTreeNodeDTO records
- ErrorCode: INVALID_TAG_COLOR, TAG_CYCLE_DETECTED
- TagRepository: findAncestorIds() recursive CTE query
- TagService: cycle detection, color validation, getTagTree()
- TagController: use TagUpdateDTO, add GET /api/tags/tree endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 15:26:23 +02:00
parent f9ac963b9f
commit 3fba740469
9 changed files with 313 additions and 10 deletions

View File

@@ -1,9 +1,10 @@
package org.raddatz.familienarchiv.controller;
import java.util.List;
import java.util.Map;
import java.util.UUID;
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;
@@ -31,8 +32,8 @@ public class TagController {
@PutMapping("/{id}")
@RequirePermission(Permission.ADMIN_TAG)
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody Map<String, String> payload) {
return ResponseEntity.ok(tagService.update(id, payload.get("name")));
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody TagUpdateDTO dto) {
return ResponseEntity.ok(tagService.update(id, dto));
}
@DeleteMapping("/{id}")
@@ -46,4 +47,9 @@ public class TagController {
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
return tagService.search(query);
}
@GetMapping("/tree")
public List<TagTreeNodeDTO> getTagTree() {
return tagService.getTagTree();
}
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import java.util.UUID;
public record TagTreeNodeDTO(UUID id, String name, String color, int documentCount, List<TagTreeNodeDTO> children) {}

View File

@@ -0,0 +1,5 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
public record TagUpdateDTO(String name, UUID parentId, String color) {}

View File

@@ -78,6 +78,12 @@ public enum ErrorCode {
/** A training run is already in progress. 409 */
TRAINING_ALREADY_RUNNING,
// --- Tags ---
/** 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,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,

View File

@@ -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;
}

View File

@@ -6,8 +6,32 @@ import java.util.UUID;
import org.raddatz.familienarchiv.model.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface TagRepository extends JpaRepository<Tag, UUID> {
Optional<Tag> findByNameIgnoreCase(String name);
List<Tag> findByNameContainingIgnoreCase(String name);
}
/**
* 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<UUID> findAncestorIds(@Param("tagId") UUID tagId);
}

View File

@@ -1,8 +1,16 @@
package org.raddatz.familienarchiv.service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
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;
@@ -16,6 +24,11 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class TagService {
static final Set<String> ALLOWED_TAG_COLORS = Set.of(
"navy", "teal", "ocean", "forest", "sage",
"sienna", "terracotta", "ochre", "rose", "violet"
);
private final TagRepository tagRepository;
public List<Tag> search(String query) {
@@ -34,9 +47,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 +70,74 @@ public class TagService {
public void delete(UUID id) {
tagRepository.delete(getById(id));
}
/**
* Returns all tags assembled into a tree. Document counts are not included here
* (they are populated by the controller layer if needed, or set to 0).
*/
public List<TagTreeNodeDTO> getTagTree() {
List<Tag> all = tagRepository.findAll();
return buildTree(all);
}
// ─── private helpers ─────────────────────────────────────────────────────
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: concurrent writes could both pass this check; the self-reference
// CHECK constraint on the DB column is the hard backstop for the self-loop case.
List<UUID> 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<TagTreeNodeDTO> buildTree(List<Tag> tags) {
Map<UUID, List<TagTreeNodeDTO>> childrenByParent = new HashMap<>();
for (Tag tag : tags) {
childrenByParent.putIfAbsent(tag.getId(), new ArrayList<>());
if (tag.getParentId() != null) {
childrenByParent.computeIfAbsent(tag.getParentId(), k -> new ArrayList<>());
}
}
for (Tag tag : tags) {
TagTreeNodeDTO node = new TagTreeNodeDTO(
tag.getId(), tag.getName(), tag.getColor(), 0,
childrenByParent.getOrDefault(tag.getId(), new ArrayList<>())
);
if (tag.getParentId() != null) {
childrenByParent.get(tag.getParentId()).add(node);
} else {
childrenByParent.get(tag.getId()); // ensure root is tracked
}
}
// Collect root nodes (tags without a parent)
List<TagTreeNodeDTO> roots = new ArrayList<>();
for (Tag tag : tags) {
if (tag.getParentId() == null) {
roots.add(new TagTreeNodeDTO(
tag.getId(), tag.getName(), tag.getColor(), 0,
childrenByParent.getOrDefault(tag.getId(), new ArrayList<>())
));
}
}
return roots;
}
}