473 lines
18 KiB
Markdown
473 lines
18 KiB
Markdown
---
|
|
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>
|