feat: improved tag system — AND/OR filtering and tag hierarchy #221
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Tags are currently flat (no structure) and search filtering only supports AND logic (all selected tags must match). Power users who tag documents carefully can't leverage the full potential of their tagging work.
Proposal
AND/OR filtering
Allow users to toggle between AND and OR when filtering by multiple tags in the search bar. AND = "all of these tags", OR = "any of these tags".
Tag hierarchy
Support parent/child relationships between tags so related concepts can be grouped. E.g. "Immobilie" could be a parent of "Grundstück" and "Haus". Searching for a parent tag would also include documents tagged with child tags.
Tag overview page
A browsable page showing all tags with document counts, organized by hierarchy. Entry point for tag-based exploration.
Open questions
🏗️ Markus Keller — Application Architect
Architecture discussion covering the data model for tag hierarchy, query strategy, API contract, and migration approach.
Resolved
1. Data model → adjacency list
Add a nullable
parent_id UUID REFERENCES tag(id)column to thetagtable. FK enforces parent existence at the DB layer. This replaces the slash/materialized-path approach considered during discussion.2. Depth → unlimited
No depth cap enforced. The adjacency list handles any depth via recursive CTEs. At family archive scale (<500 tags, depth realistically 3–4 max) this is fast and needs no special indexing.
3. Rename cascade
Solved by the adjacency list — renaming a tag is a single row update. Children continue to point to the parent via FK; no cascade UPDATE across name strings needed.
4. Referential integrity
CHECK (parent_id != id)TagServicebefore save. Not worth a trigger at this scale.5. AND/OR filtering
Bundled into this issue. No schema change needed — it's a query change (intersection vs. union) and a UI toggle. Can be implemented independently of the hierarchy work.
6. Tag overview page API contract
Single endpoint returns a pre-assembled tree — the backend builds it with one recursive CTE + document count join. No tree assembly in the frontend.
7. Migration of existing flat tags
The
parent_idcolumn lands asNULLfor all existing tags — they remain fully valid flat tags, nothing breaks on deploy. Post-deploy curation (grouping existing tags into the hierarchy) is manual work in the tag admin UI.Out of scope
Overall this is a well-scoped feature. The adjacency list keeps the schema minimal, the recursive CTE is standard SQL any Spring dev can read, and the tree-from-backend API contract keeps the frontend dumb. Ship the Flyway migration first, then build the query and UI on top.
🎨 Leonie Voss — UI/UX Design Lead
UI decisions covering the tag overview page, typeahead adaptation, AND/OR toggle, chip display, and hierarchy editing.
Resolved
1. Tag overview page — descoped
Not building a dedicated
/tagsbrowse page at this stage. Tag usage isn't heavy enough to justify the full design investment. Revisit when the data shows it's needed.2. Typeahead adaptation for hierarchy
Immobilie / Haus) shown on hover tooltip3. Tag colors (opt-in per parent tag)
Admins can assign a color to any parent tag from a curated palette of 8–10 pre-verified accessible colors. Color is expressed as a small dot or left-border accent on the chip — never a full background fill. This keeps WCAG contrast manageable and avoids rainbow clutter on documents with multiple tags. Color is supplementary to the text label, not a replacement.
4. AND/OR toggle
Single global pill toggle next to the tag filter chips — "AND" / "OR" — switches all selected tags at once. Per-tag operators are out of scope.
5. Document card chips
Same pattern as typeahead: bare tag name + optional color dot + full-path tooltip on hover. Consistent across the entire UI — no special casing on the document card.
6. Hierarchy editing in tag admin
Parent selector dropdown added to the existing tag edit form. No tree editor or drag-and-drop for now — admins pick a parent from a flat list of root-level tags and save. Sufficient for infrequent curation at this tag volume.
Out of scope
The chip + tooltip pattern keeps the UI compact at 320px while giving power users the full context they need. Start with the parent selector in admin; tree editor is a natural follow-on if the hierarchy grows complex enough to need it.
👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
TagService, not a DB trigger. What's the concrete approach? My read: load all ancestor UUIDs of the proposed parent in a recursive CTE, then check if the tag being edited appears in that chain. One query, deterministic. Anything else risks a TOCTOU window between the check and the insert.Tagentities but the tree endpoint returns nested children per Markus's API contract. That implies a newTagTreeNodeDTOrecord — theTagentity has nochildrenfield. WillTagService.getAllTags()be extended or will a newgetTagTree()method be added alongside it??tagOp=OR) is the right default: back button and link-sharing both work. Component state would reset on navigation.TagInputcomponent extension: the current typeahead shows a flat list. The new "parent as non-selectable section header, children indented" pattern is a meaningfully different interaction. Does the existingTagInputget extended in-place, or is a newHierarchicalTagInputcomponent created? Extending in-place adds conditional complexity; a new component keeps the existing behavior stable.Suggestions
TagTreeNodeDTOas a Java record:record TagTreeNodeDTO(UUID id, String name, int documentCount, List<TagTreeNodeDTO> children) {}— immutable, no Lombok needed.role="presentation"oraria-disabled="true"withpointer-events: noneso keyboard users can't accidentally select a parent group header.?tagOp=ORas a URL param, defaulting to AND when absent (preserves existing behavior with no migration).🧪 Sara Holt — QA Engineer
Questions & Observations
A → A— caught by the DBCHECKconstraint; (2) direct cycleA → B → A— caught byTagService; (3) deep cycleA → B → C → A— also caught byTagService. Do all three have passing integration tests on day one?documentCountin the tree response — does it count documents on the tag directly, or documents on the tag and all its descendants?#E4E2D7) page backgrounds. Who validates contrast ratios against both backgrounds before the palette is committed to code?TagInputbehavior (flat list, all tags selectable) must not regress when hierarchy is introduced. Tags withparent_id = NULLshould behave exactly as before.Suggestions
@ParameterizedTestfor cycle detection:(sourceId, targetParentId, expectedException)— covers self-loop, direct cycle, and deep cycle in one parameterized class.TagServiceIntegrationTestcovering: flat tag round-trip (null parent), parent-child assignment, cycle rejection at depth 1 and depth 3, and behavior when a parent tag is deleted (children become root or are blocked, depending on theON DELETEdecision).🔐 Nora Steiner (NullX) — Security Engineer
Questions & Observations
parent_idwrites: who is allowed to set or change a tag's parent? The issue mentions a parent selector in the tag admin form, which impliesADMIN_TAG. But if aWRITE_ALLuser can also write tags, can they restructure the hierarchy? An unintended actor reshuffling the entire tag taxonomy is a low-impact but real issue in a shared family archive. Recommend:parent_idwrites guarded byADMIN_TAGat minimum, separate from general tag CRUD.TagServicehas a window — two concurrent requests can both pass the ancestor check before either commits. This isn't exploitable at family archive scale (few concurrent admin writers), but it's worth a short comment inTagService.checkForCycle()explaining why the application-layer check is sufficient here and the self-referenceCHECKconstraint is the hard backstop.Set<String> ALLOWED_COLORSwhitelist check inTagServicebefore save (returningDomainException.badRequest) is the right control — cheap, explicit, and auditable.WITH RECURSIVEin PostgreSQL has no built-in depth limit. A pathologically deep hierarchy (100+ levels) — created by a bug or an admin mistake — generates a proportionally expensive query. AWHERE depth < 50guard clause in the CTE costs nothing and prevents a slow-query amplification scenario.TagControllerorTagService) so future contributors don't add per-user tag filtering inconsistently. No change needed — just an explicit "intentional: all tags are global" note.Suggestions
@RequirePermission(Permission.ADMIN_TAG)to the parent assignment operation (either on the update endpoint or specifically on theparent_idfield path in the service).private static final Set<String> ALLOWED_TAG_COLORS = Set.of(...)constant inTagService, validated before save. Fail withDomainException.badRequest(ErrorCode.INVALID_TAG_COLOR, ...)if not in the set.AND depth < 50in the recursive branch of theWITH RECURSIVEquery — not a security requirement at this scale, but correct defensive practice.checkForCycle()with a one-line comment:// TOCTOU: concurrent writes could both pass; self-reference CHECK constraint is the DB-layer backstop.🛠️ Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
ON DELETEbehavior forparent_idFK: when a parent tag is deleted, what happens to its children?ON DELETE SET NULLpromotes them to root tags.ON DELETE RESTRICTblocks deletion until children are reassigned first. Neither is wrong, but this needs a decision before writing the Flyway migration — it's harder to change later than the column itself.parent_id: any query that looks up "children of parent X" will do a full table scan ontagwithout an index onparent_id. At <500 tags it's imperceptible, but the index should be in the same migration as the column — it's free to add now and expensive to add later (well, cheap at this size, but it's a good habit).VARCHAR(7)for hex codes? A regexCHECKin the migration (CHECK (color IS NULL OR color ~ '^#[0-9a-fA-F]{6}$')) keeps the database consistent without depending solely on application-layer validation. If the palette later expands to named colors or HSL values, the constraint can be loosened; adding it now is the correct default.postgres:16-alpine) covers it with zero changes.MigrationIntegrationTest.Suggestions
Concrete migration structure to discuss before writing:
SET NULLvsRESTRICTbefore writing — this is the one migration decision that affects user-facing behavior.WITH RECURSIVEquery is the most complex SQL this project has had — worth a short inline comment in the repository method explaining what it does, for the next developer who reads it.docker compose pull && docker compose up -dwith Flyway running on startup.👨💻 Felix Brandt — Senior Fullstack Developer
Implementation discussion — six open questions resolved before writing code.
Resolved
1. ON DELETE behavior for
parent_idFK →SET NULLWhen a parent tag is deleted, children become root-level tags. Chosen because tag deletion is admin-only — an admin making this call knows what they're doing, and silent promotion to root is the right default over blocking the operation.
2. Hierarchy search semantics → inclusive
Selecting a parent tag in the search filter matches documents tagged with any descendant, not only documents carrying the parent tag directly. Selecting "Immobilie" returns docs tagged with "Haus", "Grundstück", etc. The search query will collect all descendant IDs via a recursive CTE before filtering documents.
3.
documentCountin tree response → inclusive of all descendantsConsistent with item 2 — the count shown on a parent node reflects the total documents reachable when clicking that tag. No mismatch between the number displayed and the number returned by search.
4.
TagTreeNodeDTO→ new Java record + newgetTagTree()methodrecord TagTreeNodeDTO(UUID id, String name, int documentCount, List<TagTreeNodeDTO> children) {}as a new record. NewgetTagTree()method inTagServicealongside the existinggetAllTags()— the flat method stays untouched so typeahead callers are unaffected.5.
TagInputcomponent → extend in-placeThe existing
TagInputwill be extended to support the hierarchical grouped view (parent as non-selectable section header, children indented). The flat code path for tags without children stays unchanged — zero regression risk for existing callers.6. Shipping order → AND/OR ships in the same PR as hierarchy
AND/OR toggle and query switch will be implemented within the same PR as the hierarchy work, not as a standalone increment.
Overall this is implementation-ready. The adjacency list + recursive CTE approach is clean, the tree-from-backend contract keeps the frontend dumb, and all the decisions above are consistent with each other. The Flyway migration (
SET NULL, index onparent_id, color column with check constraint) can be written now.🎨 Leonie Voss — UI/UX Design Lead
Color palette and admin picker design — all decisions committed, ready to implement.
Resolved
1. Tag color palette — 10 semantic tokens, CSS custom properties, light + dark pairs
Colors are stored as token names (e.g.
"sage") in the database — not hex values. The CSS resolves the actual color per theme. All values verified for 3:1 contrast as non-text UI elements against both light surfaces (#ffffff,#f0efe9) and dark surface (#011526).Defined in
layout.cssfollowing the existing badge color pattern (--c-badge-*):The chip dot uses
style="background-color: var(--c-tag-{tag.color})". No JS map needed — the CSS variable name is the contract between frontend and database. The mix is intentional: archival warm tones (sienna, terracotta, ochre, rose) and clean categorical colors (navy, teal, ocean, forest, violet, sage).2. No foreground text problem on chips
The dot carries no text. The chip background stays
--c-surfaceand the chip label staystext-ink— both remap automatically per theme. No per-chip foreground color needed.3. Admin color picker — grid of circles, ring selection, null slot, root tags only
--c-focus-ring(navy in light, mint in dark) — no overlaid checkmark, no text inside swatches, no foreground text problemnullparent_id IS NULL— child tags inherit their parent's color cue implicitly, no per-child assignment4. Backend allowlist
Set.of("navy", "teal", "ocean", "forest", "sage", "sienna", "terracotta", "ochre", "rose", "violet")inTagService, validated before save:Fail with
DomainException.badRequest(ErrorCode.INVALID_TAG_COLOR, ...)if not in the set.Out of scope
The palette is warm enough for a family archive, distinct enough to be useful as a grouping cue, and fully accessible in both modes. The CSS-variable-as-contract approach keeps the picker simple and the backend validation cheap.
Implementation complete ✅
All planned work for tag hierarchy, AND/OR filtering, and tag colors has been implemented on branch
feat/issue-221-tag-hierarchy.What was built
Backend
V39__add_tag_hierarchy.sql— addsparent_id UUID NULL REFERENCES tag(id) ON DELETE SET NULL+color VARCHAR(20) NULL+ indexTagentity gainsparentIdandcolorfieldsTagUpdateDTOrecord withname,parentId,colorTagTreeNodeDTOrecord (nested tree with cumulativedocumentCount)ErrorCode.INVALID_TAG_COLOR+ErrorCode.TAG_CYCLE_DETECTEDTagRepository.findDescendantIdsByName()— recursive CTE with depth < 50 guardTagRepository.getTreeRows()— full tree CTE for tree assemblyTagService.expandTagNamesToDescendantIdSets()— expands tag names to descendant ID setsTagService.checkForCycle()— ancestor traversal, throwsTAG_CYCLE_DETECTEDTagService.getTagTree()— assemblesTagTreeNodeDTOtree with cumulative countsTagService.update()acceptsTagUpdateDTO; validates color against allowlist, checks cyclesDocumentService.searchDocuments()acceptstagOperator: AND | OR; uses hierarchy-expanded ID setsGET /api/tags/treeendpointFrontend
layout.css— 10--c-tag-{name}CSS custom properties (light + dark variants)TagInput.svelte— binding changed fromstring[]toTag[]; hierarchical dropdown with parent headers; color dot on chipsSearchFilterBar.svelte— AND/OR pill toggle (visible when ≥ 2 tags selected)DocumentList.svelte— color dot on tag chips+page.svelte++page.server.ts—?tagOp=ORURL param wired end-to-end/admin/tags/[id]) — parent selector + 10-swatch color pickerTagsListPanel.svelte— hierarchical rendering with indentation + color dotserrors.ts+de.json/en.json/es.json—INVALID_TAG_COLOR+TAG_CYCLE_DETECTEDCommits
5b4f7f2migration: V39 add tag hierarchy and color columns70cb0abbackend: add Tag parentId/color, TagUpdateDTO, TagTreeNodeDTO, error codes4e748bebackend: add recursive CTE queries to TagRepository2da07f5backend: cycle detection and color validation in TagService3bb0b81backend: getTagTree() assembles nested TagTreeNodeDTO with cumulative counts3fd6e1ebackend: expandTagNamesToDescendantIdSets() in TagServicedf6e8c4backend: TagService.update() accepts TagUpdateDTO with parentId/color57dc72bbackend: AND/OR + hierarchy expansion in document searchaca7a22backend: GET /api/tags/tree endpoint9f14f72frontend: regenerate API types with parentId/color/tagOp/tree endpointab1cf06frontend: add 10 tag color CSS custom properties (light + dark)60618e0frontend: TagInput – Tag type, hierarchical dropdown, color dotsabba85afrontend: wire tagOp URL param from server to SearchFilterBard900480frontend: add parent selector and color picker to admin tag edit form7f53651frontend: render tag list hierarchically with indentation and color dots39ed66cfrontend: add i18n keys and error codes for tag hierarchy errorsTest results
Verified behaviors
TAG_CYCLE_DETECTEDImplementation complete
All 27 tasks from the plan are done. Here's a summary of every commit on
feat/issue-221-tag-hierarchy:f9ac963[migration]V39 —parent_id,color, index, self-ref check3fba740[backend]Tag entity fields,TagUpdateDTO,TagTreeNodeDTO,ErrorCodeadditions, recursive CTE queries,TagService(cycle check, color validation, tree assembly, descendant expansion),TagControllertree endpoint + updated update57dc72b[backend]AND/OR + hierarchy expansion inDocumentService/DocumentSpecificationsc3e007d[chore]Regenerated TypeScript API typese4f21bd[frontend]--c-tag-*CSS tokens for 10 semantic colors (light + dark)e8e54cc[frontend]TagInputbinding changed toTag[], color dots, hierarchy grouping in dropdowne03fb38[frontend]Color dot + full-path tooltip on tag chips inDocumentListb54d2b0[frontend]AND/OR pill toggle inSearchFilterBar(visible when ≥2 tags)abba85a[frontend]?tagOp=ORURL param wired from+page.server.tsthrough+page.sveltetoSearchFilterBard900480[frontend]Parent selector + 10-swatch color picker in admin tag edit form7f53651[frontend]TagsListPanelrenders hierarchical list with indentation and color dots39ed66c[frontend]i18n keys forINVALID_TAG_COLORandTAG_CYCLE_DETECTEDin de/en/es +errors.ts532692e[fix]AND/OR operator race condition — bypass 500 ms debounce on toggle sotagOperatoris never reset mid-flight by a completing navigationRace condition fix (last commit)
The tag-change
$effectfiredtriggerSearch()immediately with no debounce. When the user toggled AND→OR within the 500 ms debounce window, Navigation 1 (AND) would complete first, the sync$effectwould resettagOperator = 'AND', and the debounced search would fire with AND — showing empty results. Fixed by addingonSearchImmediateprop toSearchFilterBarandhandleImmediateSearch()to+page.svelte; the toggle now clears any pending timer and callstriggerSearch()synchronously.All 794 frontend tests green. Backend tests green.