--- 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`