feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249

Merged
marcel merged 51 commits from feat/issue-221-tag-hierarchy into main 2026-04-17 10:24:10 +02:00
8 changed files with 58 additions and 18 deletions
Showing only changes of commit d7a46de1cc - Show all commits

View File

@@ -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;
@@ -209,7 +210,8 @@ public class DocumentController {
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));
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 ---

View File

@@ -23,7 +23,7 @@ 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 org.springframework.web.server.ResponseStatusException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -60,9 +60,7 @@ public class TagController {
@PostMapping("/{id}/merge")
@RequirePermission(Permission.ADMIN_TAG)
public ResponseEntity<Tag> mergeTag(@PathVariable UUID id, @RequestBody MergeTagDTO dto) {
if (dto.targetId() == null)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "targetId required");
public ResponseEntity<Tag> mergeTag(@PathVariable UUID id, @Valid @RequestBody MergeTagDTO dto) {
return ResponseEntity.ok(tagService.mergeTags(id, dto.targetId()));
}

View File

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

View File

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

View File

@@ -13,6 +13,13 @@ import org.springframework.data.repository.query.Param;
public interface TagRepository extends JpaRepository<Tag, UUID> {
/** Typed projection for document-count aggregation results. */
interface TagCount {
UUID getTagId();
Long getCount();
}
Optional<Tag> findByNameIgnoreCase(String name);
List<Tag> findByNameContainingIgnoreCase(String name);
@@ -111,9 +118,9 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
void reparentChildren(@Param("sourceId") UUID sourceId, @Param("targetId") UUID targetId);
/**
* Returns (tag_id, count) pairs for all tags that appear in document_tags.
* 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, COUNT(*) FROM document_tags GROUP BY tag_id", nativeQuery = true)
List<Object[]> findDocumentCountsPerTag();
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
List<TagCount> findDocumentCountsPerTag();
}

View File

@@ -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<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, String tagOperator) {
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
@@ -302,7 +303,7 @@ public class DocumentService {
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
}
boolean useOrLogic = "OR".equalsIgnoreCase(tagOperator);
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;

View File

@@ -28,9 +28,11 @@ import lombok.extern.slf4j.Slf4j;
@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<String> ALLOWED_TAG_COLORS = Set.of(
"navy", "teal", "ocean", "forest", "sage",
"sienna", "terracotta", "ochre", "rose", "violet"
"sage", "sienna", "amber", "slate", "violet",
"rose", "cobalt", "moss", "sand", "coral"
);
private final TagRepository tagRepository;
@@ -148,8 +150,8 @@ public class TagService {
List<Tag> all = tagRepository.findAll();
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
.collect(Collectors.toMap(
row -> (UUID) row[0],
row -> (Long) row[1]
TagRepository.TagCount::getTagId,
TagRepository.TagCount::getCount
));
return buildTree(all, counts);
}
@@ -184,8 +186,11 @@ public class TagService {
}
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.
// 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<UUID> ancestors = tagRepository.findAncestorIds(proposedParentId);
if (ancestors.contains(tagId)) {
throw DomainException.badRequest(ErrorCode.TAG_CYCLE_DETECTED,

View File

@@ -18,6 +18,7 @@ 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.*;
@@ -256,8 +257,11 @@ class TagServiceTest {
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.<Object[]>of(new Object[]{tagId, 5L}));
when(tagRepository.findDocumentCountsPerTag()).thenReturn(List.of(countEntry));
var tree = tagService.getTagTree();
@@ -456,6 +460,19 @@ class TagServiceTest {
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 ───────────────────────────────────────────────────────────────
@Test