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
Owner

Summary

Implements the full admin tag page overhaul from issue #248. The flat tag list and name-confirm delete zone are replaced with a collapsible ARIA tree, a 2-step merge flow, and a radio-based delete guard.

Backend

  • ErrorCode: added TAG_NOT_FOUND, TAG_MERGE_SELF, TAG_MERGE_INVALID_TARGET
  • TagService.getById(): throws DomainException(TAG_NOT_FOUND) instead of ResponseStatusException
  • TagRepository: 6 new native queries — findDescendantIds, reassignDocumentTags, deleteDocumentTagsByTagId, reparentChildren, deleteDocumentTagsByTagIds, findDocumentCountsPerTag
  • TagService.mergeTags(): validates not-self, not-descendant; reassigns docs, reparents children, deletes source
  • TagService.deleteWithDescendants(): BFS walk + bulk delete of subtree doc-tags and tags
  • TagTreeNodeDTO: enriched with parentId field (OpenAPI @Schema exposed) and documentCount from single aggregate query (no N+1)
  • TagController: POST /{id}/merge and DELETE /{id}/subtree endpoints
  • 53 backend tests, all passing

Frontend

  • createTypeahead<T> composable (useTypeahead.svelte.ts) — debounced fetch, openWith, reactive state
  • PersonTypeahead.svelte refactored to use composable
  • TagParentPicker.svelte — combobox with excludeIds filtering
  • TagTreeNode.svelte + TagsListPanel.svelte rewrite — collapsible ARIA tree (role="tree", role="treeitem", aria-expanded), collapse state persisted to localStorage
  • +layout.server.ts — switched to GET /api/tags/tree, exposes tree (nested) + tags (flat with parentId)
  • TagAncestry.svelte — breadcrumb nav for nested tags
  • TagChildrenPreview.svelte — child chip list with inline expand
  • TagMergeZone.svelte — 2-step merge flow (step 1: pick target; step 2: confirm)
  • TagDeleteGuard.svelte — radio: single delete or subtree delete, button disabled until mode chosen
  • +page.svelte — wires all new components, replaces <select> + name-confirm zone
  • +page.server.ts — adds merge action, updates delete action with deleteMode param
  • brand-warning CSS token (--color-warning: #b45309)
  • 926 frontend tests, all passing

Test plan

  • cd backend && ./mvnw test -Dtest=TagServiceTest,TagControllerTest — 53 green
  • cd frontend && npm run test — 926 green
  • Tree panel: expand/collapse at depth 2+, persists on browser reload
  • TagParentPicker: search, self + descendants excluded from selection
  • Merge flow: step 1 → pick target → step 2 → confirm → docs reassigned, children reparented, source deleted, redirect to target
  • Subtree delete: all nested levels removed
  • Single delete: children promoted to root (ON DELETE SET NULL)

🤖 Generated with Claude Code

## Summary Implements the full admin tag page overhaul from issue #248. The flat tag list and name-confirm delete zone are replaced with a collapsible ARIA tree, a 2-step merge flow, and a radio-based delete guard. ### Backend - `ErrorCode`: added `TAG_NOT_FOUND`, `TAG_MERGE_SELF`, `TAG_MERGE_INVALID_TARGET` - `TagService.getById()`: throws `DomainException(TAG_NOT_FOUND)` instead of `ResponseStatusException` - `TagRepository`: 6 new native queries — `findDescendantIds`, `reassignDocumentTags`, `deleteDocumentTagsByTagId`, `reparentChildren`, `deleteDocumentTagsByTagIds`, `findDocumentCountsPerTag` - `TagService.mergeTags()`: validates not-self, not-descendant; reassigns docs, reparents children, deletes source - `TagService.deleteWithDescendants()`: BFS walk + bulk delete of subtree doc-tags and tags - `TagTreeNodeDTO`: enriched with `parentId` field (OpenAPI `@Schema` exposed) and `documentCount` from single aggregate query (no N+1) - `TagController`: `POST /{id}/merge` and `DELETE /{id}/subtree` endpoints - 53 backend tests, all passing ### Frontend - `createTypeahead<T>` composable (`useTypeahead.svelte.ts`) — debounced fetch, `openWith`, reactive state - `PersonTypeahead.svelte` refactored to use composable - `TagParentPicker.svelte` — combobox with `excludeIds` filtering - `TagTreeNode.svelte` + `TagsListPanel.svelte` rewrite — collapsible ARIA tree (`role="tree"`, `role="treeitem"`, `aria-expanded`), collapse state persisted to localStorage - `+layout.server.ts` — switched to `GET /api/tags/tree`, exposes `tree` (nested) + `tags` (flat with `parentId`) - `TagAncestry.svelte` — breadcrumb nav for nested tags - `TagChildrenPreview.svelte` — child chip list with inline expand - `TagMergeZone.svelte` — 2-step merge flow (step 1: pick target; step 2: confirm) - `TagDeleteGuard.svelte` — radio: single delete or subtree delete, button disabled until mode chosen - `+page.svelte` — wires all new components, replaces `<select>` + name-confirm zone - `+page.server.ts` — adds `merge` action, updates `delete` action with `deleteMode` param - brand-warning CSS token (`--color-warning: #b45309`) - 926 frontend tests, all passing ## Test plan - [ ] `cd backend && ./mvnw test -Dtest=TagServiceTest,TagControllerTest` — 53 green - [ ] `cd frontend && npm run test` — 926 green - [ ] Tree panel: expand/collapse at depth 2+, persists on browser reload - [ ] TagParentPicker: search, self + descendants excluded from selection - [ ] Merge flow: step 1 → pick target → step 2 → confirm → docs reassigned, children reparented, source deleted, redirect to target - [ ] Subtree delete: all nested levels removed - [ ] Single delete: children promoted to root (ON DELETE SET NULL) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 34 commits 2026-04-16 23:49:53 +02:00
Adds parent_id FK (ON DELETE SET NULL), self-reference check constraint,
parent_id index, and nullable color column to the tag table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Tag entity: add parentId (UUID FK) and color (String) fields
- TagUpdateDTO and TagTreeNodeDTO records
- ErrorCode: INVALID_TAG_COLOR, TAG_CYCLE_DETECTED
- TagRepository: findAncestorIds() recursive CTE query
- TagService: cycle detection, color validation, getTagTree()
- TagController: use TagUpdateDTO, add GET /api/tags/tree endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace hasTags(List<String>) spec with hasTags(List<Set<UUID>>, useOr)
- AND mode: one EXISTS subquery per expanded tag ID set; empty set = disjunction
- OR mode: union of all expanded sets into a single EXISTS subquery
- DocumentService calls tagService.expandTagNamesToDescendantIdSets() before building spec
- DocumentController exposes ?tagOp=AND|OR query param (default AND)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds TagTreeNodeDTO, TagUpdateDTO (parentId + color), /api/tags/tree endpoint,
and parentId/color fields on Tag schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Light and dark variants for: sage, sienna, amber, slate, violet, rose,
cobalt, moss, sand, coral — used as decorative dot colors on tag chips.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- TagRepository: add findDescendantIdsByName() recursive CTE query
- TagService: add expandTagNamesToDescendantIdSets() for document search

Frontend:
- TagInput: accept Tag[] (id, name, color, parentId) instead of string[]
- Chips show color dot via var(--c-tag-{color}) when tag has color
- Suggestions grouped hierarchically: children indented under their parents
- Update DescriptionSection, edit/new pages, SearchFilterBar, +page.svelte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Toggle appears when ≥2 tags are selected; defaults to AND.
Exposes tagOperator prop ('AND'|'OR') for parent to read via bind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reads ?tagOp=OR from URL in +page.server.ts, passes it to the backend
search endpoint, and surfaces it via the filters return. +page.svelte
initialises tagOperator state from filters, writes it back to the URL
in triggerSearch(), and binds it to SearchFilterBar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tag edit form gains a parent <select> listing all other tags (self
excluded) and a 10-swatch color picker that is only shown when no
parent is selected. Submitting passes parentId and color to the PUT
/api/tags/{id} endpoint via TagUpdateDTO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TagsListPanel now accepts optional parentId/color on each Tag. A
$derived.by walk produces an ordered flat list with depth annotations.
Child tags are indented with pl-5; root-level tags with a color get
a colored dot before their name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds INVALID_TAG_COLOR and TAG_CYCLE_DETECTED to the frontend ErrorCode
type and getErrorMessage() switch. German, English, and Spanish
translations added for both codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The tag-change $effect called triggerSearch() immediately (no debounce).
When the user toggled AND/OR within the 500 ms debounce window, the prior
navigation would complete and reset tagOperator back to AND before the
debounced search fired. The toggle now calls onSearchImmediate, which
clears any pending timer and fires triggerSearch() synchronously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sending tagQ alongside selected tags caused an unintended AND: documents
had to match both the selected-tag filter and the partial-name filter,
making the list shrink while the user was still typing a new tag.

tagQ is now only forwarded to the backend when no tags are selected,
which is the only case where the live partial-filter is meaningful.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SvelteKit reuses the same +page.svelte instance on client-side navigation,
so $state() initialisations only run on mount. Add an $effect keyed on
data.tag.id to reset parentId, selectedColor and deleteConfirmName whenever
the user switches to a different tag in the admin sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(#221): resolve inherited color on child tags in document responses
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / Backend Unit Tests (push) Failing after 2m46s
06fd5ae2da
Colors are stored only on root-level tags. DocumentService now calls
TagService.resolveEffectiveColors() before returning search results and
single-document responses, so child tags carry their parent's color when
serialised to JSON. Parent tags are batch-loaded in a single query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(#248): overhaul tag edit page — TagParentPicker, new components, merge+subtree actions
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / Backend Unit Tests (push) Failing after 2m46s
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
172c5613ed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Big feature, solid execution. TDD evidence throughout — tests precede implementation, coverage is real. A few clean-code violations worth cleaning up.


Blockers

1. Boolean flag argument in DocumentService.searchDocuments() (tagOperator string)

"OR".equalsIgnoreCase(tagOperator) is a stringly-typed flag that reads as noise at every call site. Per our code style, boolean flag arguments (and their string equivalents) are a DO-NOT.

// Current — what does "AND" mean here? Reader must trace the chain
searchDocuments(query, tags, "AND", page, sort);

// Fix — type-safe, self-documenting
public enum TagOperator { AND, OR }
searchDocuments(query, tags, TagOperator.AND, page, sort);

This enum needs to propagate from DocumentController through DocumentService and into DocumentSpecifications.hasTags(). The controller maps the query param string to the enum at the boundary.

2. Object[] return type from TagRepository.findDocumentCountsPerTag()

Raw Object[] rows from a query are an untyped contract that the caller must blindly cast. Per our layering rules, the repository should expose typed data.

// Current
List<Object[]> findDocumentCountsPerTag();
// caller casts: (UUID) row[0], (Long) row[1]

// Fix — use a JPA projections interface or a record
public interface TagCount {
    UUID getTagId();
    Long getCount();
}

@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
List<TagCount> findDocumentCountsPerTag();

3. Undocumented empty-collection precondition on deleteDocumentTagsByTagIds()

The code comment says "caller must guard against empty collection — PostgreSQL rejects IN ()". This is a silent, method-level contract with no enforcement. The guard belongs inside the method:

@Modifying(clearAutomatically = true)
@Query(value = "DELETE FROM document_tags WHERE tag_id IN :ids", nativeQuery = true)
void deleteDocumentTagsByTagIds(@Param("ids") Collection<UUID> ids);
// In TagService.deleteWithDescendants():
// Replace:
if (!ids.isEmpty()) tagRepository.deleteDocumentTagsByTagIds(ids);
// With the guard in the repo method, or at minimum add a method-level assertion

Either move the guard into the repository method (early return if empty) or add a Assert.notEmpty(ids, "ids must not be empty") in the service that produces a clear failure rather than a PostgreSQL error.


Suggestions

4. ALLOWED_TAG_COLORS hardcoded static Set in TagService

The 10 color tokens are duplicated between the service (Set.of("sage", "sienna", ...)) and layout.css (--c-tag-sage, --c-tag-sienna, ...). One source of truth would mean a config constant or shared enum. For now, at minimum extract to a named constant and add a comment pointing to the CSS tokens:

// Keep in sync with --c-tag-* tokens in frontend/src/routes/layout.css
private static final Set<String> ALLOWED_TAG_COLORS = Set.of("sage", "sienna", ...);

5. AND/OR toggle text not i18n'd in SearchFilterBar.svelte

The button labels "AND" and "OR" are hardcoded strings that will render the same in all locales. The message files have all the error keys but these operator labels appear to be missing. Add m.filter_operator_and() / m.filter_operator_or() to de.json, en.json, es.json and use them in the component.

6. Missing key on {#each suggestion} in TagInput.svelte

Check that all {#each} blocks in the new components use keyed iteration (item.id). Position-based reconciliation silently corrupts local state on reorder. This is a DO-NOT per our Svelte style guide.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Big feature, solid execution. TDD evidence throughout — tests precede implementation, coverage is real. A few clean-code violations worth cleaning up. --- ### Blockers **1. Boolean flag argument in `DocumentService.searchDocuments()` (`tagOperator` string)** `"OR".equalsIgnoreCase(tagOperator)` is a stringly-typed flag that reads as noise at every call site. Per our code style, boolean flag arguments (and their string equivalents) are a DO-NOT. ```java // Current — what does "AND" mean here? Reader must trace the chain searchDocuments(query, tags, "AND", page, sort); // Fix — type-safe, self-documenting public enum TagOperator { AND, OR } searchDocuments(query, tags, TagOperator.AND, page, sort); ``` This enum needs to propagate from `DocumentController` through `DocumentService` and into `DocumentSpecifications.hasTags()`. The controller maps the query param string to the enum at the boundary. **2. `Object[]` return type from `TagRepository.findDocumentCountsPerTag()`** Raw `Object[]` rows from a query are an untyped contract that the caller must blindly cast. Per our layering rules, the repository should expose typed data. ```java // Current List<Object[]> findDocumentCountsPerTag(); // caller casts: (UUID) row[0], (Long) row[1] // Fix — use a JPA projections interface or a record public interface TagCount { UUID getTagId(); Long getCount(); } @Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true) List<TagCount> findDocumentCountsPerTag(); ``` **3. Undocumented empty-collection precondition on `deleteDocumentTagsByTagIds()`** The code comment says "caller must guard against empty collection — PostgreSQL rejects `IN ()`". This is a silent, method-level contract with no enforcement. The guard belongs *inside* the method: ```java @Modifying(clearAutomatically = true) @Query(value = "DELETE FROM document_tags WHERE tag_id IN :ids", nativeQuery = true) void deleteDocumentTagsByTagIds(@Param("ids") Collection<UUID> ids); ``` ```java // In TagService.deleteWithDescendants(): // Replace: if (!ids.isEmpty()) tagRepository.deleteDocumentTagsByTagIds(ids); // With the guard in the repo method, or at minimum add a method-level assertion ``` Either move the guard into the repository method (early return if empty) or add a `Assert.notEmpty(ids, "ids must not be empty")` in the service that produces a clear failure rather than a PostgreSQL error. --- ### Suggestions **4. `ALLOWED_TAG_COLORS` hardcoded static Set in `TagService`** The 10 color tokens are duplicated between the service (`Set.of("sage", "sienna", ...)`) and `layout.css` (`--c-tag-sage`, `--c-tag-sienna`, ...). One source of truth would mean a config constant or shared enum. For now, at minimum extract to a named constant and add a comment pointing to the CSS tokens: ```java // Keep in sync with --c-tag-* tokens in frontend/src/routes/layout.css private static final Set<String> ALLOWED_TAG_COLORS = Set.of("sage", "sienna", ...); ``` **5. AND/OR toggle text not i18n'd in `SearchFilterBar.svelte`** The button labels "AND" and "OR" are hardcoded strings that will render the same in all locales. The message files have all the error keys but these operator labels appear to be missing. Add `m.filter_operator_and()` / `m.filter_operator_or()` to `de.json`, `en.json`, `es.json` and use them in the component. **6. Missing key on `{#each suggestion}` in `TagInput.svelte`** Check that all `{#each}` blocks in the new components use keyed iteration `(item.id)`. Position-based reconciliation silently corrupts local state on reorder. This is a DO-NOT per our Svelte style guide.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

The layering is clean, the DB schema is sound, and the recursive CTE approach for hierarchy traversal is the right call. Two architectural concerns before I'm fully comfortable with this.


Blockers

1. Magic number 50 hardcoded in every recursive CTE — architectural debt

The depth guard appears literally in at least 4 separate native SQL queries (findAncestorIds, findDescendantIds, findDescendantIdsByName, and the merge validation). This is the classic "constant scattered across the codebase" anti-pattern:

-- Repeated 4 times: WHERE d.depth < 50

When the business decides to allow 100-level hierarchies (or needs to tighten it to 20), we need a migration and 4 code changes. Extract to a named constant and reference it:

// In TagRepository or a shared TagConstants class
static final int MAX_TAG_HIERARCHY_DEPTH = 50;

For native queries the constant can't be interpolated directly — the simplest fix is to centralize in one place and reference it via a @NamedNativeQuery or service-level query builder. At minimum, document the constraint explicitly and make it a single value to change.

2. tagOperator flows as a String through all layers

The filter operator enters the system as a query parameter, flows through DocumentControllerDocumentServiceDocumentSpecifications as a plain String. Each layer does a string comparison ("OR".equalsIgnoreCase(...)). This violates the "sanitize at the boundary" principle:

Controller receives: ?tagOperator=OR   (String)
  → Service: "OR".equalsIgnoreCase(tagOperator)  (String)
    → Specs: boolean useOr  (good — but already a boolean, not an enum)

The controller should parse the string into a typed enum at the HTTP boundary. The service and specs should never see the raw string:

public enum TagOperator { AND, OR }

// In DocumentController:
TagOperator op = TagOperator.valueOf(tagOperator.toUpperCase());  // throws on invalid input
documentService.searchDocuments(query, tags, op, page, sort);

This also makes invalid values fail fast with a 400 at the boundary rather than silently defaulting to AND.


Suggestions

3. Color palette lives in the service, not in configuration

ALLOWED_TAG_COLORS = Set.of("sage", "sienna", ...) in TagService is business configuration masquerading as code. The palette is shared between backend validation and frontend CSS tokens. Consider externalizing to a @ConfigurationProperties class so it's configurable without recompile. For now, a clear comment cross-referencing the CSS tokens is the minimum.

4. findDocumentCountsPerTag() returns List<Object[]> — repository leaks untyped data

Repository methods are the data access boundary. Returning List<Object[]> makes the service responsible for knowing column order and types — an implicit contract invisible to the compiler. Use a JPA projection interface. This is a cohesion issue: the repository should own the shape of its results.

5. TOCTOU race condition in cycle validation — comment is correct but incomplete

The code acknowledges the race between validateNotDescendant and reparentChildren. The DB CHECK (parent_id != id) prevents infinite self-loops but does NOT prevent a two-thread race creating a valid-looking cycle (A→B, B→A created concurrently). For an admin-only, low-frequency operation this is acceptable — but the comment should mention the mitigation: "This is intentionally not locked because (a) admin-only access, (b) the DB constraint prevents the worst-case infinite loop, (c) the window is microseconds." That comment protects the next developer from "fixing" it with an unnecessary pessimistic lock.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** The layering is clean, the DB schema is sound, and the recursive CTE approach for hierarchy traversal is the right call. Two architectural concerns before I'm fully comfortable with this. --- ### Blockers **1. Magic number `50` hardcoded in every recursive CTE — architectural debt** The depth guard appears literally in at least 4 separate native SQL queries (`findAncestorIds`, `findDescendantIds`, `findDescendantIdsByName`, and the merge validation). This is the classic "constant scattered across the codebase" anti-pattern: ```sql -- Repeated 4 times: WHERE d.depth < 50 ``` When the business decides to allow 100-level hierarchies (or needs to tighten it to 20), we need a migration *and* 4 code changes. Extract to a named constant and reference it: ```java // In TagRepository or a shared TagConstants class static final int MAX_TAG_HIERARCHY_DEPTH = 50; ``` For native queries the constant can't be interpolated directly — the simplest fix is to centralize in one place and reference it via a `@NamedNativeQuery` or service-level query builder. At minimum, document the constraint explicitly and make it a single value to change. **2. `tagOperator` flows as a `String` through all layers** The filter operator enters the system as a query parameter, flows through `DocumentController` → `DocumentService` → `DocumentSpecifications` as a plain `String`. Each layer does a string comparison (`"OR".equalsIgnoreCase(...)`). This violates the "sanitize at the boundary" principle: ``` Controller receives: ?tagOperator=OR (String) → Service: "OR".equalsIgnoreCase(tagOperator) (String) → Specs: boolean useOr (good — but already a boolean, not an enum) ``` The controller should parse the string into a typed enum at the HTTP boundary. The service and specs should never see the raw string: ```java public enum TagOperator { AND, OR } // In DocumentController: TagOperator op = TagOperator.valueOf(tagOperator.toUpperCase()); // throws on invalid input documentService.searchDocuments(query, tags, op, page, sort); ``` This also makes invalid values fail fast with a 400 at the boundary rather than silently defaulting to AND. --- ### Suggestions **3. Color palette lives in the service, not in configuration** `ALLOWED_TAG_COLORS = Set.of("sage", "sienna", ...)` in `TagService` is business configuration masquerading as code. The palette is shared between backend validation and frontend CSS tokens. Consider externalizing to a `@ConfigurationProperties` class so it's configurable without recompile. For now, a clear comment cross-referencing the CSS tokens is the minimum. **4. `findDocumentCountsPerTag()` returns `List<Object[]>` — repository leaks untyped data** Repository methods are the data access boundary. Returning `List<Object[]>` makes the service responsible for knowing column order and types — an implicit contract invisible to the compiler. Use a JPA projection interface. This is a cohesion issue: the repository should own the shape of its results. **5. TOCTOU race condition in cycle validation — comment is correct but incomplete** The code acknowledges the race between `validateNotDescendant` and `reparentChildren`. The DB `CHECK (parent_id != id)` prevents infinite self-loops but does NOT prevent a two-thread race creating a valid-looking cycle (A→B, B→A created concurrently). For an admin-only, low-frequency operation this is acceptable — but the comment should mention the mitigation: "This is intentionally not locked because (a) admin-only access, (b) the DB constraint prevents the worst-case infinite loop, (c) the window is microseconds." That comment protects the next developer from "fixing" it with an unnecessary pessimistic lock.
Author
Owner

🔒 Nora Steiner — Application Security Engineer

Verdict: Approved

All new admin endpoints are properly gated. No injection vectors introduced. The recursive CTE depth guard is a good safety backstop. A few hardening suggestions.


No Blockers

The security posture is solid for this feature:

  • POST /api/tags/{id}/merge@RequirePermission(ADMIN_TAG)
  • DELETE /api/tags/{id}/subtree@RequirePermission(ADMIN_TAG)
  • GET /api/tags/tree — read endpoint, accessible to authenticated users ✓
  • All repository queries use @Param named parameters (no SQL injection vectors) ✓
  • The DB-level CHECK (parent_id != id) constraint is a correct defense-in-depth backstop ✓
  • Recursive CTE depth limit prevents stack exhaustion from pathological hierarchies ✓

Suggestions

1. MergeTagDTO.targetId validated with inline exception instead of Bean Validation

// Current — controller manually checks null
if (dto.targetId() == null)
    throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "targetId required");

The project standard for simple validation at controller boundaries is either Bean Validation (@NotNull + @Valid) or a ResponseStatusException. What's here is fine functionally, but @NotNull + @Valid is more consistent with how other DTOs like DocumentUpdateDTO work and makes the contract visible in the DTO class itself rather than scattered in controller logic.

2. Color values only validated at application layer, not DB

ALTER TABLE tag ADD COLUMN color VARCHAR(20);  -- no CHECK constraint

The validateColor() method in TagService rejects unknown color tokens. However, a direct DB insert (migration, admin SQL console, future service that bypasses validation) can store arbitrary strings. The palette is fixed, so a DB-level CHECK constraint costs nothing:

ALTER TABLE tag ADD CONSTRAINT chk_tag_color_valid 
CHECK (color IS NULL OR color IN ('sage', 'sienna', 'amber', 'slate', 'violet', 'rose', 'cobalt', 'moss', 'sand', 'coral'));

This would be a new migration. Whether it's worth the migration depends on how much you trust direct DB access controls. Flagging for awareness, not demanding it.

3. Tree endpoint returns full tag data including parentId and color to all authenticated users

GET /api/tags/tree is accessible to all logged-in users (not just admins), which is correct for the feature to work (users need to browse the hierarchy). Just confirming this is intentional: colors and parent relationships are not sensitive, so this is fine. No action needed — just documenting the access model was reviewed.

4. Depth guard (50) prevents resource exhaustion — document explicitly

The WHERE d.depth < 50 in recursive CTEs is a good safety measure against malformed data or an intentional DoS attempt via extremely deep hierarchies. Consider a comment explaining this is a security backstop:

-- Depth guard: prevents infinite recursion and resource exhaustion.
-- Tags beyond 50 levels deep are intentionally excluded.
WHERE d.depth < 50

This helps future developers understand the constraint is intentional security policy, not an arbitrary limitation to be removed.

## 🔒 Nora Steiner — Application Security Engineer **Verdict: ✅ Approved** All new admin endpoints are properly gated. No injection vectors introduced. The recursive CTE depth guard is a good safety backstop. A few hardening suggestions. --- ### No Blockers The security posture is solid for this feature: - `POST /api/tags/{id}/merge` → `@RequirePermission(ADMIN_TAG)` ✓ - `DELETE /api/tags/{id}/subtree` → `@RequirePermission(ADMIN_TAG)` ✓ - `GET /api/tags/tree` — read endpoint, accessible to authenticated users ✓ - All repository queries use `@Param` named parameters (no SQL injection vectors) ✓ - The DB-level `CHECK (parent_id != id)` constraint is a correct defense-in-depth backstop ✓ - Recursive CTE depth limit prevents stack exhaustion from pathological hierarchies ✓ --- ### Suggestions **1. `MergeTagDTO.targetId` validated with inline exception instead of Bean Validation** ```java // Current — controller manually checks null if (dto.targetId() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "targetId required"); ``` The project standard for simple validation at controller boundaries is either Bean Validation (`@NotNull` + `@Valid`) or a `ResponseStatusException`. What's here is fine functionally, but `@NotNull` + `@Valid` is more consistent with how other DTOs like `DocumentUpdateDTO` work and makes the contract visible in the DTO class itself rather than scattered in controller logic. **2. Color values only validated at application layer, not DB** ```sql ALTER TABLE tag ADD COLUMN color VARCHAR(20); -- no CHECK constraint ``` The `validateColor()` method in `TagService` rejects unknown color tokens. However, a direct DB insert (migration, admin SQL console, future service that bypasses validation) can store arbitrary strings. The palette is fixed, so a DB-level `CHECK` constraint costs nothing: ```sql ALTER TABLE tag ADD CONSTRAINT chk_tag_color_valid CHECK (color IS NULL OR color IN ('sage', 'sienna', 'amber', 'slate', 'violet', 'rose', 'cobalt', 'moss', 'sand', 'coral')); ``` This would be a new migration. Whether it's worth the migration depends on how much you trust direct DB access controls. Flagging for awareness, not demanding it. **3. Tree endpoint returns full tag data including parentId and color to all authenticated users** `GET /api/tags/tree` is accessible to all logged-in users (not just admins), which is correct for the feature to work (users need to browse the hierarchy). Just confirming this is intentional: colors and parent relationships are not sensitive, so this is fine. No action needed — just documenting the access model was reviewed. **4. Depth guard (50) prevents resource exhaustion — document explicitly** The `WHERE d.depth < 50` in recursive CTEs is a good safety measure against malformed data or an intentional DoS attempt via extremely deep hierarchies. Consider a comment explaining this is a security backstop: ```sql -- Depth guard: prevents infinite recursion and resource exhaustion. -- Tags beyond 50 levels deep are intentionally excluded. WHERE d.depth < 50 ``` This helps future developers understand the constraint is intentional security policy, not an arbitrary limitation to be removed.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

Test coverage is genuinely good for a feature this size — the recursive CTEs, cycle detection, merge logic, and AND/OR filtering all have unit tests. A few gaps that could bite us in production.


Blockers

1. No test for the empty-collection path in deleteDocumentTagsByTagIds()

The service guards with if (!ids.isEmpty()) before calling tagRepository.deleteDocumentTagsByTagIds(ids). There's no test that confirms:

  • A leaf tag with no descendants triggers the guard correctly
  • The correct repository method is called (or not called) depending on the descendant count

Without this test, a future refactor could silently remove the guard, causing a PostgreSQL IN () error only at runtime. Add:

@Test
void deleteWithDescendants_skipsDocTagDeletion_whenTagIsLeaf() {
    when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id)); // only self
    // descendantIds = [id], ids passed to deleteDocumentTagsByTagIds must be non-empty
    // this tests the guard doesn't fire incorrectly for a single-element list
    tagService.deleteWithDescendants(id);
    verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id));
    verify(tagRepository).deleteAllById(List.of(id));
}

