From c0a0aeff77990fbe26d3e8d5588f6186e6f40765 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Apr 2026 22:21:33 +0200 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 10 +- .../38-admin-tag-management/38-01-PLAN.md | 391 ++++++++++++++ .../38-admin-tag-management/38-02-PLAN.md | 500 ++++++++++++++++++ 3 files changed, 898 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/38-admin-tag-management/38-01-PLAN.md create mode 100644 .planning/phases/38-admin-tag-management/38-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ca2e35d..1a6d081 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -96,7 +96,7 @@ -### 🚧 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 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") 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 -**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 @@ -321,7 +325,7 @@ Plans: | 35. Bug Fixes | v2.4 | 3/3 | 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 | - | -| 38. Admin — Tag Management | v2.4 | 0/TBD | Not started | - | +| 38. Admin — Tag Management | v2.4 | 0/2 | Not started | - | ## Backlog diff --git a/.planning/phases/38-admin-tag-management/38-01-PLAN.md b/.planning/phases/38-admin-tag-management/38-01-PLAN.md new file mode 100644 index 0000000..c0f7906 --- /dev/null +++ b/.planning/phases/38-admin-tag-management/38-01-PLAN.md @@ -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" +--- + + +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. + + + +@.claude/get-shit-done/workflows/execute-plan.md +@.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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(); +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(); +export { app as adminItemRoutes }; +``` + + + + + + + Task 1: Schema migration + service layer with cycle detection + src/db/schema.ts, src/server/services/tag.service.ts, tests/services/tag.service.test.ts + + - 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 + + +**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(); + 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. + + + cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/services/tag.service.test.ts + + + - 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 + + All service functions implemented, cycle detection works, parentId column added to schema, migration applied, all service tests pass. + + + + Task 2: [BLOCKING] Database schema push + + + - src/db/schema.ts (verify parentId column was added) + + +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. + + + cd /home/jlmak/Projects/jlmak/GearBox && bun run db:push 2>&1 | tail -5 + + + - `bun run db:push` exits without error + - A migration file exists in drizzle/ directory containing `parent_id` + + Database migration generated and applied. The tags table has a parent_id column with SET NULL foreign key. + + + + Task 3: Admin tag API routes + route registration + integration tests + src/server/routes/admin-tags.ts, src/server/routes/admin.ts, tests/routes/admin-tags.test.ts + + - 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) + + +**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. + + + cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/routes/admin-tags.test.ts + + + - 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 + + Admin tag routes are registered, all 5 endpoints work correctly, cycle detection returns 400, delete orphans children, all integration tests pass. + + + + + +## 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)` | + + + +```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 +``` + + + +- 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) + + + +After completion, create `.planning/phases/38-admin-tag-management/38-01-SUMMARY.md` + diff --git a/.planning/phases/38-admin-tag-management/38-02-PLAN.md b/.planning/phases/38-admin-tag-management/38-02-PLAN.md new file mode 100644 index 0000000..40988b6 --- /dev/null +++ b/.planning/phases/38-admin-tag-management/38-02-PLAN.md @@ -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"' +--- + + +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. + + + +@.claude/get-shit-done/workflows/execute-plan.md +@.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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(path: string): Promise; +export function apiPost(path: string, body: unknown): Promise; +export function apiPut(path: string, body: unknown): Promise; +export function apiDelete(path: string): Promise; +export class ApiError extends Error { status: number; } +``` + +From src/client/routes/admin.tsx (sidebar, lines 42-52 — disabled Tags entry to replace): +```typescript +
+ + Tags + + Soon + +
+``` + +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 +``` +
+
+ + + + + Task 1: Client hooks + tag list page with tree view and quick-add + src/client/hooks/useAdminTags.ts, src/client/routes/admin/tags.tsx + + - 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) + + +**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("/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("/api/admin/tags", data)`, onSuccess invalidates `["admin-tags"]` AND `["tags"]` +- `useUpdateAdminTag()` — `useMutation` with `apiPut(\`/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): 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` — 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: `

Tags

` + `

{N} tags

` + - 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 ` handleChange("name", e.target.value)} className={inputClass} />` + - **Parent field** (per D-08): `` + `