feat(#248): add TagService.mergeTags() with validateNotSelf/validateNotDescendant/transferDocuments helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 22:18:41 +02:00
parent b9b572436a
commit f921284db6
3 changed files with 123 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
public record MergeTagDTO(UUID targetId) {}

View File

@@ -21,9 +21,11 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class TagService {
static final Set<String> ALLOWED_TAG_COLORS = Set.of(
@@ -73,6 +75,29 @@ public class TagService {
tagRepository.delete(getById(id));
}
@Transactional
public Tag mergeTags(UUID sourceId, UUID targetId) {
log.info("Merging tag {} into {}", sourceId, targetId);
validateNotSelf(sourceId, targetId);
getById(sourceId);
Tag target = getById(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<UUID> 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.
@@ -126,6 +151,26 @@ public class TagService {
// ─── 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<UUID> 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,