feat: Admin tag page complete overhaul — hierarchy tree, parent picker, merge, delete guard #248

Closed
opened 2026-04-16 21:23:38 +02:00 by marcel · 9 comments
Owner

Context

Issue #221 introduced infinite-depth tag hierarchy. The existing admin tag page can't cope:

  • The flat sidebar applies the same pl-5 to all children regardless of depth
  • The parent <select> is unusable beyond ~20 tags
  • There is no merge feature (unlike persons)
  • The delete zone gives no warning about children or document counts

Design spec: docs/specs/admin-tag-overhaul.html

Pre-existing out-of-scope bug: TagService.ALLOWED_TAG_COLORS doesn't match the frontend color swatches — do not fix in this issue.


Phase 1 — Backend: Tag Merge Endpoint

Files: TagRepository.java, TagService.java, TagController.java, ErrorCode.java, TagServiceTest.java, TagControllerTest.java

New repository queries

  • reassignDocumentTags(sourceId, targetId) — UPDATE document_tags, skip docs that already have targetId to avoid PK conflict
  • deleteDocumentTagsByTagId(sourceId) — remove remaining source rows
  • reparentChildren(sourceId, targetId) — UPDATE tag SET parent_id = targetId WHERE parent_id = sourceId
  • findDescendantIds(tagId) — recursive CTE returning UUID of tag + all descendants (UUID variant; by-name variant already exists)

New ErrorCode values

TAG_MERGE_SELF,           // 400 — source == target
TAG_MERGE_INVALID_TARGET  // 400 — target is a descendant of source

TagService.mergeTags(sourceId, targetId)@Transactional

  1. Validate source != target → TAG_MERGE_SELF
  2. Validate target not in findDescendantIds(sourceId)TAG_MERGE_INVALID_TARGET
  3. reassignDocumentTags
  4. deleteDocumentTagsByTagId(sourceId)
  5. reparentChildren(sourceId, targetId)
  6. deleteById(sourceId)
  7. Return surviving tag

POST /api/tags/{id}/merge@RequirePermission(ADMIN_TAG)

Body: { "targetId": "uuid" } — returns surviving Tag

Mirror of POST /api/persons/{id}/merge.

Tests (mirror PersonServiceTest / PersonControllerTest patterns)

  • mergeTags_throwsBadRequest_whenSelfMerge
  • mergeTags_throwsBadRequest_whenTargetIsDescendant
  • mergeTags_reassignsDocumentsAndReparentsChildrenAndDeletesSource
  • Controller: 401 / 403 / 200

Phase 2 — Backend: Subtree Delete Endpoint

Files: TagService.java, TagController.java, tests

TagService.deleteWithDescendants(id)@Transactional

  1. findDescendantIds(id) — get all IDs including self
  2. Bulk remove from document_tags (add deleteDocumentTagsByTagIds(Collection<UUID>) to repository)
  3. deleteAllById(descendantIds)

DELETE /api/tags/{id}/subtree@RequirePermission(ADMIN_TAG), returns 204

Tests

  • deleteWithDescendants_deletesTagAndAllDescendants
  • deleteWithDescendants_removesDocumentTagsForAllDescendants
  • Controller: auth/permission + 204

Phase 3 — Backend: Enrich Tree DTO

Files: TagTreeNodeDTO.java, TagService.java, TagController.java, TagRepository.java

  • Add parentId field to TagTreeNodeDTO (populated in buildTree() from tag.getParentId())
  • Add document count query to TagRepository (aggregate against document_tags)
  • Update TagService.getTagTree() to populate documentCount per node

Phase 4 — Regenerate API Types

After backend is running with --spring.profiles.active=dev:

cd frontend && npm run generate:api

New: POST /api/tags/{id}/merge, DELETE /api/tags/{id}/subtree, enriched TagTreeNodeDTO with parentId + documentCount.


Phase 5 — Frontend: Layout Server

File: frontend/src/routes/admin/tags/+layout.server.ts

Switch from GET /api/tagsGET /api/tags/tree. Expose both:

  • tree: TagTreeNodeDTO[] — for the tree panel
  • tags: FlatTag[] — derived flat list with parentId, for parent picker + tag lookup
function flattenTree(nodes: TagTreeNodeDTO[], parentId?: string): FlatTag[] {
    return nodes.flatMap(n => [
        { id: n.id!, name: n.name!, color: n.color, parentId, documentCount: n.documentCount ?? 0 },
        ...flattenTree(n.children ?? [], n.id)
    ]);
}

Phase 6 — Frontend: TagsListPanel Rewrite

File: frontend/src/routes/admin/tags/TagsListPanel.svelte

  • Accept tree: TagTreeNodeDTO[] instead of flat tags
  • Per-node collapse state: SvelteMap<string, boolean> persisted to localStorage as JSON
  • Default: depth-0 roots expanded, depth 1+ collapsed
  • Indent: padding-left: calc({depth} * 12px + 4px) — inline style, no depth cap
  • Each node: chevron <button aria-expanded aria-controls> (16×36px) + <a> link
  • Width: w-60 (240px, was 200px)
  • Document count: (n) suffix from node.documentCount
  • Color dot: root-level nodes with a color only
  • Keyboard: expands, collapses, ↑↓ navigate visible nodes
  • Mobile: no collapse button (existing hide/show logic unchanged)

Phase 7 — Frontend: TagParentPicker Component

New file: frontend/src/lib/components/TagParentPicker.svelte

Mirror PersonTypeahead.svelte.

Props: name, value (bound UUID), label, tags (flat list for path resolution), excludeIds (self + descendants).

  • Visible text input + hidden <input type="hidden"> storing UUID
  • 300ms debounce on GET /api/tags?query=
  • Dropdown: tag name + ancestry path subtitle (computed from tags prop)
  • excludeIds filtered client-side; backend cycle detection is the hard backstop
  • Clear (×) resets value → color picker re-appears
  • Combobox ARIA: role="combobox", aria-expanded, aria-autocomplete="list", aria-activedescendant

Phase 8 — Frontend: Edit Page Overhaul

Files:

  • frontend/src/routes/admin/tags/[id]/+page.svelte
  • frontend/src/routes/admin/tags/[id]/+page.server.ts
  • New: frontend/src/routes/admin/tags/TagMergeZone.svelte

[id]/+page.svelte changes

  1. Ancestry breadcrumb — computed ancestor chain above the name card; each segment an <a> link except the last
  2. Replace <select> with <TagParentPicker> — pass excludeIds={[data.tag.id, ...descendantIds]}
  3. Inherited color indicator — shown when parentId !== ''; color picker hidden
  4. Children preview — new card with direct children as chips {child.name} (count), max 5 + "… und N weitere →"
  5. Merge zone<TagMergeZone tag={data.tag} tags={data.tags} form={form} />
  6. Delete guard — replace current danger zone with:
    • Impact summary: document count + children count
    • Two radio options: delete-only (default) / delete-subtree
    • Name confirm input hidden until radio selected
    • Delete button enabled only when radio selected AND name confirmed

TagMergeZone.svelte (new)

Two-step inline flow (like PersonMergePanel.svelte):

  • Step 1: TagParentPicker for target + preview card (docs count + children count that will move)
  • Step 2: from/to visual summary → amber "Jetzt zusammenführen" button
<form method="POST" action="?/merge" use:enhance>
  <input type="hidden" name="targetId" bind:value={targetId} />

[id]/+page.server.ts additions

  • merge action — calls POST /api/tags/{id}/merge, on success redirect(303, /admin/tags/{targetId})
  • Updated delete action — reads deleteMode from formData; if delete-subtree calls DELETE /api/tags/{id}/subtree, otherwise existing flow

Phase 9 — Frontend: i18n + Error Codes

Files: messages/de.json, messages/en.json, messages/es.json, src/lib/errors.ts

New keys (German — translate for en/es):

  • admin_tag_parent_placeholder: "Kein übergeordnetes Schlagwort"
  • admin_tag_inherited_color: "Farbe von {name} — wird vererbt"
  • admin_tag_children_section: "Untergeordnete Schlagwörter"
  • admin_tag_children_more: "… und {count} weitere →"
  • admin_tag_merge_heading: "Zusammenführen"
  • admin_tag_merge_description: "Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen und dieses danach löschen."
  • admin_tag_merge_btn: "Mit anderem Schlagwort zusammenführen …"
  • admin_tag_merge_target_label: "Ziel-Schlagwort"
  • admin_tag_merge_preview_docs: "{count} Dokumente werden verschoben"
  • admin_tag_merge_preview_children: "{count} Untergeordnete werden umgehängt"
  • admin_tag_merge_deleted_after: "{name} wird danach gelöscht."
  • admin_tag_merge_confirm_btn: "Jetzt zusammenführen"
  • admin_tag_merge_step1: "Schritt 1 von 2 — Ziel-Schlagwort wählen"
  • admin_tag_merge_step2: "Schritt 2 von 2 — Zusammenführen bestätigen"
  • admin_tag_delete_impact: "{docs} Dokumente verknüpft · {children} Untergeordnete"
  • admin_tag_delete_only_this: "Nur dieses Schlagwort löschen"
  • admin_tag_delete_only_this_sub: "Untergeordnete werden zu {parent} verschoben"
  • admin_tag_delete_subtree: "Gesamten Teilbaum löschen"
  • admin_tag_delete_subtree_warn: "Löscht auch {count} Untergeordnete Schlagwörter unwiderruflich"
  • error_tag_merge_self: "Ein Schlagwort kann nicht mit sich selbst zusammengeführt werden."
  • error_tag_merge_invalid_target: "Das Ziel-Schlagwort ist ein Untergeordnetes und kann nicht als Ziel verwendet werden."

