From d9d953239982961c38a94ff97664093b9bc9b95c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 10 Apr 2026 10:45:22 +0200 Subject: [PATCH] docs(25): create phase plan for catalog enrichment and agent tools --- .planning/ROADMAP.md | 8 +- .../25-01-PLAN.md | 471 +++++++++++++++ .../25-02-PLAN.md | 562 ++++++++++++++++++ 3 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md create mode 100644 .planning/phases/25-catalog-enrichment-agent-tools/25-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 63e9a3a..1d71665 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -102,7 +102,11 @@ Plans: 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 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 **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 | | 23. Manual Entry Fallback | v2.0 | 1/1 | Complete | 2026-04-06 | | 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 | - | ## Backlog diff --git a/.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md b/.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md new file mode 100644 index 0000000..3efc5f1 --- /dev/null +++ b/.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```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(), +}); +``` + + +```typescript +export const tags = pgTable("tags", { + id: serial("id").primaryKey(), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); +``` + + +```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] })]); +``` + + +```typescript +await database + .insert(settings) + .values({ userId, key, value: body.value }) + .onConflictDoUpdate({ + target: [settings.userId, settings.key], + set: { value: body.value }, + }); +``` + + +```typescript +return await db.transaction(async (tx) => { + // multiple tx operations, auto-rollback on throw +}); +``` + + +```typescript +export const categories = pgTable("categories", { + // ...columns... +}, (table) => [unique().on(table.userId, table.name)]); +``` + + + + + + + Task 1: Schema migration — attribution columns + unique constraint + src/db/schema.ts + + - src/db/schema.ts (current globalItems definition at lines 136-146, categories unique constraint pattern at line 26-38) + + + - 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) + + + 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). + + + bun run db:generate && bun run db:push + + + - 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 + + globalItems table has 3 new attribution columns and a unique constraint on (brand, model), migration generated and applied + + + + Task 2: Zod schemas + upsert service functions + tests + src/shared/schemas.ts, src/shared/types.ts, src/server/services/global-item.service.ts, tests/services/global-item.service.test.ts + + - 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) + + + - 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 + + + **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; + export type BulkUpsertGlobalItemsInput = z.infer; + ``` + + **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[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) + + + bun test tests/services/global-item.service.test.ts + + + - 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 + + Zod schemas defined, upsertGlobalItem and bulkUpsertGlobalItems service functions implemented with tag sync, all tests passing + + + + + +- `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) + + + +- 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 + + + +After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-01-SUMMARY.md` + diff --git a/.planning/phases/25-catalog-enrichment-agent-tools/25-02-PLAN.md b/.planning/phases/25-catalog-enrichment-agent-tools/25-02-PLAN.md new file mode 100644 index 0000000..4ccd81e --- /dev/null +++ b/.planning/phases/25-catalog-enrichment-agent-tools/25-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```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[] }>; +``` + + +```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), +}); +``` + + +```typescript +import { Hono } from "hono"; +type Env = { Variables: { db?: any } }; +const app = new Hono(); +app.get("/", async (c) => { ... }); +app.get("/:id", async (c) => { ... }); +export { app as globalItemRoutes }; +``` + + +```typescript +export const itemToolDefinitions = [ + { name: "...", description: "...", inputSchema: { /* z.* fields */ } }, +]; +export function registerItemTools(db: Db, userId: number) { + return { tool_name: async (args): Promise => { ... } }; +} +``` + + +```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); +} +``` + + +```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; } +``` + + +```tsx +{/* Image */} +
+ {item.imageUrl ? ( ) : (
...
)} +
+{/* Header */} +``` +
+
+ + + + + Task 1: HTTP routes for single and bulk upsert + src/server/routes/global-items.ts, tests/routes/global-items.test.ts + + - 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) + + + - 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 + + + **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 + + + bun test tests/routes/global-items.test.ts + + + - 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 + + POST /api/global-items and POST /api/global-items/bulk endpoints operational with Zod validation, all route tests passing + + + + Task 2: MCP catalog tools — upsert_catalog_item and bulk_upsert_catalog + src/server/mcp/tools/catalog.ts, src/server/mcp/index.ts, tests/mcp/tools.test.ts + + - 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) + + + **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 => { + 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 => { + 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. + + + bun test tests/mcp/tools.test.ts + + + - 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 + + Two MCP catalog tools registered and functional with attribution fields, all MCP tool tests passing + + + + Task 3: Client attribution display on catalog detail page + src/client/hooks/useGlobalItems.ts, src/client/routes/global-items/$globalItemId.tsx + + - 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) + + + **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 `` of the image section) and before the `{/* Header */}` comment. Per D-03: inline below the image, not collapsible. + + ```tsx + {/* Attribution */} + {(item.imageCredit || item.imageSourceUrl) && ( +

+ {item.imageCredit && Photo: {item.imageCredit}} + {item.imageCredit && item.imageSourceUrl && · } + {item.imageSourceUrl && ( + + Source + + )} +

+ )} + ``` + + 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 && ( +
+ + View product page → + +
+ )} + ``` + + Remove the existing `mb-6` from the image div's parent className (the `
`) and let the attribution `

` handle the spacing with its `mb-6` class. + + + bun run lint && bun run build + + + - 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 + + Catalog detail page shows image attribution inline below image (credit + source link) and product page link, client types updated + + + + + +- `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 + + + +- 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 + + + +After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-02-SUMMARY.md` +