---
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:
    - "upsertGlobalItem called with sourceUrl, imageCredit, imageSourceUrl returns them in the result"
    - "Two upserts with the same (brand, model) return the same item id and created: false on the second call"
    - "Inserting a duplicate (brand, model) updates the existing row instead of failing"
    - "bulkUpsertGlobalItems returns accurate created vs updated counts matching input mix"
    - "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
    - CATL-01 manufacturer requirement satisfied by existing brand column per D-02 — no new column needed
  </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>
