docs(25): create phase plan for catalog enrichment and agent tools

This commit is contained in:
2026-04-10 10:45:22 +02:00
parent 6c0c31350e
commit d9d9532399
3 changed files with 1039 additions and 2 deletions

View File

@@ -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"
---
<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
</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>