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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
391
.planning/phases/38-admin-tag-management/38-01-PLAN.md
Normal file
391
.planning/phases/38-admin-tag-management/38-01-PLAN.md
Normal 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>
|
||||||
500
.planning/phases/38-admin-tag-management/38-02-PLAN.md
Normal file
500
.planning/phases/38-admin-tag-management/38-02-PLAN.md
Normal 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>
|
||||||
Reference in New Issue
Block a user