docs(25): create phase plan for catalog enrichment and agent tools

This commit is contained in:
2026-04-10 10:45:22 +02:00
parent 6c0c31350e
commit d9d9532399
3 changed files with 1039 additions and 2 deletions

View File

@@ -102,7 +102,11 @@ Plans:
3. A single API call with an array of items imports them all, upserting on (brand, model) conflict 3. A single API call with an array of items imports them all, upserting on (brand, model) conflict
4. An MCP agent can call `upsert_catalog_item` with attribution fields and the item appears in the catalog 4. An MCP agent can call `upsert_catalog_item` with attribution fields and the item appears in the catalog
5. An MCP agent can call `bulk_upsert_catalog` with a batch of items and all are persisted with attribution 5. An MCP agent can call `bulk_upsert_catalog` with a batch of items and all are persisted with attribution
**Plans**: TBD **Plans**: 2 plans
Plans:
- [ ] 25-01-PLAN.md — Schema migration (attribution columns + unique constraint) and upsert service layer
- [ ] 25-02-PLAN.md — HTTP upsert routes, MCP catalog tools, and client attribution display
### Phase 26: Discovery Landing Page ### Phase 26: Discovery Landing Page
**Goal**: The app opens to a public discovery feed with prominent catalog search, not a personal dashboard **Goal**: The app opens to a public discovery feed with prominent catalog search, not a personal dashboard
@@ -145,7 +149,7 @@ Plans:
| 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 | | 22. Add-from-Catalog & Thread Integration | v2.0 | 2/2 | Complete | 2026-04-06 |
| 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 | | 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 |
| 24. Public Access & Infrastructure | v2.1 | 2/2 | Complete | 2026-04-10 | | 24. Public Access & Infrastructure | v2.1 | 2/2 | Complete | 2026-04-10 |
| 25. Catalog Enrichment & Agent Tools | v2.1 | 0/TBD | Not started | - | | 25. Catalog Enrichment & Agent Tools | v2.1 | 0/2 | Not started | - |
| 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - | | 26. Discovery Landing Page | v2.1 | 0/TBD | Not started | - |
## Backlog ## Backlog

View File

