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..6ccac157 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/MergeTagDTO.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.UUID; + +public record MergeTagDTO(UUID targetId) {} 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 28be224c..c06e241b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -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 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 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 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, 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 a8c8c96f..7224c027 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -302,6 +302,79 @@ class TagServiceTest { 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); + } + // ─── delete ─────────────────────────────────────────────────────────────── @Test