Files
GearBox/.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
25-catalog-enrichment-agent-tools 01 execute 1
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
true
CATL-01
CATL-02
CATL-05
truths artifacts key_links
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
path provides contains
src/db/schema.ts globalItems table with attribution columns and unique constraint sourceUrl
path provides contains
src/shared/schemas.ts Zod schemas for upsert and bulk upsert upsertGlobalItemSchema
path provides exports
src/server/services/global-item.service.ts upsertGlobalItem and bulkUpsertGlobalItems functions
upsertGlobalItem
bulkUpsertGlobalItems
path provides
tests/services/global-item.service.test.ts Tests for upsert, duplicate handling, bulk, tags
from to via pattern
src/server/services/global-item.service.ts src/db/schema.ts onConflictDoUpdate target referencing unique constraint 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

```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(), }); ```
export const tags = pgTable("tags", {
  id: serial("id").primaryKey(),
  name: text("name").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});
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] })]);
await database
  .insert(settings)
  .values({ userId, key, value: body.value })
  .onConflictDoUpdate({
    target: [settings.userId, settings.key],
    set: { value: body.value },
  });
return await db.transaction(async (tx) => {
  // multiple tx operations, auto-rollback on throw
});
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 - CATL-01 manufacturer requirement satisfied by existing brand column per D-02 — no new column needed 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<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)
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)

<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>
After completion, create `.planning/phases/25-catalog-enrichment-agent-tools/25-01-SUMMARY.md`