errors.ts: add cases for TAG_MERGE_SELF, TAG_MERGE_INVALID_TARGET


Verification

  1. ./mvnw test -Dtest=TagServiceTest,TagControllerTest — all pass
  2. ./mvnw clean package — no regressions
  3. npm run generate:api — completes without errors
  4. Manual: tree panel expand/collapse at depth 2+, per-node state persists on reload
  5. Manual: parent picker search + path subtitle; self-as-parent blocked by backend
  6. Manual: merge flow — documents reassigned, children reparented, source deleted
  7. Manual: subtree delete — all levels removed
  8. Manual: single delete — children promoted by DB ON DELETE SET NULL
  9. npm run check + npm run lint — clean
## Context Issue #221 introduced infinite-depth tag hierarchy. The existing admin tag page can't cope: - The flat sidebar applies the same `pl-5` to all children regardless of depth - The parent `<select>` is unusable beyond ~20 tags - There is no merge feature (unlike persons) - The delete zone gives no warning about children or document counts Design spec: `docs/specs/admin-tag-overhaul.html` **Pre-existing out-of-scope bug:** `TagService.ALLOWED_TAG_COLORS` doesn't match the frontend color swatches — do not fix in this issue. --- ## Phase 1 — Backend: Tag Merge Endpoint **Files:** `TagRepository.java`, `TagService.java`, `TagController.java`, `ErrorCode.java`, `TagServiceTest.java`, `TagControllerTest.java` ### New repository queries - `reassignDocumentTags(sourceId, targetId)` — UPDATE document_tags, skip docs that already have targetId to avoid PK conflict - `deleteDocumentTagsByTagId(sourceId)` — remove remaining source rows - `reparentChildren(sourceId, targetId)` — UPDATE tag SET parent_id = targetId WHERE parent_id = sourceId - `findDescendantIds(tagId)` — recursive CTE returning UUID of tag + all descendants (UUID variant; by-name variant already exists) ### New ErrorCode values ```java TAG_MERGE_SELF, // 400 — source == target TAG_MERGE_INVALID_TARGET // 400 — target is a descendant of source ``` ### `TagService.mergeTags(sourceId, targetId)` — `@Transactional` 1. Validate source != target → `TAG_MERGE_SELF` 2. Validate target not in `findDescendantIds(sourceId)` → `TAG_MERGE_INVALID_TARGET` 3. `reassignDocumentTags` 4. `deleteDocumentTagsByTagId(sourceId)` 5. `reparentChildren(sourceId, targetId)` 6. `deleteById(sourceId)` 7. Return surviving tag ### `POST /api/tags/{id}/merge` — `@RequirePermission(ADMIN_TAG)` Body: `{ "targetId": "uuid" }` — returns surviving `Tag` Mirror of `POST /api/persons/{id}/merge`. ### Tests (mirror `PersonServiceTest` / `PersonControllerTest` patterns) - `mergeTags_throwsBadRequest_whenSelfMerge` - `mergeTags_throwsBadRequest_whenTargetIsDescendant` - `mergeTags_reassignsDocumentsAndReparentsChildrenAndDeletesSource` - Controller: 401 / 403 / 200 --- ## Phase 2 — Backend: Subtree Delete Endpoint **Files:** `TagService.java`, `TagController.java`, tests ### `TagService.deleteWithDescendants(id)` — `@Transactional` 1. `findDescendantIds(id)` — get all IDs including self 2. Bulk remove from `document_tags` (add `deleteDocumentTagsByTagIds(Collection<UUID>)` to repository) 3. `deleteAllById(descendantIds)` ### `DELETE /api/tags/{id}/subtree` — `@RequirePermission(ADMIN_TAG)`, returns 204 ### Tests - `deleteWithDescendants_deletesTagAndAllDescendants` - `deleteWithDescendants_removesDocumentTagsForAllDescendants` - Controller: auth/permission + 204 --- ## Phase 3 — Backend: Enrich Tree DTO **Files:** `TagTreeNodeDTO.java`, `TagService.java`, `TagController.java`, `TagRepository.java` - Add `parentId` field to `TagTreeNodeDTO` (populated in `buildTree()` from `tag.getParentId()`) - Add document count query to `TagRepository` (aggregate against `document_tags`) - Update `TagService.getTagTree()` to populate `documentCount` per node --- ## Phase 4 — Regenerate API Types After backend is running with `--spring.profiles.active=dev`: ```bash cd frontend && npm run generate:api ``` New: `POST /api/tags/{id}/merge`, `DELETE /api/tags/{id}/subtree`, enriched `TagTreeNodeDTO` with `parentId` + `documentCount`. --- ## Phase 5 — Frontend: Layout Server **File:** `frontend/src/routes/admin/tags/+layout.server.ts` Switch from `GET /api/tags` → `GET /api/tags/tree`. Expose both: - `tree: TagTreeNodeDTO[]` — for the tree panel - `tags: FlatTag[]` — derived flat list with `parentId`, for parent picker + tag lookup ```typescript function flattenTree(nodes: TagTreeNodeDTO[], parentId?: string): FlatTag[] { return nodes.flatMap(n => [ { id: n.id!, name: n.name!, color: n.color, parentId, documentCount: n.documentCount ?? 0 }, ...flattenTree(n.children ?? [], n.id) ]); } ``` --- ## Phase 6 — Frontend: TagsListPanel Rewrite **File:** `frontend/src/routes/admin/tags/TagsListPanel.svelte` - Accept `tree: TagTreeNodeDTO[]` instead of flat `tags` - Per-node collapse state: `SvelteMap<string, boolean>` persisted to `localStorage` as JSON - Default: depth-0 roots expanded, depth 1+ collapsed - Indent: `padding-left: calc({depth} * 12px + 4px)` — inline style, no depth cap - Each node: chevron `<button aria-expanded aria-controls>` (16×36px) + `<a>` link - Width: `w-60` (240px, was 200px) - Document count: `(n)` suffix from `node.documentCount` - Color dot: root-level nodes with a color only - Keyboard: `→` expands, `←` collapses, `↑↓` navigate visible nodes - Mobile: no collapse button (existing hide/show logic unchanged) --- ## Phase 7 — Frontend: TagParentPicker Component **New file:** `frontend/src/lib/components/TagParentPicker.svelte` Mirror `PersonTypeahead.svelte`. Props: `name`, `value` (bound UUID), `label`, `tags` (flat list for path resolution), `excludeIds` (self + descendants). - Visible text input + hidden `<input type="hidden">` storing UUID - 300ms debounce on `GET /api/tags?query=` - Dropdown: tag name + ancestry path subtitle (computed from `tags` prop) - `excludeIds` filtered client-side; backend cycle detection is the hard backstop - Clear (×) resets value → color picker re-appears - Combobox ARIA: `role="combobox"`, `aria-expanded`, `aria-autocomplete="list"`, `aria-activedescendant` --- ## Phase 8 — Frontend: Edit Page Overhaul **Files:** - `frontend/src/routes/admin/tags/[id]/+page.svelte` - `frontend/src/routes/admin/tags/[id]/+page.server.ts` - **New:** `frontend/src/routes/admin/tags/TagMergeZone.svelte` ### `[id]/+page.svelte` changes 1. **Ancestry breadcrumb** — computed ancestor chain above the name card; each segment an `<a>` link except the last 2. **Replace `<select>` with `<TagParentPicker>`** — pass `excludeIds={[data.tag.id, ...descendantIds]}` 3. **Inherited color indicator** — shown when `parentId !== ''`; color picker hidden 4. **Children preview** — new card with direct children as chips `{child.name} (count)`, max 5 + "… und N weitere →" 5. **Merge zone** — `<TagMergeZone tag={data.tag} tags={data.tags} form={form} />` 6. **Delete guard** — replace current danger zone with: - Impact summary: document count + children count - Two radio options: `delete-only` (default) / `delete-subtree` - Name confirm input hidden until radio selected - Delete button enabled only when radio selected AND name confirmed ### `TagMergeZone.svelte` (new) Two-step inline flow (like `PersonMergePanel.svelte`): - Step 1: `TagParentPicker` for target + preview card (docs count + children count that will move) - Step 2: from/to visual summary → amber "Jetzt zusammenführen" button ```svelte <form method="POST" action="?/merge" use:enhance> <input type="hidden" name="targetId" bind:value={targetId} /> ``` ### `[id]/+page.server.ts` additions - **`merge` action** — calls `POST /api/tags/{id}/merge`, on success `redirect(303, /admin/tags/{targetId})` - **Updated `delete` action** — reads `deleteMode` from formData; if `delete-subtree` calls `DELETE /api/tags/{id}/subtree`, otherwise existing flow --- ## Phase 9 — Frontend: i18n + Error Codes **Files:** `messages/de.json`, `messages/en.json`, `messages/es.json`, `src/lib/errors.ts` New keys (German — translate for en/es): - `admin_tag_parent_placeholder`: "Kein übergeordnetes Schlagwort" - `admin_tag_inherited_color`: "Farbe von {name} — wird vererbt" - `admin_tag_children_section`: "Untergeordnete Schlagwörter" - `admin_tag_children_more`: "… und {count} weitere →" - `admin_tag_merge_heading`: "Zusammenführen" - `admin_tag_merge_description`: "Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen und dieses danach löschen." - `admin_tag_merge_btn`: "Mit anderem Schlagwort zusammenführen …" - `admin_tag_merge_target_label`: "Ziel-Schlagwort" - `admin_tag_merge_preview_docs`: "{count} Dokumente werden verschoben" - `admin_tag_merge_preview_children`: "{count} Untergeordnete werden umgehängt" - `admin_tag_merge_deleted_after`: "{name} wird danach gelöscht." - `admin_tag_merge_confirm_btn`: "Jetzt zusammenführen" - `admin_tag_merge_step1`: "Schritt 1 von 2 — Ziel-Schlagwort wählen" - `admin_tag_merge_step2`: "Schritt 2 von 2 — Zusammenführen bestätigen" - `admin_tag_delete_impact`: "{docs} Dokumente verknüpft · {children} Untergeordnete" - `admin_tag_delete_only_this`: "Nur dieses Schlagwort löschen" - `admin_tag_delete_only_this_sub`: "Untergeordnete werden zu {parent} verschoben" - `admin_tag_delete_subtree`: "Gesamten Teilbaum löschen" - `admin_tag_delete_subtree_warn`: "Löscht auch {count} Untergeordnete Schlagwörter unwiderruflich" - `error_tag_merge_self`: "Ein Schlagwort kann nicht mit sich selbst zusammengeführt werden." - `error_tag_merge_invalid_target`: "Das Ziel-Schlagwort ist ein Untergeordnetes und kann nicht als Ziel verwendet werden." `errors.ts`: add cases for `TAG_MERGE_SELF`, `TAG_MERGE_INVALID_TARGET` --- ## Verification 1. `./mvnw test -Dtest=TagServiceTest,TagControllerTest` — all pass 2. `./mvnw clean package` — no regressions 3. `npm run generate:api` — completes without errors 4. Manual: tree panel expand/collapse at depth 2+, per-node state persists on reload 5. Manual: parent picker search + path subtitle; self-as-parent blocked by backend 6. Manual: merge flow — documents reassigned, children reparented, source deleted 7. Manual: subtree delete — all levels removed 8. Manual: single delete — children promoted by DB `ON DELETE SET NULL` 9. `npm run check` + `npm run lint` — clean
marcel added the featureui labels 2026-04-16 21:23:43 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

