docs(38): create phase plans for admin tag management

Two plans across 2 waves: backend (schema + service + routes + tests)
then frontend (hooks + tree list page + edit page + sidebar activation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 22:21:33 +02:00
parent d597affc1b
commit c0a0aeff77
3 changed files with 898 additions and 3 deletions

View File

@@ -96,7 +96,7 @@
</details> </details>
### 🚧 v2.4 Admin Foundation (In Progress) ### v2.4 Admin Foundation (In Progress)
- [x] **Phase 35: Bug Fixes** — Clear the v2.3 backlog: wrong modal, missing images, slow loading, auth redirect, cursor pointer (completed 2026-04-19) - [x] **Phase 35: Bug Fixes** — Clear the v2.3 backlog: wrong modal, missing images, slow loading, auth redirect, cursor pointer (completed 2026-04-19)
- [x] **Phase 36: Admin Role & Panel Foundation** — isAdmin flag, server mechanism to grant admin, gated /admin route with placeholder UI (completed 2026-04-19) - [x] **Phase 36: Admin Role & Panel Foundation** — isAdmin flag, server mechanism to grant admin, gated /admin route with placeholder UI (completed 2026-04-19)
@@ -276,7 +276,11 @@ Plans:
4. Admin can assign a parent to any tag, making it a child in the hierarchy (e.g. "down" under "insulation") 4. Admin can assign a parent to any tag, making it a child in the hierarchy (e.g. "down" under "insulation")
5. Admin can remove a parent assignment from a tag, making it a top-level tag again 5. Admin can remove a parent assignment from a tag, making it a top-level tag again
6. Admin can delete a tag; if items currently use that tag, a warning is shown before the deletion is confirmed 6. Admin can delete a tag; if items currently use that tag, a warning is shown before the deletion is confirmed
**Plans**: TBD **Plans**: 2 plans
Plans:
- [ ] 38-01-PLAN.md — Schema migration (parentId), service layer (CRUD + cycle detection), API routes, tests
- [ ] 38-02-PLAN.md — Client hooks, tag list page (tree view + quick-add + search), edit page (rename/reparent/delete), sidebar activation
**UI hint**: yes **UI hint**: yes
@@ -321,7 +325,7 @@ Plans:
| 35. Bug Fixes | v2.4 | 3/3 | Complete | 2026-04-19 | | 35. Bug Fixes | v2.4 | 3/3 | Complete | 2026-04-19 |
| 36. Admin Role & Panel Foundation | v2.4 | 2/2 | Complete | 2026-04-19 | | 36. Admin Role & Panel Foundation | v2.4 | 2/2 | Complete | 2026-04-19 |
| 37. Admin — Global Item Management | v2.4 | 0/TBD | Not started | - | | 37. Admin — Global Item Management | v2.4 | 0/TBD | Not started | - |
| 38. Admin — Tag Management | v2.4 | 0/TBD | Not started | - | | 38. Admin — Tag Management | v2.4 | 0/2 | Not started | - |
## Backlog ## Backlog

View File

@@ -0,0 +1,391 @@
---
phase: 38-admin-tag-management
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/schema.ts
- src/server/services/tag.service.ts
- src/server/routes/admin-tags.ts
- src/server/routes/admin.ts
- tests/services/tag.service.test.ts
- tests/routes/admin-tags.test.ts
autonomous: true
requirements:
- ADMN-05
- ADMN-06
- ADMN-07
- ADMN-08
- ADMN-09
- ADMN-10
must_haves:
truths:
- "GET /api/admin/tags returns all tags with parentId and itemCount"
- "POST /api/admin/tags creates a tag with name and optional parentId"
- "PUT /api/admin/tags/:id renames a tag and/or changes its parentId"
- "PUT /api/admin/tags/:id returns 400 when setting a descendant as parent (cycle detection)"
- "DELETE /api/admin/tags/:id removes a tag and orphans its children (parentId SET NULL)"
artifacts:
- path: "src/db/schema.ts"
provides: "parentId column on tags table"
contains: "parentId"
- path: "src/server/services/tag.service.ts"
provides: "getAdminTags, getTagWithCounts, createTag, updateTag, deleteTag, isDescendant"
exports: ["getAdminTags", "createTag", "updateTag", "deleteTag", "getTagWithCounts"]
- path: "src/server/routes/admin-tags.ts"
provides: "CRUD route handlers for /api/admin/tags"
exports: ["adminTagRoutes"]
- path: "tests/routes/admin-tags.test.ts"
provides: "Integration tests for admin tag CRUD + cycle detection"
min_lines: 80
key_links:
- from: "src/server/routes/admin.ts"
to: "src/server/routes/admin-tags.ts"
via: "app.route('/tags', adminTagRoutes)"
pattern: "app\\.route.*tags.*adminTagRoutes"
- from: "src/server/routes/admin-tags.ts"
to: "src/server/services/tag.service.ts"
via: "service function imports"
pattern: "from.*tag\\.service"
---
<objective>
Build the backend for admin tag management: schema migration (parentId on tags), service layer (CRUD + cycle detection), API routes, and tests.
Purpose: Provides the full server-side API that the client UI (Plan 02) will consume. Includes the parentId schema change, all CRUD service functions with cycle detection, Hono route module, route registration, and comprehensive tests.
Output: Working `/api/admin/tags` endpoints with GET (list + single), POST, PUT (with cycle guard), DELETE. Schema migration applied. Tests passing.
</objective>
<execution_context>
@.claude/get-shit-done/workflows/execute-plan.md
@.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/38-admin-tag-management/38-CONTEXT.md
@.planning/phases/38-admin-tag-management/38-RESEARCH.md
@.planning/phases/38-admin-tag-management/38-PATTERNS.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From src/db/schema.ts (current tags table, lines 204-208):
```typescript
export const tags = pgTable("tags", {
id: serial("id").primaryKey(),
name: text("name").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
```
From src/db/schema.ts (globalItemTags FK pattern, lines 216-219):
```typescript
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
```
From src/server/services/tag.service.ts (full file):
```typescript
import { asc } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { tags } from "../../db/schema.ts";
type Db = typeof prodDb;
export async function getAllTags(db: Db = prodDb) {
return db.select({ id: tags.id, name: tags.name }).from(tags).orderBy(asc(tags.name));
}
```
From src/server/routes/admin.ts (full file):
```typescript
import { Hono } from "hono";
import { requireAdmin, requireAuth } from "../middleware/auth.ts";
import { adminItemRoutes } from "./admin-items.ts";
type Env = { Variables: { db?: any; userId?: number } };
const app = new Hono<Env>();
app.use("/*", requireAuth, requireAdmin);
app.get("/", async (c) => { return c.json({ ok: true }); });
app.route("/items", adminItemRoutes);
export { app as adminRoutes };
```
From src/server/routes/admin-items.ts (pattern reference):
```typescript
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { parseId } from "../lib/params.ts";
type Env = { Variables: { db?: any; userId?: number } };
const app = new Hono<Env>();
export { app as adminItemRoutes };
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Schema migration + service layer with cycle detection</name>
<files>src/db/schema.ts, src/server/services/tag.service.ts, tests/services/tag.service.test.ts</files>
<read_first>
- src/db/schema.ts (lines 200-225 for tags table and globalItemTags)
- src/server/services/tag.service.ts (full file, 12 lines)
- src/server/services/global-item.service.ts (first 80 lines for query pattern with count/leftJoin/groupBy)
- tests/services/tag.service.test.ts (full file, 36 lines)
- .planning/phases/38-admin-tag-management/38-PATTERNS.md
</read_first>
<action>
**1. Add `parentId` to `tags` table in `src/db/schema.ts` (per D-01):**
Add a `parentId` column to the `tags` table definition, between `name` and `createdAt`:
```typescript
parentId: integer("parent_id").references(() => tags.id, { onDelete: "set null" }),
```
This is a self-referential FK using the same lambda pattern as `globalItemTags.tagId` but with `onDelete: "set null"` (per D-02) and nullable (no `.notNull()`).
**2. Generate and push the Drizzle migration:**
Run `bun run db:generate` to create the migration file, then `bun run db:push` to apply it.
**3. Extend `src/server/services/tag.service.ts` with these functions:**
Add imports: `count`, `eq` from `drizzle-orm`; `globalItemTags` from schema.
Add an **`isDescendant`** private function (not exported) that walks the ancestor chain of `candidateParentId` to check if it reaches `tagId`. Takes `allTags: { id: number; parentId: number | null }[]`, `candidateParentId: number`, `tagId: number`. Returns `boolean`. Uses a `visited` Set to guard against existing cycles in data.
```typescript
function isDescendant(
allTags: { id: number; parentId: number | null }[],
candidateParentId: number,
tagId: number,
): boolean {
let current: number | null = candidateParentId;
const visited = new Set<number>();
while (current !== null) {
if (current === tagId) return true;
if (visited.has(current)) break;
visited.add(current);
const node = allTags.find((t) => t.id === current);
current = node?.parentId ?? null;
}
return false;
}
```
Add **`getAdminTags`**: SELECT tags.id, tags.name, tags.parentId, COUNT(globalItemTags.globalItemId) as itemCount FROM tags LEFT JOIN globalItemTags ON globalItemTags.tagId = tags.id GROUP BY tags.id, tags.name, tags.parentId ORDER BY tags.name ASC. Returns flat array.
Add **`getTagWithCounts`**: Same as getAdminTags but filtered by `eq(tags.id, id)`. Returns single tag or null.
Add **`createTag`**: Takes `db, data: { name: string; parentId?: number | null }`. Inserts into tags with `name` and `parentId` (default null). Returns the created tag via `.returning()`.
Add **`updateTag`**: Takes `db, id: number, data: { name?: string; parentId?: number | null }`. If `data.parentId` is not undefined and not null, fetches all tags (id + parentId only), calls `isDescendant(allTags, data.parentId, id)`. If cycle detected, throws `new Error("Cycle detected: the selected parent is a descendant of this tag.")`. Then runs `.update(tags).set(...)` with the provided fields. Handle `parentId` explicitly: if `data.parentId === null` set it to null (reparent to top-level per D-09), if undefined omit from set. Returns updated tag or null.
Add **`deleteTag`**: Takes `db, id: number`. Deletes the tag by id, returns boolean (deleted or not). Children become top-level automatically via ON DELETE SET NULL (per D-12).
Keep existing `getAllTags` function unchanged.
**4. Extend `tests/services/tag.service.test.ts` with new test blocks:**
Add imports for the new service functions: `getAdminTags`, `createTag`, `updateTag`, `deleteTag`, `getTagWithCounts`.
Add a helper: `async function insertTag(db, name, parentId?)` that inserts a tag and returns the row.
Add these describe blocks after the existing tests:
- `describe("getAdminTags")`: test that it returns tags with parentId and itemCount fields; parentId is null for top-level tags.
- `describe("createTag")`: test creating a tag without parent (parentId null), and with a parent (parentId set to existing tag id).
- `describe("updateTag / cycle detection")`: test renaming (name change), setting parentId, setting parentId to null (reparent to top-level), and that setting a descendant as parent throws "Cycle detected".
- `describe("deleteTag")`: test deletion returns true, deletion of non-existent returns false, and that deleting a parent causes children's parentId to become null.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/tag.service.test.ts</automated>
</verify>
<acceptance_criteria>
- src/db/schema.ts contains `parentId: integer("parent_id").references(() => tags.id`
- src/db/schema.ts contains `onDelete: "set null"`
- src/server/services/tag.service.ts exports `getAdminTags`
- src/server/services/tag.service.ts exports `createTag`
- src/server/services/tag.service.ts exports `updateTag`
- src/server/services/tag.service.ts exports `deleteTag`
- src/server/services/tag.service.ts exports `getTagWithCounts`
- src/server/services/tag.service.ts contains `function isDescendant(`
- src/server/services/tag.service.ts contains `throw new Error("Cycle detected`
- tests/services/tag.service.test.ts contains `describe("getAdminTags"`
- tests/services/tag.service.test.ts contains `describe("createTag"`
- tests/services/tag.service.test.ts contains `Cycle detected`
- tests/services/tag.service.test.ts contains `describe("deleteTag"`
- `bun test tests/services/tag.service.test.ts` exits 0
</acceptance_criteria>
<done>All service functions implemented, cycle detection works, parentId column added to schema, migration applied, all service tests pass.</done>
</task>
<task type="auto">
<name>Task 2: [BLOCKING] Database schema push</name>
<files></files>
<read_first>
- src/db/schema.ts (verify parentId column was added)
</read_first>
<action>
Run `bun run db:generate` to generate the Drizzle migration for the parentId column addition. Then run `bun run db:push` to apply the migration to the development database. Verify the migration SQL contains:
```sql
ALTER TABLE "tags" ADD COLUMN "parent_id" integer REFERENCES "tags"("id") ON DELETE SET NULL;
```
If the push command prompts for confirmation, confirm it. This step is BLOCKING and must succeed before any verification.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run db:push 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `bun run db:push` exits without error
- A migration file exists in drizzle/ directory containing `parent_id`
</acceptance_criteria>
<done>Database migration generated and applied. The tags table has a parent_id column with SET NULL foreign key.</done>
</task>
<task type="auto">
<name>Task 3: Admin tag API routes + route registration + integration tests</name>
<files>src/server/routes/admin-tags.ts, src/server/routes/admin.ts, tests/routes/admin-tags.test.ts</files>
<read_first>
- src/server/routes/admin-items.ts (full file, 89 lines — exact pattern to follow)
- src/server/routes/admin.ts (full file, 20 lines)
- tests/routes/tags.test.ts (full file, 52 lines — test app factory pattern)
- src/server/lib/params.ts (parseId function signature)
- .planning/phases/38-admin-tag-management/38-PATTERNS.md (Pattern: admin-tags.ts section)
</read_first>
<action>
**1. Create `src/server/routes/admin-tags.ts`:**
Follow the exact structure of `admin-items.ts`. Import `zValidator` from `@hono/zod-validator`, `Hono`, `z` from `zod`, `parseId` from `../lib/params.ts`, and service functions from `../services/tag.service.ts`.
Define Zod schemas:
```typescript
const createTagSchema = z.object({
name: z.string().min(1),
parentId: z.number().int().positive().nullable().optional(),
});
const updateTagSchema = z.object({
name: z.string().min(1).optional(),
parentId: z.number().int().positive().nullable().optional(),
});
```
Implement 5 handlers:
- `GET /` — calls `getAdminTags(db)`, returns JSON array.
- `GET /:id` — calls `getTagWithCounts(db, id)`, returns 400 for invalid id, 404 for not found, JSON tag.
- `POST /` — validates with `createTagSchema`, calls `createTag(db, data)`, returns 201 with created tag.
- `PUT /:id` — validates with `updateTagSchema`, calls `updateTag(db, id, data)`. Wraps in try/catch: if error message starts with "Cycle detected", return 400 with `{ error: err.message }`. Otherwise re-throw. Returns 404 if tag not found.
- `DELETE /:id` — calls `deleteTag(db, id)`, returns 404 if not found, `{ success: true }` if deleted.
Export as `adminTagRoutes`.
**2. Register routes in `src/server/routes/admin.ts`:**
Add import: `import { adminTagRoutes } from "./admin-tags.ts";`
Add after the existing `app.route("/items", adminItemRoutes);` line:
```typescript
app.route("/tags", adminTagRoutes);
```
**3. Create `tests/routes/admin-tags.test.ts`:**
Follow the exact test factory pattern from `tests/routes/tags.test.ts`:
```typescript
function createTestApp(db: any) {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("db", db);
await next();
});
app.route("/api/admin/tags", adminTagRoutes);
return app;
}
```
Add a helper: `async function insertTag(db, name, parentId?)`.
Write integration tests covering:
- `describe("GET /api/admin/tags")`: returns 200 with empty array; returns tags with id, name, parentId, itemCount fields after seeding.
- `describe("POST /api/admin/tags")`: returns 201 with created tag; creates tag with parentId; returns 400 for empty name.
- `describe("PUT /api/admin/tags/:id")`: renames a tag; updates parentId; sets parentId to null (reparent to top-level per D-09); returns 400 for cycle (create A->B->C, try to set A as child of C); returns 404 for non-existent id.
- `describe("DELETE /api/admin/tags/:id")`: returns 200 with `{ success: true }`; children become orphans (parentId null) after parent deletion; returns 404 for non-existent id.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/routes/admin-tags.test.ts</automated>
</verify>
<acceptance_criteria>
- src/server/routes/admin-tags.ts contains `export { app as adminTagRoutes }`
- src/server/routes/admin-tags.ts contains `zValidator("json", createTagSchema)`
- src/server/routes/admin-tags.ts contains `zValidator("json", updateTagSchema)`
- src/server/routes/admin-tags.ts contains `c.json({ error: err.message }, 400)`
- src/server/routes/admin-tags.ts contains `return c.json(tag, 201)`
- src/server/routes/admin.ts contains `import { adminTagRoutes }`
- src/server/routes/admin.ts contains `app.route("/tags", adminTagRoutes)`
- tests/routes/admin-tags.test.ts contains `describe("GET /api/admin/tags"`
- tests/routes/admin-tags.test.ts contains `describe("POST /api/admin/tags"`
- tests/routes/admin-tags.test.ts contains `describe("PUT /api/admin/tags"`
- tests/routes/admin-tags.test.ts contains `describe("DELETE /api/admin/tags"`
- tests/routes/admin-tags.test.ts contains `Cycle detected` or `400`
- `bun test tests/routes/admin-tags.test.ts` exits 0
</acceptance_criteria>
<done>Admin tag routes are registered, all 5 endpoints work correctly, cycle detection returns 400, delete orphans children, all integration tests pass.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client -> /api/admin/tags | Untrusted input crosses admin auth boundary |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-38-01 | Elevation of Privilege | /api/admin/tags/* | mitigate | `requireAuth + requireAdmin` middleware on all admin routes (inherited from admin.ts) |
| T-38-02 | Tampering | PUT /api/admin/tags/:id (parentId) | mitigate | Server-side `isDescendant` cycle check before DB write; returns 400 |
| T-38-03 | Tampering | POST/PUT request bodies | mitigate | Zod schema validation via `zValidator` on all mutation endpoints |
| T-38-04 | Tampering | Tag name XSS | accept | React renders as text content (not innerHTML); Zod enforces `z.string().min(1)` |
</threat_model>
<verification>
```bash
# All service tests pass
bun test tests/services/tag.service.test.ts
# All route integration tests pass
bun test tests/routes/admin-tags.test.ts
# Full test suite still passes
bun test
```
</verification>
<success_criteria>
- tags table has parentId column with ON DELETE SET NULL
- GET /api/admin/tags returns tags with parentId and itemCount
- POST /api/admin/tags creates tags with optional parent
- PUT /api/admin/tags/:id updates name and/or parentId with cycle detection (400 on cycle)
- DELETE /api/admin/tags/:id removes tag, children become top-level
- All tests pass (service + route integration)
</success_criteria>
<output>
After completion, create `.planning/phases/38-admin-tag-management/38-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,500 @@
---
phase: 38-admin-tag-management
plan: 02
type: execute
wave: 2
depends_on:
- 38-01
files_modified:
- src/client/hooks/useAdminTags.ts
- src/client/routes/admin/tags.tsx
- src/client/routes/admin/tags.$tagId.tsx
- src/client/routes/admin.tsx
autonomous: false
requirements:
- ADMN-05
- ADMN-06
- ADMN-07
- ADMN-08
- ADMN-09
- ADMN-10
must_haves:
truths:
- "Admin can see all tags in a collapsible tree view with indent levels, item counts, and expand/collapse chevrons"
- "Admin can create a new tag via the quick-add form at the top of the list with optional parent"
- "Admin can search/filter tags in the tree view — non-matching leaves hidden, parents with matching children remain"
- "Admin can click a tag row to navigate to its edit page"
- "Admin can rename a tag and change its parent on the edit page"
- "Admin can delete a tag from the edit page with an impact-aware confirmation dialog"
- "Tags sidebar link in admin panel is active and navigable"
artifacts:
- path: "src/client/hooks/useAdminTags.ts"
provides: "React Query hooks for admin tag CRUD"
exports: ["useAdminTags", "useAdminTag", "useCreateAdminTag", "useUpdateAdminTag", "useDeleteAdminTag"]
- path: "src/client/routes/admin/tags.tsx"
provides: "Tag list page with tree view and quick-add form"
contains: "createFileRoute"
- path: "src/client/routes/admin/tags.$tagId.tsx"
provides: "Tag edit page with rename, reparent, and delete"
contains: "createFileRoute"
- path: "src/client/routes/admin.tsx"
provides: "Tags sidebar link enabled"
contains: 'to="/admin/tags"'
key_links:
- from: "src/client/routes/admin/tags.tsx"
to: "/api/admin/tags"
via: "useAdminTags + useCreateAdminTag hooks"
pattern: "useAdminTags|useCreateAdminTag"
- from: "src/client/routes/admin/tags.$tagId.tsx"
to: "/api/admin/tags/:id"
via: "useAdminTag + useUpdateAdminTag + useDeleteAdminTag hooks"
pattern: "useUpdateAdminTag|useDeleteAdminTag"
- from: "src/client/routes/admin.tsx"
to: "/admin/tags"
via: "Link component"
pattern: 'to="/admin/tags"'
---
<objective>
Build the client-side UI for admin tag management: React Query hooks, list page with collapsible tree view and quick-add form, edit page with rename/reparent/delete, and enable the Tags sidebar link.
Purpose: Delivers the full admin-facing UI for tag CRUD and hierarchy management, consuming the API built in Plan 01. Completes all ADMN-05 through ADMN-10 requirements at the user-facing level.
Output: Working `/admin/tags` list page with collapsible tree, quick-add, search/filter. Working `/admin/tags/$tagId` edit page with rename, parent picker (cycle-safe), and delete confirmation. Tags link active in admin sidebar.
</objective>
<execution_context>
@.claude/get-shit-done/workflows/execute-plan.md
@.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/38-admin-tag-management/38-CONTEXT.md
@.planning/phases/38-admin-tag-management/38-PATTERNS.md
@.planning/phases/38-admin-tag-management/38-UI-SPEC.md
@.planning/phases/38-admin-tag-management/38-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 outputs -->
From src/server/routes/admin-tags.ts (API contract):
```
GET /api/admin/tags -> AdminTag[] (id, name, parentId, itemCount)
GET /api/admin/tags/:id -> AdminTag (single tag with counts)
POST /api/admin/tags <- { name: string, parentId?: number | null } -> AdminTag (201)
PUT /api/admin/tags/:id <- { name?: string, parentId?: number | null } -> AdminTag (or 400 for cycle)
DELETE /api/admin/tags/:id -> { success: true } (or 404)
```
From src/client/lib/api.ts (fetch wrappers):
```typescript
export function apiGet<T>(path: string): Promise<T>;
export function apiPost<T>(path: string, body: unknown): Promise<T>;
export function apiPut<T>(path: string, body: unknown): Promise<T>;
export function apiDelete<T>(path: string): Promise<T>;
export class ApiError extends Error { status: number; }
```
From src/client/routes/admin.tsx (sidebar, lines 42-52 — disabled Tags entry to replace):
```typescript
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-300 cursor-not-allowed"
title="Coming in a future release"
>
<LucideIcon name="tag" size={16} />
<span>Tags</span>
<span className="ml-auto text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
Soon
</span>
</div>
```
From src/client/routes/admin/items.tsx (list page pattern — key classes):
```typescript
// Page header: text-lg font-semibold text-gray-900
// Subtitle: text-sm text-gray-400 mt-0.5
// Search input: w-64 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300
// Table wrapper: w-full overflow-hidden rounded-xl border border-gray-100 bg-white
// Table header: bg-gray-50 border-b border-gray-100
// Column header: px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide
// Row: border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors
// Skeleton: h-4 bg-gray-100 rounded animate-pulse
```
From src/client/routes/admin/items.$itemId.tsx (edit page pattern — key classes):
```typescript
const inputClass = "w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300";
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
const sectionClass = "border-t border-gray-100 pt-6 mt-6";
// Back link: text-sm text-gray-400 hover:text-gray-600 transition-colors mb-6 block
// Delete button: px-4 py-2 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 text-sm font-medium
// Save button: px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
// Actions row: flex items-center justify-between mt-8 pt-6 border-t border-gray-100
```
From src/client/hooks/useAdminGlobalItems.ts (hook pattern):
```typescript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
// useQuery with queryKey, queryFn
// useMutation with mutationFn, onSuccess invalidating query keys
// retry: (count, error) => error instanceof ApiError && error.status === 404 ? false : count < 3
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Client hooks + tag list page with tree view and quick-add</name>
<files>src/client/hooks/useAdminTags.ts, src/client/routes/admin/tags.tsx</files>
<read_first>
- src/client/hooks/useAdminGlobalItems.ts (full file — hook pattern to follow)
- src/client/routes/admin/items.tsx (full file — list page pattern to follow)
- src/client/lib/api.ts (first 30 lines — apiGet/apiPost/apiPut/apiDelete signatures and ApiError class)
- .planning/phases/38-admin-tag-management/38-PATTERNS.md (Pattern 5: buildTree, Pattern 6: useAdminTags hooks)
- .planning/phases/38-admin-tag-management/38-UI-SPEC.md (Tree Row spec, Quick-Add Form spec, Copywriting Contract)
</read_first>
<action>
**1. Create `src/client/hooks/useAdminTags.ts`:**
Define types:
```typescript
export interface AdminTag {
id: number;
name: string;
parentId: number | null;
itemCount: number;
}
export interface CreateTagPayload {
name: string;
parentId?: number | null;
}
export interface UpdateTagPayload {
name?: string;
parentId?: number | null;
}
```
Implement 5 hooks following the `useAdminGlobalItems.ts` pattern:
- `useAdminTags()``useQuery({ queryKey: ["admin-tags"], queryFn: () => apiGet<AdminTag[]>("/api/admin/tags") })`
- `useAdminTag(id: number | null)``useQuery` with `queryKey: ["admin-tag", id]`, `enabled: id != null`, retry suppression for 404 using `ApiError`
- `useCreateAdminTag()``useMutation` with `apiPost<AdminTag>("/api/admin/tags", data)`, onSuccess invalidates `["admin-tags"]` AND `["tags"]`
- `useUpdateAdminTag()``useMutation` with `apiPut<AdminTag>(\`/api/admin/tags/${id}\`, data)`, onSuccess invalidates `["admin-tags"]`, `["admin-tag", id]`, AND `["tags"]`
- `useDeleteAdminTag()``useMutation` with `apiDelete<{ success: boolean }>(\`/api/admin/tags/${id}\`)`, onSuccess invalidates `["admin-tags"]` AND `["tags"]`
CRITICAL: All mutations must invalidate BOTH `["admin-tags"]` and `["tags"]` to keep the public cache fresh (see RESEARCH.md Pitfall 4).
**2. Create `src/client/routes/admin/tags.tsx`:**
Route declaration: `createFileRoute("/admin/tags")` with component `AdminTagsPage`.
**Tree building utilities** (define inside the file or above the component):
`TreeNode` interface extends `AdminTag` with `children: TreeNode[]` and `depth: number`.
`buildTree(tags: AdminTag[]): TreeNode[]` — builds tree from flat array. For each tag, create a TreeNode. Tags with `parentId === null` or whose parent is not in the map become roots. Children get `depth = parent.depth + 1`. Sort children alphabetically by name within each parent.
`flattenTree(nodes: TreeNode[], expanded: Set<number>): TreeNode[]` — depth-first flatten. For each node, push it to result. Only recurse into children if `expanded.has(node.id)`.
`filterTree(nodes: TreeNode[], query: string): TreeNode[]` — per D-05: recursively filter. A node is included if its name matches (case-insensitive) OR any descendant matches. When included via descendant match, preserve the full subtree path. Non-matching leaves with no matching descendants are removed.
**Component state:**
- `searchQuery: string` — controlled search input
- `newName: string` — quick-add form name input
- `newParentId: number | null` — quick-add form parent picker
- `expanded: Set<number>` — expanded node IDs. Initialize with ALL parent IDs on data load (per D-03, start expanded).
Use `useEffect` to set `expanded` to the set of all tag IDs that have children whenever the `data` from `useAdminTags()` changes.
**Page structure (top to bottom):**
1. **Header row**`flex items-center justify-between mb-4`:
- Left: `<h1 className="text-lg font-semibold text-gray-900">Tags</h1>` + `<p className="text-sm text-gray-400 mt-0.5">{N} tags</p>`
- Right: search input with `w-64 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300`, placeholder "Search tags..."
2. **Quick-add form** (per D-07) — `flex items-center gap-3 mb-4`:
- Name input: `flex-1`, `inputClass` styling from items pattern, placeholder "Tag name..."
- Parent picker: native `<select>` with `rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none bg-white appearance-none w-48`. First option: `<option value="">No parent (top-level)</option>`. Remaining options: all tags from `data` mapped to `<option key={t.id} value={t.id}>{t.name}</option>`.
- "Add Tag" button: `px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50`, disabled when `createMutation.isPending || !newName.trim()`. Text: "Adding..." when pending, "Add Tag" otherwise.
- On submit: call `createMutation.mutateAsync({ name: newName.trim(), parentId: newParentId })`. On success: clear `newName` to `""`, reset `newParentId` to `null`.
- On error: show `<p className="text-sm text-red-500 mt-1">` below the form.
3. **Error state** — same as items.tsx: `<div className="py-12 text-center text-sm text-red-500">Failed to load tags. Please try again.</div>`
4. **Tree table card**`w-full overflow-hidden rounded-xl border border-gray-100 bg-white`:
- Table with `w-full text-sm`
- Thead: `bg-gray-50 border-b border-gray-100` with 3 columns:
- "Tag" — `px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide`
- "Items" — same styling
- "" (empty, right-aligned actions column)
- Tbody: skeleton rows (6 rows x 3 cols with `h-4 bg-gray-100 rounded animate-pulse`) when loading.
- When loaded: compute `tree = buildTree(data)`, then `filtered = searchQuery ? filterTree(tree, searchQuery) : tree`, then `rows = flattenTree(filtered, expanded)`. Render each row:
**Tree row rendering** — each `<tr>` with `key={node.id}`, `className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"`, `onClick` navigates to `/admin/tags/$tagId`:
- **Name cell** `<td className="px-4 py-2.5">`:
- `<div className="flex items-center gap-1" style={{ paddingLeft: \`${node.depth * 20}px\` }}>`:
- If `node.children.length > 0`: chevron button `<button type="button" onClick={(e) => { e.stopPropagation(); toggleExpand(node.id); }} className="text-gray-400 hover:text-gray-600 w-5 h-5 flex items-center justify-center rounded hover:bg-gray-100 p-0.5">`. Inside: `<LucideIcon name={expanded.has(node.id) ? "chevron-down" : "chevron-right"} size={14} />`
- If leaf node (no children): `<span className="w-5" />` spacer
- `<span className="font-medium text-gray-900">{node.name}</span>`
- **Items cell** `<td className="px-4 py-2.5 text-sm text-gray-400">`: `{node.itemCount} items`
- **Actions cell** `<td className="px-4 py-2.5 text-right">`: `<span className="text-xs text-gray-400">Edit</span>`
5. **Empty state** — when `!isLoading && rows.length === 0 && !isError`:
- If `searchQuery`: `<div className="py-12 text-center"><p className="text-sm text-gray-900 font-medium">No tags match your search.</p></div>`
- If no search: `<div className="py-12 text-center"><p className="text-sm font-medium text-gray-900">No tags yet</p><p className="text-sm text-gray-400 mt-1">Add your first tag using the form above.</p></div>`
**Toggle function:**
```typescript
function toggleExpand(id: number) {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
```
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- src/client/hooks/useAdminTags.ts contains `export function useAdminTags()`
- src/client/hooks/useAdminTags.ts contains `export function useAdminTag(`
- src/client/hooks/useAdminTags.ts contains `export function useCreateAdminTag()`
- src/client/hooks/useAdminTags.ts contains `export function useUpdateAdminTag()`
- src/client/hooks/useAdminTags.ts contains `export function useDeleteAdminTag()`
- src/client/hooks/useAdminTags.ts contains `queryKey: ["admin-tags"]`
- src/client/hooks/useAdminTags.ts contains `queryKey: ["tags"]` (dual invalidation)
- src/client/routes/admin/tags.tsx contains `createFileRoute("/admin/tags")`
- src/client/routes/admin/tags.tsx contains `function buildTree`
- src/client/routes/admin/tags.tsx contains `function filterTree`
- src/client/routes/admin/tags.tsx contains `chevron-down`
- src/client/routes/admin/tags.tsx contains `No parent (top-level)`
- src/client/routes/admin/tags.tsx contains `Add Tag`
- src/client/routes/admin/tags.tsx contains `No tags yet`
- `bun run build` exits 0
</acceptance_criteria>
<done>Admin tag list page renders a collapsible tree with search/filter, quick-add form creates tags with optional parent, all hooks connected with dual query key invalidation. Build passes.</done>
</task>
<task type="auto">
<name>Task 2: Tag edit page with rename, reparent, delete + sidebar link activation</name>
<files>src/client/routes/admin/tags.$tagId.tsx, src/client/routes/admin.tsx</files>
<read_first>
- src/client/routes/admin/items.$itemId.tsx (full file — edit page + delete dialog pattern)
- src/client/routes/admin.tsx (full file — sidebar, lines 42-52 for disabled Tags entry)
- src/client/hooks/useAdminTags.ts (created in Task 1 — hook signatures)
- .planning/phases/38-admin-tag-management/38-PATTERNS.md (tags.$tagId.tsx section, getDeleteConfirmText, getDescendantIds)
- .planning/phases/38-admin-tag-management/38-UI-SPEC.md (Edit Page Spec, Copywriting Contract, Delete confirmation text)
</read_first>
<action>
**1. Create `src/client/routes/admin/tags.$tagId.tsx`:**
Route declaration: `createFileRoute("/admin/tags/$tagId")` with component `AdminTagEditPage`.
Import hooks: `useAdminTag`, `useAdminTags`, `useUpdateAdminTag`, `useDeleteAdminTag` from `../../hooks/useAdminTags`.
**Helper functions (above component):**
`getDescendantIds(allTags: AdminTag[], tagId: number): Set<number>` — recursively collects all descendant IDs. Filter `allTags` for tags where `parentId === tagId`, add their IDs to result set, recurse for each. Per D-11 (client-side cycle prevention).
`getDeleteConfirmText(tag: AdminTag, childCount: number): string` — builds the confirmation message per D-13/D-14:
- If `tag.itemCount > 0`: append `"{N} {item/items} use this tag."`
- If `childCount > 0`: append `"Its {N} child {tag/tags} will become top-level."`
- Always append `"This cannot be undone."`
- Join with spaces.
- If both counts are 0: just `"This cannot be undone."`
**CSS constants** (copy verbatim from items.$itemId.tsx):
```typescript
const inputClass = "w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300";
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
```
**Component:**
Extract `tagId` from `Route.useParams()`, parse to `id = Number(tagId)`.
Hooks:
- `const { data: tag, isLoading, isError } = useAdminTag(id)`
- `const { data: allTags } = useAdminTags()` — for parent picker options and childCount
- `const updateMutation = useUpdateAdminTag()`
- `const deleteMutation = useDeleteAdminTag()`
- `const navigate = useNavigate()`
State:
- `form: { name: string; parentId: number | null }` — initialized from tag data via useEffect
- `showDeleteConfirm: boolean` — false initially
Populate form on tag load:
```typescript
useEffect(() => {
if (tag) {
setForm({ name: tag.name, parentId: tag.parentId });
}
}, [tag]);
```
Computed values:
- `excludedIds = new Set([id, ...getDescendantIds(allTags ?? [], id)])` — IDs to exclude from parent picker
- `parentOptions = (allTags ?? []).filter(t => !excludedIds.has(t.id))` — valid parent options
- `childCount = (allTags ?? []).filter(t => t.parentId === id).length` — direct children count for delete confirmation
**Loading skeleton**`max-w-2xl mx-auto`, 3 skeleton bars with `h-10 bg-gray-100 rounded-lg animate-pulse`.
**Error state**`max-w-2xl mx-auto text-center py-12`, `<p className="text-sm text-red-500">Failed to load tag. Please try again.</p>`
**Page layout**`max-w-2xl mx-auto`:
1. **Back link**: `<button type="button" onClick={() => navigate({ to: "/admin/tags" })} className="text-sm text-gray-400 hover:text-gray-600 transition-colors mb-6 block">` with text `\u2190 Tags`
2. **Page heading**: `<h1 className="text-lg font-semibold text-gray-900">{tag.name}</h1>` + `<p className="text-sm text-gray-400 mt-0.5">{tag.itemCount > 0 ? \`${tag.itemCount} items use this tag\` : "Not used by any items"}</p>`
3. **Form** with `onSubmit={handleSave}`:
- **Name field**: `<label className={labelClass}>Name</label>` + `<input type="text" value={form.name} onChange={e => handleChange("name", e.target.value)} className={inputClass} />`
- **Parent field** (per D-08): `<label className={labelClass}>Parent Tag</label>` + `<select value={form.parentId ?? ""} onChange={e => handleChange("parentId", e.target.value ? Number(e.target.value) : null)} className={inputClass + " appearance-none bg-white"}>`. Options: `<option value="">No parent (top-level)</option>` + parentOptions mapped to `<option key={t.id} value={t.id}>{t.name}</option>`.
4. **Actions row**`flex items-center justify-between mt-8 pt-6 border-t border-gray-100`:
- Left: Delete button `<button type="button" onClick={() => setShowDeleteConfirm(true)} disabled={deleteMutation.isPending} className="px-4 py-2 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50">Delete Tag</button>`
- Right: Save button `<button type="submit" disabled={updateMutation.isPending} className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50">{updateMutation.isPending ? "Saving..." : "Save Changes"}</button>`
5. **Save error**`{updateMutation.isError && <p className="text-sm text-red-500 mt-2 text-right">Failed to save. Please try again.</p>}`
6. **Delete confirmation dialog** (per D-09, D-13, D-14) — copy the exact modal structure from items.$itemId.tsx (fixed inset-0 overlay, white rounded-xl card):
- Title: `Delete "{tag.name}"?`
- Body: `{getDeleteConfirmText(tag, childCount)}`
- Cancel button: `px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg`
- Delete button: `px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg`, text "Deleting..." when pending
**handleSave:**
```typescript
async function handleSave(e: React.FormEvent) {
e.preventDefault();
await updateMutation.mutateAsync({
id,
data: { name: form.name || undefined, parentId: form.parentId },
});
}
```
**handleDelete:**
```typescript
async function handleDelete() {
await deleteMutation.mutateAsync(id);
navigate({ to: "/admin/tags" });
}
```
**2. Enable Tags sidebar link in `src/client/routes/admin.tsx`:**
Replace the entire disabled `<div>` block (lines 42-52, from `{/* Tags -- disabled (phase 38) */}` through the closing `</div>`) with an active `<Link>`:
```typescript
{/* Tags — active (phase 38) */}
<Link
to="/admin/tags"
activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}
inactiveProps={{ className: "text-gray-600 hover:bg-gray-50" }}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors"
>
<LucideIcon name="tag" size={16} />
<span>Tags</span>
</Link>
```
This follows the exact same pattern as the Items link above it (lines 32-40). Remove the "Soon" badge span entirely.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- src/client/routes/admin/tags.$tagId.tsx contains `createFileRoute("/admin/tags/$tagId")`
- src/client/routes/admin/tags.$tagId.tsx contains `function getDescendantIds(`
- src/client/routes/admin/tags.$tagId.tsx contains `function getDeleteConfirmText(`
- src/client/routes/admin/tags.$tagId.tsx contains `No parent (top-level)`
- src/client/routes/admin/tags.$tagId.tsx contains `Delete Tag`
- src/client/routes/admin/tags.$tagId.tsx contains `Save Changes`
- src/client/routes/admin/tags.$tagId.tsx contains `This cannot be undone`
- src/client/routes/admin/tags.$tagId.tsx contains `child tags will become top-level` (D-13 confirmation text fragment)
- src/client/routes/admin.tsx contains `to="/admin/tags"`
- src/client/routes/admin.tsx does NOT contain `cursor-not-allowed` (disabled entry removed)
- src/client/routes/admin.tsx does NOT contain `Coming in a future release`
- `bun run build` exits 0
</acceptance_criteria>
<done>Edit page supports rename, reparent (cycle-safe parent picker), and delete with impact-aware confirmation. Tags sidebar link active. Build passes.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Full admin tag management: list page with collapsible tree view, quick-add form, search/filter, edit page with rename/reparent/delete, impact-aware delete confirmation, enabled Tags sidebar link.</what-built>
<how-to-verify>
1. Start the dev server: `bun run dev`
2. Navigate to `/admin` as an admin user
3. Verify "Tags" appears as an active link in the sidebar (no "Soon" badge)
4. Click "Tags" — should show the tag list page with tree view
5. **Create a tag**: Type "sleeping-bag" in the quick-add form, leave parent as "No parent", click "Add Tag" — tag appears in the tree
6. **Create a child tag**: Type "down" in the quick-add form, select "sleeping-bag" as parent, click "Add Tag" — "down" appears indented under "sleeping-bag"
7. **Expand/collapse**: Click the chevron on "sleeping-bag" — "down" should hide/show
8. **Search**: Type "down" in search — "down" row appears with "sleeping-bag" parent still visible; clear search
9. **Edit**: Click on "down" row — navigates to edit page. Verify back link "← Tags", name field shows "down", parent picker shows "sleeping-bag"
10. **Rename**: Change name to "down-fill", click "Save Changes" — name updates
11. **Reparent**: Change parent to "No parent (top-level)", click "Save Changes" — tag is now top-level
12. **Cycle prevention**: Create tags A, B, C in a chain (A parent of B, B parent of C). Try to set A's parent to C — should fail with error
13. **Delete with items warning**: If a tag has items, click "Delete Tag" — confirmation should show item count
14. **Delete with children warning**: If a tag has children, click "Delete Tag" — confirmation should mention child tags becoming top-level
15. **Delete empty tag**: Create a new tag with no items or children, edit it, click "Delete Tag" — simplified confirmation "This cannot be undone."
</how-to-verify>
<resume-signal>Type "approved" if all checks pass, or describe any issues found.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> /api/admin/tags | Client sends mutation payloads to admin API |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-38-05 | Elevation of Privilege | /admin/tags client route | mitigate | Admin shell checks `auth.user.isAdmin` and redirects non-admins (existing admin.tsx guard) |
| T-38-06 | Tampering | Parent picker client-side filtering | accept | Client-side filter is UX only; server validates cycle detection authoritatively (Plan 01 T-38-02) |
</threat_model>
<verification>
```bash
# Build passes (route tree regenerated, TypeScript compiles)
bun run build
# Full test suite still passes
bun test
# Manual verification via human checkpoint
```
</verification>
<success_criteria>
- Tags sidebar link is active and navigable in admin panel
- Tag list page shows collapsible tree with indent levels, item counts, chevron toggles
- Quick-add form creates tags with optional parent
- Search/filter hides non-matching leaves, keeps parents with matching children
- Edit page allows rename and parent change with cycle-safe picker
- Delete confirmation shows item count + child count impact warnings per D-13/D-14
- Build passes, all tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/38-admin-tag-management/38-02-SUMMARY.md`
</output>