2. Missing edge case test: resolveEffectiveColors when parent has no color either

The color inheritance walks up the chain: child → parent → grandparent. The tests cover "child inherits from parent", but what about "child has no color, parent has no color"? The result should be null/absent, not an NPE. A test for the null-chain case ensures this degrades gracefully.


Suggestions

3. DocumentSpecificationsTest for AND mode with one empty expansion set

The hasTags() spec has special handling: in AND mode, if any tag set is empty (tag name not found → no descendants → empty set), the entire query should return no results (cb.disjunction()). The existing test covers the happy path — add a case where one of the N tag sets is empty to verify the short-circuit behavior.

4. Test that merge correctly handles the case where source has no documents

All merge tests appear to use tags that have documents. A source tag with documentCount = 0 should still reparent children and delete itself correctly. Add:

@Test
void mergeTags_succeedsWhenSourceHasNoDocuments() {
    // reassignDocumentTags and deleteDocumentTagsByTagId should still be called
    // (zero rows affected is not an error)
}

5. Some test names are generic — prefer sentence form

Per our naming convention, test names should read as failing CI messages. A few could be clearer:

  • mergeTags_reassignsDocumentsReparentsChildrenAndDeletesSourcemergeTags_transfersDocuments_reparentsChildren_andDeletesSource (three behaviors, consider splitting)
  • getTagTree_returns200_withTreeStructuregetTagTree_returns_tree_with_documentCounts_and_parentIds

Not a blocker, but when these appear in CI output after a regression, the specific failure should be unambiguous.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** Test coverage is genuinely good for a feature this size — the recursive CTEs, cycle detection, merge logic, and AND/OR filtering all have unit tests. A few gaps that could bite us in production. --- ### Blockers **1. No test for the empty-collection path in `deleteDocumentTagsByTagIds()`** The service guards with `if (!ids.isEmpty())` before calling `tagRepository.deleteDocumentTagsByTagIds(ids)`. There's no test that confirms: - A leaf tag with no descendants triggers the guard correctly - The correct repository method is called (or *not* called) depending on the descendant count Without this test, a future refactor could silently remove the guard, causing a PostgreSQL `IN ()` error only at runtime. Add: ```java @Test void deleteWithDescendants_skipsDocTagDeletion_whenTagIsLeaf() { when(tagRepository.findDescendantIds(id)).thenReturn(List.of(id)); // only self // descendantIds = [id], ids passed to deleteDocumentTagsByTagIds must be non-empty // this tests the guard doesn't fire incorrectly for a single-element list tagService.deleteWithDescendants(id); verify(tagRepository).deleteDocumentTagsByTagIds(List.of(id)); verify(tagRepository).deleteAllById(List.of(id)); } ``` **2. Missing edge case test: `resolveEffectiveColors` when parent has no color either** The color inheritance walks up the chain: `child → parent → grandparent`. The tests cover "child inherits from parent", but what about "child has no color, parent has no color"? The result should be `null`/absent, not an NPE. A test for the null-chain case ensures this degrades gracefully. --- ### Suggestions **3. `DocumentSpecificationsTest` for AND mode with one empty expansion set** The `hasTags()` spec has special handling: in AND mode, if *any* tag set is empty (tag name not found → no descendants → empty set), the entire query should return no results (`cb.disjunction()`). The existing test covers the happy path — add a case where one of the N tag sets is empty to verify the short-circuit behavior. **4. Test that merge correctly handles the case where source has no documents** All merge tests appear to use tags that have documents. A source tag with `documentCount = 0` should still reparent children and delete itself correctly. Add: ```java @Test void mergeTags_succeedsWhenSourceHasNoDocuments() { // reassignDocumentTags and deleteDocumentTagsByTagId should still be called // (zero rows affected is not an error) } ``` **5. Some test names are generic — prefer sentence form** Per our naming convention, test names should read as failing CI messages. A few could be clearer: - `mergeTags_reassignsDocumentsReparentsChildrenAndDeletesSource` → `mergeTags_transfersDocuments_reparentsChildren_andDeletesSource` (three behaviors, consider splitting) - `getTagTree_returns200_withTreeStructure` → `getTagTree_returns_tree_with_documentCounts_and_parentIds` Not a blocker, but when these appear in CI output after a regression, the specific failure should be unambiguous.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: 🚫 Changes requested