Backend

  • findDescendantIds(tagId) is described as a "recursive CTE" — is this a @Query(nativeQuery = true) or a stored procedure? Native @Query is the right call (no migration needed, no proc to maintain). Confirm it uses a named parameter :tagId, not string concatenation.
  • TagService.mergeTags orchestrates 6 steps. That's on the edge of "one method doing too much." Consider extracting a private transferDocuments(sourceId, targetId) helper for steps 3+4, and keeping mergeTags as the thin orchestrator. Each helper does one thing, each is named, each is testable in isolation.
  • deleteWithDescendants step order: (1) find IDs, (2) delete from document_tags, (3) delete tags. If tag.parent_id has a FK constraint, deleting all IDs in bulk via deleteAllById(descendantIds) might fail unless JPA resolves bottom-up ordering or the FK is ON DELETE SET NULL/CASCADE. Worth an explicit note that the delete order must handle self-referential FK.

Frontend

  • TagParentPicker mirrors PersonTypeahead — good. If the debounce/dropdown logic is copy-pasted, flag it. If the query endpoint, result shape, and excludeIds filtering are different enough, two components are correct. But if there's >50% shared logic, a shared useTypeahead composable or base component might pay off here.
  • TagsListPanel recursive tree — if each node renders its children inline, the file will grow past 60 lines very quickly. Consider a TagTreeNode.svelte component for the recursive part: TagsListPanel renders roots, TagTreeNode renders itself and recurses. That's two nameable visual regions.
  • Phase 8 [id]/+page.svelte contains 5 distinct visual regions: ancestry breadcrumb, parent picker, children preview, merge zone, and delete guard. Each region is a named component boundary. Are these being extracted (TagAncestry.svelte, TagChildrenPreview.svelte) or will they all live inline?
  • SvelteMap<string, boolean> for collapse state is the right call — plain Map mutations are invisible to Svelte 5's reactivity.

