docs(38): phase verification report .planning/phases/38-admin-tag-management/38-VERIFICATION.md
This commit is contained in:
964
.planning/phases/38-admin-tag-management/38-PATTERNS.md
Normal file
964
.planning/phases/38-admin-tag-management/38-PATTERNS.md
Normal file
@@ -0,0 +1,964 @@
|
||||
# Phase 38: Admin — Tag Management - Pattern Map
|
||||
|
||||
**Mapped:** 2026-04-19
|
||||
**Files analyzed:** 9 (6 new, 3 modified)
|
||||
**Analogs found:** 9 / 9
|
||||
|
||||
---
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `src/db/schema.ts` (modify) | model | CRUD | self (existing `tags` table + `globalItemTags` FK) | exact |
|
||||
| `src/server/services/tag.service.ts` (extend) | service | CRUD | `src/server/services/global-item.service.ts` | exact |
|
||||
| `src/server/routes/admin-tags.ts` (new) | route | request-response | `src/server/routes/admin-items.ts` | exact |
|
||||
| `src/server/routes/admin.ts` (modify) | config/route | request-response | self (existing `app.route("/items", adminItemRoutes)`) | exact |
|
||||
| `src/client/hooks/useAdminTags.ts` (new) | hook | request-response | `src/client/hooks/useAdminGlobalItems.ts` | exact |
|
||||
| `src/client/routes/admin/tags.tsx` (new) | component | request-response | `src/client/routes/admin/items.tsx` | role-match |
|
||||
| `src/client/routes/admin/tags.$tagId.tsx` (new) | component | request-response | `src/client/routes/admin/items.$itemId.tsx` | exact |
|
||||
| `src/client/routes/admin.tsx` (modify) | component | — | self (existing disabled Tags `<div>`) | exact |
|
||||
| `tests/routes/admin-tags.test.ts` (new) | test | — | `tests/routes/tags.test.ts` + `tests/routes/global-items.test.ts` | role-match |
|
||||
| `tests/services/tag.service.test.ts` (extend) | test | — | self (existing `tests/services/tag.service.test.ts`) | exact |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `src/db/schema.ts` — add `parentId` to `tags` table
|
||||
|
||||
**Analog:** existing `tags` table definition + `globalItemTags.tagId` FK pattern (lines 204–208, 212–223)
|
||||
|
||||
**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(),
|
||||
});
|
||||
```
|
||||
|
||||
**Self-referential FK pattern to follow** — use the lambda form `() => tags.id`, same as `globalItemTags.tagId` at line 218:
|
||||
```typescript
|
||||
// Existing onDelete cascade FK (globalItemTags lines 216-219):
|
||||
tagId: integer("tag_id")
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: "cascade" }),
|
||||
```
|
||||
|
||||
**Target change** — add one column using the same lambda syntax but `onDelete: "set null"` and nullable (no `.notNull()`):
|
||||
```typescript
|
||||
export const tags = pgTable("tags", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
parentId: integer("parent_id").references(() => tags.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/server/services/tag.service.ts` — extend with admin CRUD + cycle detection
|
||||
|
||||
**Analog:** `src/server/services/global-item.service.ts` (Drizzle query style, `count()`, `leftJoin`, `groupBy`)
|
||||
|
||||
**Existing file** (`src/server/services/tag.service.ts` lines 1–12 — 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));
|
||||
}
|
||||
```
|
||||
|
||||
**Import pattern to extend** (add alongside existing imports):
|
||||
```typescript
|
||||
import { asc, count, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { globalItemTags, tags } from "../../db/schema.ts";
|
||||
```
|
||||
|
||||
**`getAdminTags` pattern** — follow `global-item.service.ts` aggregate query style (`.select`, `.leftJoin`, `.groupBy`, `count()`):
|
||||
```typescript
|
||||
export async function getAdminTags(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
id: tags.id,
|
||||
name: tags.name,
|
||||
parentId: tags.parentId,
|
||||
itemCount: count(globalItemTags.globalItemId),
|
||||
})
|
||||
.from(tags)
|
||||
.leftJoin(globalItemTags, eq(globalItemTags.tagId, tags.id))
|
||||
.groupBy(tags.id, tags.name, tags.parentId)
|
||||
.orderBy(asc(tags.name));
|
||||
}
|
||||
```
|
||||
|
||||
**`createTag` pattern** — follow the `.insert().values().returning()` pattern used throughout services:
|
||||
```typescript
|
||||
export async function createTag(
|
||||
db: Db,
|
||||
data: { name: string; parentId?: number | null },
|
||||
) {
|
||||
const [tag] = await db
|
||||
.insert(tags)
|
||||
.values({ name: data.name, parentId: data.parentId ?? null })
|
||||
.returning();
|
||||
return tag!;
|
||||
}
|
||||
```
|
||||
|
||||
**`updateTag` pattern** — with cycle detection; follows service-level validation before DB write:
|
||||
```typescript
|
||||
export async function updateTag(
|
||||
db: Db,
|
||||
id: number,
|
||||
data: { name?: string; parentId?: number | null },
|
||||
) {
|
||||
if (data.parentId != null) {
|
||||
const allTags = await db
|
||||
.select({ id: tags.id, parentId: tags.parentId })
|
||||
.from(tags);
|
||||
if (isDescendant(allTags, data.parentId, id)) {
|
||||
throw new Error("Cycle detected: the selected parent is a descendant of this tag.");
|
||||
}
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(tags)
|
||||
.set({ ...(data.name && { name: data.name }), parentId: data.parentId })
|
||||
.where(eq(tags.id, id))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
**`deleteTag` pattern** — follows `deleteGlobalItem` pattern (delete by id, return boolean):
|
||||
```typescript
|
||||
export async function deleteTag(db: Db, id: number) {
|
||||
const [deleted] = await db
|
||||
.delete(tags)
|
||||
.where(eq(tags.id, id))
|
||||
.returning({ id: tags.id });
|
||||
return deleted != null;
|
||||
}
|
||||
```
|
||||
|
||||
**`isDescendant` cycle detection** — pure function, placed above `updateTag`, no DB calls (operates on pre-fetched flat array):
|
||||
```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; // guard against existing cycle in data
|
||||
visited.add(current);
|
||||
const node = allTags.find((t) => t.id === current);
|
||||
current = node?.parentId ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/server/routes/admin-tags.ts` — new admin CRUD route module
|
||||
|
||||
**Analog:** `src/server/routes/admin-items.ts` (lines 1–89 — full file)
|
||||
|
||||
**Imports pattern** (copy from `admin-items.ts` lines 1–10, swap service imports):
|
||||
```typescript
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { parseId } from "../lib/params.ts";
|
||||
import {
|
||||
createTag,
|
||||
deleteTag,
|
||||
getAdminTags,
|
||||
getTagWithCounts,
|
||||
updateTag,
|
||||
} from "../services/tag.service.ts";
|
||||
```
|
||||
|
||||
**Env type + app init** (identical to `admin-items.ts` lines 12–14):
|
||||
```typescript
|
||||
type Env = { Variables: { db?: any; userId?: number } };
|
||||
const app = new Hono<Env>();
|
||||
```
|
||||
|
||||
**Zod schemas** — follow `updateGlobalItemAdminSchema` style (lines 16–28), use `.nullable().optional()` for parentId:
|
||||
```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(),
|
||||
});
|
||||
```
|
||||
|
||||
**GET list handler** (follow `admin-items.ts` lines 31–52 structure):
|
||||
```typescript
|
||||
app.get("/", async (c) => {
|
||||
const db = c.get("db");
|
||||
const result = await getAdminTags(db);
|
||||
return c.json(result);
|
||||
});
|
||||
```
|
||||
|
||||
**GET single handler** (follow `admin-items.ts` lines 55–62):
|
||||
```typescript
|
||||
app.get("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid tag ID" }, 400);
|
||||
const tag = await getTagWithCounts(db, id);
|
||||
if (!tag) return c.json({ error: "Tag not found" }, 404);
|
||||
return c.json(tag);
|
||||
});
|
||||
```
|
||||
|
||||
**POST create handler** (new — no analog in `admin-items.ts` but follows same `zValidator` + service call pattern):
|
||||
```typescript
|
||||
app.post("/", zValidator("json", createTagSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const tag = await createTag(db, data);
|
||||
return c.json(tag, 201);
|
||||
});
|
||||
```
|
||||
|
||||
**PUT update handler** — note cycle detection error is caught here (follow `admin-items.ts` lines 64–77 + add 400 for cycle error):
|
||||
```typescript
|
||||
app.put("/:id", zValidator("json", updateTagSchema), async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid tag ID" }, 400);
|
||||
const data = c.req.valid("json");
|
||||
try {
|
||||
const tag = await updateTag(db, id, data);
|
||||
if (!tag) return c.json({ error: "Tag not found" }, 404);
|
||||
return c.json(tag);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith("Cycle detected")) {
|
||||
return c.json({ error: err.message }, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**DELETE handler** (follow `admin-items.ts` lines 80–87):
|
||||
```typescript
|
||||
app.delete("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid tag ID" }, 400);
|
||||
const deleted = await deleteTag(db, id);
|
||||
if (!deleted) return c.json({ error: "Tag not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
**Export** (identical to `admin-items.ts` line 89):
|
||||
```typescript
|
||||
export { app as adminTagRoutes };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/server/routes/admin.ts` — register admin tag routes
|
||||
|
||||
**Analog:** self — existing file (lines 1–21 — full file)
|
||||
|
||||
**Current registration** (lines 17–18):
|
||||
```typescript
|
||||
// Admin item management
|
||||
app.route("/items", adminItemRoutes);
|
||||
```
|
||||
|
||||
**Change** — add after existing `adminItemRoutes` registration:
|
||||
```typescript
|
||||
import { adminTagRoutes } from "./admin-tags.ts";
|
||||
// ...
|
||||
app.route("/items", adminItemRoutes);
|
||||
app.route("/tags", adminTagRoutes); // add this line
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/client/hooks/useAdminTags.ts` — new admin tag React Query hooks
|
||||
|
||||
**Analog:** `src/client/hooks/useAdminGlobalItems.ts` (lines 1–116 — full file)
|
||||
|
||||
**Imports pattern** (follow `useAdminGlobalItems.ts` lines 1–7 — note: no `useInfiniteQuery` needed, tags list is small):
|
||||
```typescript
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||
```
|
||||
|
||||
**Types pattern** (follow `useAdminGlobalItems.ts` lines 11–56 style):
|
||||
```typescript
|
||||
export interface AdminTag {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface AdminTagDetail extends AdminTag {
|
||||
childCount: number; // computed client-side or from single-tag endpoint
|
||||
}
|
||||
|
||||
export interface CreateTagPayload {
|
||||
name: string;
|
||||
parentId?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateTagPayload {
|
||||
name?: string;
|
||||
parentId?: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
**`useAdminTags` — list query** (follow `useAdminGlobalItem` single-query pattern at lines 79–88, NOT infinite query — tags are small):
|
||||
```typescript
|
||||
export function useAdminTags() {
|
||||
return useQuery({
|
||||
queryKey: ["admin-tags"],
|
||||
queryFn: () => apiGet<AdminTag[]>("/api/admin/tags"),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**`useAdminTag` — single query** (follow `useAdminGlobalItem` lines 79–88):
|
||||
```typescript
|
||||
export function useAdminTag(id: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-tag", id],
|
||||
queryFn: () => apiGet<AdminTag>(`/api/admin/tags/${id}`),
|
||||
enabled: id != null,
|
||||
retry: (count, error) =>
|
||||
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**`useCreateAdminTag`** — note: invalidates BOTH `["admin-tags"]` and `["tags"]` (public cache):
|
||||
```typescript
|
||||
export function useCreateAdminTag() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTagPayload) =>
|
||||
apiPost<AdminTag>("/api/admin/tags", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-tags"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**`useUpdateAdminTag`** (follow `useUpdateAdminGlobalItem` lines 90–105):
|
||||
```typescript
|
||||
export function useUpdateAdminTag() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateTagPayload }) =>
|
||||
apiPut<AdminTag>(`/api/admin/tags/${id}`, data),
|
||||
onSuccess: (_result, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-tags"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-tag", id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**`useDeleteAdminTag`** (follow `useDeleteAdminGlobalItem` lines 107–116):
|
||||
```typescript
|
||||
export function useDeleteAdminTag() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiDelete<{ success: boolean }>(`/api/admin/tags/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-tags"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/client/routes/admin/tags.tsx` — list page with tree view + quick-add form
|
||||
|
||||
**Analog:** `src/client/routes/admin/items.tsx` (lines 1–237 — full file)
|
||||
|
||||
**Route declaration pattern** (follow `items.tsx` lines 1–9):
|
||||
```typescript
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useAdminTags } from "../../hooks/useAdminTags";
|
||||
|
||||
export const Route = createFileRoute("/admin/tags")({
|
||||
component: AdminTagsPage,
|
||||
});
|
||||
```
|
||||
|
||||
**Page header pattern** (follow `items.tsx` lines 67–83 — heading + count + search input):
|
||||
```typescript
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Tags</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">
|
||||
{tags.length} tags
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Error state pattern** (follow `items.tsx` lines 104–109):
|
||||
```typescript
|
||||
{isError && (
|
||||
<div className="py-12 text-center text-sm text-red-500">
|
||||
Failed to load tags. Please try again.
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Table wrapper pattern** (follow `items.tsx` lines 112–114):
|
||||
```typescript
|
||||
<div className="w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
|
||||
```
|
||||
|
||||
**Table header pattern** (follow `items.tsx` lines 115–135, but cols: Name | Items | Actions):
|
||||
```typescript
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Items
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
**Row click navigation pattern** (follow `items.tsx` lines 149–155):
|
||||
```typescript
|
||||
<tr
|
||||
key={node.id}
|
||||
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => navigate({ to: "/admin/tags/$tagId", params: { tagId: String(node.id) } })}
|
||||
>
|
||||
```
|
||||
|
||||
**Skeleton loading pattern** (follow `items.tsx` lines 139–147):
|
||||
```typescript
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-gray-50">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 bg-gray-100 rounded animate-pulse" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: /* render tree rows */}
|
||||
```
|
||||
|
||||
**Empty state pattern** (follow `items.tsx` lines 210–217):
|
||||
```typescript
|
||||
{!isLoading && flatRows.length === 0 && !isError && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm font-medium text-gray-900">No tags found</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Try a different search or create a new tag.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Tree indentation pattern** (no existing analog — new pattern for this phase):
|
||||
```typescript
|
||||
// Indent 20px per depth level; chevron toggle on parent rows
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1" style={{ paddingLeft: `${node.depth * 20}px` }}>
|
||||
{node.children.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(node.id); }}
|
||||
className="text-gray-400 hover:text-gray-600 w-4 h-4 flex items-center justify-center"
|
||||
>
|
||||
{expanded.has(node.id) ? "▼" : "▶"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{node.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
```
|
||||
|
||||
**Quick-add form pattern** — no direct analog; place above the table card, follow same input + button styling:
|
||||
```typescript
|
||||
// Form above the table card
|
||||
<form onSubmit={handleCreate} className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New tag name..."
|
||||
className="flex-1 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"
|
||||
/>
|
||||
{/* Parent picker select — options from flat tags list */}
|
||||
<select
|
||||
value={newParentId ?? ""}
|
||||
onChange={(e) => setNewParentId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none bg-white appearance-none"
|
||||
>
|
||||
<option value="">No parent</option>
|
||||
{tags?.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !newName.trim()}
|
||||
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"
|
||||
>
|
||||
{createMutation.isPending ? "Adding..." : "Add"}
|
||||
</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/client/routes/admin/tags.$tagId.tsx` — edit page with rename + reparent + delete
|
||||
|
||||
**Analog:** `src/client/routes/admin/items.$itemId.tsx` (lines 1–435 — full file)
|
||||
|
||||
**Route declaration pattern** (follow `items.$itemId.tsx` lines 1–11):
|
||||
```typescript
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
useAdminTag,
|
||||
useAdminTags,
|
||||
useDeleteAdminTag,
|
||||
useUpdateAdminTag,
|
||||
} from "../../hooks/useAdminTags";
|
||||
|
||||
export const Route = createFileRoute("/admin/tags/$tagId")({
|
||||
component: AdminTagEditPage,
|
||||
});
|
||||
```
|
||||
|
||||
**Param extraction + id parse pattern** (follow `items.$itemId.tsx` lines 90–92):
|
||||
```typescript
|
||||
const { tagId } = Route.useParams();
|
||||
const id = Number(tagId);
|
||||
```
|
||||
|
||||
**Form state + populate-on-load pattern** (follow `items.$itemId.tsx` lines 102–133):
|
||||
```typescript
|
||||
const [form, setForm] = useState({ name: "", parentId: null as number | null });
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (tag) {
|
||||
setForm({ name: tag.name, parentId: tag.parentId });
|
||||
}
|
||||
}, [tag]);
|
||||
```
|
||||
|
||||
**Loading skeleton pattern** (follow `items.$itemId.tsx` lines 181–191):
|
||||
```typescript
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Error state pattern** (follow `items.$itemId.tsx` lines 194–199):
|
||||
```typescript
|
||||
if (isError || !tag) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<p className="text-sm text-red-500">Failed to load tag. Please try again.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Back link pattern** (follow `items.$itemId.tsx` lines 212–218):
|
||||
```typescript
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/admin/tags" })}
|
||||
className="text-sm text-gray-400 hover:text-gray-600 transition-colors mb-6 block"
|
||||
>
|
||||
← Tags
|
||||
</button>
|
||||
```
|
||||
|
||||
**CSS class constants** (copy verbatim from `items.$itemId.tsx` lines 176–179):
|
||||
```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";
|
||||
```
|
||||
|
||||
**Save form pattern** (follow `items.$itemId.tsx` lines 147–169 — `handleSave` + `handleChange`):
|
||||
```typescript
|
||||
function handleChange(field: keyof typeof form, value: string | number | null) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await updateMutation.mutateAsync({
|
||||
id,
|
||||
data: { name: form.name || undefined, parentId: form.parentId },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Actions row — delete left, save right** (follow `items.$itemId.tsx` lines 370–393):
|
||||
```typescript
|
||||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-100">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{updateMutation.isError && (
|
||||
<p className="text-sm text-red-500 mt-2 text-right">
|
||||
Failed to save. Please try again.
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
**Delete confirmation dialog pattern** (copy structure from `items.$itemId.tsx` lines 397–432, update text logic per D-13/D-14):
|
||||
```typescript
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Delete "{tag.name}"?
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
{getDeleteConfirmText(tag, childCount)}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Delete confirm text helper** — new utility, no analog:
|
||||
```typescript
|
||||
function getDeleteConfirmText(tag: AdminTag, childCount: number): string {
|
||||
const parts: string[] = [];
|
||||
if (tag.itemCount > 0) {
|
||||
parts.push(`${tag.itemCount} ${tag.itemCount === 1 ? "item uses" : "items use"} this tag.`);
|
||||
}
|
||||
if (childCount > 0) {
|
||||
parts.push(`Its ${childCount} child ${childCount === 1 ? "tag" : "tags"} will become top-level.`);
|
||||
}
|
||||
parts.push("This cannot be undone.");
|
||||
return parts.join(" ");
|
||||
}
|
||||
```
|
||||
|
||||
**Parent picker on edit page** — filters out current tag + all descendants (no analog; new logic):
|
||||
```typescript
|
||||
// Compute descendants to exclude from parent picker
|
||||
function getDescendantIds(allTags: AdminTag[], tagId: number): Set<number> {
|
||||
const result = new Set<number>();
|
||||
const children = allTags.filter((t) => t.parentId === tagId);
|
||||
for (const child of children) {
|
||||
result.add(child.id);
|
||||
for (const id of getDescendantIds(allTags, child.id)) result.add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Usage in render:
|
||||
const excludedIds = new Set([id, ...getDescendantIds(allTags ?? [], id)]);
|
||||
const parentOptions = (allTags ?? []).filter((t) => !excludedIds.has(t.id));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `src/client/routes/admin.tsx` — enable Tags sidebar link
|
||||
|
||||
**Analog:** self — existing disabled `<div>` (lines 43–52)
|
||||
|
||||
**Current disabled entry** (lines 43–52):
|
||||
```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>
|
||||
```
|
||||
|
||||
**Target change** — replace entirely with `<Link>`, following the Items link pattern (lines 32–40):
|
||||
```typescript
|
||||
<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>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `tests/routes/admin-tags.test.ts` — new integration test file
|
||||
|
||||
**Analog:** `tests/routes/tags.test.ts` (lines 1–52 — full file) for structure; `tests/routes/global-items.test.ts` (lines 1–60) for admin auth setup pattern.
|
||||
|
||||
**Test app factory pattern** (follow `tags.test.ts` lines 7–17, add admin auth middleware):
|
||||
```typescript
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { tags } from "../../src/db/schema.ts";
|
||||
import { adminTagRoutes } from "../../src/server/routes/admin-tags.ts";
|
||||
import { createTestDb } from "../helpers/db.ts";
|
||||
|
||||
function createTestApp(db: any) {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("db", db);
|
||||
// Admin auth bypass for tests — follow existing admin test pattern
|
||||
await next();
|
||||
});
|
||||
app.route("/api/admin/tags", adminTagRoutes);
|
||||
return app;
|
||||
}
|
||||
```
|
||||
|
||||
**Test structure** (follow `tags.test.ts` lines 19–52 `describe`/`it`/`beforeEach` pattern):
|
||||
```typescript
|
||||
describe("Admin Tag Routes", () => {
|
||||
let app: Hono;
|
||||
let db: Awaited<ReturnType<typeof createTestDb>>["db"];
|
||||
|
||||
beforeEach(async () => {
|
||||
const testDb = await createTestDb();
|
||||
db = testDb.db;
|
||||
app = createTestApp(db);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/tags", () => { /* ... */ });
|
||||
describe("POST /api/admin/tags", () => { /* ... */ });
|
||||
describe("PUT /api/admin/tags/:id", () => { /* ... */ });
|
||||
describe("DELETE /api/admin/tags/:id", () => { /* ... */ });
|
||||
});
|
||||
```
|
||||
|
||||
**Seed helper pattern** (follow `global-items.test.ts` lines 29–53 helper function style):
|
||||
```typescript
|
||||
async function insertTag(db: any, name: string, parentId?: number | null) {
|
||||
const [row] = await db
|
||||
.insert(tags)
|
||||
.values({ name, parentId: parentId ?? null })
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `tests/services/tag.service.test.ts` — extend existing test file
|
||||
|
||||
**Analog:** self — existing file (lines 1–36 — full file)
|
||||
|
||||
**Extend pattern** — add new `describe` blocks after existing ones, same `beforeEach` reset:
|
||||
```typescript
|
||||
// Add after existing "Tag Service" describe block tests:
|
||||
|
||||
describe("getAdminTags", () => {
|
||||
it("returns tags with parentId and itemCount", async () => { /* ... */ });
|
||||
it("returns parentId as null for top-level tags", async () => { /* ... */ });
|
||||
});
|
||||
|
||||
describe("createTag", () => {
|
||||
it("creates a tag and returns it", async () => { /* ... */ });
|
||||
it("creates a tag with parentId", async () => { /* ... */ });
|
||||
});
|
||||
|
||||
describe("updateTag / cycle detection", () => {
|
||||
it("renames a tag", async () => { /* ... */ });
|
||||
it("sets parentId", async () => { /* ... */ });
|
||||
it("throws on cycle when setting descendant as parent", async () => { /* ... */ });
|
||||
});
|
||||
|
||||
describe("deleteTag", () => {
|
||||
it("deletes a tag and returns true", async () => { /* ... */ });
|
||||
it("returns false for non-existent tag", async () => { /* ... */ });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Admin Authentication Guard
|
||||
**Source:** `src/server/routes/admin.ts` (lines 9–10)
|
||||
**Apply to:** `src/server/routes/admin-tags.ts` (inherited — registered under the `adminRoutes` which already has `requireAuth + requireAdmin`)
|
||||
```typescript
|
||||
// In admin.ts — already covers all sub-routes including /tags:
|
||||
app.use("/*", requireAuth, requireAdmin);
|
||||
```
|
||||
No per-route auth needed in `admin-tags.ts` itself.
|
||||
|
||||
### DB Context Retrieval
|
||||
**Source:** `src/server/routes/admin-items.ts` (line 32, 56, 69, 81)
|
||||
**Apply to:** All handlers in `src/server/routes/admin-tags.ts`
|
||||
```typescript
|
||||
const db = c.get("db");
|
||||
```
|
||||
|
||||
### ID Parsing + 400 Guard
|
||||
**Source:** `src/server/routes/admin-items.ts` (lines 57–59, 71–72, 82–83)
|
||||
**Apply to:** All `/:id` handlers in `src/server/routes/admin-tags.ts`
|
||||
```typescript
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid tag ID" }, 400);
|
||||
```
|
||||
|
||||
### `zValidator` Request Validation
|
||||
**Source:** `src/server/routes/admin-items.ts` (lines 65–67)
|
||||
**Apply to:** POST and PUT handlers in `src/server/routes/admin-tags.ts`
|
||||
```typescript
|
||||
app.put("/:id", zValidator("json", updateTagSchema), async (c) => {
|
||||
const data = c.req.valid("json");
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Dual Query Key Invalidation
|
||||
**Source:** Anti-pattern documented in RESEARCH.md (Pitfall 4); pattern shown in `useAdminGlobalItems.ts` as single-key invalidation
|
||||
**Apply to:** All mutations in `src/client/hooks/useAdminTags.ts`
|
||||
```typescript
|
||||
// Every mutation onSuccess must invalidate BOTH:
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-tags"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] }); // keep public cache fresh
|
||||
```
|
||||
|
||||
### React Query `ApiError` 404 Retry Suppression
|
||||
**Source:** `src/client/hooks/useAdminGlobalItems.ts` (lines 85–87)
|
||||
**Apply to:** `useAdminTag` single-item query in `useAdminTags.ts`
|
||||
```typescript
|
||||
retry: (count, error) =>
|
||||
error instanceof ApiError && error.status === 404 ? false : count < 3,
|
||||
```
|
||||
|
||||
### Form Input/Label CSS Classes
|
||||
**Source:** `src/client/routes/admin/items.$itemId.tsx` (lines 176–179)
|
||||
**Apply to:** `src/client/routes/admin/tags.$tagId.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";
|
||||
const sectionClass = "border-t border-gray-100 pt-6 mt-6";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File / Logic | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `buildTree` / `flattenTree` util functions | utility | transform | No tree data structures exist in the codebase. Pure JS — no library needed. |
|
||||
| `getDescendantIds` client-side helper | utility | transform | No hierarchical client-side data processing exists. Co-locate in `tags.$tagId.tsx` or a `lib/treeUtils.ts`. |
|
||||
| Tree row expand/collapse local state | component state | event-driven | No collapsible tree UI exists. Use `useState<Set<number>>` for expanded node IDs — start all expanded. |
|
||||
| Quick-add inline form | component | request-response | No inline create form on list pages — all creates go through modal or separate page in existing code. Follow same input/button styling as edit pages. |
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `src/server/routes/`, `src/server/services/`, `src/client/hooks/`, `src/client/routes/admin/`, `src/db/schema.ts`, `tests/services/`, `tests/routes/`
|
||||
**Files scanned:** 11 source files read in full
|
||||
**Pattern extraction date:** 2026-04-19
|
||||
174
.planning/phases/38-admin-tag-management/38-VERIFICATION.md
Normal file
174
.planning/phases/38-admin-tag-management/38-VERIFICATION.md
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
phase: 38-admin-tag-management
|
||||
verified: 2026-04-19T00:00:00Z
|
||||
status: human_needed
|
||||
score: 11/12 must-haves verified
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Collapsible tree view expand/collapse"
|
||||
expected: "Clicking the chevron on a parent tag row hides or shows its children; all parents start expanded on page load"
|
||||
why_human: "Stateful expand/collapse behavior and initial expansion logic cannot be verified without rendering the component"
|
||||
- test: "Search/filter tree — parents preserved when children match"
|
||||
expected: "Typing a child tag's name keeps the parent row visible while unrelated leaves disappear"
|
||||
why_human: "filterTree logic preserves parents but actual rendering behavior requires a live browser to confirm"
|
||||
- test: "Quick-add form — create tag with optional parent"
|
||||
expected: "Submitting the form with a name and a selected parent creates a child tag that appears indented under the parent"
|
||||
why_human: "Form interaction and query invalidation refresh cannot be verified without a running dev server"
|
||||
- test: "Edit page — rename and reparent with cycle prevention"
|
||||
expected: "Saving a rename updates the tag name; changing parent to a descendant of the current tag is blocked (the descendant does not appear in the parent picker)"
|
||||
why_human: "Client-side getDescendantIds filtering of the parent picker requires a live browser"
|
||||
- test: "Delete confirmation — impact-aware text"
|
||||
expected: "When a tag has items the confirmation reads '{N} item(s) use this tag. This cannot be undone.'; when it has children it reads 'Its {N} child tag(s) will become top-level. This cannot be undone.'"
|
||||
why_human: "Dialog render and conditional text composition require a live UI"
|
||||
- test: "Tags sidebar link active in admin panel"
|
||||
expected: "The Tags entry in the admin sidebar navigates to /admin/tags and shows the active highlight when on that route"
|
||||
why_human: "Link activation styling is determined at runtime by TanStack Router's activeProps mechanism"
|
||||
---
|
||||
|
||||
# Phase 38: Admin Tag Management — Verification Report
|
||||
|
||||
**Phase Goal:** Admins can fully manage the tag taxonomy — creating, renaming, organizing into a parent-child hierarchy, and deleting tags — from within the admin panel
|
||||
**Verified:** 2026-04-19
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | GET /api/admin/tags returns all tags with parentId and itemCount | VERIFIED | `getAdminTags` queries tags + LEFT JOIN globalItemTags, groups by id/name/parentId; route handler at GET / returns `c.json(result)` |
|
||||
| 2 | POST /api/admin/tags creates a tag with name and optional parentId | VERIFIED | Route uses `zValidator("json", createTagSchema)`, calls `createTag(db, data)`, returns 201 |
|
||||
| 3 | PUT /api/admin/tags/:id renames a tag and/or changes its parentId | VERIFIED | Route calls `updateTag(db, id, data)` which updates name and/or parentId via Drizzle `.update().set()` |
|
||||
| 4 | PUT /api/admin/tags/:id returns 400 when setting a descendant as parent | VERIFIED | `isDescendant` walks ancestor chain; `updateTag` throws "Cycle detected..."; route catches and returns `c.json({ error: err.message }, 400)` |
|
||||
| 5 | DELETE /api/admin/tags/:id removes a tag and orphans its children | VERIFIED | `deleteTag` deletes by id; schema has `onDelete: "set null"` on `parentId` FK; route returns `{ success: true }` |
|
||||
| 6 | Admin can see all tags in a collapsible tree view with indent levels, item counts, and expand/collapse chevrons | VERIFIED (code) / ? HUMAN (runtime) | `buildTree`, `flattenTree`, `filterTree` implemented; chevron toggles present; `expanded` Set initialized from parent IDs via useEffect |
|
||||
| 7 | Admin can create a new tag via the quick-add form at the top of the list with optional parent | VERIFIED (code) / ? HUMAN (runtime) | Form with name input + parent select + "Add Tag" button exists; calls `createMutation.mutateAsync` on submit; clears form on success |
|
||||
| 8 | Admin can search/filter tags in the tree view | VERIFIED (code) / ? HUMAN (runtime) | `filterTree` recursively preserves parents when children match; search input drives `searchQuery` state; applied to tree before flatten |
|
||||
| 9 | Admin can click a tag row to navigate to its edit page | VERIFIED | Row `onClick` calls `navigate({ to: "/admin/tags/$tagId", params: { tagId: String(node.id) } })` |
|
||||
| 10 | Admin can rename a tag and change its parent on the edit page | VERIFIED (code) / ? HUMAN (runtime) | Edit page form with name input + parent select, `handleSave` calls `useUpdateAdminTag`; `parentOptions` excludes self + all descendants |
|
||||
| 11 | Admin can delete a tag from the edit page with an impact-aware confirmation dialog | VERIFIED (code) / ? HUMAN (runtime) | `getDeleteConfirmText` builds message with item count + child count; confirmation modal renders conditionally on `showDeleteConfirm` |
|
||||
| 12 | Tags sidebar link in admin panel is active and navigable | VERIFIED | `admin.tsx` contains `<Link to="/admin/tags" ...>` with activeProps/inactiveProps; no `cursor-not-allowed` or "Coming in a future release" present |
|
||||
|
||||
**Score:** 12/12 truths verified in code (6 require human confirmation of runtime behavior)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `src/db/schema.ts` | parentId column on tags table | VERIFIED | Line 207: `parentId: integer("parent_id").references(() => tags.id, { onDelete: "set null" })` |
|
||||
| `src/server/services/tag.service.ts` | getAdminTags, getTagWithCounts, createTag, updateTag, deleteTag, isDescendant | VERIFIED | All 5 exported functions + private `isDescendant` present; 101 lines, substantive implementations |
|
||||
| `src/server/routes/admin-tags.ts` | CRUD route handlers for /api/admin/tags | VERIFIED | 81 lines; GET /, GET /:id, POST /, PUT /:id, DELETE /:id; exports `adminTagRoutes` |
|
||||
| `tests/routes/admin-tags.test.ts` | Integration tests for admin tag CRUD + cycle detection | VERIFIED | 184 lines (above 80 min); 13 tests; covers all 4 describe groups |
|
||||
| `src/client/hooks/useAdminTags.ts` | React Query hooks for admin tag CRUD | VERIFIED | 77 lines; exports all 5 hooks; dual invalidation of `["admin-tags"]` and `["tags"]` |
|
||||
| `src/client/routes/admin/tags.tsx` | Tag list page with tree view and quick-add form | VERIFIED | 303 lines; contains `createFileRoute("/admin/tags")`, `buildTree`, `filterTree`, chevron-down, "No parent (top-level)", "Add Tag", "No tags yet" |
|
||||
| `src/client/routes/admin/tags.$tagId.tsx` | Tag edit page with rename, reparent, and delete | VERIFIED | 230 lines; contains `createFileRoute("/admin/tags/$tagId")`, `getDescendantIds`, `getDeleteConfirmText`, "No parent (top-level)", "Delete Tag", "Save Changes", "This cannot be undone" |
|
||||
| `src/client/routes/admin.tsx` | Tags sidebar link enabled | VERIFIED | Contains `to="/admin/tags"` as active Link; no `cursor-not-allowed` or "Soon" badge |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `src/server/routes/admin.ts` | `src/server/routes/admin-tags.ts` | `app.route("/tags", adminTagRoutes)` | WIRED | Line 22: `app.route("/tags", adminTagRoutes)` with import on line 4 |
|
||||
| `src/server/routes/admin-tags.ts` | `src/server/services/tag.service.ts` | service function imports | WIRED | Lines 5-11: imports `createTag`, `deleteTag`, `getAdminTags`, `getTagWithCounts`, `updateTag` |
|
||||
| `src/client/routes/admin/tags.tsx` | `/api/admin/tags` | useAdminTags + useCreateAdminTag hooks | WIRED | Imports and uses both hooks; `useAdminTags()` populates tree data; `useCreateAdminTag().mutateAsync` called on form submit |
|
||||
| `src/client/routes/admin/tags.$tagId.tsx` | `/api/admin/tags/:id` | useAdminTag + useUpdateAdminTag + useDeleteAdminTag hooks | WIRED | All three hooks imported and used; `useAdminTag(id)` fetches tag; `useUpdateAdminTag().mutateAsync` on save; `useDeleteAdminTag().mutateAsync` on delete |
|
||||
| `src/client/routes/admin.tsx` | `/admin/tags` | Link component | WIRED | Active `<Link to="/admin/tags">` present with activeProps/inactiveProps |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|--------------------|--------|
|
||||
| `tags.tsx` | `data` (AdminTag[]) | `useAdminTags` → `apiGet("/api/admin/tags")` → `getAdminTags(db)` → Drizzle LEFT JOIN query | Yes — DB query with count and groupBy | FLOWING |
|
||||
| `tags.$tagId.tsx` | `tag` (AdminTag) | `useAdminTag(id)` → `apiGet("/api/admin/tags/:id")` → `getTagWithCounts(db, id)` → Drizzle query with WHERE | Yes — single-row DB query | FLOWING |
|
||||
| `tags.$tagId.tsx` | `allTags` (AdminTag[]) | `useAdminTags()` — same flow as above | Yes | FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Service tests pass | `bun test tests/services/tag.service.test.ts` | 14 pass, 0 fail | PASS |
|
||||
| Route integration tests pass | `bun test tests/routes/admin-tags.test.ts` | 13 pass, 0 fail | PASS |
|
||||
| Combined tag test suite | `bun test tests/services/tag.service.test.ts tests/routes/admin-tags.test.ts` | 27 pass, 0 fail | PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| ADMN-05 | 38-01 + 38-02 | Admin can browse all tags with item counts and parent/child relationships displayed | SATISFIED | `getAdminTags` returns `parentId` + `itemCount`; `tags.tsx` renders tree with item counts and hierarchy |
|
||||
| ADMN-06 | 38-01 + 38-02 | Admin can create a new tag with a name | SATISFIED | POST /api/admin/tags + quick-add form |
|
||||
| ADMN-07 | 38-01 + 38-02 | Admin can rename an existing tag | SATISFIED | PUT /api/admin/tags/:id accepts `name`; edit page name field + Save Changes |
|
||||
| ADMN-08 | 38-01 + 38-02 | Admin can assign a parent tag (enabling sub-tag hierarchy) | SATISFIED | PUT /api/admin/tags/:id accepts `parentId`; parent picker on quick-add and edit page |
|
||||
| ADMN-09 | 38-01 + 38-02 | Admin can remove a parent assignment (making it top-level again) | SATISFIED | PUT with `parentId: null` handled; "No parent (top-level)" option in both pickers; `updateTag` explicitly handles `null` |
|
||||
| ADMN-10 | 38-01 + 38-02 | Admin can delete a tag, with a warning if items are currently using it | SATISFIED | DELETE endpoint removes tag; `getDeleteConfirmText` shows item count warning; modal renders impact text |
|
||||
|
||||
All 6 phase requirements satisfied.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
No blockers or warnings found.
|
||||
|
||||
| File | Pattern Checked | Result |
|
||||
|------|----------------|--------|
|
||||
| `src/server/routes/admin-tags.ts` | TODO/FIXME, empty returns, stubs | Clean |
|
||||
| `src/server/services/tag.service.ts` | TODO/FIXME, empty returns, stubs | Clean |
|
||||
| `src/client/hooks/useAdminTags.ts` | Hardcoded empty values, disconnected props | Clean |
|
||||
| `src/client/routes/admin/tags.tsx` | Placeholders, empty handlers | Clean |
|
||||
| `src/client/routes/admin/tags.$tagId.tsx` | Placeholders, empty handlers | Clean |
|
||||
| `src/client/routes/admin.tsx` | `cursor-not-allowed`, "Coming in a future release" | Clean — disabled entry fully replaced |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
The backend is fully verified programmatically (27 tests passing). The client code is structurally correct and wired, but the following runtime behaviors require a human to confirm in a live browser:
|
||||
|
||||
#### 1. Collapsible tree expand/collapse
|
||||
|
||||
**Test:** Navigate to `/admin/tags`. Create a parent tag (e.g. "gear") and a child tag (e.g. "clothing" under "gear"). Observe the tree. Click the chevron on "gear".
|
||||
**Expected:** "clothing" disappears. Click the chevron again — "clothing" reappears. On initial load, all parents start expanded.
|
||||
**Why human:** `expanded` Set is initialized by a `useEffect` that runs after data loads; toggling is stateful. Cannot verify expand/collapse rendering without a live component.
|
||||
|
||||
#### 2. Search/filter — parents preserved when children match
|
||||
|
||||
**Test:** With the tree above, type "clothing" in the search box.
|
||||
**Expected:** Both the "gear" parent row and the "clothing" child row remain visible. Unrelated tags disappear.
|
||||
**Why human:** `filterTree` preserves parents of matching descendants, but the rendered output of the filtered + flattened tree needs visual confirmation.
|
||||
|
||||
#### 3. Quick-add form — create tag with optional parent
|
||||
|
||||
**Test:** In the quick-add form, type "down" as the name and select "gear" as the parent. Click "Add Tag".
|
||||
**Expected:** The form clears, and "down" appears in the tree indented under "gear". The tag count in the header increments.
|
||||
**Why human:** Mutation + cache invalidation refresh flow requires a running app.
|
||||
|
||||
#### 4. Edit page — rename and cycle-safe reparent
|
||||
|
||||
**Test:** Click on a tag to open its edit page. Change the name and click "Save Changes" — name updates. Then: create tags A (no parent), B (parent: A), C (parent: B). Open A's edit page and observe the parent picker.
|
||||
**Expected:** The parent picker does NOT show B or C (descendants are excluded). Saving a rename updates the displayed name.
|
||||
**Why human:** `getDescendantIds` client-side filtering of parentOptions and mutation round-trip require a live browser.
|
||||
|
||||
#### 5. Delete confirmation — impact-aware text
|
||||
|
||||
**Test (items warning):** Find or create a tag used by at least one item. On its edit page, click "Delete Tag".
|
||||
**Expected:** Confirmation modal reads "{N} item(s) use this tag. This cannot be undone."
|
||||
|
||||
**Test (children warning):** Find or create a parent tag. Click "Delete Tag".
|
||||
**Expected:** Confirmation modal reads "Its {N} child tag(s) will become top-level. This cannot be undone."
|
||||
|
||||
**Test (empty tag):** Create a tag with no items or children. Click "Delete Tag".
|
||||
**Expected:** Confirmation modal reads only "This cannot be undone."
|
||||
**Why human:** `getDeleteConfirmText` logic is correct in code but conditional rendering of the modal text needs visual verification.
|
||||
|
||||
#### 6. Tags sidebar link active in admin panel
|
||||
|
||||
**Test:** Navigate to `/admin`. Observe the sidebar.
|
||||
**Expected:** "Tags" link is present without a "Soon" badge and is clickable. Navigating to `/admin/tags` applies the active highlight style.
|
||||
**Why human:** TanStack Router's `activeProps` activation is runtime behavior.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No code gaps. All artifacts exist, are substantive, are wired, and have real data flowing through them. All 27 backend tests pass. The 6 human verification items are behavioral/visual runtime checks that the automated scan cannot perform.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-04-19_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
Reference in New Issue
Block a user