@@ -0,0 +1,471 @@
---
phase: 25-catalog-enrichment-agent-tools
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/schema.ts
- src/shared/schemas.ts
- src/shared/types.ts
- src/server/services/global-item.service.ts
- tests/services/global-item.service.test.ts
autonomous: true
requirements:
- CATL-01
- CATL-02
- CATL-05
must_haves:
truths:
- "globalItems table has sourceUrl, imageCredit, imageSourceUrl columns"
- "globalItems table has a unique constraint on (brand, model)"
- "Inserting a duplicate (brand, model) updates the existing row instead of failing"
- "Bulk upsert returns accurate created vs updated counts"
- "Tags are synced (create-if-not-exists) when provided, left untouched when omitted"
artifacts:
- path: "src/db/schema.ts"
provides: "globalItems table with attribution columns and unique constraint"
contains: "sourceUrl"
- path: "src/shared/schemas.ts"
provides: "Zod schemas for upsert and bulk upsert"
contains: "upsertGlobalItemSchema"
- path: "src/server/services/global-item.service.ts"
provides: "upsertGlobalItem and bulkUpsertGlobalItems functions"
exports: ["upsertGlobalItem", "bulkUpsertGlobalItems"]
- path: "tests/services/global-item.service.test.ts"
provides: "Tests for upsert, duplicate handling, bulk, tags"
key_links:
- from: "src/server/services/global-item.service.ts"
to: "src/db/schema.ts"
via: "onConflictDoUpdate target referencing unique constraint"
pattern: "onConflictDoUpdate.*target.*globalItems\\.brand.*globalItems\\.model"
---
<objective>
Add attribution columns and unique constraint to globalItems, create upsert service functions, and define Zod validation schemas for catalog enrichment.
Purpose: Establish the data layer foundation that HTTP routes (Plan 02) and MCP tools (Plan 02) will call.
Output: Schema migration applied, upsertGlobalItem + bulkUpsertGlobalItems service functions, Zod schemas, passing tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@src/db/schema.ts
@src/shared/schemas.ts
@src/shared/types.ts
@src/server/services/global-item.service.ts
@src/server/routes/settings.ts
@src/server/services/setup.service.ts
@tests/services/global-item.service.test.ts
<interfaces>
<!-- Current globalItems table (src/db/schema.ts lines 136-146) -->
```typescript
export const globalItems = pgTable("global_items", {
id: serial("id").primaryKey(),
brand: text("brand").notNull(),
model: text("model").notNull(),
category: text("category"),
weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"),
imageUrl: text("image_url"),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
```
<!-- Tags table (src/db/schema.ts lines 150-154) -->
```typescript
export const tags = pgTable("tags", {
id: serial("id").primaryKey(),
name: text("name").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
```
<!-- globalItemTags junction (src/db/schema.ts lines 158-169) -->
```typescript
export const globalItemTags = pgTable("global_item_tags", {
globalItemId: integer("global_item_id").notNull().references(() => globalItems.id, { onDelete: "cascade" }),
tagId: integer("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }),
}, (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })]);
```
<!-- Multi-column onConflictDoUpdate pattern (src/server/routes/settings.ts lines 33-37) -->
```typescript
await database
.insert(settings)
.values({ userId, key, value: body.value })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: body.value },
});
```
<!-- Transaction pattern (src/server/services/setup.service.ts) -->
```typescript
return await db.transaction(async (tx) => {
// multiple tx operations, auto-rollback on throw
});
```
<!-- Categories table unique constraint pattern (src/db/schema.ts) -->
```typescript
export const categories = pgTable("categories", {
// ...columns...
}, (table) => [unique().on(table.userId, table.name)]);
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Schema migration — attribution columns + unique constraint</name>
<files>src/db/schema.ts</files>
<read_first>
- src/db/schema.ts (current globalItems definition at lines 136-146, categories unique constraint pattern at line 26-38)
</read_first>
<behavior>
- After migration: globalItems table has sourceUrl, imageCredit, imageSourceUrl columns (all text, nullable)
- After migration: inserting two rows with same (brand, model) raises a unique violation
- Existing rows are unaffected (columns default to null)
</behavior>
<action>
1. In `src/db/schema.ts`, update the `globalItems` table definition to add three new columns and a unique constraint:
```typescript
export const globalItems = pgTable(
"global_items",
{
id: serial("id").primaryKey(),
brand: text("brand").notNull(),
model: text("model").notNull(),
category: text("category"),
weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"),
imageUrl: text("image_url"),
description: text("description"),
sourceUrl: text("source_url"),
imageCredit: text("image_credit"),
imageSourceUrl: text("image_source_url"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [unique().on(table.brand, table.model)],
);
```
2. Import `unique` from `drizzle-orm/pg-core` if not already imported (check existing imports at top of file).
3. Check for duplicate (brand, model) pairs in the dev database before generating migration:
```bash
# If duplicates exist, deduplicate before migration
```
4. Generate and apply the migration:
```bash
bun run db:generate
bun run db:push
```
Per D-01 (three attribution columns), D-04 (unique constraint on brand+model).
</action>
<verify>
<automated>bun run db:generate && bun run db:push</automated>
</verify>
<acceptance_criteria>
- src/db/schema.ts contains `sourceUrl: text("source_url")`
- src/db/schema.ts contains `imageCredit: text("image_credit")`
- src/db/schema.ts contains `imageSourceUrl: text("image_source_url")`
- src/db/schema.ts contains `unique().on(table.brand, table.model)`
- A new migration SQL file exists in drizzle-pg/ directory
- `bun run db:push` exits 0
</acceptance_criteria>
<done>globalItems table has 3 new attribution columns and a unique constraint on (brand, model), migration generated and applied</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Zod schemas + upsert service functions + tests</name>
<files>src/shared/schemas.ts, src/shared/types.ts, src/server/services/global-item.service.ts, tests/services/global-item.service.test.ts</files>
<read_first>
- src/shared/schemas.ts (existing schema patterns, especially createItemSchema)
- src/shared/types.ts (type inference patterns from schemas)
- src/server/services/global-item.service.ts (current service, Db type, imports)
- src/server/routes/settings.ts (onConflictDoUpdate pattern at lines 33-37)
- src/server/services/setup.service.ts (transaction pattern)
- tests/services/global-item.service.test.ts (existing test structure, createTestDb usage)
- tests/helpers/db.ts (test database setup)
</read_first>
<behavior>
- upsertGlobalItem: inserting a new (brand, model) creates a row and returns it with id
- upsertGlobalItem: inserting an existing (brand, model) updates all non-key fields and returns the updated row
- upsertGlobalItem: attribution fields (sourceUrl, imageCredit, imageSourceUrl) are persisted and returned
- upsertGlobalItem: when tags are provided, creates tags if not existing and links them to the item
- upsertGlobalItem: when tags are omitted (undefined), existing tags are left untouched
- upsertGlobalItem: when tags are empty array, existing tags are cleared
- bulkUpsertGlobalItems: processes an array of items in a single transaction
- bulkUpsertGlobalItems: returns { created: N, updated: M, items: [...] } with correct counts
- bulkUpsertGlobalItems: rolls back entire transaction if any item fails
- bulkUpsertGlobalItems: handles mix of new and existing items correctly
</behavior>
<action>
**1. Add Zod schemas to `src/shared/schemas.ts`:**
```typescript
// Single catalog item upsert schema
export const upsertGlobalItemSchema = z.object({
brand: z.string().min(1, "Brand is required"),
model: z.string().min(1, "Model is required"),
category: z.string().optional(),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
imageUrl: z.string().url().optional().or(z.literal("")),
description: z.string().optional(),
sourceUrl: z.string().url().optional().or(z.literal("")),
imageCredit: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
tags: z.array(z.string().min(1).max(100)).max(20).optional(),
});
// Bulk catalog upsert schema
export const bulkUpsertGlobalItemsSchema = z.object({
items: z.array(upsertGlobalItemSchema).min(1).max(100),
});
```
Per D-09 (request body shape), D-08 (max 100 items).
**2. Add type exports to `src/shared/types.ts`:**
Add after existing type imports:
```typescript
import type { upsertGlobalItemSchema, bulkUpsertGlobalItemsSchema } from "./schemas.ts";
// ...
export type UpsertGlobalItemInput = z.infer<typeof upsertGlobalItemSchema>;
export type BulkUpsertGlobalItemsInput = z.infer<typeof bulkUpsertGlobalItemsSchema>;
```
**3. Add service functions to `src/server/services/global-item.service.ts`:**
Add imports for `unique` if needed and add the following functions:
```typescript
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
// Add a helper to sync tags for a global item (create-if-not-exists)
async function syncGlobalItemTags(
tx: Parameters<Parameters<Db["transaction"]>[0]>[0],
globalItemId: number,
tagNames: string[],
) {
// Delete existing tags for this item
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, globalItemId));
for (const name of tagNames) {
// Upsert tag (create if not exists)
const [tag] = await tx
.insert(tags)
.values({ name })
.onConflictDoUpdate({ target: tags.name, set: { name } })
.returning({ id: tags.id });
await tx.insert(globalItemTags).values({ globalItemId, tagId: tag.id });
}
}
export async function upsertGlobalItem(
db: Db,
data: {
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
},
) {
return await db.transaction(async (tx) => {
// Check if exists to determine created vs updated
const [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(and(eq(globalItems.brand, data.brand), eq(globalItems.model, data.model)));
const { tags: tagNames, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
model: itemData.model,
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
})
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
set: {
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
},
})
.returning();
// Sync tags only if explicitly provided
if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames);
}
return { item, created: !existing };
});
}
export async function bulkUpsertGlobalItems(
db: Db,
itemsData: Array<{
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
}>,
) {
return await db.transaction(async (tx) => {
let created = 0;
let updated = 0;
const results = [];
for (const data of itemsData) {
const [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(and(eq(globalItems.brand, data.brand), eq(globalItems.model, data.model)));
const { tags: tagNames, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
model: itemData.model,
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
})
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
set: {
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
priceCents: itemData.priceCents ?? null,
imageUrl: itemData.imageUrl ?? null,
description: itemData.description ?? null,
sourceUrl: itemData.sourceUrl ?? null,
imageCredit: itemData.imageCredit ?? null,
imageSourceUrl: itemData.imageSourceUrl ?? null,
},
})
.returning();
if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames);
}
if (existing) updated++;
else created++;
results.push(item);
}
return { created, updated, items: results };
});
}
```
Per D-05 (ON CONFLICT DO UPDATE), D-07 (all-or-nothing transaction), D-08 (max 100 — enforced at Zod level).
**4. Add tests to `tests/services/global-item.service.test.ts`:**
Add a new `describe("upsert operations")` block with tests for:
- `upsertGlobalItem` creates new item and returns { item, created: true }
- `upsertGlobalItem` updates existing item on (brand, model) conflict and returns { item, created: false }
- `upsertGlobalItem` persists sourceUrl, imageCredit, imageSourceUrl
- `upsertGlobalItem` with tags creates tags and links them
- `upsertGlobalItem` without tags leaves existing tags untouched
- `upsertGlobalItem` with empty tags array clears existing tags
- `bulkUpsertGlobalItems` processes array, returns correct created/updated counts
- `bulkUpsertGlobalItems` handles mix of new and existing items
- `bulkUpsertGlobalItems` rolls back on error (test by inserting an item then causing a constraint violation in the same batch — though with upsert this is hard; test by verifying transaction atomicity)
</action>
<verify>
<automated>bun test tests/services/global-item.service.test.ts</automated>
</verify>
<acceptance_criteria>
- src/shared/schemas.ts contains `export const upsertGlobalItemSchema`
- src/shared/schemas.ts contains `export const bulkUpsertGlobalItemsSchema`
- src/shared/schemas.ts contains `.max(100)` for bulk items array
- src/server/services/global-item.service.ts contains `export async function upsertGlobalItem`
- src/server/services/global-item.service.ts contains `export async function bulkUpsertGlobalItems`
- src/server/services/global-item.service.ts contains `onConflictDoUpdate`
- src/server/services/global-item.service.ts contains `db.transaction`
- tests/services/global-item.service.test.ts contains `upsert` in at least 5 test descriptions
- `bun test tests/services/global-item.service.test.ts` exits 0
</acceptance_criteria>
<done>Zod schemas defined, upsertGlobalItem and bulkUpsertGlobalItems service functions implemented with tag sync, all tests passing</done>
</task>
</tasks>
<verification>
- `bun run db:push` exits 0 (schema valid)
- `bun test tests/services/global-item.service.test.ts` exits 0 (all upsert tests pass)
- `bun run lint` exits 0 (no Biome errors)
</verification>
<success_criteria>
- globalItems table has sourceUrl, imageCredit, imageSourceUrl columns and unique(brand, model) constraint
- upsertGlobalItem function creates or updates based on (brand, model) conflict
- bulkUpsertGlobalItems function processes arrays in a single transaction with created/updated counts
- Tag sync creates tags if not existing, clears on empty array, leaves untouched when omitted
- All service-level tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,562 @@
---
phase: 25-catalog-enrichment-agent-tools
plan: 02
type: execute
wave: 2
depends_on: ["25-01"]
files_modified:
- src/server/routes/global-items.ts
- src/server/mcp/tools/catalog.ts
- src/server/mcp/index.ts
- src/client/hooks/useGlobalItems.ts
- src/client/routes/global-items/$globalItemId.tsx
- tests/routes/global-items.test.ts
- tests/mcp/tools.test.ts
autonomous: true
requirements:
- CATL-03
- CATL-04
- SEED-01
- SEED-02
- SEED-03
must_haves:
truths:
- "POST /api/global-items upserts a single catalog item and returns the item with id"
- "POST /api/global-items/bulk upserts up to 100 items in a single transaction and returns created/updated counts"
- "POST /api/global-items/bulk rejects the entire batch if any item fails validation"
- "MCP tool upsert_catalog_item writes a global item with attribution fields"
- "MCP tool bulk_upsert_catalog batch-writes global items via the bulk service"
- "Catalog detail page shows image credit and source link below the image when present"
artifacts:
- path: "src/server/routes/global-items.ts"
provides: "POST / and POST /bulk route handlers"
contains: "bulkUpsertGlobalItems"
- path: "src/server/mcp/tools/catalog.ts"
provides: "upsert_catalog_item and bulk_upsert_catalog MCP tool definitions + handlers"
exports: ["catalogToolDefinitions", "registerCatalogTools"]
- path: "src/server/mcp/index.ts"
provides: "Catalog tool registration in createMcpServer"
contains: "registerCatalogTools"
- path: "src/client/routes/global-items/$globalItemId.tsx"
provides: "Attribution display below image"
contains: "imageCredit"
- path: "tests/routes/global-items.test.ts"
provides: "Tests for POST single and bulk endpoints"
- path: "tests/mcp/tools.test.ts"
provides: "Tests for catalog MCP tools"
key_links:
- from: "src/server/routes/global-items.ts"
to: "src/server/services/global-item.service.ts"
via: "import and call upsertGlobalItem / bulkUpsertGlobalItems"
pattern: "import.*upsertGlobalItem.*bulkUpsertGlobalItems"
- from: "src/server/mcp/tools/catalog.ts"
to: "src/server/services/global-item.service.ts"
via: "import and call upsertGlobalItem / bulkUpsertGlobalItems"
pattern: "import.*upsertGlobalItem.*bulkUpsertGlobalItems"
- from: "src/server/mcp/index.ts"
to: "src/server/mcp/tools/catalog.ts"
via: "import catalogToolDefinitions + registerCatalogTools"
pattern: "catalogToolDefinitions.*registerCatalogTools"
---
<objective>
Add HTTP upsert endpoints, MCP catalog tools, and client-side attribution display for global items.
Purpose: Complete the API and agent tooling layer so MCP agents can seed the catalog, and display attribution metadata on catalog detail pages.
Output: POST /api/global-items, POST /api/global-items/bulk, two MCP tools, attribution UI on detail page, passing tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/25-catalog-enrichment-agent-tools/25-01-SUMMARY.md
@src/server/routes/global-items.ts
@src/server/mcp/index.ts
@src/server/mcp/tools/items.ts
@src/server/mcp/tools/images.ts
@src/server/services/global-item.service.ts
@src/shared/schemas.ts
@src/client/hooks/useGlobalItems.ts
@src/client/routes/global-items/$globalItemId.tsx
@tests/routes/global-items.test.ts
@tests/mcp/tools.test.ts
<interfaces>
<!-- From Plan 01 output: service functions (src/server/services/global-item.service.ts) -->
```typescript
export async function upsertGlobalItem(
db: Db,
data: {
brand: string; model: string; category?: string; weightGrams?: number;
priceCents?: number; imageUrl?: string; description?: string;
sourceUrl?: string; imageCredit?: string; imageSourceUrl?: string;
tags?: string[];
},
): Promise<{ item: GlobalItem; created: boolean }>;
export async function bulkUpsertGlobalItems(
db: Db,
itemsData: Array<{ /* same fields as above */ }>,
): Promise<{ created: number; updated: number; items: GlobalItem[] }>;
```
<!-- From Plan 01 output: Zod schemas (src/shared/schemas.ts) -->
```typescript
export const upsertGlobalItemSchema = z.object({
brand: z.string().min(1), model: z.string().min(1),
category: z.string().optional(), weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
imageUrl: z.string().url().optional().or(z.literal("")),
description: z.string().optional(), sourceUrl: z.string().url().optional().or(z.literal("")),
imageCredit: z.string().optional(), imageSourceUrl: z.string().url().optional().or(z.literal("")),
tags: z.array(z.string().min(1).max(100)).max(20).optional(),
});
export const bulkUpsertGlobalItemsSchema = z.object({
items: z.array(upsertGlobalItemSchema).min(1).max(100),
});
```
<!-- Existing route pattern (src/server/routes/global-items.ts) -->
```typescript
import { Hono } from "hono";
type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", async (c) => { ... });
app.get("/:id", async (c) => { ... });
export { app as globalItemRoutes };
```
<!-- MCP tool pattern (src/server/mcp/tools/items.ts) -->
```typescript
export const itemToolDefinitions = [
{ name: "...", description: "...", inputSchema: { /* z.* fields */ } },
];
export function registerItemTools(db: Db, userId: number) {
return { tool_name: async (args): Promise<ToolResult> => { ... } };
}
```
<!-- MCP registration pattern (src/server/mcp/index.ts) -->
```typescript
// Image tools (no userId needed):
const imageHandlers = registerImageTools();
for (const def of imageToolDefinitions) {
const handler = imageHandlers[def.name as keyof typeof imageHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
```
<!-- Client GlobalItem interface (src/client/hooks/useGlobalItems.ts lines 4-14) -->
```typescript
interface GlobalItem {
id: number; brand: string; model: string; category: string | null;
weightGrams: number | null; priceCents: number | null;
imageUrl: string | null; description: string | null; createdAt: string;
}
interface GlobalItemWithOwnerCount extends GlobalItem { ownerCount: number; }
```
<!-- Catalog detail page image section ($globalItemId.tsx lines 65-85) -->
```tsx
{/* Image */}
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">
{item.imageUrl ? ( <img ... /> ) : ( <div>...</div> )}
</div>
{/* Header */}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: HTTP routes for single and bulk upsert</name>
<files>src/server/routes/global-items.ts, tests/routes/global-items.test.ts</files>
<read_first>
- src/server/routes/global-items.ts (current GET-only routes)
- src/server/routes/setups.ts (POST route with zValidator pattern)
- src/server/services/global-item.service.ts (upsertGlobalItem, bulkUpsertGlobalItems signatures from Plan 01)
- src/shared/schemas.ts (upsertGlobalItemSchema, bulkUpsertGlobalItemsSchema from Plan 01)
- src/server/index.ts (auth middleware — confirm POST on /api/global-items requires auth, lines 150-170)
- tests/routes/global-items.test.ts (existing test structure)
- tests/helpers/db.ts (createTestDb helper)
</read_first>
<behavior>
- POST /api/global-items with valid body returns 200 with { item, created: true/false }
- POST /api/global-items with invalid body (missing brand) returns 400
- POST /api/global-items/bulk with valid body returns 200 with { created, updated, items }
- POST /api/global-items/bulk with >100 items returns 400
- POST /api/global-items/bulk with invalid item in array returns 400 (rejected before DB)
- POST /api/global-items/bulk with empty array returns 400
</behavior>
<action>
**1. Add imports and POST routes to `src/server/routes/global-items.ts`:**
Add these imports at the top:
```typescript
import { zValidator } from "@hono/zod-validator";
import {
upsertGlobalItemSchema,
bulkUpsertGlobalItemsSchema,
} from "../../shared/schemas.ts";
import {
upsertGlobalItem,
bulkUpsertGlobalItems,
} from "../services/global-item.service.ts";
```
Update the existing imports to include the new service functions (keep `getGlobalItemWithOwnerCount` and `searchGlobalItems`).
Add after the existing GET routes:
```typescript
// Single item upsert — per D-10
app.post("/", zValidator("json", upsertGlobalItemSchema), async (c) => {
const db = c.get("db");
const data = c.req.valid("json");
const result = await upsertGlobalItem(db, data);
return c.json(result);
});
// Bulk upsert — per D-06, D-07, D-08
app.post("/bulk", zValidator("json", bulkUpsertGlobalItemsSchema), async (c) => {
const db = c.get("db");
const { items } = c.req.valid("json");
const result = await bulkUpsertGlobalItems(db, items);
return c.json(result);
});
```
No auth middleware changes needed — the existing auth middleware in `src/server/index.ts` already requires auth for all non-GET requests on `/api/global-items*`.
**2. Add tests to `tests/routes/global-items.test.ts`:**
Add a `describe("POST /api/global-items")` block with tests:
- Valid single upsert returns 200 with item and created flag
- Missing brand returns 400
- Duplicate (brand, model) upserts instead of creating duplicate
Add a `describe("POST /api/global-items/bulk")` block with tests:
- Valid bulk upsert returns 200 with created/updated counts
- Empty items array returns 400
- Array with >100 items returns 400 (mock or construct 101 items)
- Invalid item in array returns 400 and nothing is persisted
- Mix of new and existing items returns correct counts
</action>
<verify>
<automated>bun test tests/routes/global-items.test.ts</automated>
</verify>
<acceptance_criteria>
- src/server/routes/global-items.ts contains `app.post("/", zValidator("json", upsertGlobalItemSchema)`
- src/server/routes/global-items.ts contains `app.post("/bulk", zValidator("json", bulkUpsertGlobalItemsSchema)`
- src/server/routes/global-items.ts contains `import.*upsertGlobalItem`
- src/server/routes/global-items.ts contains `import.*bulkUpsertGlobalItems`
- tests/routes/global-items.test.ts contains at least 4 test cases with `POST`
- `bun test tests/routes/global-items.test.ts` exits 0
</acceptance_criteria>
<done>POST /api/global-items and POST /api/global-items/bulk endpoints operational with Zod validation, all route tests passing</done>
</task>
<task type="auto">
<name>Task 2: MCP catalog tools — upsert_catalog_item and bulk_upsert_catalog</name>
<files>src/server/mcp/tools/catalog.ts, src/server/mcp/index.ts, tests/mcp/tools.test.ts</files>
<read_first>
- src/server/mcp/tools/items.ts (full file — tool definition + handler pattern with ToolResult, textResult, errorResult)
- src/server/mcp/tools/images.ts (no-userId factory pattern)
- src/server/mcp/index.ts (registration loop pattern, createMcpServer function)
- src/server/services/global-item.service.ts (upsertGlobalItem, bulkUpsertGlobalItems from Plan 01)
- tests/mcp/tools.test.ts (existing MCP tool test structure)
</read_first>
<action>
**1. Create `src/server/mcp/tools/catalog.ts`:**
```typescript
import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
upsertGlobalItem,
bulkUpsertGlobalItems,
} from "../../services/global-item.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] };
}
const catalogItemInputSchema = {
brand: z.string().describe("Brand or manufacturer name"),
model: z.string().describe("Model name — combined with brand forms the unique identifier"),
category: z.string().optional().describe("Category name (e.g., 'Bags', 'Lights')"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("MSRP price in cents (e.g., 9999 = $99.99)"),
imageUrl: z.string().optional().describe("URL to the product image"),
description: z.string().optional().describe("Product description"),
sourceUrl: z.string().optional().describe("URL to the product page on manufacturer/retailer site"),
imageCredit: z.string().optional().describe("Image credit — photographer or source name"),
imageSourceUrl: z.string().optional().describe("Original URL where the image was sourced from"),
tags: z.array(z.string()).optional().describe("Tags for categorization (created automatically if new)"),
};
export const catalogToolDefinitions = [
{
name: "upsert_catalog_item",
description:
"Add or update a single item in the global catalog. If an item with the same brand and model already exists, it will be updated. Includes attribution fields for image credit and source tracking. Requires authentication.",
inputSchema: catalogItemInputSchema,
},
{
name: "bulk_upsert_catalog",
description:
"Add or update multiple items in the global catalog in a single batch (max 100). All items are processed in one transaction — if any item fails, the entire batch is rolled back. Each item is upserted on (brand, model) uniqueness.",
inputSchema: {
items: z
.array(z.object(catalogItemInputSchema))
.max(100)
.describe("Array of catalog items to upsert (max 100 per batch)"),
},
},
];
// Catalog tools operate on shared catalog — no userId needed for data scoping
// db is passed for database access
export function registerCatalogTools(db: Db) {
return {
upsert_catalog_item: async (args: {
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
}): Promise<ToolResult> => {
try {
const result = await upsertGlobalItem(db, args);
return textResult({
...result.item,
created: result.created,
});
} catch (err) {
return errorResult((err as Error).message);
}
},
bulk_upsert_catalog: async (args: {
items: Array<{
brand: string;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
imageUrl?: string;
description?: string;
sourceUrl?: string;
imageCredit?: string;
imageSourceUrl?: string;
tags?: string[];
}>;
}): Promise<ToolResult> => {
try {
const result = await bulkUpsertGlobalItems(db, args.items);
return textResult({
created: result.created,
updated: result.updated,
totalProcessed: result.items.length,
items: result.items,
});
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}
```
Per D-11 (upsert_catalog_item), D-12 (bulk_upsert_catalog), D-13 (auth via existing MCP middleware), D-14 (register in index.ts following pattern), SEED-03 (attribution fields as parameters).
**2. Register in `src/server/mcp/index.ts`:**
Add import at the top with the other tool imports:
```typescript
import {
catalogToolDefinitions,
registerCatalogTools,
} from "./tools/catalog.ts";
```
Add registration block inside `createMcpServer` function, after the image tools registration (around line 56):
```typescript
// Register catalog tools (no userId needed — catalog is global)
const catalogHandlers = registerCatalogTools(db);
for (const def of catalogToolDefinitions) {
const handler = catalogHandlers[def.name as keyof typeof catalogHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
```
Do NOT modify the `createMcpServer(db, userId)` function signature — just pass `db` only to `registerCatalogTools`.
**3. Add tests to `tests/mcp/tools.test.ts`:**
Add a `describe("catalog tools")` block with tests:
- `upsert_catalog_item` creates a new global item and returns it with created: true
- `upsert_catalog_item` updates existing item on (brand, model) match
- `upsert_catalog_item` includes attribution fields (sourceUrl, imageCredit, imageSourceUrl) in result
- `bulk_upsert_catalog` processes array and returns created/updated counts
- `bulk_upsert_catalog` returns totalProcessed matching input length
- Tool definitions include all attribution fields in inputSchema
Test by calling `registerCatalogTools(db)` directly and invoking handlers, following the pattern in the existing MCP tools tests.
</action>
<verify>
<automated>bun test tests/mcp/tools.test.ts</automated>
</verify>
<acceptance_criteria>
- src/server/mcp/tools/catalog.ts exists and contains `export const catalogToolDefinitions`
- src/server/mcp/tools/catalog.ts contains `export function registerCatalogTools`
- src/server/mcp/tools/catalog.ts contains `upsert_catalog_item` in definitions
- src/server/mcp/tools/catalog.ts contains `bulk_upsert_catalog` in definitions
- src/server/mcp/tools/catalog.ts contains `sourceUrl` and `imageCredit` and `imageSourceUrl` in inputSchema
- src/server/mcp/index.ts contains `import.*catalogToolDefinitions.*registerCatalogTools`
- src/server/mcp/index.ts contains `registerCatalogTools(db)`
- tests/mcp/tools.test.ts contains `upsert_catalog_item` in at least 2 test descriptions
- `bun test tests/mcp/tools.test.ts` exits 0
</acceptance_criteria>
<done>Two MCP catalog tools registered and functional with attribution fields, all MCP tool tests passing</done>
</task>
<task type="auto">
<name>Task 3: Client attribution display on catalog detail page</name>
<files>src/client/hooks/useGlobalItems.ts, src/client/routes/global-items/$globalItemId.tsx</files>
<read_first>
- src/client/hooks/useGlobalItems.ts (GlobalItem interface at lines 4-14)
- src/client/routes/global-items/$globalItemId.tsx (full component, image section at lines 65-85)
</read_first>
<action>
**1. Update `GlobalItem` interface in `src/client/hooks/useGlobalItems.ts`:**
Add three new fields to the `GlobalItem` interface (after `description`):
```typescript
interface GlobalItem {
id: number;
brand: string;
model: string;
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageUrl: string | null;
description: string | null;
sourceUrl: string | null;
imageCredit: string | null;
imageSourceUrl: string | null;
createdAt: string;
}
```
`GlobalItemWithOwnerCount` extends `GlobalItem` so it inherits the new fields automatically.
**2. Add attribution display to `src/client/routes/global-items/$globalItemId.tsx`:**
Insert attribution text immediately after the image `div` (after line 85 — the closing `</div>` of the image section) and before the `{/* Header */}` comment. Per D-03: inline below the image, not collapsible.
```tsx
{/* Attribution */}
{(item.imageCredit || item.imageSourceUrl) && (
<p className="text-xs text-gray-400 mt-1 mb-6">
{item.imageCredit && <span>Photo: {item.imageCredit}</span>}
{item.imageCredit && item.imageSourceUrl && <span> · </span>}
{item.imageSourceUrl && (
<a
href={item.imageSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-gray-600 transition-colors"
>
Source
</a>
)}
</p>
)}
```
Also add `sourceUrl` display: if `item.sourceUrl` exists, show it as a link in the specs/details section (after the description, at the bottom):
```tsx
{item.sourceUrl && (
<div className="mt-4">
<a
href={item.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-500 hover:text-blue-600 underline transition-colors"
>
View product page &rarr;
</a>
</div>
)}
```
Remove the existing `mb-6` from the image div's parent className (the `<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">`) and let the attribution `<p>` handle the spacing with its `mb-6` class.
</action>
<verify>
<automated>bun run lint && bun run build</automated>
</verify>
<acceptance_criteria>
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `sourceUrl: string | null`
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `imageCredit: string | null`
- src/client/hooks/useGlobalItems.ts GlobalItem interface contains `imageSourceUrl: string | null`
- src/client/routes/global-items/$globalItemId.tsx contains `item.imageCredit`
- src/client/routes/global-items/$globalItemId.tsx contains `item.imageSourceUrl`
- src/client/routes/global-items/$globalItemId.tsx contains `item.sourceUrl`
- src/client/routes/global-items/$globalItemId.tsx contains `Photo:`
- `bun run build` exits 0 (no TypeScript errors)
- `bun run lint` exits 0
</acceptance_criteria>
<done>Catalog detail page shows image attribution inline below image (credit + source link) and product page link, client types updated</done>
</task>
</tasks>
<verification>
- `bun test tests/routes/global-items.test.ts` exits 0
- `bun test tests/mcp/tools.test.ts` exits 0
- `bun run build` exits 0
- `bun run lint` exits 0
- `bun test` full suite exits 0
</verification>
<success_criteria>
- POST /api/global-items accepts and upserts a single catalog item with attribution fields
- POST /api/global-items/bulk accepts up to 100 items, rejects entire batch on validation failure, returns created/updated counts
- upsert_catalog_item MCP tool writes to globalItems with all attribution fields
- bulk_upsert_catalog MCP tool batch-writes via the bulk service
- Catalog detail page displays image credit and source link below the image when present
- Catalog detail page displays product page link when sourceUrl is present
- All tests pass, build succeeds, lint clean
</success_criteria>
<output>
After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-02-SUMMARY.md`
</output>