Suggestions

  • In mergeTags, lead with guard clauses: self-merge check first, descendant check second, happy path at the bottom at the lowest indentation level.
  • The flattenTree utility in Phase 5 runs in load() server-side — good. Make sure it doesn't get called inside a client-side $derived.by() that re-runs on every reactive update.
  • In TagMergeZone.svelte, the two-step state should be $state flags, not inferred from {#if form?.step}. The step is client UI state; the form result is server state. Keep them separate.
  • {#each nodes as node (node.id)} — all {#each} blocks over tag nodes must be keyed. Unkeyed blocks will silently corrupt collapse state on list reordering.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations **Backend** - `findDescendantIds(tagId)` is described as a "recursive CTE" — is this a `@Query(nativeQuery = true)` or a stored procedure? Native `@Query` is the right call (no migration needed, no proc to maintain). Confirm it uses a named parameter `:tagId`, not string concatenation. - `TagService.mergeTags` orchestrates 6 steps. That's on the edge of "one method doing too much." Consider extracting a private `transferDocuments(sourceId, targetId)` helper for steps 3+4, and keeping `mergeTags` as the thin orchestrator. Each helper does one thing, each is named, each is testable in isolation. - `deleteWithDescendants` step order: (1) find IDs, (2) delete from `document_tags`, (3) delete tags. If `tag.parent_id` has a FK constraint, deleting all IDs in bulk via `deleteAllById(descendantIds)` might fail unless JPA resolves bottom-up ordering or the FK is `ON DELETE SET NULL`/`CASCADE`. Worth an explicit note that the delete order must handle self-referential FK. **Frontend** - `TagParentPicker` mirrors `PersonTypeahead` — good. If the debounce/dropdown logic is copy-pasted, flag it. If the query endpoint, result shape, and `excludeIds` filtering are different enough, two components are correct. But if there's >50% shared logic, a shared `useTypeahead` composable or base component might pay off here. - `TagsListPanel` recursive tree — if each node renders its children inline, the file will grow past 60 lines very quickly. Consider a `TagTreeNode.svelte` component for the recursive part: `TagsListPanel` renders roots, `TagTreeNode` renders itself and recurses. That's two nameable visual regions. - Phase 8 `[id]/+page.svelte` contains 5 distinct visual regions: ancestry breadcrumb, parent picker, children preview, merge zone, and delete guard. Each region is a named component boundary. Are these being extracted (`TagAncestry.svelte`, `TagChildrenPreview.svelte`) or will they all live inline? - `SvelteMap<string, boolean>` for collapse state is the right call — plain `Map` mutations are invisible to Svelte 5's reactivity. ✅ ### Suggestions - In `mergeTags`, lead with guard clauses: self-merge check first, descendant check second, happy path at the bottom at the lowest indentation level. - The `flattenTree` utility in Phase 5 runs in `load()` server-side — good. Make sure it doesn't get called inside a client-side `$derived.by()` that re-runs on every reactive update. - In `TagMergeZone.svelte`, the two-step state should be `$state` flags, not inferred from `{#if form?.step}`. The step is client UI state; the form result is server state. Keep them separate. - `{#each nodes as node (node.id)}` — all `{#each}` blocks over tag nodes must be keyed. Unkeyed blocks will silently corrupt collapse state on list reordering.
Author
Owner

🏛️ Markus Keller — Application Architect

Questions & Observations

Data layer

  • reassignDocumentTags and deleteDocumentTagsByTagId are bulk @Modifying @Query statements on a junction table. These need @Modifying(clearAutomatically = true) (or an explicit flush before the subsequent deleteAllById) to avoid stale first-level cache after bulk updates. Without it, JPA may still see deleted rows as present during the same transaction.
  • Does the tag table have an index on parent_id? The recursive CTE traverses parent_id at every level. Without an index, each level of the recursion is a full table scan. This is invisible at 50 tags, painful at 10k. Worth checking if #221 added this; if not, a CREATE INDEX idx_tag_parent_id ON tag(parent_id) Flyway migration belongs in this issue.
  • "Children promoted by ON DELETE SET NULL" for the single-tag delete — is the FK constraint with ON DELETE SET NULL already in the schema from #221, or does this issue need a migration to add it? The spec doesn't mention a migration for this case.

@Modifying and native queries

  • reassignDocumentTags is described as an UPDATE document_tags ... WHERE NOT EXISTS. This is native SQL, not JPQL. The spec should be explicit: @Query(value = "UPDATE ...", nativeQuery = true). JPQL cannot express UPDATE ... WHERE NOT EXISTS (subquery) cleanly, and I want implementors to reach for the right annotation on first pass.

DTO design

  • TagTreeNodeDTO gains parentId and documentCount. If documentCount can logically be 0 (leaf tag with no documents), the backend should guarantee it is never null — use int not Integer, and annotate @Schema(requiredMode = REQUIRED). The frontend fallback ?? 0 should be unnecessary; let the backend make the guarantee.
  • documentCount is populated via a per-node aggregate query. If getTagTree() calls the count query N times (once per tag), that's N+1 behavior on every tree load. Consider a single aggregate query that returns all (tag_id, count) pairs and merges them into buildTree().

Module boundaries

  • TagService.mergeTags calls only tag-domain repositories. TagService.deleteWithDescendants likewise. No cross-domain service calls in scope. Clean.

Suggestions

  • Explicitly list the new Flyway migration file(s) needed in the spec verification section: VN__add_tag_parent_id_index.sql and/or VN__add_tag_parent_fk_on_delete_set_null.sql if these don't already exist. Migrations are the only auditable record of schema changes.
  • For the documentCount aggregate: SELECT tag_id, COUNT(*) FROM document_tags GROUP BY tag_id returns a result set that can be converted to a Map<UUID, Long> and merged into buildTree() in a single pass. That's one query instead of N.
## 🏛️ Markus Keller — Application Architect ### Questions & Observations **Data layer** - `reassignDocumentTags` and `deleteDocumentTagsByTagId` are bulk `@Modifying @Query` statements on a junction table. These need `@Modifying(clearAutomatically = true)` (or an explicit flush before the subsequent `deleteAllById`) to avoid stale first-level cache after bulk updates. Without it, JPA may still see deleted rows as present during the same transaction. - Does the `tag` table have an index on `parent_id`? The recursive CTE traverses `parent_id` at every level. Without an index, each level of the recursion is a full table scan. This is invisible at 50 tags, painful at 10k. Worth checking if #221 added this; if not, a `CREATE INDEX idx_tag_parent_id ON tag(parent_id)` Flyway migration belongs in this issue. - "Children promoted by `ON DELETE SET NULL`" for the single-tag delete — is the FK constraint with `ON DELETE SET NULL` already in the schema from #221, or does this issue need a migration to add it? The spec doesn't mention a migration for this case. **`@Modifying` and native queries** - `reassignDocumentTags` is described as an `UPDATE document_tags ... WHERE NOT EXISTS`. This is native SQL, not JPQL. The spec should be explicit: `@Query(value = "UPDATE ...", nativeQuery = true)`. JPQL cannot express `UPDATE ... WHERE NOT EXISTS (subquery)` cleanly, and I want implementors to reach for the right annotation on first pass. **DTO design** - `TagTreeNodeDTO` gains `parentId` and `documentCount`. If `documentCount` can logically be 0 (leaf tag with no documents), the backend should guarantee it is never `null` — use `int` not `Integer`, and annotate `@Schema(requiredMode = REQUIRED)`. The frontend fallback `?? 0` should be unnecessary; let the backend make the guarantee. - `documentCount` is populated via a per-node aggregate query. If `getTagTree()` calls the count query N times (once per tag), that's N+1 behavior on every tree load. Consider a single aggregate query that returns all `(tag_id, count)` pairs and merges them into `buildTree()`. **Module boundaries** - `TagService.mergeTags` calls only tag-domain repositories. `TagService.deleteWithDescendants` likewise. No cross-domain service calls in scope. ✅ Clean. ### Suggestions - Explicitly list the new Flyway migration file(s) needed in the spec verification section: `VN__add_tag_parent_id_index.sql` and/or `VN__add_tag_parent_fk_on_delete_set_null.sql` if these don't already exist. Migrations are the only auditable record of schema changes. - For the `documentCount` aggregate: `SELECT tag_id, COUNT(*) FROM document_tags GROUP BY tag_id` returns a result set that can be converted to a `Map<UUID, Long>` and merged into `buildTree()` in a single pass. That's one query instead of N.
Author
Owner

🧪 Sara Holt — QA Engineer

Questions & Observations

Missing test cases for TagServiceTest

The spec names the happy path and two error cases for merge, but these edge cases are missing:

  • mergeTags_whenSourceHasNoDocuments_deletesSourceAndReparentsChildren — step 3 is a no-op; verify it doesn't error
  • mergeTags_whenTargetAlreadyOnSomeDocuments_skipsThoseDocuments — this is the PK-conflict avoidance case ("skip docs that already have targetId"). It's the most subtle behavior in the whole feature and deserves its own explicit test
  • mergeTags_whenSourceHasNoChildren_deletesSafelyreparentChildren with zero rows should be a safe no-op
  • deleteWithDescendants_whenTagHasNoDescendants_deletesTagOnly — boundary case: a leaf node

For TagControllerTest, the spec says "401 / 403 / 200" but omits 404. What happens when {id} doesn't exist? Add merge_returns404_when_sourceTag_doesNotExist.

deleteDocumentTagsByTagIds(Collection<UUID>) edge case

An IN () clause with an empty collection is invalid SQL in PostgreSQL and will throw at runtime. Add a test deleteWithDescendants_whenCollectionIsEmpty_isNoOp and guard the repository call with a size check.

Frontend test coverage gaps

  • TagsListPanel collapse state persists to localStorage — this behavior should be an explicit component test: collapse a node, reload, assert it is still collapsed. vitest-browser-svelte can read localStorage.
  • Keyboard navigation (//↑↓) — these are behavioral requirements, not visual ones. They need assertion-based tests, not screenshots. Consider Playwright tests for each key binding.
  • Delete guard: three conditions must all be true before the button enables (radio selected AND name typed AND name matches). Consider parametrized tests: (no radio, no name) → disabled; (radio, no name) → disabled; (radio, wrong name) → disabled; (radio, correct name) → enabled.

Acceptance criteria gaps

  • What's the expected behavior when GET /api/tags/tree returns an empty array? Does the panel show an empty state or is empty tree impossible?
  • Ancestry breadcrumb: what renders for a root-level tag with no parent? No breadcrumb, or a single-segment breadcrumb?
  • After a successful merge, the user is redirected to /admin/tags/{targetId}. What should the page title/breadcrumb show immediately after redirect?

Suggestions

  • Mirror the PersonServiceTest test naming convention: mergeTags_throwsBadRequest_whenSelfMerge — keep it consistent across the test file.
  • The deleteWithDescendants integration test (if written against real PostgreSQL via Testcontainers) is the only test that can catch the FK deletion ordering issue. Consider adding an integration test alongside the unit tests for this method specifically.
## 🧪 Sara Holt — QA Engineer ### Questions & Observations **Missing test cases for `TagServiceTest`** The spec names the happy path and two error cases for merge, but these edge cases are missing: - `mergeTags_whenSourceHasNoDocuments_deletesSourceAndReparentsChildren` — step 3 is a no-op; verify it doesn't error - `mergeTags_whenTargetAlreadyOnSomeDocuments_skipsThoseDocuments` — this is the PK-conflict avoidance case ("skip docs that already have targetId"). It's the most subtle behavior in the whole feature and deserves its own explicit test - `mergeTags_whenSourceHasNoChildren_deletesSafely` — `reparentChildren` with zero rows should be a safe no-op - `deleteWithDescendants_whenTagHasNoDescendants_deletesTagOnly` — boundary case: a leaf node For `TagControllerTest`, the spec says "401 / 403 / 200" but omits 404. What happens when `{id}` doesn't exist? Add `merge_returns404_when_sourceTag_doesNotExist`. **`deleteDocumentTagsByTagIds(Collection<UUID>)` edge case** An `IN ()` clause with an empty collection is invalid SQL in PostgreSQL and will throw at runtime. Add a test `deleteWithDescendants_whenCollectionIsEmpty_isNoOp` and guard the repository call with a size check. **Frontend test coverage gaps** - `TagsListPanel` collapse state persists to `localStorage` — this behavior should be an explicit component test: collapse a node, reload, assert it is still collapsed. `vitest-browser-svelte` can read `localStorage`. - Keyboard navigation (`→`/`←`/`↑↓`) — these are behavioral requirements, not visual ones. They need assertion-based tests, not screenshots. Consider Playwright tests for each key binding. - Delete guard: three conditions must all be true before the button enables (radio selected AND name typed AND name matches). Consider parametrized tests: (no radio, no name) → disabled; (radio, no name) → disabled; (radio, wrong name) → disabled; (radio, correct name) → enabled. **Acceptance criteria gaps** - What's the expected behavior when `GET /api/tags/tree` returns an empty array? Does the panel show an empty state or is empty tree impossible? - Ancestry breadcrumb: what renders for a root-level tag with no parent? No breadcrumb, or a single-segment breadcrumb? - After a successful merge, the user is redirected to `/admin/tags/{targetId}`. What should the page title/breadcrumb show immediately after redirect? ### Suggestions - Mirror the `PersonServiceTest` test naming convention: `mergeTags_throwsBadRequest_whenSelfMerge` ✅ — keep it consistent across the test file. - The `deleteWithDescendants` integration test (if written against real PostgreSQL via Testcontainers) is the only test that can catch the FK deletion ordering issue. Consider adding an integration test alongside the unit tests for this method specifically.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Questions & Observations

Authorization

Both new endpoints specify @RequirePermission(ADMIN_TAG) — mirrors existing tag write endpoints. The permission boundary test spec lists 401/403/200. One addition: also test 401 for the subtree delete endpoint specifically — the spec mentions it for merge but the subtree delete section only says "auth/permission + 204."

Input validation at controller boundary

  • POST /api/tags/{id}/merge body { "targetId": "uuid" } — what happens if targetId is null or absent from the body? The service validates sourceId != targetId but the null case hits that check as a NullPointerException, not a clean BAD_REQUEST. Add a controller-level guard: if (dto.getTargetId() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "targetId required").
  • DELETE /api/tags/{id}/subtree — no request body, just a path parameter. Clean.

Client-side security boundary — explicit documentation needed

Phase 7 states: "excludeIds filtered client-side; backend cycle detection is the hard backstop." This is correct architecture. However, without an explicit comment in the code, a future developer might remove the backend TAG_MERGE_INVALID_TARGET validation thinking "the frontend already handles it." The implementation should include a comment on the service validation: // Security backstop — do not remove. Frontend excludeIds is UX-only, not a security control.

Bulk query parameter binding

deleteDocumentTagsByTagIds(Collection<UUID>) — if this is a native @Query with IN (:ids), Spring Data JPA binds the collection as parameters correctly (not string-interpolated). Just confirm the @Query annotation uses :ids as a named parameter, not IN ( + string join + ).

Transactional atomicity

mergeTags is @Transactional — validation, reassignment, reparent, and delete are atomic. If deleteById(sourceId) fails on a FK violation, the whole merge rolls back cleanly. The only risk is if clearAutomatically = true is missing from @Modifying (see Markus's comment) — a stale cache could mask an incomplete state mid-transaction.

Suggestions

  • Add a null-check for targetId at the controller boundary before the service call. ResponseStatusException(BAD_REQUEST) is acceptable per the project's error handling conventions for simple controller validation.
  • The merge and subtree delete are both irreversible. The UI confirmation (radio + name confirm) covers the UX side. No need for a backend { "confirm": true } field — the UI flow is sufficient for a family archive context.
  • No new dependencies introduced by this feature, so no new CVE surface.
## 🔐 Nora "NullX" Steiner — Application Security Engineer ### Questions & Observations **Authorization** Both new endpoints specify `@RequirePermission(ADMIN_TAG)` — mirrors existing tag write endpoints. ✅ The permission boundary test spec lists 401/403/200. One addition: also test `401` for the subtree delete endpoint specifically — the spec mentions it for merge but the subtree delete section only says "auth/permission + 204." **Input validation at controller boundary** - `POST /api/tags/{id}/merge` body `{ "targetId": "uuid" }` — what happens if `targetId` is null or absent from the body? The service validates `sourceId != targetId` but the null case hits that check as a NullPointerException, not a clean `BAD_REQUEST`. Add a controller-level guard: `if (dto.getTargetId() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "targetId required")`. - `DELETE /api/tags/{id}/subtree` — no request body, just a path parameter. Clean. ✅ **Client-side security boundary — explicit documentation needed** Phase 7 states: "`excludeIds` filtered client-side; backend cycle detection is the hard backstop." This is correct architecture. However, without an explicit comment in the code, a future developer might remove the backend `TAG_MERGE_INVALID_TARGET` validation thinking "the frontend already handles it." The implementation should include a comment on the service validation: `// Security backstop — do not remove. Frontend excludeIds is UX-only, not a security control.` **Bulk query parameter binding** `deleteDocumentTagsByTagIds(Collection<UUID>)` — if this is a native `@Query` with `IN (:ids)`, Spring Data JPA binds the collection as parameters correctly (not string-interpolated). ✅ Just confirm the `@Query` annotation uses `:ids` as a named parameter, not `IN (` + string join + `)`. **Transactional atomicity** `mergeTags` is `@Transactional` — validation, reassignment, reparent, and delete are atomic. If `deleteById(sourceId)` fails on a FK violation, the whole merge rolls back cleanly. ✅ The only risk is if `clearAutomatically = true` is missing from `@Modifying` (see Markus's comment) — a stale cache could mask an incomplete state mid-transaction. ### Suggestions - Add a null-check for `targetId` at the controller boundary before the service call. `ResponseStatusException(BAD_REQUEST)` is acceptable per the project's error handling conventions for simple controller validation. - The merge and subtree delete are both irreversible. The UI confirmation (radio + name confirm) covers the UX side. No need for a backend `{ "confirm": true }` field — the UI flow is sufficient for a family archive context. - No new dependencies introduced by this feature, so no new CVE surface. ✅
Author
Owner

🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist

Questions & Observations

Touch targets — critical

The chevron button is specified as 16×36px. WCAG 2.2 requires a minimum of 44×44px for all interactive controls. The collapse chevron is the primary navigation mechanism for the tree — this must be fixed. Recommend: wrap the chevron in a <button class="flex items-center justify-center min-h-[44px] min-w-[44px]"> with the 16px icon centered inside. The adjacent tag link covers horizontal space, so the 44px height is the real fix needed.

ARIA tree semantics

The spec specifies aria-expanded and aria-controls on the chevron button — good. But for proper assistive technology support, the tree container needs role="tree" and each node row needs role="treeitem". Without these, screen readers announce the tree as a flat list, not a navigable hierarchy. VoiceOver and NVDA both rely on role="treeitem" to communicate "this item has children" to the user.

Brand color — amber button

TagMergeZone specifies an "amber" confirm button (Jetzt zusammenführen). Amber is not in the project's brand palette (navy/mint/sand). If the intent is to signal a destructive-adjacent action, two options:

  • Use bg-amber-600 hover:bg-amber-700 text-white from Tailwind and document it as an intentional one-off for dangerous confirmations
  • Stay on-brand with bg-brand-navy and prefix with a warning icon to signal the weight of the action

Pick one and be consistent with how the person merge zone handles this.

Delete guard UX — hidden input reveal

"Name confirm input hidden until radio selected" — for the senior audience (60+), UI appearing unexpectedly after a click is disorienting. Consider showing the input disabled from the start: <input disabled class="opacity-50" />. The user sees the full flow upfront and isn't surprised when new UI materializes.

Root-level tag edge case in delete guard subtitle

admin_tag_delete_only_this_sub: "Untergeordnete werden zu {parent} verschoben" — what renders when the tag being deleted is a root (no parent)? {parent} would be empty. Needs a fallback: "Untergeordnete werden zur obersten Ebene verschoben" or similar.

Ancestry breadcrumb overflow

Deep hierarchies (5+ levels) will overflow the card width on mobile (320px). Consider middle-truncation: Root > … > Grandparent > Parent > Current, where only first and last segments are always shown.

Children preview "… und N weitere →"

This is rendered as a link (). Where does it navigate? If it links to the tag's own page or a filtered list view, use <a href="...">. If it expands inline, use <button>. The element type must match the behavior.

Suggestions

  • Add aria-label={m.close()} or aria-label={m.clear_selection()} to the × clear button in TagParentPicker. Without it, screen readers announce just "button."
  • Run AxeBuilder specifically on the tree panel — role="tree" + aria-expanded + aria-activedescendant combinations are one of the more reliably caught axe violation patterns. Catching it in E2E before ship is much cheaper than after.
  • The color dot on "root-level nodes only" — non-root nodes inherit color but show no visual indicator in the tree. Consider showing the inherited color as a faded/smaller dot on child nodes so users can visually trace the color inheritance. The color pickers in the edit page already show "inherited from parent"; the tree should reinforce that signal.
## 🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist ### Questions & Observations **Touch targets — critical** The chevron button is specified as `16×36px`. WCAG 2.2 requires a minimum of 44×44px for all interactive controls. The collapse chevron is the primary navigation mechanism for the tree — this must be fixed. Recommend: wrap the chevron in a `<button class="flex items-center justify-center min-h-[44px] min-w-[44px]">` with the 16px icon centered inside. The adjacent tag link covers horizontal space, so the 44px height is the real fix needed. **ARIA tree semantics** The spec specifies `aria-expanded` and `aria-controls` on the chevron button — good. But for proper assistive technology support, the tree container needs `role="tree"` and each node row needs `role="treeitem"`. Without these, screen readers announce the tree as a flat list, not a navigable hierarchy. VoiceOver and NVDA both rely on `role="treeitem"` to communicate "this item has children" to the user. **Brand color — amber button** `TagMergeZone` specifies an "amber" confirm button (`Jetzt zusammenführen`). Amber is not in the project's brand palette (navy/mint/sand). If the intent is to signal a destructive-adjacent action, two options: - Use `bg-amber-600 hover:bg-amber-700 text-white` from Tailwind and document it as an intentional one-off for dangerous confirmations - Stay on-brand with `bg-brand-navy` and prefix with a warning icon `⚠` to signal the weight of the action Pick one and be consistent with how the person merge zone handles this. **Delete guard UX — hidden input reveal** "Name confirm input hidden until radio selected" — for the senior audience (60+), UI appearing unexpectedly after a click is disorienting. Consider showing the input disabled from the start: `<input disabled class="opacity-50" />`. The user sees the full flow upfront and isn't surprised when new UI materializes. **Root-level tag edge case in delete guard subtitle** `admin_tag_delete_only_this_sub`: "Untergeordnete werden zu {parent} verschoben" — what renders when the tag being deleted is a root (no parent)? `{parent}` would be empty. Needs a fallback: "Untergeordnete werden zur obersten Ebene verschoben" or similar. **Ancestry breadcrumb overflow** Deep hierarchies (5+ levels) will overflow the card width on mobile (320px). Consider middle-truncation: `Root > … > Grandparent > Parent > Current`, where only first and last segments are always shown. **Children preview "… und N weitere →"** This is rendered as a link (`→`). Where does it navigate? If it links to the tag's own page or a filtered list view, use `<a href="...">`. If it expands inline, use `<button>`. The element type must match the behavior. ### Suggestions - Add `aria-label={m.close()}` or `aria-label={m.clear_selection()}` to the × clear button in `TagParentPicker`. Without it, screen readers announce just "button." - Run `AxeBuilder` specifically on the tree panel — `role="tree"` + `aria-expanded` + `aria-activedescendant` combinations are one of the more reliably caught axe violation patterns. Catching it in E2E before ship is much cheaper than after. - The color dot on "root-level nodes only" — non-root nodes inherit color but show no visual indicator in the tree. Consider showing the inherited color as a faded/smaller dot on child nodes so users can visually trace the color inheritance. The color pickers in the edit page already show "inherited from parent"; the tree should reinforce that signal.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Questions & Observations

Database index — check before shipping

The findDescendantIds recursive CTE traverses tag.parent_id at every level. Without an index on tag(parent_id), each CTE recursion step is a full table scan. With 50-200 tags this is invisible; at 5k+ it shows up in Loki. Check whether #221 added CREATE INDEX idx_tag_parent_id ON tag(parent_id). If not, this issue needs a Flyway migration to add it. The spec's verification section should explicitly name that migration.

Missing Flyway migration entries

The spec doesn't list any new migration files. Two candidates worth confirming:

  • idx_tag_parent_id index (if not already present)
  • ON DELETE SET NULL FK constraint on tag.parent_id for the single-tag delete (if not already present from #221)

If both are already in the schema from #221, great — note it explicitly so the next person doesn't have to check. If not, add the migrations and include ./mvnw test -Dtest=MigrationIntegrationTest in the verification steps.

Bulk operation observability

TagService.deleteWithDescendants could delete hundreds of document_tags rows in one transaction. At family archive scale this is fine, but a single logger.info("Deleting subtree rooted at {}, {} descendants", rootId, descendantIds.size()) before the bulk delete gives Loki something useful when a slow operation is reported. Same suggestion for mergeTags.

No new infrastructure

This feature adds no new services, containers, ports, volumes, or dependencies. The Docker Compose file does not change.

CI pipeline — API type regeneration

Phase 4 (npm run generate:api) requires a running backend with --spring.profiles.active=dev. This is a developer step, not CI-automated. Two questions:

  1. Is the generated src/lib/api.d.ts (or equivalent) committed to the repo?
  2. If yes, does CI verify it's up-to-date (i.e., re-generate and fail on diff)?

If the generated file is committed and goes stale, TypeScript will silently compile against outdated types. Either automate the regeneration check in CI or document clearly that the dev must regenerate before pushing.

IN () with empty collection

Sara flagged this in the test context — I'll add the infrastructure angle: deleteDocumentTagsByTagIds(emptyCollection) with a native IN () will throw a PostgreSQL syntax error in production, not just in tests. The fix (size check before the query) is a one-liner but must be in the implementation, not just the tests.

Suggestions

  • Add logger.info(...) calls at the start of mergeTags and deleteWithDescendants — two lines that make these irreversible operations visible in Loki without being noisy.
  • After the feature is merged, verify the migration sequence runs cleanly from scratch: ./scripts/reset-db.sh && ./mvnw spring-boot:run — this catches migration ordering issues that unit tests miss.
## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Questions & Observations **Database index — check before shipping** The `findDescendantIds` recursive CTE traverses `tag.parent_id` at every level. Without an index on `tag(parent_id)`, each CTE recursion step is a full table scan. With 50-200 tags this is invisible; at 5k+ it shows up in Loki. Check whether #221 added `CREATE INDEX idx_tag_parent_id ON tag(parent_id)`. If not, this issue needs a Flyway migration to add it. The spec's verification section should explicitly name that migration. **Missing Flyway migration entries** The spec doesn't list any new migration files. Two candidates worth confirming: - `idx_tag_parent_id` index (if not already present) - `ON DELETE SET NULL` FK constraint on `tag.parent_id` for the single-tag delete (if not already present from #221) If both are already in the schema from #221, great — note it explicitly so the next person doesn't have to check. If not, add the migrations and include `./mvnw test -Dtest=MigrationIntegrationTest` in the verification steps. **Bulk operation observability** `TagService.deleteWithDescendants` could delete hundreds of `document_tags` rows in one transaction. At family archive scale this is fine, but a single `logger.info("Deleting subtree rooted at {}, {} descendants", rootId, descendantIds.size())` before the bulk delete gives Loki something useful when a slow operation is reported. Same suggestion for `mergeTags`. **No new infrastructure** This feature adds no new services, containers, ports, volumes, or dependencies. The Docker Compose file does not change. ✅ **CI pipeline — API type regeneration** Phase 4 (`npm run generate:api`) requires a running backend with `--spring.profiles.active=dev`. This is a developer step, not CI-automated. Two questions: 1. Is the generated `src/lib/api.d.ts` (or equivalent) committed to the repo? 2. If yes, does CI verify it's up-to-date (i.e., re-generate and fail on diff)? If the generated file is committed and goes stale, TypeScript will silently compile against outdated types. Either automate the regeneration check in CI or document clearly that the dev must regenerate before pushing. **`IN ()` with empty collection** Sara flagged this in the test context — I'll add the infrastructure angle: `deleteDocumentTagsByTagIds(emptyCollection)` with a native `IN ()` will throw a PostgreSQL syntax error in production, not just in tests. The fix (size check before the query) is a one-liner but must be in the implementation, not just the tests. ### Suggestions - Add `logger.info(...)` calls at the start of `mergeTags` and `deleteWithDescendants` — two lines that make these irreversible operations visible in Loki without being noisy. - After the feature is merged, verify the migration sequence runs cleanly from scratch: `./scripts/reset-db.sh && ./mvnw spring-boot:run` — this catches migration ordering issues that unit tests miss.
Author
Owner

🏛️ Markus Keller — Architect Discussion Summary

Follow-up to my earlier review comment. Worked through 5 open items with the team — all resolved.


Item 1 — Flyway migrations: nothing new needed

V39 (add_tag_hierarchy.sql) from #221 already covers everything:

  • CREATE INDEX idx_tag_parent_id ON tag(parent_id)
  • ALTER TABLE tag ADD COLUMN parent_id UUID REFERENCES tag(id) ON DELETE SET NULL
  • ALTER TABLE tag ADD CONSTRAINT chk_tag_no_self_reference CHECK (parent_id != id)

Issue #248 requires zero new Flyway migrations. The spec should note this explicitly so no one writes a redundant migration.


Item 2 — documentCount: single aggregate query, not N+1

Add findDocumentCountsPerTag() to TagRepository:

@Query("SELECT t.id, COUNT(dt.documentId) FROM Tag t LEFT JOIN t.documents dt GROUP BY t.id")
List<Object[]> findDocumentCountsPerTag();

In getTagTree(): convert to Map<UUID, Long>, pass into buildTree() as a lookup. One query total, O(1) per node.


Item 3 — @Modifying(clearAutomatically = true) required on both bulk methods

reassignDocumentTags and deleteDocumentTagsByTagId must both declare @Modifying(clearAutomatically = true). Without it, JPA's first-level cache is not flushed after the native UPDATE/DELETE, creating a stale-read landmine for any future step added to the merge transaction.


Item 4 — Full native SQL specified for all four repository methods

All four methods require @Query(nativeQuery = true). Exact SQL:

reassignDocumentTags

UPDATE document_tags
SET tag_id = :targetId
WHERE tag_id = :sourceId
  AND NOT EXISTS (
    SELECT 1 FROM document_tags d2
    WHERE d2.document_id = document_tags.document_id
      AND d2.tag_id = :targetId
  )

deleteDocumentTagsByTagId

DELETE FROM document_tags WHERE tag_id = :tagId

deleteDocumentTagsByTagIds

DELETE FROM document_tags WHERE tag_id IN (:ids)

⚠️ Guard required: if (!ids.isEmpty()) repository.deleteDocumentTagsByTagIds(ids) — PostgreSQL rejects IN ().

findDescendantIds

WITH RECURSIVE descendants AS (
    SELECT id FROM tag WHERE id = :tagId
    UNION ALL
    SELECT t.id FROM tag t
    INNER JOIN descendants d ON t.parent_id = d.id
)
SELECT id FROM descendants

Item 5 — documentCount is non-null: int + @Schema(requiredMode = REQUIRED)

Backend guarantees documentCount is never null (0 for tags with no documents). DTO field:

@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int documentCount;

The ?? 0 fallback in flattenTree is unnecessary once the type is regenerated and can be removed.


Overall this is a well-scoped spec. The data layer decisions are sound — adjacency list, native CTEs, manual junction table cleanup. No architectural concerns remaining.

## 🏛️ Markus Keller — Architect Discussion Summary Follow-up to my earlier review comment. Worked through 5 open items with the team — all resolved. --- ### ✅ Item 1 — Flyway migrations: nothing new needed V39 (`add_tag_hierarchy.sql`) from #221 already covers everything: - `CREATE INDEX idx_tag_parent_id ON tag(parent_id)` ✅ - `ALTER TABLE tag ADD COLUMN parent_id UUID REFERENCES tag(id) ON DELETE SET NULL` ✅ - `ALTER TABLE tag ADD CONSTRAINT chk_tag_no_self_reference CHECK (parent_id != id)` ✅ **Issue #248 requires zero new Flyway migrations.** The spec should note this explicitly so no one writes a redundant migration. --- ### ✅ Item 2 — `documentCount`: single aggregate query, not N+1 Add `findDocumentCountsPerTag()` to `TagRepository`: ```java @Query("SELECT t.id, COUNT(dt.documentId) FROM Tag t LEFT JOIN t.documents dt GROUP BY t.id") List<Object[]> findDocumentCountsPerTag(); ``` In `getTagTree()`: convert to `Map<UUID, Long>`, pass into `buildTree()` as a lookup. One query total, O(1) per node. --- ### ✅ Item 3 — `@Modifying(clearAutomatically = true)` required on both bulk methods `reassignDocumentTags` and `deleteDocumentTagsByTagId` must both declare `@Modifying(clearAutomatically = true)`. Without it, JPA's first-level cache is not flushed after the native UPDATE/DELETE, creating a stale-read landmine for any future step added to the merge transaction. --- ### ✅ Item 4 — Full native SQL specified for all four repository methods All four methods require `@Query(nativeQuery = true)`. Exact SQL: **`reassignDocumentTags`** ```sql UPDATE document_tags SET tag_id = :targetId WHERE tag_id = :sourceId AND NOT EXISTS ( SELECT 1 FROM document_tags d2 WHERE d2.document_id = document_tags.document_id AND d2.tag_id = :targetId ) ``` **`deleteDocumentTagsByTagId`** ```sql DELETE FROM document_tags WHERE tag_id = :tagId ``` **`deleteDocumentTagsByTagIds`** ```sql DELETE FROM document_tags WHERE tag_id IN (:ids) ``` ⚠️ Guard required: `if (!ids.isEmpty()) repository.deleteDocumentTagsByTagIds(ids)` — PostgreSQL rejects `IN ()`. **`findDescendantIds`** ```sql WITH RECURSIVE descendants AS ( SELECT id FROM tag WHERE id = :tagId UNION ALL SELECT t.id FROM tag t INNER JOIN descendants d ON t.parent_id = d.id ) SELECT id FROM descendants ``` --- ### ✅ Item 5 — `documentCount` is non-null: `int` + `@Schema(requiredMode = REQUIRED)` Backend guarantees `documentCount` is never null (0 for tags with no documents). DTO field: ```java @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private int documentCount; ``` The `?? 0` fallback in `flattenTree` is unnecessary once the type is regenerated and can be removed. --- Overall this is a well-scoped spec. The data layer decisions are sound — adjacency list, native CTEs, manual junction table cleanup. No architectural concerns remaining.
Author
Owner

👨‍💻 Felix Brandt — Developer Discussion Summary

Follow-up to my earlier review comment. Worked through 5 open component and code structure decisions — all resolved.


Item 1 — TagTreeNode.svelte is a required new file

TagsListPanel handles too many concerns if recursive node rendering stays inline. Required split:

  • TagsListPanel.svelte — owns SvelteMap collapse state, localStorage sync, keyboard handler, renders root nodes via TagTreeNode
  • TagTreeNode.svelte (new) — props: node: TagTreeNodeDTO, depth: number, collapseMap: SvelteMap<string, boolean> — renders chevron + link + count badge, recurses into children

Item 2 — Three more required new files for [id]/+page.svelte

The page has 5 visual regions. Only TagMergeZone.svelte was called out — adding the other three:

  • TagAncestry.svelte (new) — props: tag: Tag, tags: FlatTag[] — ancestor chain as linked breadcrumb segments
  • TagChildrenPreview.svelte (new) — props: children: FlatTag[] — up to 5 child chips with doc count + overflow link
  • TagDeleteGuard.svelte (new) — props: tag: Tag, descendantCount: number, documentCount: number, form — radio options, impact summary, name confirm, submit

[id]/+page.svelte becomes a thin orchestrator that composes these four components.


Item 3 — useTypeahead.svelte.ts shared composable

TagParentPicker and PersonTypeahead share identical debounce + dropdown + hidden-input + ARIA mechanics. Required new file:

  • src/lib/composables/useTypeahead.svelte.ts (new) — owns debounce timer, $state for query/results/isOpen/activeIndex, $derived active-descendant ID

Both TagParentPicker.svelte (new) and PersonTypeahead.svelte (refactor) consume it. Migrating PersonTypeahead is in scope for this issue — both components must use the composable when this ships.


Item 4 — canDelete must be a named $derived in TagDeleteGuard

No inline conditions on the submit button. Required in TagDeleteGuard.svelte:

const canDelete = $derived(
    selectedMode !== null &&
    nameInput.trim() === tag.name
);

Template: disabled={!canDelete}. Intent is named; template stays declarative.


Item 5 — mergeTags decomposes into three private helpers

The 6-step method must be split. Required private methods in TagService:

private void validateNotSelf(UUID sourceId, UUID targetId)        // throws TAG_MERGE_SELF
private void validateNotDescendant(UUID sourceId, UUID targetId)  // throws TAG_MERGE_INVALID_TARGET
private void transferDocuments(UUID sourceId, UUID targetId)      // reassign + cleanup document_tags

Public method becomes a 5-line orchestrator:

@Transactional
public Tag mergeTags(UUID sourceId, UUID targetId) {
    validateNotSelf(sourceId, targetId);
    validateNotDescendant(sourceId, targetId);
    transferDocuments(sourceId, targetId);
    tagRepository.reparentChildren(sourceId, targetId);
    tagRepository.deleteById(sourceId);
    return tagRepository.findById(targetId)
        .orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + targetId));
}

The spec is now explicit enough to implement without structural surprises. Main addition beyond the original: useTypeahead.svelte.ts is the most impactful new piece — it refactors existing code and needs its own failing tests before the composable is written.

## 👨‍💻 Felix Brandt — Developer Discussion Summary Follow-up to my earlier review comment. Worked through 5 open component and code structure decisions — all resolved. --- ### ✅ Item 1 — `TagTreeNode.svelte` is a required new file `TagsListPanel` handles too many concerns if recursive node rendering stays inline. Required split: - **`TagsListPanel.svelte`** — owns `SvelteMap` collapse state, localStorage sync, keyboard handler, renders root nodes via `TagTreeNode` - **`TagTreeNode.svelte`** *(new)* — props: `node: TagTreeNodeDTO`, `depth: number`, `collapseMap: SvelteMap<string, boolean>` — renders chevron + link + count badge, recurses into children --- ### ✅ Item 2 — Three more required new files for `[id]/+page.svelte` The page has 5 visual regions. Only `TagMergeZone.svelte` was called out — adding the other three: - **`TagAncestry.svelte`** *(new)* — props: `tag: Tag`, `tags: FlatTag[]` — ancestor chain as linked breadcrumb segments - **`TagChildrenPreview.svelte`** *(new)* — props: `children: FlatTag[]` — up to 5 child chips with doc count + overflow link - **`TagDeleteGuard.svelte`** *(new)* — props: `tag: Tag`, `descendantCount: number`, `documentCount: number`, `form` — radio options, impact summary, name confirm, submit `[id]/+page.svelte` becomes a thin orchestrator that composes these four components. --- ### ✅ Item 3 — `useTypeahead.svelte.ts` shared composable `TagParentPicker` and `PersonTypeahead` share identical debounce + dropdown + hidden-input + ARIA mechanics. Required new file: - **`src/lib/composables/useTypeahead.svelte.ts`** *(new)* — owns debounce timer, `$state` for query/results/isOpen/activeIndex, `$derived` active-descendant ID Both `TagParentPicker.svelte` (new) and `PersonTypeahead.svelte` (refactor) consume it. Migrating `PersonTypeahead` is in scope for this issue — both components must use the composable when this ships. --- ### ✅ Item 4 — `canDelete` must be a named `$derived` in `TagDeleteGuard` No inline conditions on the submit button. Required in `TagDeleteGuard.svelte`: ```svelte const canDelete = $derived( selectedMode !== null && nameInput.trim() === tag.name ); ``` Template: `disabled={!canDelete}`. Intent is named; template stays declarative. --- ### ✅ Item 5 — `mergeTags` decomposes into three private helpers The 6-step method must be split. Required private methods in `TagService`: ```java private void validateNotSelf(UUID sourceId, UUID targetId) // throws TAG_MERGE_SELF private void validateNotDescendant(UUID sourceId, UUID targetId) // throws TAG_MERGE_INVALID_TARGET private void transferDocuments(UUID sourceId, UUID targetId) // reassign + cleanup document_tags ``` Public method becomes a 5-line orchestrator: ```java @Transactional public Tag mergeTags(UUID sourceId, UUID targetId) { validateNotSelf(sourceId, targetId); validateNotDescendant(sourceId, targetId); transferDocuments(sourceId, targetId); tagRepository.reparentChildren(sourceId, targetId); tagRepository.deleteById(sourceId); return tagRepository.findById(targetId) .orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + targetId)); } ``` --- The spec is now explicit enough to implement without structural surprises. Main addition beyond the original: `useTypeahead.svelte.ts` is the most impactful new piece — it refactors existing code and needs its own failing tests before the composable is written.
Author
Owner

🎨 Leonie Voss — UI/UX Discussion Summary

Follow-up to my earlier review comment. Worked through 6 open accessibility, brand, and interaction decisions — all resolved.


Item 1 — Chevron touch target: min-h-[44px] min-w-[44px] required

The 16×36px spec value is replaced. TagTreeNode.svelte chevron button must use:

<button class="flex items-center justify-center min-h-[44px] min-w-[44px]" ...>
  <svg class="w-4 h-4 transition-transform" .../>
</button>

Icon stays 16px centered inside the 44px tap zone. WCAG 2.2 SC 2.5.8 compliant.


Item 2 — Full ARIA tree pattern required

aria-expanded on the chevron alone is not enough. Full pattern:

  • TagsListPanel.svelte: <ul role="tree" aria-label={m.admin_tag_tree_label()}>
  • TagTreeNode.svelte: <li role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined}>
  • Children list: <ul role="group">
  • Leaf nodes: aria-expanded={undefined} (omitted), never false
  • New i18n key required: admin_tag_tree_label ("Schlagwort-Baum") in all three languages

Item 3 — New brand-warning design token (replaces ad-hoc amber)

Amber is not in the palette. Adding a proper token:

/* layout.css */
--color-warning: #B45309;  /* amber-700, 5.7:1 contrast on white — AA pass */

Utilities: bg-brand-warning, text-brand-warning, border-brand-warning.

TagMergeZone confirm button: bg-brand-warning hover:bg-brand-warning/90 text-white. Reusable for all future "consequential but not destructive" confirmations.


Item 4 — Delete guard: modal pattern, no name-confirm input

The name-confirm pattern is not used elsewhere in the app. Replacing with the standard modal confirmation flow:

  1. On the page: impact summary + two radio options + "Löschen" button
  2. Button enabled when: const canDelete = $derived(selectedMode !== null) (radio selected only)
  3. On click: opens standard confirmation modal
  4. Modal confirm → form submit

Name-confirm i18n keys dropped from Phase 9. Felix's canDelete derived simplifies accordingly.


Item 5 — Root tag delete subtitle: fallback i18n key

admin_tag_delete_only_this_sub ("Untergeordnete werden zu {parent} verschoben") would render empty {parent} for root tags. Two keys required:

  • admin_tag_delete_only_this_sub — shown when tag.parentId is set
  • admin_tag_delete_only_this_sub_root: "Untergeordnete werden zur obersten Ebene verschoben" — shown when tag.parentId is null
{#if tag.parentId}
  {m.admin_tag_delete_only_this_sub({ parent: parentName })}
{:else}
  {m.admin_tag_delete_only_this_sub_root()}
{/if}

TagChildrenPreview.svelte uses a <button> with $state(showAll):

let showAll = $state(false);
const visibleChildren = $derived(showAll ? children : children.slice(0, 5));

{#if !showAll && children.length > 5}
  <button onclick={() => showAll = true} class="text-sm text-brand-navy/60 hover:text-brand-navy">
    … und {children.length - 5} weitere
  </button>
{/if}

No navigation, no . The overflow line disappears on expand.


Accessibility is in good shape once the ARIA tree pattern and touch targets are locked in. The brand-warning token is the biggest addition to the design system from this issue.

## 🎨 Leonie Voss — UI/UX Discussion Summary Follow-up to my earlier review comment. Worked through 6 open accessibility, brand, and interaction decisions — all resolved. --- ### ✅ Item 1 — Chevron touch target: `min-h-[44px] min-w-[44px]` required The `16×36px` spec value is replaced. `TagTreeNode.svelte` chevron button must use: ```svelte <button class="flex items-center justify-center min-h-[44px] min-w-[44px]" ...> <svg class="w-4 h-4 transition-transform" .../> </button> ``` Icon stays 16px centered inside the 44px tap zone. WCAG 2.2 SC 2.5.8 compliant. --- ### ✅ Item 2 — Full ARIA tree pattern required `aria-expanded` on the chevron alone is not enough. Full pattern: - `TagsListPanel.svelte`: `<ul role="tree" aria-label={m.admin_tag_tree_label()}>` - `TagTreeNode.svelte`: `<li role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined}>` - Children list: `<ul role="group">` - Leaf nodes: `aria-expanded={undefined}` (omitted), never `false` - New i18n key required: `admin_tag_tree_label` ("Schlagwort-Baum") in all three languages --- ### ✅ Item 3 — New `brand-warning` design token (replaces ad-hoc amber) Amber is not in the palette. Adding a proper token: ```css /* layout.css */ --color-warning: #B45309; /* amber-700, 5.7:1 contrast on white — AA pass */ ``` Utilities: `bg-brand-warning`, `text-brand-warning`, `border-brand-warning`. `TagMergeZone` confirm button: `bg-brand-warning hover:bg-brand-warning/90 text-white`. Reusable for all future "consequential but not destructive" confirmations. --- ### ✅ Item 4 — Delete guard: modal pattern, no name-confirm input The name-confirm pattern is not used elsewhere in the app. Replacing with the standard modal confirmation flow: 1. **On the page**: impact summary + two radio options + "Löschen" button 2. **Button enabled when**: `const canDelete = $derived(selectedMode !== null)` (radio selected only) 3. **On click**: opens standard confirmation modal 4. **Modal confirm** → form submit Name-confirm i18n keys dropped from Phase 9. Felix's `canDelete` derived simplifies accordingly. --- ### ✅ Item 5 — Root tag delete subtitle: fallback i18n key `admin_tag_delete_only_this_sub` ("Untergeordnete werden zu {parent} verschoben") would render empty `{parent}` for root tags. Two keys required: - `admin_tag_delete_only_this_sub` — shown when `tag.parentId` is set - `admin_tag_delete_only_this_sub_root`: "Untergeordnete werden zur obersten Ebene verschoben" — shown when `tag.parentId` is null ```svelte {#if tag.parentId} {m.admin_tag_delete_only_this_sub({ parent: parentName })} {:else} {m.admin_tag_delete_only_this_sub_root()} {/if} ``` --- ### ✅ Item 6 — "… und N weitere" expands inline, not a link `TagChildrenPreview.svelte` uses a `<button>` with `$state(showAll)`: ```svelte let showAll = $state(false); const visibleChildren = $derived(showAll ? children : children.slice(0, 5)); {#if !showAll && children.length > 5} <button onclick={() => showAll = true} class="text-sm text-brand-navy/60 hover:text-brand-navy"> … und {children.length - 5} weitere </button> {/if} ``` No navigation, no `→`. The overflow line disappears on expand. --- Accessibility is in good shape once the ARIA tree pattern and touch targets are locked in. The `brand-warning` token is the biggest addition to the design system from this issue.
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#248