feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249
@@ -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 ---
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user