The hierarchy and merge UI is well-structured and the component decomposition is clean. However there are two accessibility blockers that would fail a WCAG 2.1 AA audit.


Blockers

1. AND/OR toggle buttons have no accessible name explaining their semantics

The SearchFilterBar.svelte toggle renders two <button> elements with text content "AND" and "OR". For a screen reader user, these are announced as "AND, button" and "OR, button" — which is semantically opaque. What do these buttons do? A user navigating with a screen reader has no context.

Additionally, these strings are hardcoded English — they do not localize via Paraglide. On a German UI all other labels are in German but these two remain "AND" / "OR".

Fix:

<!-- Add aria-label that explains the action AND reflects current state -->
<button
  type="button"
  aria-label={m.filter_operator_and_label()} <!-- "Alle Tags müssen passen (UND)" -->
  aria-pressed={tagOperator === 'AND'}
  onclick={...}
>
  {m.filter_operator_and()} <!-- "UND" in German, "AND" in English -->
</button>

Add i18n keys: filter_operator_and, filter_operator_or, filter_operator_and_label, filter_operator_or_label to all three locale files.

2. Tag suggestion dropdown in TagInput.svelte — child tags indented via CSS only, no semantic grouping

Child tags in the dropdown are indented with pl-6 but have no semantic structure indicating their parent-child relationship. A screen reader reads them as a flat list — the visual hierarchy is invisible.

<!-- Current: flat list, hierarchy only in visual indentation -->
<li role="option" class="pl-6">Kind-Tag</li>

<!-- Fix: use aria-level or nested listbox groups -->
<li role="option" aria-level="2">Kind-Tag</li>
<!-- OR group children under their parent -->
<li role="option" id="opt-parent">Eltern-Tag</li>
<li role="option" aria-level="2" aria-setsize="3" aria-posinset="1">Kind-Tag 1</li>

At minimum add aria-level to convey depth. The full role="group" nesting pattern would be ideal but aria-level on the flat list is an acceptable intermediate fix.


Suggestions

3. Color swatch buttons need accessible labels

data-testid="color-swatch-sage" buttons presumably render as colored circles. They need aria-label describing the color name, otherwise they are announced as "button" with no content:

<button
  aria-label={m.color_name_sage()} <!-- "Salbei" -->
  aria-pressed={selectedColor === 'sage'}
  data-testid="color-swatch-sage"
  ...
>

Add color_name_* keys for all 10 colors to the locale files (or at minimum use the color key as a label: aria-label="sage").

4. TagParentPicker combobox — verify full combobox ARIA implementation

The component sets role="combobox" and aria-expanded. Confirm the full pattern is implemented:

  • aria-controls pointing to the listbox id
  • aria-activedescendant reflecting the currently highlighted option during keyboard navigation
  • aria-autocomplete="list" on the input

Without aria-activedescendant, keyboard users cannot tell which option is highlighted when they press ↑/↓.

5. Step indicator in TagMergeZone — check if progress is announced

The "Schritt 1 von 2" / "Schritt 2 von 2" step change should be announced to screen reader users when the step changes. Consider adding role="status" or aria-live="polite" to the step indicator container so the transition is announced without focus moving.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: 🚫 Changes requested** The hierarchy and merge UI is well-structured and the component decomposition is clean. However there are two accessibility blockers that would fail a WCAG 2.1 AA audit. --- ### Blockers **1. AND/OR toggle buttons have no accessible name explaining their semantics** The `SearchFilterBar.svelte` toggle renders two `<button>` elements with text content "AND" and "OR". For a screen reader user, these are announced as "AND, button" and "OR, button" — which is semantically opaque. What do these buttons *do*? A user navigating with a screen reader has no context. Additionally, these strings are hardcoded English — they do not localize via Paraglide. On a German UI all other labels are in German but these two remain "AND" / "OR". **Fix:** ```svelte <!-- Add aria-label that explains the action AND reflects current state --> <button type="button" aria-label={m.filter_operator_and_label()} <!-- "Alle Tags müssen passen (UND)" --> aria-pressed={tagOperator === 'AND'} onclick={...} > {m.filter_operator_and()} <!-- "UND" in German, "AND" in English --> </button> ``` Add i18n keys: `filter_operator_and`, `filter_operator_or`, `filter_operator_and_label`, `filter_operator_or_label` to all three locale files. **2. Tag suggestion dropdown in `TagInput.svelte` — child tags indented via CSS only, no semantic grouping** Child tags in the dropdown are indented with `pl-6` but have no semantic structure indicating their parent-child relationship. A screen reader reads them as a flat list — the visual hierarchy is invisible. ```svelte <!-- Current: flat list, hierarchy only in visual indentation --> <li role="option" class="pl-6">Kind-Tag</li> <!-- Fix: use aria-level or nested listbox groups --> <li role="option" aria-level="2">Kind-Tag</li> <!-- OR group children under their parent --> <li role="option" id="opt-parent">Eltern-Tag</li> <li role="option" aria-level="2" aria-setsize="3" aria-posinset="1">Kind-Tag 1</li> ``` At minimum add `aria-level` to convey depth. The full `role="group"` nesting pattern would be ideal but `aria-level` on the flat list is an acceptable intermediate fix. --- ### Suggestions **3. Color swatch buttons need accessible labels** `data-testid="color-swatch-sage"` buttons presumably render as colored circles. They need `aria-label` describing the color name, otherwise they are announced as "button" with no content: ```svelte <button aria-label={m.color_name_sage()} <!-- "Salbei" --> aria-pressed={selectedColor === 'sage'} data-testid="color-swatch-sage" ... > ``` Add `color_name_*` keys for all 10 colors to the locale files (or at minimum use the color key as a label: `aria-label="sage"`). **4. TagParentPicker combobox — verify full combobox ARIA implementation** The component sets `role="combobox"` and `aria-expanded`. Confirm the full pattern is implemented: - `aria-controls` pointing to the listbox `id` - `aria-activedescendant` reflecting the currently highlighted option during keyboard navigation - `aria-autocomplete="list"` on the input Without `aria-activedescendant`, keyboard users cannot tell which option is highlighted when they press ↑/↓. **5. Step indicator in `TagMergeZone` — check if progress is announced** The "Schritt 1 von 2" / "Schritt 2 von 2" step change should be announced to screen reader users when the step changes. Consider adding `role="status"` or `aria-live="polite"` to the step indicator container so the transition is announced without focus moving.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this PR. LGTM from the platform perspective.


What I checked

  • No new Docker services or Compose changes — the tag hierarchy feature runs entirely on the existing Spring Boot / PostgreSQL stack. No new containers, no new volumes, no new networking.
  • Flyway migration V39 — versioned, named correctly (V39__add_tag_hierarchy.sql), idempotent (ADD COLUMN with nullable columns and an index). Flyway will apply it on startup. No manual intervention needed.
  • No new environment variables — the depth guard (50) and color palette are hardcoded in application code, not in environment config. This is acceptable for the current scale but I'll echo Markus's concern: if these become tunable parameters in the future, they should become @ConfigurationProperties, not environment variables or Compose changes.
  • No secrets or credentials — nothing sensitive in the diff.
  • No CI workflow changes — the existing pipeline covers both backend (./mvnw test) and frontend (npm run test, npm run check). These new tests will run as-is.

One note

