docs(25): create phase plan for catalog enrichment and agent tools
This commit is contained in:
471
.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md
Normal file
471
.planning/phases/25-catalog-enrichment-agent-tools/25-01-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user