docs(38): phase verification report .planning/phases/38-admin-tag-management/38-VERIFICATION.md

This commit is contained in:
2026-04-19 22:36:48 +02:00
parent 6a5ffe8e2f
commit 5d417b7c6e
2 changed files with 1138 additions and 0 deletions

View 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 204208, 212223)
**Current `tags` table** (lines 204208):
```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 112 — 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 189 — full file)
**Imports pattern** (copy from `admin-items.ts` lines 110, 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 1214):
```typescript
type Env = { Variables: { db?: any; userId?: number } };
const app = new Hono<Env>();
```
**Zod schemas** — follow `updateGlobalItemAdminSchema` style (lines 1628), 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 3152 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 5562):
```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 6477 + 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 8087):
```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 121 — full file)
**Current registration** (lines 1718):
```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 1116 — full file)
**Imports pattern** (follow `useAdminGlobalItems.ts` lines 17 — 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 1156 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 7988, 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 7988):
```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 90105):
```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 107116):
```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 1237 — full file)
**Route declaration pattern** (follow `items.tsx` lines 19):
```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 6783 — 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 104109):
```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 112114):
```typescript
<div className="w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
```
**Table header pattern** (follow `items.tsx` lines 115135, 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 149155):
```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 139147):
```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 210217):
```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 1435 — full file)
**Route declaration pattern** (follow `items.$itemId.tsx` lines 111):
```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 9092):
```typescript
const { tagId } = Route.useParams();
const id = Number(tagId);
```
**Form state + populate-on-load pattern** (follow `items.$itemId.tsx` lines 102133):
```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 181191):
```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 194199):
```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 212218):
```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 176179):
```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 147169 — `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 370393):
```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 397432, 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 4352)
**Current disabled entry** (lines 4352):
```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 3240):
```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 152 — full file) for structure; `tests/routes/global-items.test.ts` (lines 160) for admin auth setup pattern.
**Test app factory pattern** (follow `tags.test.ts` lines 717, 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 1952 `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 2953 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 136 — 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 910)
**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 5759, 7172, 8283)
**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 6567)
**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 8587)
**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 176179)
**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

View 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)_