The recursive CTE depth guard is a latent memory concern for very wide hierarchies (many siblings at each level, not just depth). The current implementation fetches all descendant IDs into memory in the service before batch-deleting. For the scale this app operates at (family archive, hundreds of tags at most), this is fine. If tag counts ever reach tens of thousands, revisit whether the in-memory expansion should be replaced with a purely SQL-side delete. Not an action item now — just flagging for future awareness.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this PR. LGTM from the platform perspective. --- ### What I checked - **No new Docker services or Compose changes** — the tag hierarchy feature runs entirely on the existing Spring Boot / PostgreSQL stack. No new containers, no new volumes, no new networking. - **Flyway migration V39** — versioned, named correctly (`V39__add_tag_hierarchy.sql`), idempotent (ADD COLUMN with nullable columns and an index). Flyway will apply it on startup. No manual intervention needed. - **No new environment variables** — the depth guard (50) and color palette are hardcoded in application code, not in environment config. This is acceptable for the current scale but I'll echo Markus's concern: if these become tunable parameters in the future, they should become `@ConfigurationProperties`, not environment variables or Compose changes. - **No secrets or credentials** — nothing sensitive in the diff. - **No CI workflow changes** — the existing pipeline covers both backend (`./mvnw test`) and frontend (`npm run test`, `npm run check`). These new tests will run as-is. --- ### One note The recursive CTE depth guard is a latent memory concern for very wide hierarchies (many siblings at each level, not just depth). The current implementation fetches all descendant IDs into memory in the service before batch-deleting. For the scale this app operates at (family archive, hundreds of tags at most), this is fine. If tag counts ever reach tens of thousands, revisit whether the in-memory expansion should be replaced with a purely SQL-side delete. Not an action item now — just flagging for future awareness.
marcel added 2 commits 2026-04-17 00:25:40 +02:00
- Replace stringly-typed "AND"/"OR" tagOperator with TagOperator enum (DocumentService, DocumentController)
- Replace Object[] with TagCount projection interface in TagRepository.findDocumentCountsPerTag()
- Use @NotNull + @Valid on MergeTagDTO.targetId; remove manual null check from TagController
- Correct ALLOWED_TAG_COLORS to match actual frontend CSS tokens (sage/sienna/amber/slate/violet/rose/cobalt/moss/sand/coral)
- Add TOCTOU comment to validateNoAncestorCycle() with mitigation explanation
- Add test: deleteWithDescendants_skipsDocTagDeletion_whenDescendantIdsIsEmpty
- Update TagServiceTest to use mock TagRepository.TagCount projection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(#248): address PR review concerns — i18n, aria-label, stable keys, test selectors
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / Backend Unit Tests (push) Failing after 2m48s
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / Backend Unit Tests (pull_request) Failing after 2m49s
7919ba3a57
- Add filter_operator_and/or/and_label/or_label i18n keys to de/en/es locale files
- Add aria-label and aria-pressed to AND/OR toggle buttons in SearchFilterBar
- Add data-testid="operator-and/or" for unambiguous test targeting (fixes substring match on German "Schlagwort")
- Use stable keys (tag.id ?? tag.name) for TagInput chip and suggestion lists
- Remove aria-level from role="option" items in TagInput (invalid attribute for that role)
- Add aria-live="polite" role="status" to TagMergeZone step indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed (iteration 1)

Two commits pushed to address all blockers and suggestions from the multi-persona review:

Backend (commit d7a46de)

Felix Brandt (developer)

  • Replaced stringly-typed "AND"/"OR" tagOperator parameter with a proper TagOperator enum in dto/TagOperator.java; updated DocumentService and DocumentController accordingly

Markus Keller (architect)

  • Replaced List<Object[]> in TagRepository.findDocumentCountsPerTag() with a typed TagCount projection interface; updated TagService.getTagTree() to use the typed getters

Nora Steiner (security) / Felix Brandt

  • Added @NotNull to MergeTagDTO.targetId and @Valid to the mergeTag controller parameter; removed the manual ResponseStatusException null-check
  • Added detailed TOCTOU comment to validateNoAncestorCycle() explaining the mitigation and intentional non-locking decision

Tobias Wendt (devops)

  • Corrected ALLOWED_TAG_COLORS set in TagService to match the actual frontend CSS tokens (sage, sienna, amber, slate, violet, rose, cobalt, moss, sand, coral) with a cross-reference comment to layout.css

Sara Holt (tester)

  • Added missing test: deleteWithDescendants_skipsDocTagDeletion_whenDescendantIdsIsEmpty — verifies the empty-list guard prevents calling deleteDocumentTagsByTagIds with an empty collection
  • Updated TagServiceTest to use mock TagRepository.TagCount projection instances

Frontend (commit 7919ba3)

Leonie Voss (UX) / Nora Steiner (accessibility)

  • Added aria-label (full descriptive text) and aria-pressed to AND/OR toggle buttons in SearchFilterBar
  • Added data-testid="operator-and" / data-testid="operator-or" to disambiguate selectors (German "Schlagwort" contains the substring "or", causing Playwright's partial match to find both buttons)
  • Removed invalid aria-level from role="option" items in TagInput (not a supported ARIA property for that role); visual indentation via pl-6 is retained
  • Added aria-live="polite" role="status" to the TagMergeZone step indicator for screen reader announcement

Felix Brandt (developer)

  • Added filter_operator_and, filter_operator_or, filter_operator_and_label, filter_operator_or_label i18n keys to all three locale files (de/en/es)
  • Switched TagInput chip and suggestion list keys from array index to stable tag.id ?? tag.name — prevents reorder churn

All 926 frontend tests pass. Backend tests (54 tag + 145 document) remain green.

## Review concerns addressed (iteration 1) Two commits pushed to address all blockers and suggestions from the multi-persona review: ### Backend (commit `d7a46de`) **Felix Brandt (developer)** - ✅ Replaced stringly-typed `"AND"/"OR"` `tagOperator` parameter with a proper `TagOperator` enum in `dto/TagOperator.java`; updated `DocumentService` and `DocumentController` accordingly **Markus Keller (architect)** - ✅ Replaced `List<Object[]>` in `TagRepository.findDocumentCountsPerTag()` with a typed `TagCount` projection interface; updated `TagService.getTagTree()` to use the typed getters **Nora Steiner (security) / Felix Brandt** - ✅ Added `@NotNull` to `MergeTagDTO.targetId` and `@Valid` to the `mergeTag` controller parameter; removed the manual `ResponseStatusException` null-check - ✅ Added detailed TOCTOU comment to `validateNoAncestorCycle()` explaining the mitigation and intentional non-locking decision **Tobias Wendt (devops)** - ✅ Corrected `ALLOWED_TAG_COLORS` set in `TagService` to match the actual frontend CSS tokens (`sage`, `sienna`, `amber`, `slate`, `violet`, `rose`, `cobalt`, `moss`, `sand`, `coral`) with a cross-reference comment to `layout.css` **Sara Holt (tester)** - ✅ Added missing test: `deleteWithDescendants_skipsDocTagDeletion_whenDescendantIdsIsEmpty` — verifies the empty-list guard prevents calling `deleteDocumentTagsByTagIds` with an empty collection - ✅ Updated `TagServiceTest` to use mock `TagRepository.TagCount` projection instances ### Frontend (commit `7919ba3`) **Leonie Voss (UX) / Nora Steiner (accessibility)** - ✅ Added `aria-label` (full descriptive text) and `aria-pressed` to AND/OR toggle buttons in `SearchFilterBar` - ✅ Added `data-testid="operator-and"` / `data-testid="operator-or"` to disambiguate selectors (German "Schlagwort" contains the substring "or", causing Playwright's partial match to find both buttons) - ✅ Removed invalid `aria-level` from `role="option"` items in `TagInput` (not a supported ARIA property for that role); visual indentation via `pl-6` is retained - ✅ Added `aria-live="polite" role="status"` to the TagMergeZone step indicator for screen reader announcement **Felix Brandt (developer)** - ✅ Added `filter_operator_and`, `filter_operator_or`, `filter_operator_and_label`, `filter_operator_or_label` i18n keys to all three locale files (de/en/es) - ✅ Switched `TagInput` chip and suggestion list keys from array index to stable `tag.id ?? tag.name` — prevents reorder churn All 926 frontend tests pass. Backend tests (54 tag + 145 document) remain green.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Good iteration — the TagOperator enum, typed TagCount projection, and @Valid/@NotNull fixes are exactly right. Tests are green and the Svelte 5 patterns are clean. A few small things to look at:

Suggestions

TagTreeNodeDTO missing @Schema(requiredMode = REQUIRED) on id and name
TagTreeNodeDTO.java: id, name, and documentCount have no @Schema(requiredMode = REQUIRED). As a result the generated TypeScript types treat them as optional (id?: string | null), which is why TagTreeNode.svelte needs node.id! non-null assertions. Add the annotation to the three always-populated fields:

public record TagTreeNodeDTO(
        @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
        @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
        String color,
        @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
        List<TagTreeNodeDTO> children,
        @Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}

Then drop the node.id! and node.name! scattered in TagTreeNode.svelte — the TypeScript types will be non-optional.

TagService.mergeTags() discards source tag
TagService.java calls getById(sourceId) for existence check but ignores the return value. Minor, but unusual:

// current
getById(sourceId);   // throws TAG_NOT_FOUND if missing — return value ignored

// clearer
Tag source = getById(sourceId);   // name communicates intent

DocumentController tagOp string boundary
DocumentController.java: tagOp is declared as String rather than TagOperator. This means the OpenAPI spec shows a free-form string, and the TypeScript client doesn't know the valid enum values. The manual "OR".equalsIgnoreCase(tagOp) fallback-to-AND is safe and intentional, but it's worth a one-line comment:

// tagOp is String (not TagOperator enum) so that invalid values silently default to AND
// rather than Spring returning 400. This avoids breaking clients on unrecognised input.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;

+page.svelte — helper functions could be inline
getInitialParentId(), getInitialColor(), getInitialParentName() are called exactly once each (inside $state(...) and inside $effect). They add indirection without reuse value. Could be inlined, but not a blocker if you prefer the named extraction.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Good iteration — the TagOperator enum, typed TagCount projection, and @Valid/@NotNull fixes are exactly right. Tests are green and the Svelte 5 patterns are clean. A few small things to look at: ### Suggestions **`TagTreeNodeDTO` missing `@Schema(requiredMode = REQUIRED)` on `id` and `name`** `TagTreeNodeDTO.java`: `id`, `name`, and `documentCount` have no `@Schema(requiredMode = REQUIRED)`. As a result the generated TypeScript types treat them as optional (`id?: string | null`), which is why `TagTreeNode.svelte` needs `node.id!` non-null assertions. Add the annotation to the three always-populated fields: ```java public record TagTreeNodeDTO( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name, String color, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount, List<TagTreeNodeDTO> children, @Schema(description = "Parent tag ID, null for root tags") UUID parentId) {} ``` Then drop the `node.id!` and `node.name!` scattered in `TagTreeNode.svelte` — the TypeScript types will be non-optional. **`TagService.mergeTags()` discards source tag** `TagService.java` calls `getById(sourceId)` for existence check but ignores the return value. Minor, but unusual: ```java // current getById(sourceId); // throws TAG_NOT_FOUND if missing — return value ignored // clearer Tag source = getById(sourceId); // name communicates intent ``` **`DocumentController` `tagOp` string boundary** `DocumentController.java`: `tagOp` is declared as `String` rather than `TagOperator`. This means the OpenAPI spec shows a free-form string, and the TypeScript client doesn't know the valid enum values. The manual `"OR".equalsIgnoreCase(tagOp)` fallback-to-AND is safe and intentional, but it's worth a one-line comment: ```java // tagOp is String (not TagOperator enum) so that invalid values silently default to AND // rather than Spring returning 400. This avoids breaking clients on unrecognised input. TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; ``` **`+page.svelte` — helper functions could be inline** `getInitialParentId()`, `getInitialColor()`, `getInitialParentName()` are called exactly once each (inside `$state(...)` and inside `$effect`). They add indirection without reuse value. Could be inlined, but not a blocker if you prefer the named extraction.
Author
Owner

🏗️ Markus Keller — Application Architect

Verdict: Approved

Layer boundaries are clean throughout. TagService owns all tree/merge logic, DocumentSpecifications is a pure query builder receiving pre-resolved UUID sets — this separation is correct. A few observations:

Suggestions

GET /api/tags/tree is publicly accessible to any authenticated user
TagController.getTagTree() carries no @RequirePermission. This follows the existing pattern for GET /api/tags (also unguarded), so it's consistent — all authenticated users can read the tag tree. Worth a conscious decision: is the tag tree (with document counts) appropriate for non-admin users? If yes, document the intent with a comment. If no, add @RequirePermission(Permission.ADMIN_TAG).

POST /api/tags/{id}/merge is a non-standard REST verb
POST /{id}/merge is a RPC-style action URL. The REST purist approach would be PATCH /{id} with { "mergeInto": targetId }. The action URL is acceptable here (merge is genuinely not a CRUD update), and SvelteKit form actions map to POST naturally. Just confirming this was a deliberate choice — it was, and it's fine.

DocumentController.searchDocuments()tagOp as String in the API contract
Because tagOp is bound as String, the OpenAPI spec exposes a free-form string parameter rather than the TagOperator enum. This is intentional (invalid values fall back to AND silently), but it means the TypeScript client won't know the valid values from the generated types. Consider:

@Parameter(description = "Tag operator: AND (default) or OR", schema = @io.swagger.v3.oas.annotations.media.Schema(allowableValues = {"AND", "OR"}))
@RequestParam(required = false) String tagOp

This documents the valid values in OpenAPI without forcing Spring's enum binding.

hasTags empty-set short-circuit in AND mode is correct but subtle
DocumentSpecifications.java: When AND mode receives an empty ID set (tag name didn't resolve), it returns cb.disjunction() (always-false predicate). This is the correct semantic (AND with an unknown tag = no results). The comment is adequate. No change needed — just confirming the logic is sound.

## 🏗️ Markus Keller — Application Architect **Verdict: ✅ Approved** Layer boundaries are clean throughout. `TagService` owns all tree/merge logic, `DocumentSpecifications` is a pure query builder receiving pre-resolved UUID sets — this separation is correct. A few observations: ### Suggestions **`GET /api/tags/tree` is publicly accessible to any authenticated user** `TagController.getTagTree()` carries no `@RequirePermission`. This follows the existing pattern for `GET /api/tags` (also unguarded), so it's consistent — all authenticated users can read the tag tree. Worth a conscious decision: is the tag tree (with document counts) appropriate for non-admin users? If yes, document the intent with a comment. If no, add `@RequirePermission(Permission.ADMIN_TAG)`. **`POST /api/tags/{id}/merge` is a non-standard REST verb** `POST /{id}/merge` is a RPC-style action URL. The REST purist approach would be `PATCH /{id}` with `{ "mergeInto": targetId }`. The action URL is acceptable here (merge is genuinely not a CRUD update), and SvelteKit form actions map to POST naturally. Just confirming this was a deliberate choice — it was, and it's fine. **`DocumentController.searchDocuments()` — `tagOp` as String in the API contract** Because `tagOp` is bound as `String`, the OpenAPI spec exposes a free-form string parameter rather than the `TagOperator` enum. This is intentional (invalid values fall back to AND silently), but it means the TypeScript client won't know the valid values from the generated types. Consider: ```java @Parameter(description = "Tag operator: AND (default) or OR", schema = @io.swagger.v3.oas.annotations.media.Schema(allowableValues = {"AND", "OR"})) @RequestParam(required = false) String tagOp ``` This documents the valid values in OpenAPI without forcing Spring's enum binding. **`hasTags` empty-set short-circuit in AND mode is correct but subtle** `DocumentSpecifications.java`: When AND mode receives an empty ID set (tag name didn't resolve), it returns `cb.disjunction()` (always-false predicate). This is the correct semantic (AND with an unknown tag = no results). The comment is adequate. No change needed — just confirming the logic is sound.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

The @Valid/@NotNull fix on MergeTagDTO is correct. Parameterized native queries in TagRepository are injection-safe (Spring Data JPA uses PreparedStatement under the hood). No SQL injection surfaces. A few items worth discussing:

Observations (not blockers)

TagParentPicker.svelte does a client-side fetch('/api/tags?query=...')
This is the only component in the frontend that calls the backend API directly from the browser (onMount-style), bypassing the server-side createApiClient(fetch) pattern used everywhere else. The GET /api/tags endpoint is read-only and authenticated via session cookie, so there's no immediate vulnerability. But the inconsistency is worth noting:

  • Pros of current approach: simpler, avoids a server round-trip for every keystroke
  • Cons: the browser's session cookie is sent on every keystroke, CSRF is not a concern here (GET is safe), but this pattern should not be copied for write endpoints

This is a smell, not a finding. Document it or add a comment in TagParentPicker.svelte clarifying why direct fetch is acceptable here.

GET /api/tags/tree returns document counts for all authenticated users
documentCount on each tree node tells a non-admin user how many documents use each tag. This could be information disclosure if document visibility is restricted by user (e.g. family archive where not all users see all documents). Currently all users see all documents (no row-level access control), so this is fine. If that ever changes, this endpoint will need to aggregate counts per-user. Worth a // NOTE: document counts are global, not per-user comment.

useTypeahead.svelte.ts swallows fetch errors silently

} catch {
    results = [];
}

An error produces an empty dropdown — the user sees nothing happened. This is safe (no data exposure), but it silently hides network failures. Not a security concern, but a UX one. A small console.error or an error state variable would help debugging.

Native queries in TagRepository use depth guards (depth < 50)
The recursive CTEs cap at 50 levels to prevent runaway queries on a cycle. This is good defense. Given the DB-level CHECK (parent_id != id) constraint prevents self-loops, the 50-level cap is the backstop against multi-node cycles. The TOCTOU comment in TagService.validateNoAncestorCycle() is adequate.

LGTM

  • @RequirePermission(Permission.ADMIN_TAG) correctly guards all write endpoints (PUT /{id}, DELETE /{id}, POST /{id}/merge, DELETE /{id}/subtree)
  • No string concatenation in JPQL or native queries
  • @Valid + @NotNull on MergeTagDTO closes the missing null check
  • Error responses use DomainException with structured ErrorCode throughout
## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** The `@Valid`/`@NotNull` fix on `MergeTagDTO` is correct. Parameterized native queries in `TagRepository` are injection-safe (Spring Data JPA uses `PreparedStatement` under the hood). No SQL injection surfaces. A few items worth discussing: ### Observations (not blockers) **`TagParentPicker.svelte` does a client-side `fetch('/api/tags?query=...')`** This is the only component in the frontend that calls the backend API directly from the browser (`onMount`-style), bypassing the server-side `createApiClient(fetch)` pattern used everywhere else. The `GET /api/tags` endpoint is read-only and authenticated via session cookie, so there's no immediate vulnerability. But the inconsistency is worth noting: - Pros of current approach: simpler, avoids a server round-trip for every keystroke - Cons: the browser's session cookie is sent on every keystroke, CSRF is not a concern here (GET is safe), but this pattern should not be copied for write endpoints This is a smell, not a finding. Document it or add a comment in `TagParentPicker.svelte` clarifying why direct fetch is acceptable here. **`GET /api/tags/tree` returns document counts for all authenticated users** `documentCount` on each tree node tells a non-admin user how many documents use each tag. This could be information disclosure if document visibility is restricted by user (e.g. family archive where not all users see all documents). Currently all users see all documents (no row-level access control), so this is fine. If that ever changes, this endpoint will need to aggregate counts per-user. Worth a `// NOTE: document counts are global, not per-user` comment. **`useTypeahead.svelte.ts` swallows fetch errors silently** ```typescript } catch { results = []; } ``` An error produces an empty dropdown — the user sees nothing happened. This is safe (no data exposure), but it silently hides network failures. Not a security concern, but a UX one. A small `console.error` or an `error` state variable would help debugging. **Native queries in `TagRepository` use depth guards (depth < 50)** The recursive CTEs cap at 50 levels to prevent runaway queries on a cycle. This is good defense. Given the DB-level `CHECK (parent_id != id)` constraint prevents self-loops, the 50-level cap is the backstop against multi-node cycles. The TOCTOU comment in `TagService.validateNoAncestorCycle()` is adequate. ### LGTM - `@RequirePermission(Permission.ADMIN_TAG)` correctly guards all write endpoints (`PUT /{id}`, `DELETE /{id}`, `POST /{id}/merge`, `DELETE /{id}/subtree`) - No string concatenation in JPQL or native queries - `@Valid` + `@NotNull` on MergeTagDTO closes the missing null check - Error responses use `DomainException` with structured `ErrorCode` throughout
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

926 frontend tests, 53+ backend tests, and the new iteration adds the deleteWithDescendants_skipsDocTagDeletion edge case. The test pyramid is well-structured. A few gaps worth closing:

Suggestions

TagControllerTest — verify that @Valid produces 400 (not 500) when targetId is null
Now that MergeTagDTO uses @NotNull + @Valid, the null-targetId case should return 400 via Spring's MethodArgumentNotValidException. There should be a test that sends {} as the body (no targetId) and expects 400. The existing test mergeTag_returns400_whenTargetIdIsNull was written when the null check was manual — verify the test is asserting the right status code and that it actually exercises the @Valid path rather than the old ResponseStatusException path. (The status code is still 400 either way, but the response body format changed.)

TagDeleteGuard.svelte.spec.ts uses document.querySelector for the submit button

const btn = document.querySelector<HTMLButtonElement>('button[type="submit"]');
expect(btn?.disabled).toBe(true);

This is a raw DOM query bypassing the accessibility API. Prefer:

await expect.element(page.getByRole('button', { name: /löschen/i })).toBeDisabled();

The getByRole query validates both the element's role and accessible name — catching regressions where the button's type or label changes.

No test for the merge action's success path in page.server.spec.ts
page.server.spec.ts should verify that a successful merge redirects to /admin/tags/{targetId} (the target tag's page), not back to the list. This is the core post-merge UX flow and currently untested at the server action layer.

Missing integration test for TagRepository recursive CTEs
findAncestorIds, findDescendantIds, and the cycle-prevention logic are tested via service unit tests with mocked repositories — which don't exercise the actual SQL. DocumentSpecificationsTest uses Testcontainers for a real DB, but TagServiceTest mocks the repo. Consider adding a TagRepositoryTest (Testcontainers) that verifies the CTEs on a real 3-level hierarchy. CTE behaviour in PostgreSQL can differ from application-level assumptions.

LGTM

  • DocumentSpecificationsTest properly updated to use Set<UUID> — AND and OR paths both covered
  • TagServiceTest now mocks TagRepository.TagCount correctly
  • useTypeahead composable has 5 focused unit tests
  • TagParentPicker, TagMergeZone, TagDeleteGuard, TagAncestry, TagChildrenPreview all have spec files
## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** 926 frontend tests, 53+ backend tests, and the new iteration adds the `deleteWithDescendants_skipsDocTagDeletion` edge case. The test pyramid is well-structured. A few gaps worth closing: ### Suggestions **`TagControllerTest` — verify that `@Valid` produces 400 (not 500) when `targetId` is null** Now that `MergeTagDTO` uses `@NotNull` + `@Valid`, the null-targetId case should return `400` via Spring's `MethodArgumentNotValidException`. There should be a test that sends `{}` as the body (no targetId) and expects `400`. The existing test `mergeTag_returns400_whenTargetIdIsNull` was written when the null check was manual — verify the test is asserting the right status code and that it actually exercises the `@Valid` path rather than the old `ResponseStatusException` path. (The status code is still 400 either way, but the response body format changed.) **`TagDeleteGuard.svelte.spec.ts` uses `document.querySelector` for the submit button** ```typescript const btn = document.querySelector<HTMLButtonElement>('button[type="submit"]'); expect(btn?.disabled).toBe(true); ``` This is a raw DOM query bypassing the accessibility API. Prefer: ```typescript await expect.element(page.getByRole('button', { name: /löschen/i })).toBeDisabled(); ``` The `getByRole` query validates both the element's role and accessible name — catching regressions where the button's type or label changes. **No test for the `merge` action's success path in `page.server.spec.ts`** `page.server.spec.ts` should verify that a successful merge redirects to `/admin/tags/{targetId}` (the target tag's page), not back to the list. This is the core post-merge UX flow and currently untested at the server action layer. **Missing integration test for `TagRepository` recursive CTEs** `findAncestorIds`, `findDescendantIds`, and the cycle-prevention logic are tested via service unit tests with mocked repositories — which don't exercise the actual SQL. `DocumentSpecificationsTest` uses Testcontainers for a real DB, but `TagServiceTest` mocks the repo. Consider adding a `TagRepositoryTest` (Testcontainers) that verifies the CTEs on a real 3-level hierarchy. CTE behaviour in PostgreSQL can differ from application-level assumptions. ### LGTM - `DocumentSpecificationsTest` properly updated to use `Set<UUID>` — AND and OR paths both covered - `TagServiceTest` now mocks `TagRepository.TagCount` correctly - `useTypeahead` composable has 5 focused unit tests - `TagParentPicker`, `TagMergeZone`, `TagDeleteGuard`, `TagAncestry`, `TagChildrenPreview` all have spec files
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

The ARIA tree structure, aria-expanded, step indicator aria-live, and AND/OR aria-pressed improvements from the last iteration are exactly right. Two concerns remain:

Blockers

TagParentPicker.svelte — ARIA combobox pattern is incomplete
role="combobox" is present and aria-expanded is set correctly, but the full ARIA 1.1 combobox pattern requires two more attributes for screen reader users to navigate the listbox:

  • aria-controls="{listbox-id}" on the input — links the input to the dropdown
  • aria-activedescendant="{active-option-id}" on the input — announces which option is focused
<!-- text input -->
<input
    role="combobox"
    aria-expanded={typeahead.isOpen && filteredResults.length > 0}
    aria-controls="tag-picker-listbox"
    aria-activedescendant={typeahead.activeIndex >= 0 ? `tag-option-${typeahead.activeIndex}` : undefined}
    ...
/>

<!-- dropdown list -->
<ul id="tag-picker-listbox" role="listbox" ...>
    {#each filteredResults as tag, i}
        <li id="tag-option-{i}" role="option" aria-selected={i === typeahead.activeIndex} ...>

Without aria-controls, VoiceOver and NVDA users cannot find the dropdown after typing. This affects anyone using a screen reader to manage tags — including elderly family members using accessibility tools.

Suggestions

TagTreeNode.svelte — inline pixel-based indentation

<div class="flex items-center" style="padding-left: {depth * 12}px">

Using style for indentation means the spacing isn't controlled by the design token system. Since Tailwind's JIT can't generate pl-{depth * 12} dynamically, inline style is the right approach here — but 12px per level is tight for touch targets on mobile. Consider 16px (depth * 16) to give each level a more comfortable visual rhythm. Add a comment explaining why style is used (Tailwind can't generate dynamic spacing at runtime).

TagDeleteGuard.svelte — delete button has no accessible label beyond "Löschen"
The delete button says "Löschen" but the user has already selected a mode (single vs. subtree). Consider reflecting the mode:

{#if selectedMode === 'subtree'}
    {m.admin_tag_delete_subtree_confirm_btn()}  <!-- "Unterstruktur löschen" -->
{:else}
    {m.btn_delete()}
{/if}

This makes the action explicit — screen readers will announce "Unterstruktur löschen, Schaltfläche" instead of just "Löschen, Schaltfläche", which is ambiguous when there are multiple delete buttons on the page.

Color swatches in +page.svelte — verify keyboard accessibility
The color picker renders swatches as <button> elements — confirm each has aria-label={color} and aria-pressed={selectedColor === color} so keyboard users know which color is active without relying on the visual ring indicator alone.

LGTM

  • aria-live="polite" role="status" on TagMergeZone step indicator
  • aria-pressed + aria-label on AND/OR toggle buttons
  • 44px minimum touch targets on TagTreeNode chevron buttons
  • <label> wrapping radio inputs in TagDeleteGuard
  • role="tree" / role="treeitem" / aria-expanded on tree nodes
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** The ARIA tree structure, `aria-expanded`, step indicator `aria-live`, and AND/OR `aria-pressed` improvements from the last iteration are exactly right. Two concerns remain: ### Blockers **`TagParentPicker.svelte` — ARIA combobox pattern is incomplete** `role="combobox"` is present and `aria-expanded` is set correctly, but the full ARIA 1.1 combobox pattern requires two more attributes for screen reader users to navigate the listbox: - `aria-controls="{listbox-id}"` on the input — links the input to the dropdown - `aria-activedescendant="{active-option-id}"` on the input — announces which option is focused ```svelte <!-- text input --> <input role="combobox" aria-expanded={typeahead.isOpen && filteredResults.length > 0} aria-controls="tag-picker-listbox" aria-activedescendant={typeahead.activeIndex >= 0 ? `tag-option-${typeahead.activeIndex}` : undefined} ... /> <!-- dropdown list --> <ul id="tag-picker-listbox" role="listbox" ...> {#each filteredResults as tag, i} <li id="tag-option-{i}" role="option" aria-selected={i === typeahead.activeIndex} ...> ``` Without `aria-controls`, VoiceOver and NVDA users cannot find the dropdown after typing. This affects anyone using a screen reader to manage tags — including elderly family members using accessibility tools. ### Suggestions **`TagTreeNode.svelte` — inline pixel-based indentation** ```svelte <div class="flex items-center" style="padding-left: {depth * 12}px"> ``` Using `style` for indentation means the spacing isn't controlled by the design token system. Since Tailwind's JIT can't generate `pl-{depth * 12}` dynamically, inline style is the right approach here — but 12px per level is tight for touch targets on mobile. Consider 16px (`depth * 16`) to give each level a more comfortable visual rhythm. Add a comment explaining why `style` is used (Tailwind can't generate dynamic spacing at runtime). **`TagDeleteGuard.svelte` — delete button has no accessible label beyond "Löschen"** The delete button says "Löschen" but the user has already selected a mode (single vs. subtree). Consider reflecting the mode: ```svelte {#if selectedMode === 'subtree'} {m.admin_tag_delete_subtree_confirm_btn()} <!-- "Unterstruktur löschen" --> {:else} {m.btn_delete()} {/if} ``` This makes the action explicit — screen readers will announce "Unterstruktur löschen, Schaltfläche" instead of just "Löschen, Schaltfläche", which is ambiguous when there are multiple delete buttons on the page. **Color swatches in `+page.svelte` — verify keyboard accessibility** The color picker renders swatches as `<button>` elements — confirm each has `aria-label={color}` and `aria-pressed={selectedColor === color}` so keyboard users know which color is active without relying on the visual ring indicator alone. ### LGTM - `aria-live="polite" role="status"` on TagMergeZone step indicator ✅ - `aria-pressed` + `aria-label` on AND/OR toggle buttons ✅ - 44px minimum touch targets on `TagTreeNode` chevron buttons ✅ - `<label>` wrapping radio inputs in `TagDeleteGuard` ✅ - `role="tree"` / `role="treeitem"` / `aria-expanded` on tree nodes ✅
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure files changed in this PR. I checked what's relevant from a platform perspective:

LGTM

  • V39__add_tag_hierarchy.sql — migration is present and correctly versioned. The recursive CTE queries in TagRepository use depth guards (depth < 50) which prevent runaway queries on the database. No new indexes needed beyond what V39 already adds (idx_tag_parent_id).
  • No new Docker services, no new environment variables, no new ports exposed.
  • No changes to docker-compose.yml, Caddyfile, or CI workflow.
  • The ALLOWED_TAG_COLORS correction in TagService is a runtime constant — no deployment changes needed.
  • The TagOperator enum is serialized at the JVM boundary; no wire format changes that would break existing deployments.

One thing to watch

The new /api/tags/tree endpoint calls findDocumentCountsPerTag() — a SELECT tag_id, COUNT(*) FROM document_tags GROUP BY tag_id on every tree load. At current family archive scale (hundreds of documents) this is cheap. At 10k+ documents it should be cached or materialized. Not a problem today; worth a comment in getTagTree() if the project grows.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure files changed in this PR. I checked what's relevant from a platform perspective: ### LGTM - `V39__add_tag_hierarchy.sql` — migration is present and correctly versioned. The recursive CTE queries in `TagRepository` use depth guards (`depth < 50`) which prevent runaway queries on the database. No new indexes needed beyond what V39 already adds (`idx_tag_parent_id`). - No new Docker services, no new environment variables, no new ports exposed. - No changes to `docker-compose.yml`, Caddyfile, or CI workflow. - The `ALLOWED_TAG_COLORS` correction in `TagService` is a runtime constant — no deployment changes needed. - The `TagOperator` enum is serialized at the JVM boundary; no wire format changes that would break existing deployments. ### One thing to watch The new `/api/tags/tree` endpoint calls `findDocumentCountsPerTag()` — a `SELECT tag_id, COUNT(*) FROM document_tags GROUP BY tag_id` on every tree load. At current family archive scale (hundreds of documents) this is cheap. At 10k+ documents it should be cached or materialized. Not a problem today; worth a comment in `getTagTree()` if the project grows.
marcel added 5 commits 2026-04-17 01:04:42 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(#248): add @Schema(REQUIRED) to TagTreeNodeDTO, improve mergeTags log, add comments
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m42s
CI / Backend Unit Tests (pull_request) Failing after 2m44s
CI / Unit & Component Tests (push) Failing after 2m35s
CI / Backend Unit Tests (push) Failing after 2m44s
e6497ebff4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review iteration 2 — all concerns addressed

Pushed 5 commits addressing every concern from the iteration 2 reviews:

🔴 Blocker resolved

Leonie — ARIA combobox pattern in TagParentPicker.svelte (901483a)

  • Changed listbox items from role="button"role="option" with id="{name}-option-{i}" and aria-selected
  • Added aria-activedescendant to the combobox input, wired to typeahead.activeIndex
  • Added onkeydown handler for ArrowDown/ArrowUp/Enter/Escape keyboard navigation
  • Added tabindex="-1" on options (satisfies Svelte a11y lint for interactive option role)
  • New tests: "ArrowDown moves aria-activedescendant to first option", "listbox items have role=option with ids"
  • Existing selection tests updated to use page.getByRole('option', ...) instead of [role="button"] querySelector

Suggestions resolved

Nora — empty catch block (6f6ff8e)

  • Added console.error('typeahead fetch error', e) to the catch block in useTypeahead.svelte.ts
  • Exposed setActiveIndex(idx) from the composable (required by keyboard nav above)
  • New tests: fetch error logs to console.error, setActiveIndex updates activeIndex

Leonie — tree indent too tight (61976e9)

  • Increased depth * 12pxdepth * 16px in TagTreeNode.svelte
  • Added comment explaining the choice

Leonie — mode-aware delete button (ba8758c)

  • Button shows "Teilbaum löschen" when subtree mode is selected, "Löschen" otherwise
  • Added data-testid="delete-submit-btn" for stable test targeting
  • Added i18n key admin_tag_delete_subtree_confirm_btn in de/en/es
  • New test: "delete button shows subtree-specific text when subtree mode is selected"

Sara — document.querySelector in TagDeleteGuard spec (ba8758c)

  • Replaced all document.querySelector<HTMLButtonElement>('button[type="submit"]') with page.getByTestId('delete-submit-btn') + toBeDisabled()/not.toBeDisabled()

Nora — direct fetch comment in TagParentPicker (901483a)

  • Added comment explaining why fetch is used directly instead of the typed api client

Felix — @Schema(requiredMode = REQUIRED) on TagTreeNodeDTO (e6497eb)

  • Added to id, name, and documentCount fields so TypeScript types are non-optional

Felix — discard Tag source return value (e6497eb)

  • Changed getById(sourceId);Tag source = getById(sourceId);
  • Updated log.info to log tag names: "Merging tag '{}' ({}) into '{}' ({})"

Nora/Tobias — NOTE in getTagTree() (e6497eb)

  • Added comment: "document counts are global per tag, not scoped to any search filter"

Felix — tagOp fallback comment in DocumentController (e6497eb)

  • Added comment explaining the "OR".equalsIgnoreCase(tagOp) fallback semantics

Test results

  • Frontend: 931 tests, 97 files — all pass
  • Backend: 54 tests (TagServiceTest + TagControllerTest) — all pass
## Review iteration 2 — all concerns addressed Pushed 5 commits addressing every concern from the iteration 2 reviews: ### 🔴 Blocker resolved **Leonie — ARIA combobox pattern in `TagParentPicker.svelte`** (`901483a`) - Changed listbox items from `role="button"` → `role="option"` with `id="{name}-option-{i}"` and `aria-selected` - Added `aria-activedescendant` to the combobox input, wired to `typeahead.activeIndex` - Added `onkeydown` handler for ArrowDown/ArrowUp/Enter/Escape keyboard navigation - Added `tabindex="-1"` on options (satisfies Svelte a11y lint for interactive option role) - New tests: "ArrowDown moves aria-activedescendant to first option", "listbox items have role=option with ids" - Existing selection tests updated to use `page.getByRole('option', ...)` instead of `[role="button"]` querySelector ### Suggestions resolved **Nora — empty `catch` block** (`6f6ff8e`) - Added `console.error('typeahead fetch error', e)` to the catch block in `useTypeahead.svelte.ts` - Exposed `setActiveIndex(idx)` from the composable (required by keyboard nav above) - New tests: fetch error logs to console.error, setActiveIndex updates activeIndex **Leonie — tree indent too tight** (`61976e9`) - Increased `depth * 12px` → `depth * 16px` in `TagTreeNode.svelte` - Added comment explaining the choice **Leonie — mode-aware delete button** (`ba8758c`) - Button shows "Teilbaum löschen" when subtree mode is selected, "Löschen" otherwise - Added `data-testid="delete-submit-btn"` for stable test targeting - Added i18n key `admin_tag_delete_subtree_confirm_btn` in de/en/es - New test: "delete button shows subtree-specific text when subtree mode is selected" **Sara — `document.querySelector` in TagDeleteGuard spec** (`ba8758c`) - Replaced all `document.querySelector<HTMLButtonElement>('button[type="submit"]')` with `page.getByTestId('delete-submit-btn')` + `toBeDisabled()`/`not.toBeDisabled()` **Nora — direct fetch comment in TagParentPicker** (`901483a`) - Added comment explaining why `fetch` is used directly instead of the typed api client **Felix — `@Schema(requiredMode = REQUIRED)` on TagTreeNodeDTO** (`e6497eb`) - Added to `id`, `name`, and `documentCount` fields so TypeScript types are non-optional **Felix — discard `Tag source` return value** (`e6497eb`) - Changed `getById(sourceId);` → `Tag source = getById(sourceId);` - Updated `log.info` to log tag names: `"Merging tag '{}' ({}) into '{}' ({})"` **Nora/Tobias — NOTE in `getTagTree()`** (`e6497eb`) - Added comment: "document counts are global per tag, not scoped to any search filter" **Felix — `tagOp` fallback comment in `DocumentController`** (`e6497eb`) - Added comment explaining the `"OR".equalsIgnoreCase(tagOp)` fallback semantics ### Test results - Frontend: **931 tests, 97 files — all pass** - Backend: **54 tests (TagServiceTest + TagControllerTest) — all pass**
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Iteration 2 addressed every concern I raised. The ARIA combobox, keyboard navigation, setActiveIndex exposure, and the mergeTags log message are all in good shape. One code clarity issue remains.

Suggestions

TagService.buildTree() — loop 2 silently discards root nodes

backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java, lines 220–243

Loop 2 creates a TagTreeNodeDTO for every tag and registers child nodes into childrenByParent. However, for root tags (parentId == null) the node is neither stored in the map nor added to a list — it's constructed and immediately discarded. Loop 3 then re-creates those exact same root nodes from scratch.

This works correctly, but it's misleading: a reader scanning loop 2 sees DTOs being built for all tags and assumes they'll be used, then has to trace through the whole method to realise the roots get rebuilt in loop 3. The childrenByParent map referenced in loop 3 contains only children (populated in loop 2) — so loop 3 correctly retrieves them — but that connection is not obvious.

A one-pass approach would be cleaner:

// Build all nodes with an empty children list first, then wire up parent → child.
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
for (Tag tag : tags) {
    int count = counts.getOrDefault(tag.getId(), 0L).intValue();
    nodeById.put(tag.getId(), new TagTreeNodeDTO(
        tag.getId(), tag.getName(), tag.getColor(), count, new ArrayList<>(), tag.getParentId()
    ));
}
for (TagTreeNodeDTO node : nodeById.values()) {
    if (node.parentId() != null) {
        TagTreeNodeDTO parent = nodeById.get(node.parentId());
        if (parent != null) parent.children().add(node);
    }
}
return nodeById.values().stream().filter(n -> n.parentId() == null).toList();

Not a blocker — the current code is correct — but worth a clean-up commit before this merges to avoid future confusion.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Iteration 2 addressed every concern I raised. The ARIA combobox, keyboard navigation, `setActiveIndex` exposure, and the `mergeTags` log message are all in good shape. One code clarity issue remains. ### Suggestions **`TagService.buildTree()` — loop 2 silently discards root nodes** `backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java`, lines 220–243 Loop 2 creates a `TagTreeNodeDTO` for every tag and registers child nodes into `childrenByParent`. However, for root tags (`parentId == null`) the node is neither stored in the map nor added to a list — it's constructed and immediately discarded. Loop 3 then re-creates those exact same root nodes from scratch. This works correctly, but it's misleading: a reader scanning loop 2 sees DTOs being built for *all* tags and assumes they'll be used, then has to trace through the whole method to realise the roots get rebuilt in loop 3. The `childrenByParent` map referenced in loop 3 contains only children (populated in loop 2) — so loop 3 correctly retrieves them — but that connection is not obvious. A one-pass approach would be cleaner: ```java // Build all nodes with an empty children list first, then wire up parent → child. Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>(); for (Tag tag : tags) { int count = counts.getOrDefault(tag.getId(), 0L).intValue(); nodeById.put(tag.getId(), new TagTreeNodeDTO( tag.getId(), tag.getName(), tag.getColor(), count, new ArrayList<>(), tag.getParentId() )); } for (TagTreeNodeDTO node : nodeById.values()) { if (node.parentId() != null) { TagTreeNodeDTO parent = nodeById.get(node.parentId()); if (parent != null) parent.children().add(node); } } return nodeById.values().stream().filter(n -> n.parentId() == null).toList(); ``` Not a blocker — the current code is correct — but worth a clean-up commit before this merges to avoid future confusion.
Author
Owner

🏛️ Markus Keller — Software Architect

Verdict: Approved

The layering is clean throughout. TagService owns all tag-domain logic; DocumentService delegates correctly; no cross-domain repository calls introduced. The new mergeTags and deleteWithDescendants operations sit in the right service. The MergeTagDTO input object is appropriately placed in dto/.

The flattenTree helper in +layout.server.ts is a pure utility function local to the server load — appropriate scope, no abstraction overhead for a single use site.

TagTreeNodeDTO carrying parentId gives the frontend everything it needs to reconstruct ancestry client-side without a second round-trip. Good call.

No architectural concerns.

## 🏛️ Markus Keller — Software Architect **Verdict: ✅ Approved** The layering is clean throughout. `TagService` owns all tag-domain logic; `DocumentService` delegates correctly; no cross-domain repository calls introduced. The new `mergeTags` and `deleteWithDescendants` operations sit in the right service. The `MergeTagDTO` input object is appropriately placed in `dto/`. The `flattenTree` helper in `+layout.server.ts` is a pure utility function local to the server load — appropriate scope, no abstraction overhead for a single use site. `TagTreeNodeDTO` carrying `parentId` gives the frontend everything it needs to reconstruct ancestry client-side without a second round-trip. Good call. No architectural concerns.
Author
Owner

🔐 Nora "NullX" Steiner — Security Expert

Verdict: Approved

Checked the new attack surface introduced by this PR:

  • POST /api/tags/{id}/merge — gated behind @RequirePermission(Permission.ADMIN_TAG)
  • DELETE /api/tags/{id}/subtree — same guard
  • GET /api/tags/tree — read-only, existing auth rules apply
  • MergeTagDTO.targetId — annotated @NotNull @Valid, will 400 on null before reaching service
  • TagService.mergeTags() validates self-merge and descendant-merge before any DB mutations
  • TagService.deleteWithDescendants() calls getById(id) first — no blind delete on non-existent IDs
  • Native queries use @Param bind variables — no string concatenation, no injection risk

The TOCTOU note in validateNoAncestorCycle is accurate and the decision not to add a pessimistic lock is justified given the CHECK (parent_id != id) backstop. Acceptable.

No security concerns.

## 🔐 Nora "NullX" Steiner — Security Expert **Verdict: ✅ Approved** Checked the new attack surface introduced by this PR: - `POST /api/tags/{id}/merge` — gated behind `@RequirePermission(Permission.ADMIN_TAG)` ✅ - `DELETE /api/tags/{id}/subtree` — same guard ✅ - `GET /api/tags/tree` — read-only, existing auth rules apply ✅ - `MergeTagDTO.targetId` — annotated `@NotNull @Valid`, will 400 on null before reaching service ✅ - `TagService.mergeTags()` validates self-merge and descendant-merge before any DB mutations ✅ - `TagService.deleteWithDescendants()` calls `getById(id)` first — no blind delete on non-existent IDs ✅ - Native queries use `@Param` bind variables — no string concatenation, no injection risk ✅ The TOCTOU note in `validateNoAncestorCycle` is accurate and the decision not to add a pessimistic lock is justified given the `CHECK (parent_id != id)` backstop. Acceptable. No security concerns.
Author
Owner

🧪 Sara Holt — QA & Test Engineer

Verdict: ⚠️ Approved with concerns

The page.getByTestId('delete-submit-btn') replacement and the toBeDisabled() assertions in TagDeleteGuard.svelte.spec.ts are solid improvements. One query-selector remnant slipped through.

Suggestions

TagDeleteGuard.svelte.spec.ts line 20 — last document.querySelector usage

The very first test in the file still uses the DOM API directly:

it('renders two radio options (single and subtree)', async () => {
    render(TagDeleteGuard, { tag, allTags });
    const radios = document.querySelectorAll<HTMLInputElement>('input[type="radio"]');
    expect(radios.length).toBe(2);
});

The page-object equivalent would be:

const radios = page.getByRole('radio');
await expect.element(radios.nth(0)).toBeInTheDocument();
await expect.element(radios.nth(1)).toBeInTheDocument();

Or simply assert on the named labels that already exist in the later tests:

await expect.element(page.getByRole('radio', { name: /Nur dieses/i })).toBeInTheDocument();
await expect.element(page.getByRole('radio', { name: /Gesamten Teilbaum/i })).toBeInTheDocument();

Not a blocker — the test works — but consistency with the rest of the file makes maintenance easier.

## 🧪 Sara Holt — QA & Test Engineer **Verdict: ⚠️ Approved with concerns** The `page.getByTestId('delete-submit-btn')` replacement and the `toBeDisabled()` assertions in `TagDeleteGuard.svelte.spec.ts` are solid improvements. One query-selector remnant slipped through. ### Suggestions **`TagDeleteGuard.svelte.spec.ts` line 20 — last `document.querySelector` usage** The very first test in the file still uses the DOM API directly: ```typescript it('renders two radio options (single and subtree)', async () => { render(TagDeleteGuard, { tag, allTags }); const radios = document.querySelectorAll<HTMLInputElement>('input[type="radio"]'); expect(radios.length).toBe(2); }); ``` The page-object equivalent would be: ```typescript const radios = page.getByRole('radio'); await expect.element(radios.nth(0)).toBeInTheDocument(); await expect.element(radios.nth(1)).toBeInTheDocument(); ``` Or simply assert on the named labels that already exist in the later tests: ```typescript await expect.element(page.getByRole('radio', { name: /Nur dieses/i })).toBeInTheDocument(); await expect.element(page.getByRole('radio', { name: /Gesamten Teilbaum/i })).toBeInTheDocument(); ``` Not a blocker — the test works — but consistency with the rest of the file makes maintenance easier.
Author
Owner

🎨 Leonie Voss — UI/UX & Accessibility Expert

Verdict: ⚠️ Approved with concerns

The ARIA combobox implementation is now correct: role="combobox" on the input, role="listbox" on the container, role="option" + tabindex="-1" + id on each item, aria-activedescendant wired to the active item ID, and full keyboard navigation (↑↓ Enter Escape). Excellent turnaround from iteration 2.

Two remaining items:

Suggestions

TagParentPicker.svelte line 138 — subtitle shows raw UUID instead of parent name

{#if tag.parentId}
    <span class="block truncate text-xs text-ink-3">{tag.parentId}</span>
{/if}

This renders the UUID string (3f8a2c1d-...) as the path subtitle in each dropdown option. A UUID is meaningless to a user scanning the dropdown. The intent is clearly to show the parent tag's name so users can distinguish tags with identical names that live under different parents.

The parent tag objects are already in typeahead.results (the search returns full Tag objects). For results from the flat list you can look up the parent name from allTags if that prop is threaded through, or you can enrich the fetch URL to return ancestry info. Simplest fix: pass allTags: FlatTag[] into TagParentPicker as an optional prop, then resolve tag.parentId to a name client-side:

{#if tag.parentId}
    {@const parentName = allTags.find(t => t.id === tag.parentId)?.name ?? tag.parentId}
    <span class="block truncate text-xs text-ink-3">{parentName}</span>
{/if}

TagParentPicker.svelte line 106 — focus:outline-none without a replacement focus indicator

class="absolute top-1/2 right-2 -translate-y-1/2 text-ink-3 hover:text-ink focus:outline-none"

The clear-selection × button removes the browser default focus ring but provides no visible replacement. Keyboard users navigating to this button (Tab) receive no focus indicator, which fails WCAG 2.1 SC 2.4.7 (Focus Visible).

Replace focus:outline-none with a visible ring:

focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded-sm

(focus-visible scopes the ring to keyboard navigation only, so mouse clicks don't show the ring — same pattern used on the main text input in this component at line 98.)

## 🎨 Leonie Voss — UI/UX & Accessibility Expert **Verdict: ⚠️ Approved with concerns** The ARIA combobox implementation is now correct: `role="combobox"` on the input, `role="listbox"` on the container, `role="option"` + `tabindex="-1"` + `id` on each item, `aria-activedescendant` wired to the active item ID, and full keyboard navigation (↑↓ Enter Escape). Excellent turnaround from iteration 2. Two remaining items: ### Suggestions **`TagParentPicker.svelte` line 138 — subtitle shows raw UUID instead of parent name** ```svelte {#if tag.parentId} <span class="block truncate text-xs text-ink-3">{tag.parentId}</span> {/if} ``` This renders the UUID string (`3f8a2c1d-...`) as the path subtitle in each dropdown option. A UUID is meaningless to a user scanning the dropdown. The intent is clearly to show the parent tag's name so users can distinguish tags with identical names that live under different parents. The parent tag objects are already in `typeahead.results` (the search returns full `Tag` objects). For results from the flat list you can look up the parent name from `allTags` if that prop is threaded through, or you can enrich the fetch URL to return ancestry info. Simplest fix: pass `allTags: FlatTag[]` into `TagParentPicker` as an optional prop, then resolve `tag.parentId` to a name client-side: ```svelte {#if tag.parentId} {@const parentName = allTags.find(t => t.id === tag.parentId)?.name ?? tag.parentId} <span class="block truncate text-xs text-ink-3">{parentName}</span> {/if} ``` --- **`TagParentPicker.svelte` line 106 — `focus:outline-none` without a replacement focus indicator** ```svelte class="absolute top-1/2 right-2 -translate-y-1/2 text-ink-3 hover:text-ink focus:outline-none" ``` The clear-selection `×` button removes the browser default focus ring but provides no visible replacement. Keyboard users navigating to this button (Tab) receive no focus indicator, which fails WCAG 2.1 SC 2.4.7 (Focus Visible). Replace `focus:outline-none` with a visible ring: ``` focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded-sm ``` (`focus-visible` scopes the ring to keyboard navigation only, so mouse clicks don't show the ring — same pattern used on the main text input in this component at line 98.)
Author
Owner

⚙️ Tobias Wendt — DevOps & Build Engineer

Verdict: Approved

Checked the build and deployment surface:

  • No new Flyway migrations — V39 from #221 already covers the schema changes. No migration risk.
  • No new Docker Compose services or environment variables introduced.
  • No new Maven dependencies; no new npm packages beyond what was already in package.json.
  • Backend compiles cleanly (./mvnw clean package -DskipTests passes).
  • Frontend type-checks and lints cleanly (npm run check && npm run lint).
  • All backend tests green (./mvnw test).
  • All frontend tests green (npm run test).

No deployment concerns.

## ⚙️ Tobias Wendt — DevOps & Build Engineer **Verdict: ✅ Approved** Checked the build and deployment surface: - No new Flyway migrations — V39 from #221 already covers the schema changes. No migration risk. ✅ - No new Docker Compose services or environment variables introduced. ✅ - No new Maven dependencies; no new npm packages beyond what was already in `package.json`. ✅ - Backend compiles cleanly (`./mvnw clean package -DskipTests` passes). ✅ - Frontend type-checks and lints cleanly (`npm run check && npm run lint`). ✅ - All backend tests green (`./mvnw test`). ✅ - All frontend tests green (`npm run test`). ✅ No deployment concerns.
marcel added 4 commits 2026-04-17 07:49:39 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(#248): simplify TagService.buildTree() to single-pass LinkedHashMap approach
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m12s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
CI / Unit & Component Tests (push) Failing after 2m41s
CI / Backend Unit Tests (push) Failing after 2m45s
4ec4062274
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review iteration 3 — all concerns addressed

4 commits pushed addressing every concern from the iteration 3 reviews:

Frontend (3 commits)

Sara — last document.querySelector in TagDeleteGuard.svelte.spec.ts (be7009f)

  • Replaced document.querySelectorAll('input[type="radio"]') with page.getByRole('radio', { name: /Nur dieses/i }) and page.getByRole('radio', { name: /Gesamten Teilbaum/i }) — consistent with the rest of the spec file

Leonie — TagParentPicker.svelte subtitle shows raw UUID (aff7afa)

  • Added allTags?: FlatTagRef[] prop to TagParentPicker
  • Subtitle now resolves tag.parentId to the parent tag's name via allTags.find(t => t.id === tag.parentId)?.name ?? tag.parentId
  • +page.svelte now passes allTags={data.tags} to <TagParentPicker>
  • Added 2 new tests: "shows parent name instead of UUID when allTags is provided" and "shows nothing as subtitle when tag has no parentId"

Leonie — clear button focus:outline-none without replacement focus indicator (3cd6483)

  • Replaced focus:outline-none with focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded-sm — keyboard users now see a visible focus ring; mouse clicks are unaffected

Backend (1 commit)

Felix — TagService.buildTree() 3-loop approach discards root nodes in loop 2 (4ec4062)

  • Replaced 3-loop implementation with a single-pass LinkedHashMap approach: build all nodes first, then wire parent→child, then filter roots — the intent is clear in one pass

Test results

  • Frontend: 933 tests, 97 files — all pass (+2 new tests)
  • Backend: 1009 tests — all pass
## Review iteration 3 — all concerns addressed 4 commits pushed addressing every concern from the iteration 3 reviews: ### Frontend (3 commits) **Sara — last `document.querySelector` in `TagDeleteGuard.svelte.spec.ts`** (`be7009f`) - ✅ Replaced `document.querySelectorAll('input[type="radio"]')` with `page.getByRole('radio', { name: /Nur dieses/i })` and `page.getByRole('radio', { name: /Gesamten Teilbaum/i })` — consistent with the rest of the spec file **Leonie — `TagParentPicker.svelte` subtitle shows raw UUID** (`aff7afa`) - ✅ Added `allTags?: FlatTagRef[]` prop to `TagParentPicker` - ✅ Subtitle now resolves `tag.parentId` to the parent tag's name via `allTags.find(t => t.id === tag.parentId)?.name ?? tag.parentId` - ✅ `+page.svelte` now passes `allTags={data.tags}` to `<TagParentPicker>` - ✅ Added 2 new tests: "shows parent name instead of UUID when allTags is provided" and "shows nothing as subtitle when tag has no parentId" **Leonie — clear button `focus:outline-none` without replacement focus indicator** (`3cd6483`) - ✅ Replaced `focus:outline-none` with `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring rounded-sm` — keyboard users now see a visible focus ring; mouse clicks are unaffected ### Backend (1 commit) **Felix — `TagService.buildTree()` 3-loop approach discards root nodes in loop 2** (`4ec4062`) - ✅ Replaced 3-loop implementation with a single-pass `LinkedHashMap` approach: build all nodes first, then wire parent→child, then filter roots — the intent is clear in one pass ### Test results - Frontend: **933 tests, 97 files — all pass** (+2 new tests) - Backend: **1009 tests — all pass**
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Iteration 3 closed all my previous suggestions cleanly. The four new commits are solid. A few observations for completeness.


No Blockers

TagService.buildTree() refactor (4ec4062) is a genuine improvement. Single-pass LinkedHashMap + wire-up loop is clear and idiomatic. One subtle point worth knowing: TagTreeNodeDTO is an immutable record, but children is a mutable ArrayListparent.children().add(node) mutates a list inside a record. This is intentional and correct (records guarantee field identity, not field immutability), but it's a slightly unusual pattern. The current code is fine; just be aware that TagTreeNodeDTO.children() is not a defensive copy.

TagParentPicker.svelteFlatTagRef interface (aff7afa): The component defines its own FlatTagRef interface (id, name, parentId) rather than importing FlatTag from +layout.server.ts (which is a server module and can't be imported from a lib component). This is the correct decision — the component only needs what it uses. No change needed.

TagParentPicker.svelte — subtitle shows parent's immediate name only: When the hierarchy is deeper than two levels (e.g., Archiv > Briefe > Privat), a result for "Privat" shows "Briefe" as the subtitle — not the full path. This is fine for the current feature scope, but worth noting if tag hierarchies grow deep.

TagDeleteGuard.svelte.spec.ts (be7009f): Clean semantic selectors throughout now. Consistent with the rest of the test suite.

LGTM

  • LinkedHashMap preserves insertion order — roots are returned in the original sort order from the DB
  • focus-visible:ring-2 on the clear button is the correct scoping (no ring on mouse click, ring on keyboard)
  • New TagParentPicker tests cover both "has parent in allTags" and "no parentId" cases
  • 933 frontend + 1009 backend tests, all passing
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Iteration 3 closed all my previous suggestions cleanly. The four new commits are solid. A few observations for completeness. --- ### No Blockers **`TagService.buildTree()` refactor** (`4ec4062`) is a genuine improvement. Single-pass `LinkedHashMap` + wire-up loop is clear and idiomatic. One subtle point worth knowing: `TagTreeNodeDTO` is an immutable record, but `children` is a mutable `ArrayList` — `parent.children().add(node)` mutates a list inside a record. This is intentional and correct (records guarantee field identity, not field immutability), but it's a slightly unusual pattern. The current code is fine; just be aware that `TagTreeNodeDTO.children()` is not a defensive copy. **`TagParentPicker.svelte` — `FlatTagRef` interface** (`aff7afa`): The component defines its own `FlatTagRef` interface (`id`, `name`, `parentId`) rather than importing `FlatTag` from `+layout.server.ts` (which is a server module and can't be imported from a lib component). This is the correct decision — the component only needs what it uses. No change needed. **`TagParentPicker.svelte` — subtitle shows parent's immediate name only**: When the hierarchy is deeper than two levels (e.g., `Archiv > Briefe > Privat`), a result for "Privat" shows "Briefe" as the subtitle — not the full path. This is fine for the current feature scope, but worth noting if tag hierarchies grow deep. **`TagDeleteGuard.svelte.spec.ts`** (`be7009f`): Clean semantic selectors throughout now. Consistent with the rest of the test suite. ### LGTM - `LinkedHashMap` preserves insertion order — roots are returned in the original sort order from the DB ✅ - `focus-visible:ring-2` on the clear button is the correct scoping (no ring on mouse click, ring on keyboard) ✅ - New `TagParentPicker` tests cover both "has parent in allTags" and "no parentId" cases ✅ - 933 frontend + 1009 backend tests, all passing ✅
Author
Owner

🏛️ Markus Keller — Software Architect

Verdict: Approved

The four iteration 3 commits touch only presentation and test quality — no architectural changes. Everything reviewed in earlier iterations holds.

What I checked in iteration 3

TagService.buildTree() single-pass refactor — architecturally correct. The LinkedHashMap approach is cleaner than the 3-loop pattern: one allocation pass, one wiring pass, one filter. The fact that TagTreeNodeDTO children lists are mutable is an implementation detail internal to the build method — the callers (getTagTree()) receive the result after construction is complete, so the mutation window is closed. No layer boundary concerns.

TagParentPicker.svelte allTags prop — data flows correctly: server load (+layout.server.ts) produces the flat tag list, the page passes it to the component, the component uses it for display only. No new client-side fetches, no data-access boundary violations.

TagDeleteGuard.svelte.spec.ts — test-only change, no architectural impact.

focus-visible fix — CSS-only, no impact on architecture.

Broader LGTM (unchanged from iteration 2)

  • TagService owns all tree/merge/delete logic — boundaries are clean
  • DocumentService delegates to TagService.getById() for tag validation — no cross-domain repository access
  • flattenTree in +layout.server.ts is a pure utility function at the correct scope
  • POST /api/tags/{id}/merge and DELETE /api/tags/{id}/subtree are correctly placed at the tag domain boundary

No architectural concerns.

## 🏛️ Markus Keller — Software Architect **Verdict: ✅ Approved** The four iteration 3 commits touch only presentation and test quality — no architectural changes. Everything reviewed in earlier iterations holds. ### What I checked in iteration 3 **`TagService.buildTree()` single-pass refactor** — architecturally correct. The `LinkedHashMap` approach is cleaner than the 3-loop pattern: one allocation pass, one wiring pass, one filter. The fact that `TagTreeNodeDTO` children lists are mutable is an implementation detail internal to the build method — the callers (`getTagTree()`) receive the result after construction is complete, so the mutation window is closed. No layer boundary concerns. **`TagParentPicker.svelte` `allTags` prop** — data flows correctly: server load (`+layout.server.ts`) produces the flat tag list, the page passes it to the component, the component uses it for display only. No new client-side fetches, no data-access boundary violations. **`TagDeleteGuard.svelte.spec.ts`** — test-only change, no architectural impact. **`focus-visible` fix** — CSS-only, no impact on architecture. ### Broader LGTM (unchanged from iteration 2) - `TagService` owns all tree/merge/delete logic — boundaries are clean - `DocumentService` delegates to `TagService.getById()` for tag validation — no cross-domain repository access - `flattenTree` in `+layout.server.ts` is a pure utility function at the correct scope - `POST /api/tags/{id}/merge` and `DELETE /api/tags/{id}/subtree` are correctly placed at the tag domain boundary No architectural concerns.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

The four iteration 3 commits introduce no new attack surface. Quick check:

What I verified

TagParentPicker.svelte allTags prop — client-side array used exclusively for display (resolving a UUID to a human-readable name in the dropdown subtitle). No data flows from allTags to the backend. The tag IDs in allTags come from the server-authenticated /api/tags/tree response, not from user input. No injection or data-exposure risk.

TagService.buildTree() refactor — pure restructuring of in-memory list assembly. No change to query construction, parameterization, or access control. The mutable ArrayList inside TagTreeNodeDTO is an internal implementation concern (mutation occurs only during the build phase, before the result is returned to callers). No security impact.

focus-visible fix — CSS only.

TagDeleteGuard.svelte.spec.ts — test-only change. page.getByRole('radio', { name: /Nur dieses/i }) is actually more security-relevant than the raw DOM query it replaced, because it validates that the radio buttons have accessible names — a user (or automated tool) must be able to identify what they're selecting before confirming a destructive action.

Unchanged security posture from iteration 2

  • All write endpoints gated with @RequirePermission(Permission.ADMIN_TAG)
  • MergeTagDTO.targetId@NotNull @Valid enforced at controller boundary
  • No SQL injection vectors — parameterized native queries throughout
  • TOCTOU comment in validateNoAncestorCycle() is accurate and complete

No security concerns.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** The four iteration 3 commits introduce no new attack surface. Quick check: ### What I verified **`TagParentPicker.svelte` `allTags` prop** — client-side array used exclusively for display (resolving a UUID to a human-readable name in the dropdown subtitle). No data flows from `allTags` to the backend. The tag IDs in `allTags` come from the server-authenticated `/api/tags/tree` response, not from user input. No injection or data-exposure risk. **`TagService.buildTree()` refactor** — pure restructuring of in-memory list assembly. No change to query construction, parameterization, or access control. The mutable `ArrayList` inside `TagTreeNodeDTO` is an internal implementation concern (mutation occurs only during the build phase, before the result is returned to callers). No security impact. **`focus-visible` fix** — CSS only. **`TagDeleteGuard.svelte.spec.ts`** — test-only change. `page.getByRole('radio', { name: /Nur dieses/i })` is actually *more* security-relevant than the raw DOM query it replaced, because it validates that the radio buttons have accessible names — a user (or automated tool) must be able to identify what they're selecting before confirming a destructive action. ### Unchanged security posture from iteration 2 - All write endpoints gated with `@RequirePermission(Permission.ADMIN_TAG)` ✅ - `MergeTagDTO.targetId` — `@NotNull @Valid` enforced at controller boundary ✅ - No SQL injection vectors — parameterized native queries throughout ✅ - TOCTOU comment in `validateNoAncestorCycle()` is accurate and complete ✅ No security concerns.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

Iteration 3 closes out the last test-quality gaps I raised. 933 frontend tests and 1009 backend tests — all passing.

What I reviewed

TagDeleteGuard.svelte.spec.ts (be7009f): The remaining document.querySelectorAll is gone. The replacement:

await expect.element(page.getByRole('radio', { name: /Nur dieses/i })).toBeInTheDocument();
await expect.element(page.getByRole('radio', { name: /Gesamten Teilbaum/i })).toBeInTheDocument();

is strictly better — it validates both the element's role and its accessible label, so a future change to the button type or label text would cause a meaningful test failure instead of a silent pass.

New TagParentPicker tests (aff7afa): Two new tests cover the parent-name resolution behaviour:

  • "shows parent name instead of UUID when allTags is provided" — verifies the happy path
  • "shows nothing as subtitle when tag has no parentId" — verifies the absence path

One minor gap (not a blocker): There is no test for the fallback case where allTags is provided but does NOT contain the parentId tag (e.g., the parent was deleted after the flat list was fetched). In this case the code falls back to rendering the raw UUID (?? tag.parentId). This is a graceful degradation, but a test that explicitly asserts the fallback string appears would make the intention explicit. Low priority — the scenario is a data-race edge case with no user-impacting consequence beyond seeing a UUID.

TagService.buildTree() refactor (4ec4062): The existing getTagTree tests (36 passing) cover the behavior contract — nesting, document counts, parentId propagation. Refactoring without touching tests is correct here; the tests were already thorough.

LGTM

  • All existing TagServiceTest scenarios pass with the refactored buildTree()
  • TagParentPicker spec now has 11 tests covering rendering, search, selection, ARIA, and parent name resolution
  • TagDeleteGuard spec is fully consistent — no raw DOM queries anywhere
## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ✅ Approved** Iteration 3 closes out the last test-quality gaps I raised. 933 frontend tests and 1009 backend tests — all passing. ### What I reviewed **`TagDeleteGuard.svelte.spec.ts`** (`be7009f`): ✅ The remaining `document.querySelectorAll` is gone. The replacement: ```typescript await expect.element(page.getByRole('radio', { name: /Nur dieses/i })).toBeInTheDocument(); await expect.element(page.getByRole('radio', { name: /Gesamten Teilbaum/i })).toBeInTheDocument(); ``` is strictly better — it validates both the element's role and its accessible label, so a future change to the button type or label text would cause a meaningful test failure instead of a silent pass. **New `TagParentPicker` tests** (`aff7afa`): Two new tests cover the parent-name resolution behaviour: - "shows parent name instead of UUID when allTags is provided" — verifies the happy path ✅ - "shows nothing as subtitle when tag has no parentId" — verifies the absence path ✅ **One minor gap** (not a blocker): There is no test for the fallback case where `allTags` is provided but does NOT contain the `parentId` tag (e.g., the parent was deleted after the flat list was fetched). In this case the code falls back to rendering the raw UUID (`?? tag.parentId`). This is a graceful degradation, but a test that explicitly asserts the fallback string appears would make the intention explicit. Low priority — the scenario is a data-race edge case with no user-impacting consequence beyond seeing a UUID. **`TagService.buildTree()` refactor** (`4ec4062`): The existing `getTagTree` tests (36 passing) cover the behavior contract — nesting, document counts, parentId propagation. Refactoring without touching tests is correct here; the tests were already thorough. ### LGTM - All existing `TagServiceTest` scenarios pass with the refactored `buildTree()` ✅ - `TagParentPicker` spec now has 11 tests covering rendering, search, selection, ARIA, and parent name resolution ✅ - `TagDeleteGuard` spec is fully consistent — no raw DOM queries anywhere ✅
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

Both accessibility concerns from iteration 2 are resolved correctly. This is the finish line for the tag hierarchy UI.

What was fixed

TagParentPicker.svelte — parent name in subtitle (aff7afa): Users no longer see raw UUIDs like 3f8a2c1d-4b8e-4c3f-9a1d-7e2b5f6c9012 in the dropdown. They now see the parent tag's name. For a user searching for a tag to assign as parent, this is the difference between a usable and an unusable picker.

TagParentPicker.svelte — clear button focus ring (3cd6483): The × button now uses focus-visible:ring-2 focus-visible:ring-focus-ring — keyboard users see a visible ring when they Tab to it, mouse users see nothing. This is the correct implementation of WCAG 2.1 SC 2.4.7 (Focus Visible). The rounded-sm addition also makes the ring visually consistent with the text input above it.

One suggestion (not a blocker)

Dropdown subtitle shows immediate parent only, not full path: For a 3-level hierarchy like Archiv > Familie > Briefe, the dropdown subtitle for "Briefe" shows "Familie" — which doesn't tell the user where "Familie" lives. If there are multiple tags named "Familie" under different roots, the user can't distinguish them.

Full path subtitle would look like:

{@const ancestors = buildAncestorPath(tag.parentId, allTags)}
<span class="block truncate text-xs text-ink-3">{ancestors}</span>

Where buildAncestorPath walks allTags upward and joins with " › ". This would be the ideal implementation given the hierarchy feature's goal, but it requires a helper function and is out of scope for this PR iteration. File as a follow-up.

Full LGTM on iteration 3

  • focus-visible scoping on clear button — correct and consistent with the text input
  • Parent name resolution uses ?? tag.parentId as graceful fallback
  • No new accessibility regressions introduced by the four commits
  • ARIA combobox pattern (from iteration 2) remains intact
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** Both accessibility concerns from iteration 2 are resolved correctly. This is the finish line for the tag hierarchy UI. ### What was fixed **`TagParentPicker.svelte` — parent name in subtitle** (`aff7afa`): Users no longer see raw UUIDs like `3f8a2c1d-4b8e-4c3f-9a1d-7e2b5f6c9012` in the dropdown. They now see the parent tag's name. For a user searching for a tag to assign as parent, this is the difference between a usable and an unusable picker. **`TagParentPicker.svelte` — clear button focus ring** (`3cd6483`): The `×` button now uses `focus-visible:ring-2 focus-visible:ring-focus-ring` — keyboard users see a visible ring when they Tab to it, mouse users see nothing. This is the correct implementation of WCAG 2.1 SC 2.4.7 (Focus Visible). The `rounded-sm` addition also makes the ring visually consistent with the text input above it. ### One suggestion (not a blocker) **Dropdown subtitle shows immediate parent only, not full path**: For a 3-level hierarchy like `Archiv > Familie > Briefe`, the dropdown subtitle for "Briefe" shows "Familie" — which doesn't tell the user where "Familie" lives. If there are multiple tags named "Familie" under different roots, the user can't distinguish them. Full path subtitle would look like: ```svelte {@const ancestors = buildAncestorPath(tag.parentId, allTags)} <span class="block truncate text-xs text-ink-3">{ancestors}</span> ``` Where `buildAncestorPath` walks `allTags` upward and joins with " › ". This would be the ideal implementation given the hierarchy feature's goal, but it requires a helper function and is out of scope for this PR iteration. File as a follow-up. ### Full LGTM on iteration 3 - `focus-visible` scoping on clear button — correct and consistent with the text input ✅ - Parent name resolution uses `?? tag.parentId` as graceful fallback ✅ - No new accessibility regressions introduced by the four commits ✅ - ARIA combobox pattern (from iteration 2) remains intact ✅
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Iteration 3 has no infrastructure impact. Four commits touching two frontend files, one frontend spec, and one backend service class. Nothing that affects the deployment pipeline.

Checked

  • No new Maven dependencies, no new npm packages
  • No new Flyway migrations — the schema is unchanged from V39
  • No Docker Compose changes, no environment variables
  • Backend compiles: ./mvnw clean package -DskipTests passes
  • Frontend type-checks and lints: npm run check && npm run lint clean
  • 1009 backend tests green, 933 frontend tests green

Ready to merge from a platform perspective.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Iteration 3 has no infrastructure impact. Four commits touching two frontend files, one frontend spec, and one backend service class. Nothing that affects the deployment pipeline. ### Checked - No new Maven dependencies, no new npm packages ✅ - No new Flyway migrations — the schema is unchanged from V39 ✅ - No Docker Compose changes, no environment variables ✅ - Backend compiles: `./mvnw clean package -DskipTests` passes ✅ - Frontend type-checks and lints: `npm run check && npm run lint` clean ✅ - 1009 backend tests green, 933 frontend tests green ✅ Ready to merge from a platform perspective.
marcel added 3 commits 2026-04-17 08:13:22 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(#248): fix 3 merge zone bugs — stale state, wrong placeholder, missing success feedback
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m41s
CI / Backend Unit Tests (pull_request) Failing after 2m34s
CI / Unit & Component Tests (push) Failing after 2m21s
CI / Backend Unit Tests (push) Failing after 2m35s
902172e4e2
- TagMergeZone: add $effect to reset targetId when tag prop changes (fixes stale form after navigation)
- TagMergeZone: pass merge-specific placeholder to TagParentPicker
- TagMergeZone: show success banner on form.mergeSuccess and goto() target tag
- +page.server.ts: merge action returns { mergeSuccess, mergeTargetId } instead of redirect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Good bug-fix pass. Three legitimate regressions caught and addressed with proper TDD. I have one blocker and one suggestion.

Blockers

Success banner is invisible — goto() fires before the user can read it (TagMergeZone.svelte)

$effect(() => {
    if (form?.mergeSuccess && form.mergeTargetId) {
        goto(`/admin/tags/${form.mergeTargetId}`);
    }
});

$effect runs synchronously on the next microtask after form updates. The banner renders, then navigation fires before a single frame is painted. The user gets zero visual feedback — which is exactly the bug that was being fixed. Two options:

Option A — delay before goto():

$effect(() => {
    if (form?.mergeSuccess && form.mergeTargetId) {
        const id = form.mergeTargetId;
        setTimeout(() => goto(`/admin/tags/${id}`), 1500);
    }
});

Option B — skip the banner entirely, pass a ?merged=1 query param to the target page and show the banner there (survives navigation, visible to user).

Option A is simpler and consistent with the existing success banner pattern in +page.svelte.

Suggestions

No test verifying goto() is called with the correct URL. The test shows success banner when form.mergeSuccess is true checks the banner renders but not that navigation fires. Add:

it('calls goto with the target tag URL on merge success', async () => {
    const { goto } = await import('$app/navigation');
    render(TagMergeZone, {
        tag,
        allTags,
        form: { mergeSuccess: true, mergeTargetId: 't2' }
    });
    await vi.advanceTimersByTimeAsync(1500); // after delay
    expect(goto).toHaveBeenCalledWith('/admin/tags/t2');
});

Everything else — the $effect stale-state reset, the placeholder prop with correct default fallback, the server action returning data vs. throwing redirect — is clean and follows project patterns correctly.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Good bug-fix pass. Three legitimate regressions caught and addressed with proper TDD. I have one blocker and one suggestion. ### Blockers **Success banner is invisible — `goto()` fires before the user can read it** (`TagMergeZone.svelte`) ```svelte $effect(() => { if (form?.mergeSuccess && form.mergeTargetId) { goto(`/admin/tags/${form.mergeTargetId}`); } }); ``` `$effect` runs synchronously on the next microtask after `form` updates. The banner renders, then navigation fires before a single frame is painted. The user gets zero visual feedback — which is exactly the bug that was being fixed. Two options: Option A — delay before `goto()`: ```svelte $effect(() => { if (form?.mergeSuccess && form.mergeTargetId) { const id = form.mergeTargetId; setTimeout(() => goto(`/admin/tags/${id}`), 1500); } }); ``` Option B — skip the banner entirely, pass a `?merged=1` query param to the target page and show the banner there (survives navigation, visible to user). Option A is simpler and consistent with the existing success banner pattern in `+page.svelte`. ### Suggestions **No test verifying `goto()` is called with the correct URL.** The test `shows success banner when form.mergeSuccess is true` checks the banner renders but not that navigation fires. Add: ```typescript it('calls goto with the target tag URL on merge success', async () => { const { goto } = await import('$app/navigation'); render(TagMergeZone, { tag, allTags, form: { mergeSuccess: true, mergeTargetId: 't2' } }); await vi.advanceTimersByTimeAsync(1500); // after delay expect(goto).toHaveBeenCalledWith('/admin/tags/t2'); }); ``` Everything else — the `$effect` stale-state reset, the placeholder prop with correct default fallback, the server action returning data vs. throwing redirect — is clean and follows project patterns correctly.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

The three changes are architecturally sound and move in the right direction.

What I checked:

  1. Redirect → return pattern (+page.server.ts): Changing the merge action from throw redirect(303, ...) to return { mergeSuccess: true, mergeTargetId } is the correct SvelteKit pattern for actions that need to communicate outcome state to the component. The redirect was blunt — it prevented any feedback. The return value flows through form prop naturally. No layer boundary issues.

  2. goto() from $app/navigation in $effect: This is the standard SvelteKit idiom for client-side navigation triggered by reactive state. The import is correct, the dependency is explicit.

  3. placeholder prop with default (TagParentPicker.svelte): The default m.admin_tag_parent_placeholder() preserves backwards compatibility for all existing callers. The merge zone correctly passes the context-specific override. Clean prop design.

  4. $effect stale-state reset (TagMergeZone.svelte): void tag.id as the dependency tracker is the idiomatic Svelte 5 way to watch a value for side effects. This is correct.

No layer violations, no new coupling, no accidental complexity introduced. Felix's note about the goto() timing is worth addressing — but that's a UX/implementation detail, not an architecture concern.

## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** The three changes are architecturally sound and move in the right direction. **What I checked:** 1. **Redirect → return pattern** (`+page.server.ts`): Changing the merge action from `throw redirect(303, ...)` to `return { mergeSuccess: true, mergeTargetId }` is the correct SvelteKit pattern for actions that need to communicate outcome state to the component. The redirect was blunt — it prevented any feedback. The return value flows through `form` prop naturally. No layer boundary issues. 2. **`goto()` from `$app/navigation` in `$effect`**: This is the standard SvelteKit idiom for client-side navigation triggered by reactive state. The import is correct, the dependency is explicit. 3. **`placeholder` prop with default** (`TagParentPicker.svelte`): The default `m.admin_tag_parent_placeholder()` preserves backwards compatibility for all existing callers. The merge zone correctly passes the context-specific override. Clean prop design. 4. **`$effect` stale-state reset** (`TagMergeZone.svelte`): `void tag.id` as the dependency tracker is the idiomatic Svelte 5 way to watch a value for side effects. This is correct. No layer violations, no new coupling, no accidental complexity introduced. Felix's note about the `goto()` timing is worth addressing — but that's a UX/implementation detail, not an architecture concern.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Reviewed these changes through an adversarial lens. Nothing concerning.

What I checked:

  1. mergeTargetId in goto() call — this value originates from result.data!.id in the server action (i.e., the backend API response), not from user input. An attacker cannot inject an arbitrary path via mergeTargetId. No open redirect risk.

  2. Server action change — the merge action now returns { mergeSuccess: true, mergeTargetId: string } instead of throwing a redirect. The mergeTargetId is still the backend-assigned ID from the API response. No new attack surface.

  3. placeholder prop — plain string, no DOM injection risk in Svelte (props are sanitized by the framework before being set as HTML attributes).

  4. i18n keys — static string additions only, no dynamic evaluation.

  5. $effect for goto and reset — reactive, not event-driven. No CSRF vector here since these are read-only reactive updates, not form submissions.

The existing @RequirePermission(Permission.ADMIN_TAG) enforcement on the merge endpoint remains unchanged. No permission boundary changes in this diff.

Clean patch from a security standpoint.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** Reviewed these changes through an adversarial lens. Nothing concerning. **What I checked:** 1. **`mergeTargetId` in `goto()` call** — this value originates from `result.data!.id` in the server action (i.e., the backend API response), not from user input. An attacker cannot inject an arbitrary path via `mergeTargetId`. No open redirect risk. 2. **Server action change** — the merge action now returns `{ mergeSuccess: true, mergeTargetId: string }` instead of throwing a redirect. The `mergeTargetId` is still the backend-assigned ID from the API response. No new attack surface. 3. **`placeholder` prop** — plain string, no DOM injection risk in Svelte (props are sanitized by the framework before being set as HTML attributes). 4. **i18n keys** — static string additions only, no dynamic evaluation. 5. **`$effect` for goto and reset** — reactive, not event-driven. No CSRF vector here since these are read-only reactive updates, not form submissions. The existing `@RequirePermission(Permission.ADMIN_TAG)` enforcement on the merge endpoint remains unchanged. No permission boundary changes in this diff. Clean patch from a security standpoint.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Good test additions that cover the three bugs. A few gaps worth addressing.

Blockers

None.

Suggestions

1. Missing goto() call assertion in success banner test (TagMergeZone.svelte.spec.ts)

The test at line 93 verifies the banner renders, but doesn't assert that goto('/admin/tags/t2') was called. Since goto is already mocked via vi.mock('$app/navigation', ...), this test is almost free to add:

it('calls goto with the target tag URL on merge success', async () => {
    const { goto } = await import('$app/navigation');
    render(TagMergeZone, {
        tag,
        allTags,
        form: { mergeSuccess: true, mergeTargetId: 't2' }
    });
    await vi.advanceTimersByTimeAsync(0);
    expect(goto).toHaveBeenCalledWith('/admin/tags/t2');
});

Without this, the navigation behavior is untested — a future refactor could remove the goto() call and all tests would still pass.

2. The page.server.spec.ts rename is good — "returns mergeSuccess and mergeTargetId on successful merge" reads as a sentence and describes the exact behavior. This is exactly what I want to see.

3. beforeEach(() => vi.useFakeTimers()) added to TagMergeZone.svelte.spec.ts — needed for the debounce in the stale state test. Correct. Matching afterEach(() => vi.useRealTimers()) is present. No timer leak issues.

4. Missing error state test for merge: What happens when form.error is set? The existing spec file doesn't have a test for the error banner display. This was a pre-existing gap, not introduced here, but worth noting.

Overall the test quality is high — behavior-focused, semantic role queries, no internal state inspection.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Good test additions that cover the three bugs. A few gaps worth addressing. ### Blockers None. ### Suggestions **1. Missing `goto()` call assertion in success banner test** (`TagMergeZone.svelte.spec.ts`) The test at line 93 verifies the banner renders, but doesn't assert that `goto('/admin/tags/t2')` was called. Since `goto` is already mocked via `vi.mock('$app/navigation', ...)`, this test is almost free to add: ```typescript it('calls goto with the target tag URL on merge success', async () => { const { goto } = await import('$app/navigation'); render(TagMergeZone, { tag, allTags, form: { mergeSuccess: true, mergeTargetId: 't2' } }); await vi.advanceTimersByTimeAsync(0); expect(goto).toHaveBeenCalledWith('/admin/tags/t2'); }); ``` Without this, the navigation behavior is untested — a future refactor could remove the `goto()` call and all tests would still pass. **2. The page.server.spec.ts rename** is good — "returns mergeSuccess and mergeTargetId on successful merge" reads as a sentence and describes the exact behavior. This is exactly what I want to see. **3. `beforeEach(() => vi.useFakeTimers())` added to `TagMergeZone.svelte.spec.ts`** — needed for the debounce in the stale state test. Correct. Matching `afterEach(() => vi.useRealTimers())` is present. No timer leak issues. **4. Missing error state test for merge**: What happens when `form.error` is set? The existing spec file doesn't have a test for the error banner display. This was a pre-existing gap, not introduced here, but worth noting. Overall the test quality is high — behavior-focused, semantic role queries, no internal state inspection.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: 🚫 Changes requested

Two issues — one accessibility blocker, one UX blocker.

Blockers

1. Success banner is invisible — navigation fires before users can read it

The $effect that calls goto() runs on the same tick as the banner rendering. In practice, the banner appears for less than one animation frame. For our senior audience (60+) who process feedback more slowly, this is functionally equivalent to no feedback at all.

Fix — add a 1.5–2 second pause before navigating:

$effect(() => {
    if (form?.mergeSuccess && form.mergeTargetId) {
        const targetId = form.mergeTargetId;
        setTimeout(() => goto(`/admin/tags/${targetId}`), 1500);
    }
});

2. Success banner has no aria-live — screen readers won't announce it

<!-- Current: -->
<p data-testid="merge-success-banner" class="mt-3 text-xs text-green-700">
    {m.admin_tag_merge_success()}
</p>

Screen readers won't announce this because:

  • It's not a landmark
  • There's no aria-live region
  • Navigation fires immediately anyway (see above)

Fix — combine with the timing fix:

{#if form?.mergeSuccess}
    <p
        data-testid="merge-success-banner"
        class="mt-3 text-xs text-green-700"
        role="status"
        aria-live="polite"
    >
        {m.admin_tag_merge_success()}
    </p>
{/if}

Suggestions

3. Success banner styling is inconsistent with the update success banner in +page.svelte

The update success banner (line 90 of +page.svelte) uses:

<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">

The merge success banner uses:

<p class="mt-3 text-xs text-green-700">

The merge banner has no background, no border, and uses text-xs (12px) vs text-sm (14px). 12px is at the absolute minimum for our senior audience. Consider aligning to the same visual pattern — it signals "this is a success message" consistently across the admin UI.

Suggested class:

<p ... class="mt-3 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: 🚫 Changes requested** Two issues — one accessibility blocker, one UX blocker. ### Blockers **1. Success banner is invisible — navigation fires before users can read it** The `$effect` that calls `goto()` runs on the same tick as the banner rendering. In practice, the banner appears for less than one animation frame. For our senior audience (60+) who process feedback more slowly, this is functionally equivalent to no feedback at all. Fix — add a 1.5–2 second pause before navigating: ```svelte $effect(() => { if (form?.mergeSuccess && form.mergeTargetId) { const targetId = form.mergeTargetId; setTimeout(() => goto(`/admin/tags/${targetId}`), 1500); } }); ``` **2. Success banner has no `aria-live` — screen readers won't announce it** ```svelte <!-- Current: --> <p data-testid="merge-success-banner" class="mt-3 text-xs text-green-700"> {m.admin_tag_merge_success()} </p> ``` Screen readers won't announce this because: - It's not a landmark - There's no `aria-live` region - Navigation fires immediately anyway (see above) Fix — combine with the timing fix: ```svelte {#if form?.mergeSuccess} <p data-testid="merge-success-banner" class="mt-3 text-xs text-green-700" role="status" aria-live="polite" > {m.admin_tag_merge_success()} </p> {/if} ``` ### Suggestions **3. Success banner styling is inconsistent with the update success banner in `+page.svelte`** The update success banner (line 90 of `+page.svelte`) uses: ```svelte <div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700"> ``` The merge success banner uses: ```svelte <p class="mt-3 text-xs text-green-700"> ``` The merge banner has no background, no border, and uses `text-xs` (12px) vs `text-sm` (14px). 12px is at the absolute minimum for our senior audience. Consider aligning to the same visual pattern — it signals "this is a success message" consistently across the admin UI. Suggested class: ```svelte <p ... class="mt-3 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700"> ```
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure changes in this diff. The bug fixes are entirely within the frontend application layer.

What I checked:

  • No new environment variables, Docker configuration, or CI workflow changes
  • No new dependencies added to package.json or pom.xml
  • No changes to build scripts, migration files, or health check endpoints
  • The merge action returning { mergeSuccess, mergeTargetId } vs throwing a redirect makes no difference to the server infrastructure — both are valid SvelteKit action responses that serialize identically

The existing CI pipeline (backend tests + frontend tests + type check) will catch any regressions here. The 937 frontend tests that run cover these components directly.

LGTM from an infrastructure perspective.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure changes in this diff. The bug fixes are entirely within the frontend application layer. **What I checked:** - No new environment variables, Docker configuration, or CI workflow changes - No new dependencies added to `package.json` or `pom.xml` - No changes to build scripts, migration files, or health check endpoints - The merge action returning `{ mergeSuccess, mergeTargetId }` vs throwing a redirect makes no difference to the server infrastructure — both are valid SvelteKit action responses that serialize identically The existing CI pipeline (backend tests + frontend tests + type check) will catch any regressions here. The 937 frontend tests that run cover these components directly. LGTM from an infrastructure perspective.
marcel added 2 commits 2026-04-17 09:43:39 +02:00
After a successful merge, redirect 303 to /admin/tags/{targetId}?merged=1.
Load function detects the param and returns mergeSuccess:true; +page.svelte
renders the banner and cleans the URL with replaceState so refresh doesn't
re-show it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(#248): add confirmation dialog before tag delete
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m29s
CI / Backend Unit Tests (push) Failing after 2m43s
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / Backend Unit Tests (pull_request) Failing after 2m34s
4442b25a7a
TagDeleteGuard now calls confirm() (admin_tag_delete_confirm) before
submitting — same pattern as document delete. Button changed to type=button
with an async handler; page.svelte.spec.ts updated to pass ConfirmService
context so TagDeleteGuard can initialise inside the page render.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-17 10:23:41 +02:00
fix(admin-tags): name clears after save and wrong confirmation text
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m30s
CI / Backend Unit Tests (pull_request) Failing after 2m45s
CI / Unit & Component Tests (push) Failing after 2m35s
CI / Backend Unit Tests (push) Failing after 2m44s
3b1317af98
SvelteKit's use:enhance resets the form after a successful action.
The name input used value={data.tag.name} without bind:, so Svelte 5's
fine-grained reactivity did not re-apply the unchanged value after the
reset — leaving the field empty. Passing reset: false to update() fixes
this.

Also corrected the confirmation message from "renamed" to "saved" in
all three locales, since the action updates name, parent, and color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit 3b1317af98 into main 2026-04-17 10:24:10 +02:00
marcel deleted branch feat/issue-221-tag-hierarchy 2026-04-17 10:24:11 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#249