Biome auto-fix for formatting (line length, ternary wrapping) and import organization in files touched by phase 34 i18n work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1356 lines
42 KiB
Markdown
1356 lines
42 KiB
Markdown
# Catalog Schema Migration Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Replace `globalItems.brand` text field with a normalized `manufacturers` table, wiring all services, routes, MCP tools, and seed data to use the new FK.
|
|
|
|
**Architecture:** Add `manufacturers` table → migrate `globalItems` to drop `brand` and add `manufacturerId` FK → update every service, route, MCP tool, and seed that references `globalItems.brand`. API responses keep returning a `brand` string (populated via join) so client components need no changes. API inputs replace `brand: string` with `manufacturerSlug: string` for ergonomic upserts.
|
|
|
|
**Tech Stack:** Drizzle ORM + PostgreSQL (PGlite in tests), Hono, Zod, Bun test runner.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| Action | File |
|
|
|--------|------|
|
|
| Modify | `src/db/schema.ts` — add manufacturers table, update globalItems |
|
|
| Create | `src/server/services/manufacturer.service.ts` |
|
|
| Create | `src/server/routes/manufacturers.ts` |
|
|
| Modify | `src/server/index.ts` — register manufacturers route |
|
|
| Modify | `src/server/services/global-item.service.ts` — upsert + search use manufacturerId |
|
|
| Modify | `src/shared/schemas.ts` — replace brand with manufacturerSlug in upsert schemas |
|
|
| Modify | `src/shared/types.ts` — re-derive UpsertGlobalItemInput |
|
|
| Modify | `src/server/services/item.service.ts` — join manufacturers, replace brand ref |
|
|
| Modify | `src/server/services/setup.service.ts` — join manufacturers, replace brand ref |
|
|
| Modify | `src/server/services/discovery.service.ts` — join manufacturers, replace brand ref |
|
|
| Modify | `src/server/services/profile.service.ts` — join manufacturers, replace brand ref |
|
|
| Modify | `src/server/services/csv.service.ts` — join manufacturers, replace brand ref |
|
|
| Modify | `src/server/services/thread.service.ts` — join manufacturers, replace brand ref |
|
|
| Modify | `src/server/mcp/tools/catalog.ts` — replace brand with manufacturerSlug |
|
|
| Modify | `src/db/seed-global-items.ts` — add seedManufacturers, update seedGlobalItems |
|
|
| Modify | `src/db/global-items-seed.json` — replace brand with manufacturerSlug |
|
|
| Modify | `src/db/dev-seed-data.ts` — add DEV_MANUFACTURERS, update DEV_GLOBAL_ITEMS |
|
|
| Modify | `src/db/dev-seed.ts` — insert manufacturers before globalItems |
|
|
| Modify | `tests/helpers/db.ts` — add manufacturers to TRUNCATE_TABLES |
|
|
| Create | `tests/services/manufacturer.service.test.ts` |
|
|
| Modify | `tests/services/global-item.service.test.ts` — update insertGlobalItem helper + tests |
|
|
|
|
---
|
|
|
|
## Task 1: Add `manufacturers` table to schema
|
|
|
|
**Files:**
|
|
- Modify: `src/db/schema.ts`
|
|
|
|
- [ ] **Step 1: Add manufacturers table to schema.ts**
|
|
|
|
Open `src/db/schema.ts`. After the `users` table and before `categories`, insert:
|
|
|
|
```typescript
|
|
// ── Manufacturers ────────────────────────────────────────────────────
|
|
|
|
export const manufacturers = pgTable("manufacturers", {
|
|
id: serial("id").primaryKey(),
|
|
name: text("name").notNull().unique(),
|
|
slug: text("slug").notNull().unique(),
|
|
website: text("website").notNull(),
|
|
tier: integer("tier").notNull().default(1),
|
|
active: boolean("active").notNull().default(true),
|
|
country: text("country"),
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
});
|
|
```
|
|
|
|
Add `boolean` to the import at the top:
|
|
```typescript
|
|
import {
|
|
boolean,
|
|
doublePrecision,
|
|
integer,
|
|
pgTable,
|
|
primaryKey,
|
|
serial,
|
|
text,
|
|
timestamp,
|
|
unique,
|
|
} from "drizzle-orm/pg-core";
|
|
```
|
|
|
|
- [ ] **Step 2: Generate and push the migration**
|
|
|
|
```bash
|
|
bun run db:generate
|
|
bun run db:push
|
|
```
|
|
|
|
Expected: new `manufacturers` table created with no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/db/schema.ts drizzle-pg/
|
|
git commit -m "feat: add manufacturers table to schema"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Manufacturer service
|
|
|
|
**Files:**
|
|
- Create: `src/server/services/manufacturer.service.ts`
|
|
- Create: `tests/services/manufacturer.service.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `tests/services/manufacturer.service.test.ts`:
|
|
|
|
```typescript
|
|
import { beforeEach, describe, expect, it } from "bun:test";
|
|
import { manufacturers } from "../../src/db/schema.ts";
|
|
import {
|
|
createManufacturer,
|
|
getManufacturerBySlug,
|
|
listManufacturers,
|
|
} from "../../src/server/services/manufacturer.service.ts";
|
|
import { createTestDb } from "../helpers/db.ts";
|
|
|
|
let db: Awaited<ReturnType<typeof createTestDb>>["db"];
|
|
|
|
beforeEach(async () => {
|
|
({ db } = await createTestDb());
|
|
});
|
|
|
|
describe("createManufacturer", () => {
|
|
it("inserts a manufacturer and returns it", async () => {
|
|
const result = await createManufacturer(db, {
|
|
name: "Apidura",
|
|
slug: "apidura",
|
|
website: "https://apidura.com",
|
|
tier: 1,
|
|
country: "GB",
|
|
});
|
|
expect(result.id).toBeGreaterThan(0);
|
|
expect(result.name).toBe("Apidura");
|
|
expect(result.slug).toBe("apidura");
|
|
expect(result.active).toBe(true);
|
|
});
|
|
|
|
it("throws on duplicate slug", async () => {
|
|
await createManufacturer(db, {
|
|
name: "Apidura",
|
|
slug: "apidura",
|
|
website: "https://apidura.com",
|
|
});
|
|
await expect(
|
|
createManufacturer(db, {
|
|
name: "Apidura Copy",
|
|
slug: "apidura",
|
|
website: "https://other.com",
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("getManufacturerBySlug", () => {
|
|
it("returns manufacturer when found", async () => {
|
|
await createManufacturer(db, {
|
|
name: "Revelate Designs",
|
|
slug: "revelate-designs",
|
|
website: "https://revelatedesigns.com",
|
|
});
|
|
const result = await getManufacturerBySlug(db, "revelate-designs");
|
|
expect(result?.name).toBe("Revelate Designs");
|
|
});
|
|
|
|
it("returns null when not found", async () => {
|
|
const result = await getManufacturerBySlug(db, "nope");
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("listManufacturers", () => {
|
|
it("returns all manufacturers ordered by name", async () => {
|
|
await createManufacturer(db, { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com" });
|
|
await createManufacturer(db, { name: "Apidura", slug: "apidura", website: "https://apidura.com" });
|
|
const result = await listManufacturers(db);
|
|
expect(result[0]?.name).toBe("Apidura");
|
|
expect(result[1]?.name).toBe("Ortlieb");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
bun test tests/services/manufacturer.service.test.ts
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Also add `manufacturers` to the TRUNCATE_TABLES list in `tests/helpers/db.ts`**
|
|
|
|
In `tests/helpers/db.ts`, add `"manufacturers"` before `"users"`:
|
|
|
|
```typescript
|
|
const TRUNCATE_TABLES = [
|
|
"shares",
|
|
"setup_items",
|
|
"setups",
|
|
"thread_candidates",
|
|
"threads",
|
|
"community_prices",
|
|
"market_prices",
|
|
"items",
|
|
"global_item_tags",
|
|
"global_items",
|
|
"tags",
|
|
"oauth_tokens",
|
|
"oauth_codes",
|
|
"oauth_clients",
|
|
"api_keys",
|
|
"settings",
|
|
"categories",
|
|
"manufacturers",
|
|
"users",
|
|
];
|
|
```
|
|
|
|
- [ ] **Step 4: Create `src/server/services/manufacturer.service.ts`**
|
|
|
|
```typescript
|
|
import { asc, eq } from "drizzle-orm";
|
|
import { db as prodDb } from "../../db/index.ts";
|
|
import { manufacturers } from "../../db/schema.ts";
|
|
|
|
type Db = typeof prodDb;
|
|
|
|
export type CreateManufacturerInput = {
|
|
name: string;
|
|
slug: string;
|
|
website: string;
|
|
tier?: number;
|
|
country?: string;
|
|
};
|
|
|
|
export async function listManufacturers(db: Db = prodDb) {
|
|
return db.select().from(manufacturers).orderBy(asc(manufacturers.name));
|
|
}
|
|
|
|
export async function getManufacturerBySlug(db: Db = prodDb, slug: string) {
|
|
const [row] = await db
|
|
.select()
|
|
.from(manufacturers)
|
|
.where(eq(manufacturers.slug, slug));
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function createManufacturer(
|
|
db: Db = prodDb,
|
|
data: CreateManufacturerInput,
|
|
) {
|
|
const [row] = await db
|
|
.insert(manufacturers)
|
|
.values({
|
|
name: data.name,
|
|
slug: data.slug,
|
|
website: data.website,
|
|
tier: data.tier ?? 1,
|
|
country: data.country ?? null,
|
|
})
|
|
.returning();
|
|
return row!;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to confirm they pass**
|
|
|
|
```bash
|
|
bun test tests/services/manufacturer.service.test.ts
|
|
```
|
|
|
|
Expected: PASS (3 test suites, all green).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/server/services/manufacturer.service.ts tests/services/manufacturer.service.test.ts tests/helpers/db.ts
|
|
git commit -m "feat: manufacturer service with list, get, create"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Manufacturers API route
|
|
|
|
**Files:**
|
|
- Create: `src/server/routes/manufacturers.ts`
|
|
- Modify: `src/server/index.ts`
|
|
- Modify: `src/shared/schemas.ts`
|
|
|
|
- [ ] **Step 1: Add Zod schema for manufacturer creation to `src/shared/schemas.ts`**
|
|
|
|
Append after the existing global item schemas (around line 147):
|
|
|
|
```typescript
|
|
export const createManufacturerSchema = z.object({
|
|
name: z.string().min(1).max(200),
|
|
slug: z
|
|
.string()
|
|
.min(1)
|
|
.max(100)
|
|
.regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
|
|
website: z.string().url(),
|
|
tier: z.number().int().min(1).max(3).optional(),
|
|
country: z.string().length(2).optional(),
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Create `src/server/routes/manufacturers.ts`**
|
|
|
|
```typescript
|
|
import { zValidator } from "@hono/zod-validator";
|
|
import { Hono } from "hono";
|
|
import { createManufacturerSchema } from "../../shared/schemas.ts";
|
|
import {
|
|
createManufacturer,
|
|
getManufacturerBySlug,
|
|
listManufacturers,
|
|
} from "../services/manufacturer.service.ts";
|
|
|
|
type Env = { Variables: { db?: any } };
|
|
|
|
const app = new Hono<Env>();
|
|
|
|
app.get("/", async (c) => {
|
|
const db = c.get("db");
|
|
return c.json(await listManufacturers(db));
|
|
});
|
|
|
|
app.get("/:slug", async (c) => {
|
|
const db = c.get("db");
|
|
const slug = c.req.param("slug");
|
|
const manufacturer = await getManufacturerBySlug(db, slug);
|
|
if (!manufacturer) return c.json({ error: "Manufacturer not found" }, 404);
|
|
return c.json(manufacturer);
|
|
});
|
|
|
|
app.post("/", zValidator("json", createManufacturerSchema), async (c) => {
|
|
const db = c.get("db");
|
|
const data = c.req.valid("json");
|
|
try {
|
|
const manufacturer = await createManufacturer(db, data);
|
|
return c.json(manufacturer, 201);
|
|
} catch {
|
|
return c.json({ error: "Manufacturer with this name or slug already exists" }, 409);
|
|
}
|
|
});
|
|
|
|
export { app as manufacturerRoutes };
|
|
```
|
|
|
|
- [ ] **Step 3: Register the route in `src/server/index.ts`**
|
|
|
|
Find the existing import of `globalItemRoutes` and add alongside it:
|
|
|
|
```typescript
|
|
import { manufacturerRoutes } from "./routes/manufacturers.ts";
|
|
```
|
|
|
|
Find where `globalItemRoutes` is registered (around line 292) and add below it:
|
|
|
|
```typescript
|
|
app.route("/api/manufacturers", manufacturerRoutes);
|
|
```
|
|
|
|
Note: GET routes are public (no auth middleware needed — manufacturers are read-only public data). POST is protected by the existing auth middleware that covers all POST/PUT/DELETE on `/api/*`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/server/routes/manufacturers.ts src/server/index.ts src/shared/schemas.ts
|
|
git commit -m "feat: manufacturers route — list, get, create"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Seed manufacturers
|
|
|
|
**Files:**
|
|
- Modify: `src/db/seed-global-items.ts`
|
|
|
|
- [ ] **Step 1: Add `seedManufacturers` to `src/db/seed-global-items.ts`**
|
|
|
|
Replace the full contents of `src/db/seed-global-items.ts` with:
|
|
|
|
```typescript
|
|
import seedData from "./global-items-seed.json";
|
|
import { db as prodDb } from "./index.ts";
|
|
import { globalItems, manufacturers, tags } from "./schema.ts";
|
|
|
|
type Db = typeof prodDb;
|
|
|
|
export const SEED_MANUFACTURERS = [
|
|
{ name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 },
|
|
{ name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 },
|
|
{ name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 },
|
|
{ name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 },
|
|
{ name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 },
|
|
{ name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 },
|
|
{ name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 },
|
|
{ name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 },
|
|
{ name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 },
|
|
{ name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 },
|
|
{ name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 },
|
|
{ name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 },
|
|
{ name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 },
|
|
{ name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 },
|
|
{ name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 },
|
|
{ name: "Canyon", slug: "canyon", website: "https://canyon.com", country: "DE", tier: 1 },
|
|
{ name: "Specialized", slug: "specialized", website: "https://specialized.com", country: "US", tier: 1 },
|
|
{ name: "Trek", slug: "trek", website: "https://trekbikes.com", country: "US", tier: 1 },
|
|
{ name: "Salsa Cycles", slug: "salsa-cycles", website: "https://salsacycles.com", country: "US", tier: 1 },
|
|
{ name: "Surly", slug: "surly", website: "https://surlybikes.com", country: "US", tier: 1 },
|
|
];
|
|
|
|
const SEED_TAGS = [
|
|
"bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing",
|
|
"mountaineering", "road-cycling", "gravel", "running", "trail-running",
|
|
"handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag",
|
|
"fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag",
|
|
"tent", "bivy", "tarp", "hammock",
|
|
"sleeping-bag", "sleeping-pad", "quilt", "pillow",
|
|
"stove", "cookware", "mug", "utensils",
|
|
"water-filter", "water-bottle",
|
|
"headlamp", "bike-light", "lantern",
|
|
"gps", "bike-computer", "power-bank", "solar-panel",
|
|
"multi-tool", "pump", "repair-kit", "lock",
|
|
"rain-jacket", "base-layer", "gloves", "shoe",
|
|
];
|
|
|
|
export async function seedManufacturers(db: Db = prodDb) {
|
|
for (const m of SEED_MANUFACTURERS) {
|
|
await db
|
|
.insert(manufacturers)
|
|
.values(m)
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
|
|
export async function seedTags(db: Db = prodDb) {
|
|
const existing = await db.select().from(tags);
|
|
const existingNames = new Set(existing.map((t) => t.name));
|
|
for (const name of SEED_TAGS) {
|
|
if (!existingNames.has(name)) {
|
|
await db.insert(tags).values({ name });
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function seedGlobalItems(db: Db = prodDb) {
|
|
await seedManufacturers(db);
|
|
|
|
const existing = await db.select().from(globalItems).limit(1);
|
|
if (existing.length > 0) return;
|
|
|
|
const allManufacturers = await db.select().from(manufacturers);
|
|
const mfByName = new Map(allManufacturers.map((m) => [m.name, m.id]));
|
|
|
|
for (const item of seedData) {
|
|
const manufacturerId = mfByName.get(item.brand);
|
|
if (!manufacturerId) continue; // skip items with no matching manufacturer
|
|
|
|
await db.insert(globalItems).values({
|
|
manufacturerId,
|
|
model: item.model,
|
|
category: item.category ?? null,
|
|
weightGrams: item.weightGrams ?? null,
|
|
priceCents: item.priceCents ?? null,
|
|
description: item.description ?? null,
|
|
});
|
|
}
|
|
|
|
await seedTags(db);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/db/seed-global-items.ts
|
|
git commit -m "feat: seed manufacturers list, update seedGlobalItems to resolve by name"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Migrate `globalItems` — drop brand, add manufacturerId
|
|
|
|
**Files:**
|
|
- Modify: `src/db/schema.ts`
|
|
|
|
- [ ] **Step 1: Update `globalItems` in `src/db/schema.ts`**
|
|
|
|
Find the `globalItems` table. Remove the `brand` column and add `manufacturerId`. Change the unique constraint. The updated table definition:
|
|
|
|
```typescript
|
|
export const globalItems = pgTable(
|
|
"global_items",
|
|
{
|
|
id: serial("id").primaryKey(),
|
|
manufacturerId: integer("manufacturer_id")
|
|
.notNull()
|
|
.references(() => manufacturers.id),
|
|
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"),
|
|
dominantColor: text("dominant_color"),
|
|
cropZoom: doublePrecision("crop_zoom"),
|
|
cropX: doublePrecision("crop_x"),
|
|
cropY: doublePrecision("crop_y"),
|
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
},
|
|
(table) => [unique().on(table.manufacturerId, table.model)],
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 2: Generate and push migration**
|
|
|
|
```bash
|
|
bun run db:generate
|
|
bun run db:push
|
|
```
|
|
|
|
If the push fails due to existing data violating `NOT NULL`, that's expected in dev — clear the table first:
|
|
|
|
```bash
|
|
bun run db:push --force-reset
|
|
# or connect to the DB and: TRUNCATE global_items CASCADE;
|
|
```
|
|
|
|
Then re-push.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/db/schema.ts drizzle-pg/
|
|
git commit -m "feat: migrate globalItems — drop brand text, add manufacturerId FK"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Update `global-item.service.ts`
|
|
|
|
**Files:**
|
|
- Modify: `src/server/services/global-item.service.ts`
|
|
- Modify: `tests/services/global-item.service.test.ts`
|
|
|
|
- [ ] **Step 1: Update the test helper and tests in `tests/services/global-item.service.test.ts`**
|
|
|
|
Replace the `insertGlobalItem` helper at the top of the file:
|
|
|
|
```typescript
|
|
async function insertManufacturer(db: TestDb["db"], name = "Apidura", slug = "apidura") {
|
|
const [row] = await db
|
|
.insert(schema.manufacturers)
|
|
.values({ name, slug, website: `https://${slug}.com` })
|
|
.returning();
|
|
return row!;
|
|
}
|
|
|
|
async function insertGlobalItem(
|
|
db: TestDb["db"],
|
|
data: {
|
|
manufacturerId: number;
|
|
model: string;
|
|
category?: string;
|
|
weightGrams?: number;
|
|
priceCents?: number;
|
|
},
|
|
) {
|
|
const [row] = await db
|
|
.insert(globalItems)
|
|
.values({
|
|
manufacturerId: data.manufacturerId,
|
|
model: data.model,
|
|
category: data.category ?? null,
|
|
weightGrams: data.weightGrams ?? null,
|
|
priceCents: data.priceCents ?? null,
|
|
})
|
|
.returning();
|
|
return row!;
|
|
}
|
|
```
|
|
|
|
Also update all test cases that pass `brand:` to pass `manufacturerSlug:` and set up a manufacturer before inserting global items.
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
bun test tests/services/global-item.service.test.ts
|
|
```
|
|
|
|
Expected: FAIL — type errors or runtime errors on `brand` field.
|
|
|
|
- [ ] **Step 3: Rewrite `src/server/services/global-item.service.ts`**
|
|
|
|
```typescript
|
|
import type { SQL } from "drizzle-orm";
|
|
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
|
|
import { db as prodDb } from "../../db/index.ts";
|
|
import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts";
|
|
|
|
type Db = typeof prodDb;
|
|
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
|
|
async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise<number> {
|
|
const [m] = await (db as Db)
|
|
.select({ id: manufacturers.id })
|
|
.from(manufacturers)
|
|
.where(eq(manufacturers.slug, slug));
|
|
if (!m) throw new Error(`Manufacturer not found: ${slug}`);
|
|
return m.id;
|
|
}
|
|
|
|
export async function searchGlobalItems(
|
|
db: Db = prodDb,
|
|
query?: string,
|
|
tagNames?: string[],
|
|
) {
|
|
const conditions: SQL[] = [];
|
|
|
|
if (query) {
|
|
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
const pattern = `%${escaped}%`;
|
|
conditions.push(
|
|
or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!,
|
|
);
|
|
}
|
|
|
|
if (tagNames && tagNames.length > 0) {
|
|
conditions.push(
|
|
sql`${globalItems.id} IN (
|
|
SELECT ${globalItemTags.globalItemId}
|
|
FROM ${globalItemTags}
|
|
JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId}
|
|
WHERE ${tags.name} IN (${sql.join(
|
|
tagNames.map((t) => sql`${t}`),
|
|
sql`, `,
|
|
)})
|
|
GROUP BY ${globalItemTags.globalItemId}
|
|
HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length}
|
|
)`,
|
|
);
|
|
}
|
|
|
|
const baseQuery = db
|
|
.select({
|
|
id: globalItems.id,
|
|
manufacturerId: globalItems.manufacturerId,
|
|
brand: manufacturers.name,
|
|
model: globalItems.model,
|
|
category: globalItems.category,
|
|
weightGrams: globalItems.weightGrams,
|
|
priceCents: globalItems.priceCents,
|
|
imageUrl: globalItems.imageUrl,
|
|
description: globalItems.description,
|
|
sourceUrl: globalItems.sourceUrl,
|
|
imageCredit: globalItems.imageCredit,
|
|
imageSourceUrl: globalItems.imageSourceUrl,
|
|
dominantColor: globalItems.dominantColor,
|
|
cropZoom: globalItems.cropZoom,
|
|
cropX: globalItems.cropX,
|
|
cropY: globalItems.cropY,
|
|
createdAt: globalItems.createdAt,
|
|
})
|
|
.from(globalItems)
|
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id));
|
|
|
|
if (conditions.length === 0) {
|
|
return baseQuery;
|
|
}
|
|
|
|
return baseQuery.where(and(...conditions));
|
|
}
|
|
|
|
export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
|
|
const [item] = await db
|
|
.select({
|
|
id: globalItems.id,
|
|
manufacturerId: globalItems.manufacturerId,
|
|
brand: manufacturers.name,
|
|
model: globalItems.model,
|
|
category: globalItems.category,
|
|
weightGrams: globalItems.weightGrams,
|
|
priceCents: globalItems.priceCents,
|
|
imageUrl: globalItems.imageUrl,
|
|
description: globalItems.description,
|
|
sourceUrl: globalItems.sourceUrl,
|
|
imageCredit: globalItems.imageCredit,
|
|
imageSourceUrl: globalItems.imageSourceUrl,
|
|
dominantColor: globalItems.dominantColor,
|
|
cropZoom: globalItems.cropZoom,
|
|
cropX: globalItems.cropX,
|
|
cropY: globalItems.cropY,
|
|
createdAt: globalItems.createdAt,
|
|
})
|
|
.from(globalItems)
|
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
|
.where(eq(globalItems.id, id));
|
|
|
|
if (!item) return null;
|
|
|
|
const [result] = await db
|
|
.select({ ownerCount: count() })
|
|
.from(items)
|
|
.where(eq(items.globalItemId, id));
|
|
|
|
return { ...item, ownerCount: result?.ownerCount ?? 0 };
|
|
}
|
|
|
|
async function syncGlobalItemTags(
|
|
tx: TxDb,
|
|
globalItemId: number,
|
|
tagNames: string[],
|
|
) {
|
|
await tx
|
|
.delete(globalItemTags)
|
|
.where(eq(globalItemTags.globalItemId, globalItemId));
|
|
|
|
for (const name of tagNames) {
|
|
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: {
|
|
manufacturerSlug: string;
|
|
model: string;
|
|
category?: string;
|
|
weightGrams?: number;
|
|
priceCents?: number;
|
|
imageUrl?: string;
|
|
description?: string;
|
|
sourceUrl?: string;
|
|
imageCredit?: string;
|
|
imageSourceUrl?: string;
|
|
tags?: string[];
|
|
},
|
|
) {
|
|
const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug);
|
|
|
|
return await db.transaction(async (tx) => {
|
|
const [existing] = await tx
|
|
.select({ id: globalItems.id })
|
|
.from(globalItems)
|
|
.where(
|
|
and(
|
|
eq(globalItems.manufacturerId, manufacturerId),
|
|
eq(globalItems.model, data.model),
|
|
),
|
|
);
|
|
|
|
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
|
|
|
|
const [item] = await tx
|
|
.insert(globalItems)
|
|
.values({
|
|
manufacturerId,
|
|
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.manufacturerId, 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);
|
|
}
|
|
|
|
return { item: item!, created: !existing };
|
|
});
|
|
}
|
|
|
|
export async function bulkUpsertGlobalItems(
|
|
db: Db,
|
|
itemsData: Array<{
|
|
manufacturerSlug: 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 resultItems = [];
|
|
|
|
for (const data of itemsData) {
|
|
const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug);
|
|
|
|
const [existing] = await tx
|
|
.select({ id: globalItems.id })
|
|
.from(globalItems)
|
|
.where(
|
|
and(
|
|
eq(globalItems.manufacturerId, manufacturerId),
|
|
eq(globalItems.model, data.model),
|
|
),
|
|
);
|
|
|
|
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
|
|
|
|
const [item] = await tx
|
|
.insert(globalItems)
|
|
.values({
|
|
manufacturerId,
|
|
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.manufacturerId, 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++;
|
|
}
|
|
resultItems.push(item!);
|
|
}
|
|
|
|
return { created, updated, items: resultItems };
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
bun test tests/services/global-item.service.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/server/services/global-item.service.ts tests/services/global-item.service.test.ts
|
|
git commit -m "feat: global-item service uses manufacturerSlug, joins manufacturers for brand"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Update Zod schemas and types
|
|
|
|
**Files:**
|
|
- Modify: `src/shared/schemas.ts`
|
|
- Modify: `src/shared/types.ts`
|
|
|
|
- [ ] **Step 1: Update `upsertGlobalItemSchema` in `src/shared/schemas.ts`**
|
|
|
|
Replace the `brand` field with `manufacturerSlug`:
|
|
|
|
```typescript
|
|
export const upsertGlobalItemSchema = z.object({
|
|
manufacturerSlug: z.string().min(1, "Manufacturer slug 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(),
|
|
dominantColor: z.string().nullable().optional(),
|
|
cropZoom: z.number().nullable().optional(),
|
|
cropX: z.number().nullable().optional(),
|
|
cropY: z.number().nullable().optional(),
|
|
});
|
|
```
|
|
|
|
`bulkUpsertGlobalItemsSchema` references `upsertGlobalItemSchema` and needs no direct change.
|
|
|
|
- [ ] **Step 2: Check `src/shared/types.ts` for any hardcoded `brand` references in global item types**
|
|
|
|
Open `src/shared/types.ts`. If `UpsertGlobalItemInput` or `GlobalItem` is manually typed (not inferred), update to use `manufacturerSlug` / `manufacturerId`. If these types are inferred via `z.infer<>` from the Zod schemas and Drizzle, they will update automatically.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/shared/schemas.ts src/shared/types.ts
|
|
git commit -m "feat: upsertGlobalItemSchema — brand → manufacturerSlug"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Update `item.service.ts`
|
|
|
|
**Files:**
|
|
- Modify: `src/server/services/item.service.ts`
|
|
|
|
- [ ] **Step 1: Add manufacturers to imports and joins**
|
|
|
|
In `src/server/services/item.service.ts`, update the import:
|
|
|
|
```typescript
|
|
import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";
|
|
```
|
|
|
|
In `getAllItems`, add a left join to manufacturers after the globalItems join:
|
|
|
|
```typescript
|
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
|
```
|
|
|
|
Replace the two `globalItems.brand` references:
|
|
|
|
```typescript
|
|
// name computation: was globalItems.brand || ' ' || globalItems.model
|
|
name: sql<string>`COALESCE(
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
|
ELSE ${items.name}
|
|
END,
|
|
${items.name}
|
|
)`.as("name"),
|
|
|
|
// brand field: was COALESCE(globalItems.brand, items.brand)
|
|
brand: sql<string | null>`COALESCE(${manufacturers.name}, ${items.brand})`.as("brand"),
|
|
```
|
|
|
|
Apply the same two replacements in `getItemById` (same patterns, same file).
|
|
|
|
- [ ] **Step 2: Update `createItem` function**
|
|
|
|
The `createItem` function fetches brand+model from globalItems to build the item name. Update the select:
|
|
|
|
```typescript
|
|
const [gi] = await db
|
|
.select({ name: manufacturers.name, model: globalItems.model })
|
|
.from(globalItems)
|
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
|
.where(eq(globalItems.id, data.globalItemId));
|
|
if (gi) {
|
|
name = `${gi.name} ${gi.model}`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/server/services/item.service.ts
|
|
git commit -m "feat: item service joins manufacturers for brand display"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Update remaining services
|
|
|
|
**Files:**
|
|
- Modify: `src/server/services/setup.service.ts`
|
|
- Modify: `src/server/services/discovery.service.ts`
|
|
- Modify: `src/server/services/profile.service.ts`
|
|
- Modify: `src/server/services/csv.service.ts`
|
|
- Modify: `src/server/services/thread.service.ts`
|
|
|
|
The pattern is the same in all five files. For each:
|
|
1. Add `manufacturers` to the schema import
|
|
2. Add `.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))` after every `.leftJoin(globalItems, ...)`
|
|
3. Replace every `${globalItems.brand}` with `${manufacturers.name}`
|
|
|
|
- [ ] **Step 1: `src/server/services/setup.service.ts`**
|
|
|
|
Add to import:
|
|
```typescript
|
|
import { ..., manufacturers } from "../../db/schema.ts";
|
|
```
|
|
|
|
The file has three query functions (around lines 82, 134, 188). In each, add manufacturers join after the globalItems join:
|
|
```typescript
|
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
|
```
|
|
|
|
Replace all four `${globalItems.brand}` occurrences:
|
|
- Lines ~82, 134, 188: `THEN ${globalItems.brand} || ' ' || ${globalItems.model}` → `THEN ${manufacturers.name} || ' ' || ${globalItems.model}`
|
|
- Line ~195: `THEN ${globalItems.brand} ELSE ${items.brand} END` → `THEN ${manufacturers.name} ELSE ${items.brand} END`
|
|
|
|
- [ ] **Step 2: `src/server/services/discovery.service.ts`**
|
|
|
|
Add to import:
|
|
```typescript
|
|
import { ..., manufacturers } from "../../db/schema.ts";
|
|
```
|
|
|
|
In `getPopularItemsByTags` (around line 160), add manufacturers join. This query starts FROM globalItems, so use innerJoin (globalItems always has a manufacturer):
|
|
```typescript
|
|
.from(globalItems)
|
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
|
.innerJoin(globalItemTags, ...)
|
|
```
|
|
|
|
Replace `brand: globalItems.brand` in the select:
|
|
```typescript
|
|
brand: manufacturers.name,
|
|
```
|
|
|
|
- [ ] **Step 3: `src/server/services/profile.service.ts`**
|
|
|
|
Add to import:
|
|
```typescript
|
|
import { ..., manufacturers } from "../../db/schema.ts";
|
|
```
|
|
|
|
Add manufacturers join after the globalItems join and replace `${globalItems.brand}` → `${manufacturers.name}` (same pattern as setup.service.ts).
|
|
|
|
- [ ] **Step 4: `src/server/services/csv.service.ts`**
|
|
|
|
Add to import and add manufacturers join after globalItems join. Replace `${globalItems.brand}` → `${manufacturers.name}`.
|
|
|
|
- [ ] **Step 5: `src/server/services/thread.service.ts`**
|
|
|
|
This service joins from `threadCandidates` and uses `leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))`. Add:
|
|
```typescript
|
|
.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
|
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
|
```
|
|
|
|
Replace `${globalItems.brand}` → `${manufacturers.name}`.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/server/services/setup.service.ts src/server/services/discovery.service.ts src/server/services/profile.service.ts src/server/services/csv.service.ts src/server/services/thread.service.ts
|
|
git commit -m "feat: all services join manufacturers for global item brand display"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Update MCP catalog tools
|
|
|
|
**Files:**
|
|
- Modify: `src/server/mcp/tools/catalog.ts`
|
|
|
|
- [ ] **Step 1: Replace `brand` with `manufacturerSlug` in `catalogItemInputSchema`**
|
|
|
|
In `src/server/mcp/tools/catalog.ts`, update `catalogItemInputSchema`:
|
|
|
|
```typescript
|
|
const catalogItemInputSchema = {
|
|
manufacturerSlug: z
|
|
.string()
|
|
.describe("Manufacturer slug (e.g. 'apidura', 'revelate-designs') — must exist in the manufacturers table"),
|
|
model: z
|
|
.string()
|
|
.describe("Model name — combined with manufacturerSlug forms the unique identifier"),
|
|
category: z
|
|
.string()
|
|
.optional()
|
|
.describe("Category name (e.g., 'bags', 'shelters', 'sleep')"),
|
|
weightGrams: z.number().optional().describe("Weight in grams"),
|
|
priceCents: z
|
|
.number()
|
|
.optional()
|
|
.describe("MSRP price in cents (e.g., 9999 = €99.99)"),
|
|
imageUrl: z.string().optional().describe("URL to the product image"),
|
|
description: z.string().optional().describe("Product description"),
|
|
sourceUrl: z
|
|
.string()
|
|
.optional()
|
|
.describe("URL to the product page on manufacturer/retailer site"),
|
|
imageCredit: z
|
|
.string()
|
|
.optional()
|
|
.describe("Image credit — photographer or source name"),
|
|
imageSourceUrl: z
|
|
.string()
|
|
.optional()
|
|
.describe("Original URL where the image was sourced from"),
|
|
tags: z
|
|
.array(z.string())
|
|
.optional()
|
|
.describe("Tags for categorization (created automatically if new)"),
|
|
};
|
|
```
|
|
|
|
Update the handler type annotations: replace `brand: string` with `manufacturerSlug: string` in both `upsert_catalog_item` and `bulk_upsert_catalog` handler args types.
|
|
|
|
Update the tool descriptions to mention slugs:
|
|
- `upsert_catalog_item`: "...identified by (manufacturerSlug, model)..."
|
|
- `bulk_upsert_catalog`: "...upserted on (manufacturerSlug, model) uniqueness..."
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/server/mcp/tools/catalog.ts
|
|
git commit -m "feat: MCP catalog tools use manufacturerSlug instead of brand"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Update dev seed data
|
|
|
|
**Files:**
|
|
- Modify: `src/db/dev-seed-data.ts`
|
|
- Modify: `src/db/dev-seed.ts`
|
|
- Modify: `src/db/global-items-seed.json`
|
|
|
|
- [ ] **Step 1: Add `DEV_MANUFACTURERS` to `src/db/dev-seed-data.ts`**
|
|
|
|
At the top of the file, add:
|
|
|
|
```typescript
|
|
export const DEV_MANUFACTURERS = [
|
|
{ name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 as const },
|
|
{ name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 as const },
|
|
{ name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 as const },
|
|
{ name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 as const },
|
|
{ name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 as const },
|
|
{ name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 as const },
|
|
{ name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 as const },
|
|
{ name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 as const },
|
|
{ name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 as const },
|
|
{ name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 as const },
|
|
{ name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 as const },
|
|
{ name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 as const },
|
|
{ name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 as const },
|
|
{ name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 as const },
|
|
{ name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 as const },
|
|
] as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Update `DEV_GLOBAL_ITEMS` in `src/db/dev-seed-data.ts`**
|
|
|
|
Replace the `brand` field with `manufacturerSlug` on every entry. Example for the first few entries:
|
|
|
|
```typescript
|
|
export const DEV_GLOBAL_ITEMS = [
|
|
// Bags (indices 0-5)
|
|
{
|
|
manufacturerSlug: "revelate-designs",
|
|
model: "Terrapin System",
|
|
category: "bags",
|
|
weightGrams: 529,
|
|
priceCents: 18500,
|
|
description: "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated seat bag mount.",
|
|
},
|
|
{
|
|
manufacturerSlug: "apidura",
|
|
model: "Expedition Handlebar Pack",
|
|
category: "bags",
|
|
weightGrams: 300,
|
|
priceCents: 16000,
|
|
description: "14L waterproof handlebar roll bag with internal dry bag and accessory pocket.",
|
|
},
|
|
{
|
|
manufacturerSlug: "ortlieb",
|
|
model: "Frame-Pack RC",
|
|
category: "bags",
|
|
weightGrams: 250,
|
|
priceCents: 12000,
|
|
description: "6L waterproof roll-closure frame bag with TIZIP zipper for full-frame bikes.",
|
|
},
|
|
// ... continue for all entries, replacing brand with manufacturerSlug
|
|
];
|
|
```
|
|
|
|
Apply `brand → manufacturerSlug` for every entry in the array.
|
|
|
|
- [ ] **Step 3: Update `src/db/dev-seed.ts`** to insert manufacturers first
|
|
|
|
In `seedDevData`, before step 1 (seed global items), add:
|
|
|
|
```typescript
|
|
// ── 0. Insert dev manufacturers ────────────────────────────────
|
|
for (const m of DEV_MANUFACTURERS) {
|
|
await database
|
|
.insert(schema.manufacturers)
|
|
.values(m)
|
|
.onConflictDoNothing();
|
|
}
|
|
console.log(` ${DEV_MANUFACTURERS.length} manufacturers seeded.`);
|
|
```
|
|
|
|
Also add `DEV_MANUFACTURERS` to the import from `./dev-seed-data.ts`.
|
|
|
|
In step 5 (insert global items), update the insertion block to use `manufacturerSlug`:
|
|
|
|
```typescript
|
|
for (const item of DEV_GLOBAL_ITEMS) {
|
|
const key = `${item.manufacturerSlug}::${item.model}`;
|
|
const existingId = existingGlobalItemMap.get(key);
|
|
// ... resolve manufacturerId from slug before inserting
|
|
const [mfRow] = await database
|
|
.select({ id: schema.manufacturers.id })
|
|
.from(schema.manufacturers)
|
|
.where(eq(schema.manufacturers.slug, item.manufacturerSlug));
|
|
if (!mfRow) continue;
|
|
|
|
if (existingId) {
|
|
globalItemIds.push(existingId);
|
|
} else {
|
|
const [inserted] = await database
|
|
.insert(schema.globalItems)
|
|
.values({
|
|
manufacturerId: mfRow.id,
|
|
model: item.model,
|
|
category: item.category,
|
|
weightGrams: item.weightGrams,
|
|
priceCents: item.priceCents,
|
|
description: item.description,
|
|
})
|
|
.returning();
|
|
if (!inserted) throw new Error(`Failed to insert: ${item.manufacturerSlug} ${item.model}`);
|
|
globalItemIds.push(inserted.id);
|
|
newGlobalCount++;
|
|
}
|
|
}
|
|
```
|
|
|
|
Also update the `existingGlobalItemMap` to key by `manufacturerId::model`. After loading `existingGlobalItems`, build the manufacturer slug → id map first, then key the map:
|
|
|
|
```typescript
|
|
// Build manufacturer slug → id map
|
|
const allMfrs = await database.select().from(schema.manufacturers);
|
|
const mfrSlugToId = new Map(allMfrs.map((m) => [m.slug, m.id]));
|
|
|
|
// Build existing global item map keyed by manufacturerId::model
|
|
const existingGlobalItems = await database.select().from(schema.globalItems);
|
|
const existingGlobalItemMap = new Map<string, number>();
|
|
for (const gi of existingGlobalItems) {
|
|
existingGlobalItemMap.set(`${gi.manufacturerId}::${gi.model}`, gi.id);
|
|
}
|
|
|
|
// When checking/inserting each item:
|
|
const mfrId = mfrSlugToId.get(item.manufacturerSlug);
|
|
if (!mfrId) continue;
|
|
const key = `${mfrId}::${item.model}`;
|
|
const existingId = existingGlobalItemMap.get(key);
|
|
```
|
|
|
|
- [ ] **Step 4: Update `src/db/global-items-seed.json`**
|
|
|
|
Replace `"brand"` with `"manufacturerSlug"` using the manufacturer slugs. Example:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"manufacturerSlug": "revelate-designs",
|
|
"model": "Terrapin System",
|
|
"category": "bags",
|
|
"weightGrams": 529,
|
|
"priceCents": 18500,
|
|
"description": "Waterproof saddle bag with 14L capacity..."
|
|
},
|
|
...
|
|
]
|
|
```
|
|
|
|
Apply `brand → manufacturerSlug` for all ~20 entries, converting brand names to slugs (lowercase, hyphens).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/db/dev-seed-data.ts src/db/dev-seed.ts src/db/global-items-seed.json
|
|
git commit -m "feat: dev seed and json seed use manufacturerSlug"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Run full test suite
|
|
|
|
- [ ] **Step 1: Run all tests**
|
|
|
|
```bash
|
|
bun test
|
|
```
|
|
|
|
Expected: all tests pass. Common failure patterns:
|
|
- `brand` referenced in a test helper → update to `manufacturerSlug`
|
|
- Missing manufacturer in a test that inserts globalItems directly → add `insertManufacturer` call first
|
|
- TypeScript type errors on service function signatures → check all call sites
|
|
|
|
- [ ] **Step 2: Fix any failures, commit**
|
|
|
|
```bash
|
|
git add -p
|
|
git commit -m "fix: update remaining test references after brand → manufacturerSlug migration"
|
|
```
|
|
|
|
- [ ] **Step 3: Verify dev seed runs cleanly**
|
|
|
|
```bash
|
|
bun run db:seed:dev
|
|
```
|
|
|
|
Expected output includes "X manufacturers seeded" and all subsequent counts without errors.
|
|
|
|
- [ ] **Step 4: Commit if any fixes were needed**
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "fix: dev seed after manufacturers migration"
|
|
```
|