17 Commits

Author SHA1 Message Date
16058d0f4d chore: update bun.lock for @anthropic-ai/sdk
Some checks failed
CI / ci (push) Failing after 15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 16:49:33 +02:00
065b262b5b chore: add db:crawl and db:crawl-all npm scripts 2026-04-18 16:45:54 +02:00
44602d409e feat: crawl-all batch runner — iterate active manufacturers by tier 2026-04-18 16:45:39 +02:00
3d2911cedc feat: crawl-manufacturer agent script — Haiku tool-use loop + bulk upsert 2026-04-18 16:45:17 +02:00
b2a725a646 feat: canonical taxonomy — categories and tags for ingestion 2026-04-18 16:44:32 +02:00
44b1eac0ba feat(catalog): migrate dev seed data to manufacturer-slug-based global items
Replace brand text field with manufacturerSlug in DEV_GLOBAL_ITEMS,
global-items-seed.json, and seed-global-items.ts. Add DEV_MANUFACTURERS
for dev-only brands not in SEED_MANUFACTURERS. Expand SEED_MANUFACTURERS
with 8 additional manufacturers referenced by seed JSON (Nemo, Therm-a-Rest,
Toaks, Katadyn, HydraPak, Nitecore, Outdoor Research, Exposure Lights).
Update dev-seed.ts to resolve slug→id before insert and use manufacturerId
as the deduplication key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 16:37:27 +02:00
0b4715b80c fix: update all tests and MCP catalog tool for manufacturerId schema migration 2026-04-18 16:30:11 +02:00
a508773809 feat: all services join manufacturers for global item brand display 2026-04-18 16:24:24 +02:00
2924c2269c feat: item service joins manufacturers for brand display 2026-04-18 16:22:10 +02:00
12b3f8e380 feat: upsertGlobalItemSchema — brand → manufacturerSlug 2026-04-18 16:21:32 +02:00
5037350aa0 feat: global-item service uses manufacturerSlug, joins manufacturers for brand 2026-04-18 16:21:25 +02:00
8ff680ef92 feat: migrate globalItems — drop brand text, add manufacturerId FK 2026-04-18 16:19:31 +02:00
f868bbdecf feat: seed manufacturers list, update seedGlobalItems to resolve by name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 16:16:52 +02:00
ec27df1d0f feat: manufacturers route — list, get, create
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 16:16:27 +02:00
8c1b19f07d feat: manufacturer service with list, get, create
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 16:15:40 +02:00
7de3e9e957 feat: add manufacturers table to schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:54:23 +02:00
2cb83a63f1 docs: catalog population implementation plans (schema migration + ingestion script) 2026-04-18 14:49:34 +02:00
38 changed files with 4530 additions and 379 deletions

View File

@@ -5,6 +5,7 @@
"": {
"name": "gearbox",
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.1024.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0",
"@hono/oidc-auth": "^1.8.1",
@@ -53,6 +54,8 @@
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
@@ -847,6 +850,8 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -1087,6 +1092,8 @@
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],

View File

@@ -429,48 +429,7 @@ main().catch((err) => {
});
```
- [ ] **Step 3: Add market prices upsert after the bulk upsert**
After `upsertItems`, add a call to also upsert `marketPrices` for each item that has a price. This requires knowing the item IDs returned from the bulk upsert and the manufacturer's country/currency. Add this helper after `upsertItems`:
```typescript
async function upsertMarketPrices(
globalItemIds: number[],
items: CatalogItem[],
): Promise<void> {
for (let i = 0; i < globalItemIds.length; i++) {
const item = items[i];
const globalItemId = globalItemIds[i];
if (!item?.priceCents || !globalItemId) continue;
// Derive market from currency
const market = item.priceCurrency === "EUR" ? "EU"
: item.priceCurrency === "USD" ? "US"
: item.priceCurrency === "GBP" ? "GB"
: item.priceCurrency;
await fetch(`${GEARBOX_URL}/api/global-items/${globalItemId}/market-prices`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": GEARBOX_API_KEY,
},
body: JSON.stringify({
market,
currency: item.priceCurrency,
priceCents: item.priceCents,
source: "manufacturer-crawl",
}),
});
}
}
```
Call `upsertMarketPrices` in `main()` after the bulk upsert, passing the item IDs from the API response.
Note: The bulk upsert response returns `items[]` with IDs. Store those and pass them here. Update the `upsertItems` function return type to also return `itemIds: number[]`.
- [ ] **Step 4: Commit**
- [ ] **Step 3: Commit**
```bash
git add scripts/crawl-manufacturer.ts

View File

@@ -0,0 +1,12 @@
CREATE TABLE "manufacturers" (
"id" serial PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"website" text NOT NULL,
"tier" integer DEFAULT 1 NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"country" text,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "manufacturers_name_unique" UNIQUE("name"),
CONSTRAINT "manufacturers_slug_unique" UNIQUE("slug")
);

View File

@@ -0,0 +1,4 @@
ALTER TABLE "global_items" ADD COLUMN "manufacturer_id" integer NOT NULL REFERENCES "manufacturers"("id");--> statement-breakpoint
ALTER TABLE "global_items" DROP CONSTRAINT "global_items_brand_model_unique";--> statement-breakpoint
ALTER TABLE "global_items" DROP COLUMN "brand";--> statement-breakpoint
ALTER TABLE "global_items" ADD CONSTRAINT "global_items_manufacturer_id_model_unique" UNIQUE("manufacturer_id","model");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,20 @@
"when": 1776096142720,
"tag": "0006_remarkable_susan_delgado",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1776516850497,
"tag": "0007_steady_sasquatch",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1776521936465,
"tag": "0008_productive_tyrannus",
"breakpoints": true
}
]
}

View File

@@ -15,7 +15,9 @@
"test:e2e:ui": "bunx playwright test --ui",
"lint": "bunx @biomejs/biome check .",
"db:seed:dev": "bun run src/db/dev-seed.ts",
"backfill:colors": "bun run scripts/backfill-dominant-colors.ts"
"backfill:colors": "bun run scripts/backfill-dominant-colors.ts",
"db:crawl": "bun run scripts/crawl-manufacturer.ts",
"db:crawl-all": "bun run scripts/crawl-all.ts"
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
@@ -36,6 +38,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.1024.0",
"@aws-sdk/s3-request-presigner": "^3.1024.0",
"@hono/oidc-auth": "^1.8.1",

84
scripts/crawl-all.ts Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bun
/**
* Crawl all active manufacturers of a given tier.
*
* Usage:
* bun run scripts/crawl-all.ts --tier=1
* bun run scripts/crawl-all.ts --tier=1 --dry-run
*
* Env vars required:
* GEARBOX_URL — Base URL of the GearBox instance (default: http://localhost:3000)
* GEARBOX_API_KEY — GearBox API key with write access
* ANTHROPIC_API_KEY — Anthropic API key (passed through to crawl-manufacturer)
*/
const GEARBOX_URL = process.env.GEARBOX_URL ?? "http://localhost:3000";
const GEARBOX_API_KEY = process.env.GEARBOX_API_KEY ?? "";
const args = Object.fromEntries(
process.argv
.slice(2)
.filter((a) => a.startsWith("--"))
.map((a) => {
const [k, v] = a.slice(2).split("=");
return [k, v ?? "true"];
}),
);
const tier = args["tier"] ? Number(args["tier"]) : 1;
const dryRun = args["dry-run"] === "true";
async function listActiveManufacturers(targetTier: number) {
const res = await fetch(`${GEARBOX_URL}/api/manufacturers`);
if (!res.ok) throw new Error(`Failed to list manufacturers: HTTP ${res.status}`);
const all = await res.json() as Array<{ slug: string; tier: number; active: boolean; name: string }>;
return all.filter((m) => m.active && m.tier === targetTier);
}
async function main() {
if (!GEARBOX_API_KEY) {
console.error("GEARBOX_API_KEY env var is required");
process.exit(1);
}
const manufacturers = await listActiveManufacturers(tier);
console.log(`Found ${manufacturers.length} active tier-${tier} manufacturers\n`);
const results: Array<{ slug: string; status: "ok" | "error"; error?: string }> = [];
for (const m of manufacturers) {
console.log(`\n${"─".repeat(50)}`);
console.log(`Crawling: ${m.name} (${m.slug})`);
try {
const extraArgs = dryRun ? ["--dry-run"] : [];
const proc = Bun.spawn(
["bun", "run", "scripts/crawl-manufacturer.ts", `--manufacturer=${m.slug}`, ...extraArgs],
{ stdout: "inherit", stderr: "inherit", env: process.env },
);
const exitCode = await proc.exited;
if (exitCode !== 0) throw new Error(`Exited with code ${exitCode}`);
results.push({ slug: m.slug, status: "ok" });
} catch (err) {
console.error(` ERROR: ${(err as Error).message}`);
results.push({ slug: m.slug, status: "error", error: (err as Error).message });
}
}
console.log(`\n${"=".repeat(50)}`);
console.log("Summary:");
for (const r of results) {
const icon = r.status === "ok" ? "✓" : "✗";
console.log(` ${icon} ${r.slug}${r.error ? `${r.error}` : ""}`);
}
const failed = results.filter((r) => r.status === "error");
if (failed.length > 0) {
console.error(`\n${failed.length} manufacturer(s) failed`);
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env bun
/**
* Crawl a manufacturer's website and upsert their products into the GearBox catalog.
*
* Usage:
* bun run scripts/crawl-manufacturer.ts --manufacturer=apidura
* bun run scripts/crawl-manufacturer.ts --manufacturer=canyon --dry-run
*
* Env vars required:
* ANTHROPIC_API_KEY — Anthropic API key
* GEARBOX_URL — Base URL of the GearBox instance (default: http://localhost:3000)
* GEARBOX_API_KEY — GearBox API key with write access
*/
import Anthropic from "@anthropic-ai/sdk";
import { CATEGORIES } from "./taxonomy/categories.ts";
import { TAGS } from "./taxonomy/tags.ts";
const GEARBOX_URL = process.env.GEARBOX_URL ?? "http://localhost:3000";
const GEARBOX_API_KEY = process.env.GEARBOX_API_KEY ?? "";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? "";
const MODEL = "claude-haiku-4-5-20251001";
const MAX_TOOL_ROUNDS = 30; // safety limit
// ── Parse CLI args ────────────────────────────────────────────────
const args = Object.fromEntries(
process.argv
.slice(2)
.filter((a) => a.startsWith("--"))
.map((a) => {
const [k, v] = a.slice(2).split("=");
return [k, v ?? "true"];
}),
);
const manufacturerSlug = args["manufacturer"];
const dryRun = args["dry-run"] === "true";
if (!manufacturerSlug) {
console.error("Usage: bun run scripts/crawl-manufacturer.ts --manufacturer=<slug>");
process.exit(1);
}
if (!GEARBOX_API_KEY) {
console.error("GEARBOX_API_KEY env var is required");
process.exit(1);
}
if (!ANTHROPIC_API_KEY) {
console.error("ANTHROPIC_API_KEY env var is required");
process.exit(1);
}
// ── Fetch manufacturer from GearBox ──────────────────────────────
async function fetchManufacturer(slug: string) {
const res = await fetch(`${GEARBOX_URL}/api/manufacturers/${slug}`);
if (!res.ok) {
throw new Error(`Manufacturer not found: ${slug} (HTTP ${res.status})`);
}
return res.json() as Promise<{
id: number;
name: string;
slug: string;
website: string;
tier: number;
country: string | null;
}>;
}
// ── Tool: fetch a web page ────────────────────────────────────────
async function fetchPage(url: string): Promise<string> {
try {
const res = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; GearBox-Catalog-Bot/1.0)",
Accept: "text/html,application/xhtml+xml",
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return `HTTP ${res.status} for ${url}`;
const html = await res.text();
// Strip scripts, styles, and excessive whitespace for token efficiency
return html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/\s{3,}/g, " ")
.slice(0, 60_000); // cap at 60k chars to stay within context
} catch (err) {
return `Error fetching ${url}: ${(err as Error).message}`;
}
}
// ── Build system prompt ───────────────────────────────────────────
function buildSystemPrompt(manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>) {
return `You are a product data extraction agent for GearBox, a gear management app for bikepacking, cycling, and hiking.
Your task: crawl ${manufacturer.name}'s website (${manufacturer.website}) and extract their complete product catalog.
For each product, extract:
- model: string (product name WITHOUT the brand prefix)
- category: one of [${CATEGORIES.join(", ")}]
- weightGrams: number | null (weight in grams — convert if shown in oz/lbs/kg)
- priceCents: number | null (MSRP in cents, base currency)
- priceCurrency: string (ISO currency code — "EUR" for DE brands, "USD" for US, "GBP" for GB, etc.)
- description: string | null (1-3 sentence product description)
- sourceUrl: string (direct product page URL)
- tags: string[] (from this list only: [${TAGS.join(", ")}])
Rules:
- model must NOT include the brand name (e.g., "Terrapin System" not "Revelate Designs Terrapin System")
- Only include outdoor/adventure/cycling products. Skip accessories under €5, clothing if not relevant to the target categories.
- If weight is not listed on a product page, use null — do not guess.
- Assign 2-5 relevant tags per item.
- Extract every product in their catalog, not just featured ones. Navigate to all relevant subcategories.
When done, output a JSON array of product objects as your final message. Do not wrap in markdown — raw JSON only.
Example output:
[
{
"model": "Expedition Handlebar Pack",
"category": "bags",
"weightGrams": 300,
"priceCents": 16000,
"priceCurrency": "GBP",
"description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket.",
"sourceUrl": "https://apidura.com/shop/expedition-handlebar-pack/",
"tags": ["bikepacking", "handlebar-bag", "bike-bag"]
}
]`;
}
// ── Agentic tool-use loop ─────────────────────────────────────────
type CatalogItem = {
model: string;
category: string;
weightGrams: number | null;
priceCents: number | null;
priceCurrency: string;
description: string | null;
sourceUrl: string;
tags: string[];
};
async function runCrawlAgent(manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>): Promise<CatalogItem[]> {
const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY });
const tools: Anthropic.Tool[] = [
{
name: "fetch_page",
description: "Fetch the HTML content of a URL. Use this to explore the manufacturer's website and product pages.",
input_schema: {
type: "object" as const,
properties: {
url: { type: "string", description: "The URL to fetch" },
},
required: ["url"],
},
},
];
const messages: Anthropic.MessageParam[] = [
{
role: "user",
content: `Crawl ${manufacturer.name}'s website at ${manufacturer.website} and extract their complete product catalog. Start with the homepage or sitemap, navigate to all product categories, and return the full product list as JSON.`,
},
];
let rounds = 0;
while (rounds < MAX_TOOL_ROUNDS) {
rounds++;
console.log(` [round ${rounds}] calling model...`);
const response = await client.messages.create({
model: MODEL,
max_tokens: 8192,
system: buildSystemPrompt(manufacturer),
tools,
messages,
});
// Add assistant response to history
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "end_turn") {
// Final message — extract JSON from text content
const textBlock = response.content.find((b) => b.type === "text");
if (!textBlock || textBlock.type !== "text") {
throw new Error("Agent finished without text output");
}
return parseAgentOutput(textBlock.text);
}
if (response.stop_reason !== "tool_use") {
throw new Error(`Unexpected stop reason: ${response.stop_reason}`);
}
// Process tool calls
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
if (block.name === "fetch_page") {
const { url } = block.input as { url: string };
console.log(` [tool] fetch_page ${url}`);
const content = await fetchPage(url);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content,
});
}
}
messages.push({ role: "user", content: toolResults });
}
throw new Error(`Agent exceeded ${MAX_TOOL_ROUNDS} tool rounds without finishing`);
}
function parseAgentOutput(text: string): CatalogItem[] {
// Handle agent wrapping output in markdown code blocks
const cleaned = text.replace(/^```json\s*/i, "").replace(/\s*```$/i, "").trim();
const parsed = JSON.parse(cleaned);
if (!Array.isArray(parsed)) throw new Error("Agent output is not a JSON array");
return parsed;
}
// ── Upsert to GearBox API ─────────────────────────────────────────
async function upsertItems(
slug: string,
items: CatalogItem[],
): Promise<{ created: number; updated: number }> {
const payload = items.map((item) => ({
manufacturerSlug: slug,
model: item.model,
category: item.category,
weightGrams: item.weightGrams ?? undefined,
priceCents: item.priceCents ?? undefined,
description: item.description ?? undefined,
sourceUrl: item.sourceUrl,
tags: item.tags,
}));
// Chunk into batches of 100 (API limit)
let totalCreated = 0;
let totalUpdated = 0;
for (let i = 0; i < payload.length; i += 100) {
const batch = payload.slice(i, i + 100);
const res = await fetch(`${GEARBOX_URL}/api/global-items/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": GEARBOX_API_KEY,
},
body: JSON.stringify({ items: batch }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Bulk upsert failed (HTTP ${res.status}): ${err}`);
}
const result = await res.json() as { created: number; updated: number };
totalCreated += result.created;
totalUpdated += result.updated;
console.log(` batch ${Math.floor(i / 100) + 1}: +${result.created} new, ~${result.updated} updated`);
}
return { created: totalCreated, updated: totalUpdated };
}
// ── Main ──────────────────────────────────────────────────────────
async function main() {
console.log(`\nCrawling manufacturer: ${manufacturerSlug}`);
if (dryRun) console.log("DRY RUN — products will not be saved\n");
const manufacturer = await fetchManufacturer(manufacturerSlug);
console.log(`Found: ${manufacturer.name} (${manufacturer.website})\n`);
console.log("Starting agent crawl...");
const items = await runCrawlAgent(manufacturer);
console.log(`\nAgent extracted ${items.length} products`);
if (dryRun) {
console.log("\nDry run output (first 3 items):");
console.log(JSON.stringify(items.slice(0, 3), null, 2));
return;
}
console.log("\nUpserting to catalog...");
const { created, updated } = await upsertItems(manufacturerSlug, items);
console.log(`\nDone: ${created} created, ${updated} updated`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,20 @@
/**
* Canonical category values for globalItems.category.
* These are the only valid values the ingestion agent should use.
*/
export const CATEGORIES = [
"bags", // bikepacking bags, dry bags, stuff sacks
"shelters", // tents, bivys, tarps, hammocks
"sleep", // sleeping bags, quilts, pads, pillows
"cooking", // stoves, cookware, mugs, utensils
"lighting", // headlamps, bike lights, lanterns
"water", // filters, bottles, bladders
"electronics", // power banks, solar panels, GPS, bike computers
"tools", // multi-tools, pumps, repair kits, locks
"clothing", // jackets, base layers, gloves, shoes
"navigation", // GPS devices, maps, compasses
"bikes", // complete bikes
"components", // drivetrain, brakes, wheels, handlebars, saddles, stems
] as const;
export type Category = (typeof CATEGORIES)[number];

31
scripts/taxonomy/tags.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Canonical tags for globalItems.
* Mirrors the seed tags in src/db/seed-global-items.ts.
* The agent should only use tags from this list.
*/
export const TAGS = [
// Activity
"bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing",
"mountaineering", "road-cycling", "gravel", "running", "trail-running",
// Bag subtypes
"handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag",
"fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag",
// Shelter subtypes
"tent", "bivy", "tarp", "hammock",
// Sleep subtypes
"sleeping-bag", "sleeping-pad", "quilt", "pillow",
// Cooking subtypes
"stove", "cookware", "mug", "utensils",
// Water subtypes
"water-filter", "water-bottle",
// Lighting subtypes
"headlamp", "bike-light", "lantern",
// Electronics subtypes
"gps", "bike-computer", "power-bank", "solar-panel",
// Tools subtypes
"multi-tool", "pump", "repair-kit", "lock",
// Clothing subtypes
"rain-jacket", "base-layer", "gloves", "shoe",
] as const;
export type Tag = (typeof TAGS)[number];

View File

@@ -17,13 +17,34 @@ export const DEV_CATEGORIES = [
{ name: "Navigation", icon: "compass" },
] as const;
// ── Manufacturers ──────────────────────────────────────────────────
// Seeded with onConflictDoNothing — safe to overlap with SEED_MANUFACTURERS.
export const DEV_MANUFACTURERS = [
{ name: "Rockgeist", slug: "rockgeist", website: "https://rockgeist.com", country: "US", tier: 1 },
{ name: "Oveja Negra", slug: "oveja-negra", website: "https://ovejanegrabikewear.com", country: "US", tier: 1 },
{ name: "Durston", slug: "durston", website: "https://durstondesigns.com", country: "US", tier: 1 },
{ name: "Enlightened Equipment", slug: "enlightened-equipment", website: "https://enlightenedequipment.com", country: "US", tier: 1 },
{ name: "BRS", slug: "brs", website: "https://brs-outdoor.com", country: "CN", tier: 1 },
{ name: "Soto", slug: "soto", website: "https://sotostoves.com", country: "JP", tier: 1 },
{ name: "Snow Peak", slug: "snow-peak", website: "https://snowpeak.com", country: "JP", tier: 1 },
{ name: "Lezyne", slug: "lezyne", website: "https://lezyne.com", country: "US", tier: 1 },
{ name: "Fenix", slug: "fenix", website: "https://fenixlighting.com", country: "CN", tier: 1 },
{ name: "Park Tool", slug: "park-tool", website: "https://parktool.com", country: "US", tier: 1 },
{ name: "Gorilla Tape", slug: "gorilla-tape", website: "https://gorillatough.com", country: "US", tier: 1 },
{ name: "Patagonia", slug: "patagonia", website: "https://patagonia.com", country: "US", tier: 1 },
{ name: "Frogg Toggs", slug: "frogg-toggs", website: "https://froggtoggs.com", country: "US", tier: 1 },
{ name: "Buff", slug: "buff", website: "https://buffwear.com", country: "ES", tier: 1 },
{ name: "Anker", slug: "anker", website: "https://anker.com", country: "CN", tier: 1 },
] as const;
// ── Global Items ───────────────────────────────────────────────────
// Index positions are referenced by user items, thread candidates, and tag assignments.
export const DEV_GLOBAL_ITEMS = [
// Bags (indices 0-5)
{
brand: "Revelate Designs",
manufacturerSlug: "revelate-designs",
model: "Terrapin System",
category: "bags",
weightGrams: 529,
@@ -32,7 +53,7 @@ export const DEV_GLOBAL_ITEMS = [
"Waterproof saddle bag with 14L capacity, roll-top closure, and integrated seat bag mount.",
},
{
brand: "Apidura",
manufacturerSlug: "apidura",
model: "Expedition Handlebar Pack",
category: "bags",
weightGrams: 300,
@@ -41,7 +62,7 @@ export const DEV_GLOBAL_ITEMS = [
"14L waterproof handlebar roll bag with internal dry bag and accessory pocket.",
},
{
brand: "Ortlieb",
manufacturerSlug: "ortlieb",
model: "Frame-Pack RC",
category: "bags",
weightGrams: 250,
@@ -50,7 +71,7 @@ export const DEV_GLOBAL_ITEMS = [
"6L waterproof roll-closure frame bag with TIZIP zipper for full-frame bikes.",
},
{
brand: "Rockgeist",
manufacturerSlug: "rockgeist",
model: "BarJam",
category: "bags",
weightGrams: 142,
@@ -59,7 +80,7 @@ export const DEV_GLOBAL_ITEMS = [
"Ultralight handlebar harness with side-loading dry bag compatibility.",
},
{
brand: "Oveja Negra",
manufacturerSlug: "oveja-negra",
model: "Superwedgie",
category: "bags",
weightGrams: 170,
@@ -68,7 +89,7 @@ export const DEV_GLOBAL_ITEMS = [
"Half-frame bag with easy-access zipper and internal organization.",
},
{
brand: "Apidura",
manufacturerSlug: "apidura",
model: "Racing Top Tube Pack",
category: "bags",
weightGrams: 72,
@@ -79,7 +100,7 @@ export const DEV_GLOBAL_ITEMS = [
// Shelter (indices 6-9)
{
brand: "Zpacks",
manufacturerSlug: "zpacks",
model: "Duplex",
category: "shelter",
weightGrams: 539,
@@ -88,7 +109,7 @@ export const DEV_GLOBAL_ITEMS = [
"Dyneema Composite Fabric two-person trekking pole shelter, freestanding with optional poles.",
},
{
brand: "Tarptent",
manufacturerSlug: "tarptent",
model: "Stratospire Li",
category: "shelter",
weightGrams: 737,
@@ -97,7 +118,7 @@ export const DEV_GLOBAL_ITEMS = [
"Two-person double-wall tent in Dyneema with dual vestibules and excellent ventilation.",
},
{
brand: "Durston",
manufacturerSlug: "durston",
model: "X-Mid 1 Solid",
category: "shelter",
weightGrams: 880,
@@ -106,7 +127,7 @@ export const DEV_GLOBAL_ITEMS = [
"Single-wall silpoly trekking pole tent with symmetrical design and two vestibules.",
},
{
brand: "Big Agnes",
manufacturerSlug: "big-agnes",
model: "Copper Spur HV UL1",
category: "shelter",
weightGrams: 936,
@@ -117,7 +138,7 @@ export const DEV_GLOBAL_ITEMS = [
// Sleep System (indices 10-14)
{
brand: "Enlightened Equipment",
manufacturerSlug: "enlightened-equipment",
model: "Enigma 20F",
category: "sleep",
weightGrams: 567,
@@ -126,7 +147,7 @@ export const DEV_GLOBAL_ITEMS = [
"20F down quilt with 850FP DownTek water-resistant fill, sewn footbox option.",
},
{
brand: "Therm-a-Rest",
manufacturerSlug: "therm-a-rest",
model: "NeoAir XLite NXT",
category: "sleep",
weightGrams: 354,
@@ -135,7 +156,7 @@ export const DEV_GLOBAL_ITEMS = [
"R-value 4.5 ultralight inflatable sleeping pad with ThermaCapture reflective technology.",
},
{
brand: "Nemo",
manufacturerSlug: "nemo",
model: "Tensor Insulated Regular",
category: "sleep",
weightGrams: 425,
@@ -144,7 +165,7 @@ export const DEV_GLOBAL_ITEMS = [
"R-value 4.2 insulated sleeping pad with Spaceframe baffles for stability.",
},
{
brand: "Sea to Summit",
manufacturerSlug: "sea-to-summit",
model: "Aeros Premium Pillow",
category: "sleep",
weightGrams: 79,
@@ -153,7 +174,7 @@ export const DEV_GLOBAL_ITEMS = [
"Brushed 50D polyester inflatable pillow with multifunctional valve.",
},
{
brand: "Western Mountaineering",
manufacturerSlug: "western-mountaineering",
model: "NanoLite 22F",
category: "sleep",
weightGrams: 510,
@@ -164,7 +185,7 @@ export const DEV_GLOBAL_ITEMS = [
// Cooking (indices 15-19)
{
brand: "BRS",
manufacturerSlug: "brs",
model: "BRS-3000T",
category: "cooking",
weightGrams: 25,
@@ -173,7 +194,7 @@ export const DEV_GLOBAL_ITEMS = [
"Ultralight titanium canister stove, 25g with piezo ignition, 2700W output.",
},
{
brand: "Soto",
manufacturerSlug: "soto",
model: "WindMaster",
category: "cooking",
weightGrams: 67,
@@ -182,7 +203,7 @@ export const DEV_GLOBAL_ITEMS = [
"Micro-regulator stove with concave burner head for excellent wind resistance.",
},
{
brand: "Toaks",
manufacturerSlug: "toaks",
model: "Light Titanium 750ml",
category: "cooking",
weightGrams: 86,
@@ -191,7 +212,7 @@ export const DEV_GLOBAL_ITEMS = [
"Titanium pot with graduated measurements, lid, and folding bail handle.",
},
{
brand: "Snow Peak",
manufacturerSlug: "snow-peak",
model: "Ti-Mini Solo Combo",
category: "cooking",
weightGrams: 198,
@@ -200,7 +221,7 @@ export const DEV_GLOBAL_ITEMS = [
"Titanium cookset with 850ml pot, lid/pan, and nesting mug for solo cooking.",
},
{
brand: "MSR",
manufacturerSlug: "msr",
model: "PocketRocket Deluxe",
category: "cooking",
weightGrams: 83,
@@ -211,7 +232,7 @@ export const DEV_GLOBAL_ITEMS = [
// Lighting (indices 20-22)
{
brand: "Nitecore",
manufacturerSlug: "nitecore",
model: "NU25 UL",
category: "lighting",
weightGrams: 28,
@@ -220,7 +241,7 @@ export const DEV_GLOBAL_ITEMS = [
"Rechargeable ultralight headlamp with 400 lumens, red/high-CRI aux LEDs.",
},
{
brand: "Lezyne",
manufacturerSlug: "lezyne",
model: "Lite Drive 1200+",
category: "lighting",
weightGrams: 176,
@@ -229,7 +250,7 @@ export const DEV_GLOBAL_ITEMS = [
"1200 lumen USB-C rechargeable bike light with MOR optical lens design.",
},
{
brand: "Fenix",
manufacturerSlug: "fenix",
model: "HL60R",
category: "lighting",
weightGrams: 134,
@@ -240,7 +261,7 @@ export const DEV_GLOBAL_ITEMS = [
// Tools & Repair (indices 23-25)
{
brand: "Park Tool",
manufacturerSlug: "park-tool",
model: "IB-3",
category: "tools",
weightGrams: 175,
@@ -249,7 +270,7 @@ export const DEV_GLOBAL_ITEMS = [
"Folding hex/Torx multi-tool with 3-6mm hex, T25, Phillips and flathead.",
},
{
brand: "Lezyne",
manufacturerSlug: "lezyne",
model: "CNC Chain Breaker",
category: "tools",
weightGrams: 28,
@@ -258,7 +279,7 @@ export const DEV_GLOBAL_ITEMS = [
"CNC-machined aluminum chain tool compatible with 8-12 speed chains.",
},
{
brand: "Gorilla Tape",
manufacturerSlug: "gorilla-tape",
model: "Mini Duct Tape Roll",
category: "tools",
weightGrams: 30,
@@ -268,7 +289,7 @@ export const DEV_GLOBAL_ITEMS = [
// Clothing (indices 26-28)
{
brand: "Patagonia",
manufacturerSlug: "patagonia",
model: "R1 Air Full-Zip",
category: "clothing",
weightGrams: 266,
@@ -277,7 +298,7 @@ export const DEV_GLOBAL_ITEMS = [
"Breathable midlayer fleece with open-knit R1 Air fabric for high-output activities.",
},
{
brand: "Frogg Toggs",
manufacturerSlug: "frogg-toggs",
model: "Ultra-Lite2 Rain Suit",
category: "clothing",
weightGrams: 340,
@@ -286,7 +307,7 @@ export const DEV_GLOBAL_ITEMS = [
"Budget ultralight rain jacket and pants set, DriPore breathable material.",
},
{
brand: "Buff",
manufacturerSlug: "buff",
model: "Merino Wool Multifunctional",
category: "clothing",
weightGrams: 43,
@@ -297,7 +318,7 @@ export const DEV_GLOBAL_ITEMS = [
// Water (indices 29-31)
{
brand: "Sawyer",
manufacturerSlug: "sawyer",
model: "Squeeze SP129",
category: "water",
weightGrams: 85,
@@ -306,7 +327,7 @@ export const DEV_GLOBAL_ITEMS = [
"0.1 micron hollow-fiber water filter with high flow rate and backflush capability.",
},
{
brand: "Katadyn",
manufacturerSlug: "katadyn",
model: "BeFree 1L",
category: "water",
weightGrams: 63,
@@ -315,7 +336,7 @@ export const DEV_GLOBAL_ITEMS = [
"EZ-Clean membrane filter with collapsible Hydrapak flask, 2L/min flow rate.",
},
{
brand: "HydraPak",
manufacturerSlug: "hydrapak",
model: "Seeker 2L",
category: "water",
weightGrams: 76,
@@ -326,7 +347,7 @@ export const DEV_GLOBAL_ITEMS = [
// Electronics (indices 32-33)
{
brand: "Anker",
manufacturerSlug: "anker",
model: "Nano Power Bank 10000 PD",
category: "electronics",
weightGrams: 220,
@@ -335,7 +356,7 @@ export const DEV_GLOBAL_ITEMS = [
"10000mAh 30W USB-C PD power bank with built-in display and passthrough charging.",
},
{
brand: "Garmin",
manufacturerSlug: "garmin",
model: "inReach Mini 2",
category: "electronics",
weightGrams: 100,
@@ -346,7 +367,7 @@ export const DEV_GLOBAL_ITEMS = [
// Navigation (indices 34-35)
{
brand: "Wahoo",
manufacturerSlug: "wahoo",
model: "ELEMNT BOLT V2",
category: "navigation",
weightGrams: 68,
@@ -355,7 +376,7 @@ export const DEV_GLOBAL_ITEMS = [
"GPS cycling computer with color display, turn-by-turn navigation, and smart trainer integration.",
},
{
brand: "Ortlieb",
manufacturerSlug: "ortlieb",
model: "Ultimate Six Classic",
category: "navigation",
weightGrams: 500,

View File

@@ -7,6 +7,7 @@ import { and, eq, like, sql } from "drizzle-orm";
import {
DEV_CATEGORIES,
DEV_GLOBAL_ITEMS,
DEV_MANUFACTURERS,
DEV_MARKET_PRICES,
DEV_SETTINGS,
DEV_SETUPS,
@@ -79,9 +80,12 @@ async function seedDevData(database: Db = db) {
await clearDevData(database);
try {
// ── 1. Seed global items and tags ─────────────────────────
// ── 1. Seed global items, tags, and dev-specific manufacturers
await seedGlobalItems(database);
console.log(" Global items and tags seeded.");
for (const m of DEV_MANUFACTURERS) {
await database.insert(schema.manufacturers).values(m).onConflictDoNothing();
}
console.log(" Global items, tags, and manufacturers seeded.");
// ── 2. Insert dev user ─────────────────────────────────────
const [user] = await database
@@ -123,20 +127,29 @@ async function seedDevData(database: Db = db) {
// ── 5. Insert global items and tag assignments ─────────────
// DEV_GLOBAL_ITEMS may overlap with seed-global-items.json entries.
// Insert only items that don't already exist (by brand+model).
// Insert only items that don't already exist (by manufacturerId+model).
const allManufacturers = await database.select().from(schema.manufacturers);
const mfBySlug = new Map(allManufacturers.map((m) => [m.slug, m.id]));
const existingGlobalItems = await database
.select()
.from(schema.globalItems);
const existingGlobalItemMap = new Map<string, number>();
for (const gi of existingGlobalItems) {
existingGlobalItemMap.set(`${gi.brand}::${gi.model}`, gi.id);
existingGlobalItemMap.set(`${gi.manufacturerId}::${gi.model}`, gi.id);
}
const globalItemIds: number[] = [];
let newGlobalCount = 0;
for (const item of DEV_GLOBAL_ITEMS) {
const key = `${item.brand}::${item.model}`;
const mfId = mfBySlug.get(item.manufacturerSlug);
if (!mfId) {
console.warn(` Skipping "${item.model}" — unknown manufacturer slug: ${item.manufacturerSlug}`);
globalItemIds.push(0); // placeholder to keep index alignment
continue;
}
const key = `${mfId}::${item.model}`;
const existingId = existingGlobalItemMap.get(key);
if (existingId) {
globalItemIds.push(existingId);
@@ -144,7 +157,7 @@ async function seedDevData(database: Db = db) {
const [inserted] = await database
.insert(schema.globalItems)
.values({
brand: item.brand,
manufacturerId: mfId,
model: item.model,
category: item.category,
weightGrams: item.weightGrams,
@@ -154,7 +167,7 @@ async function seedDevData(database: Db = db) {
.returning();
if (!inserted)
throw new Error(
`Failed to insert global item: ${item.brand} ${item.model}`,
`Failed to insert global item: ${item.manufacturerSlug} ${item.model}`,
);
globalItemIds.push(inserted.id);
newGlobalCount++;

View File

@@ -1,6 +1,6 @@
[
{
"brand": "Revelate Designs",
"manufacturerSlug": "revelate-designs",
"model": "Terrapin System",
"category": "bags",
"weightGrams": 529,
@@ -8,7 +8,7 @@
"description": "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated Revelate seat bag mount."
},
{
"brand": "Apidura",
"manufacturerSlug": "apidura",
"model": "Expedition Handlebar Pack",
"category": "bags",
"weightGrams": 300,
@@ -16,7 +16,7 @@
"description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket."
},
{
"brand": "Ortlieb",
"manufacturerSlug": "ortlieb",
"model": "Frame-Pack Toptube",
"category": "bags",
"weightGrams": 180,
@@ -24,7 +24,7 @@
"description": "4L waterproof top-tube bag with magnetic closure and reflective details."
},
{
"brand": "Revelate Designs",
"manufacturerSlug": "revelate-designs",
"model": "Tangle Frame Bag",
"category": "bags",
"weightGrams": 170,
@@ -32,7 +32,7 @@
"description": "Full-frame bag with water-resistant construction and multiple internal pockets."
},
{
"brand": "Big Agnes",
"manufacturerSlug": "big-agnes",
"model": "Copper Spur HV UL1",
"category": "shelters",
"weightGrams": 879,
@@ -40,7 +40,7 @@
"description": "Ultralight 1-person freestanding tent with high-volume hub design and DAC Featherlite poles."
},
{
"brand": "Tarptent",
"manufacturerSlug": "tarptent",
"model": "Protrail Li",
"category": "shelters",
"weightGrams": 454,
@@ -48,7 +48,7 @@
"description": "Ultralight single-wall trekking pole shelter in Dyneema composite fabric."
},
{
"brand": "Outdoor Research",
"manufacturerSlug": "outdoor-research",
"model": "Helium Bivy",
"category": "shelters",
"weightGrams": 510,
@@ -56,7 +56,7 @@
"description": "Waterproof breathable bivy sack with single-hoop pole and full-zip entry."
},
{
"brand": "Sea to Summit",
"manufacturerSlug": "sea-to-summit",
"model": "Spark SP1",
"category": "sleep-systems",
"weightGrams": 375,
@@ -64,7 +64,7 @@
"description": "Ultralight 850+ fill down sleeping bag rated to 40F/4C with Ultra-Dry Down."
},
{
"brand": "Nemo",
"manufacturerSlug": "nemo",
"model": "Tensor Ultralight Insulated Regular",
"category": "sleep-systems",
"weightGrams": 425,
@@ -72,7 +72,7 @@
"description": "3-inch thick insulated sleeping pad with R-value 4.2 and Spaceframe baffles."
},
{
"brand": "Therm-a-Rest",
"manufacturerSlug": "therm-a-rest",
"model": "NeoAir XLite NXT",
"category": "sleep-systems",
"weightGrams": 354,
@@ -80,7 +80,7 @@
"description": "Ultralight insulated air pad with R-value 4.5, Triangular Core Matrix, and WingLock valve."
},
{
"brand": "MSR",
"manufacturerSlug": "msr",
"model": "PocketRocket 2",
"category": "cooking",
"weightGrams": 73,
@@ -88,7 +88,7 @@
"description": "Ultralight canister stove with adjustable flame control, boils 1L in 3.5 minutes."
},
{
"brand": "Toaks",
"manufacturerSlug": "toaks",
"model": "Titanium 750ml Pot",
"category": "cooking",
"weightGrams": 103,
@@ -96,7 +96,7 @@
"description": "Ultralight titanium pot with lid and foldable handles, 750ml capacity."
},
{
"brand": "Katadyn",
"manufacturerSlug": "katadyn",
"model": "BeFree 1.0L",
"category": "hydration",
"weightGrams": 59,
@@ -104,7 +104,7 @@
"description": "Ultralight hollow fiber water filter with 0.1 micron filtration and 1L soft flask."
},
{
"brand": "HydraPak",
"manufacturerSlug": "hydrapak",
"model": "Seeker 2L",
"category": "hydration",
"weightGrams": 73,
@@ -112,7 +112,7 @@
"description": "Collapsible 2L water storage with wide mouth and compatible with Katadyn BeFree filter."
},
{
"brand": "Nitecore",
"manufacturerSlug": "nitecore",
"model": "NU25 UL",
"category": "lighting",
"weightGrams": 28,
@@ -120,7 +120,7 @@
"description": "Ultralight USB-C rechargeable headlamp with 400 lumens max and red light mode."
},
{
"brand": "Exposure Lights",
"manufacturerSlug": "exposure-lights",
"model": "Revo Dynamo",
"category": "lighting",
"weightGrams": 130,
@@ -128,7 +128,7 @@
"description": "Dynamo-powered front light with 800 lumens, built-in standlight, and USB charging output."
},
{
"brand": "Surly",
"manufacturerSlug": "surly",
"model": "24-Pack Rack",
"category": "racks",
"weightGrams": 750,
@@ -136,7 +136,7 @@
"description": "Front rack for bikepacking with 24-pack platform, fits most forks with mid-blade eyelets."
},
{
"brand": "Salsa",
"manufacturerSlug": "salsa-cycles",
"model": "Anything Cage HD",
"category": "accessories",
"weightGrams": 80,

View File

@@ -1,4 +1,5 @@
import {
boolean,
doublePrecision,
integer,
pgTable,
@@ -20,6 +21,19 @@ export const users = pgTable("users", {
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// ── 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(),
});
// ── Categories ──────────────────────────────────────────────────────
export const categories = pgTable(
@@ -163,7 +177,9 @@ export const globalItems = pgTable(
"global_items",
{
id: serial("id").primaryKey(),
brand: text("brand").notNull(),
manufacturerId: integer("manufacturer_id")
.notNull()
.references(() => manufacturers.id),
model: text("model").notNull(),
category: text("category"),
weightGrams: doublePrecision("weight_grams"),
@@ -179,7 +195,7 @@ export const globalItems = pgTable(
cropY: doublePrecision("crop_y"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [unique().on(table.brand, table.model)],
(table) => [unique().on(table.manufacturerId, table.model)],
);
// ── Tags ───────────────────────────────────────────────────────────

View File

@@ -1,81 +1,68 @@
import seedData from "./global-items-seed.json";
import { db as prodDb } from "./index.ts";
import { globalItems, tags } from "./schema.ts";
import { globalItems, manufacturers, tags } from "./schema.ts";
type Db = typeof prodDb;
const SEED_TAGS = [
// Hobby / activity tags (used by onboarding hobby picker)
"bikepacking",
"cycling",
"hiking",
"backpacking",
"camping",
"climbing",
"mountaineering",
"road-cycling",
"gravel",
"running",
"trail-running",
// Bag types
"handlebar-bag",
"framebag",
"saddlebag",
"top-tube-bag",
"stem-bag",
"fork-bag",
"feed-bag",
"dry-bag",
"stuff-sack",
// Bike bags (parent)
"bike-bag",
// Shelter
"tent",
"bivy",
"tarp",
"hammock",
// Sleep system
"sleeping-bag",
"sleeping-pad",
"quilt",
"pillow",
// Cooking
"stove",
"cookware",
"mug",
"utensils",
// Water
"water-filter",
"water-bottle",
// Lighting
"headlamp",
"bike-light",
"lantern",
// Navigation & electronics
"gps",
"bike-computer",
"power-bank",
"solar-panel",
// Tools & repair
"multi-tool",
"pump",
"repair-kit",
"lock",
// Clothing
"rain-jacket",
"base-layer",
"gloves",
"shoe",
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 },
// Additional manufacturers referenced in seed data
{ name: "Nemo", slug: "nemo", website: "https://nemoequipment.com", country: "US", tier: 1 },
{ name: "Therm-a-Rest", slug: "therm-a-rest", website: "https://thermarest.com", country: "US", tier: 1 },
{ name: "Toaks", slug: "toaks", website: "https://toaksoutdoor.com", country: "CN", tier: 1 },
{ name: "Katadyn", slug: "katadyn", website: "https://katadyn.com", country: "CH", tier: 1 },
{ name: "HydraPak", slug: "hydrapak", website: "https://hydrapak.com", country: "US", tier: 1 },
{ name: "Nitecore", slug: "nitecore", website: "https://nitecore.com", country: "CN", tier: 1 },
{ name: "Outdoor Research", slug: "outdoor-research", website: "https://outdoorresearch.com", country: "US", tier: 1 },
{ name: "Exposure Lights", slug: "exposure-lights", website: "https://exposurelights.com", country: "GB", tier: 1 },
];
/**
* Seed curated tags for outdoor/adventure gear.
* Idempotent: inserts only tags that don't already exist.
*/
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 });
@@ -83,17 +70,21 @@ export async function seedTags(db: Db = prodDb) {
}
}
/**
* Seed the global items table with initial bikepacking gear data.
* Idempotent: skips if any rows already exist.
*/
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 mfBySlug = new Map(allManufacturers.map((m) => [m.slug, m.id]));
for (const item of seedData) {
const manufacturerId = mfBySlug.get(item.manufacturerSlug);
if (!manufacturerId) continue;
await db.insert(globalItems).values({
brand: item.brand,
manufacturerId,
model: item.model,
category: item.category ?? null,
weightGrams: item.weightGrams ?? null,

View File

@@ -18,6 +18,7 @@ import { communityPriceRoutes } from "./routes/community-prices.ts";
import { discoveryRoutes } from "./routes/discovery.ts";
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
import { globalItemRoutes } from "./routes/global-items.ts";
import { manufacturerRoutes } from "./routes/manufacturers.ts";
import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts";
import { marketPriceRoutes } from "./routes/market-prices.ts";
@@ -290,6 +291,7 @@ app.route("/api/users", profileRoutes);
app.route("/api/setups", setupRoutes);
app.route("/api/discovery", discoveryRoutes);
app.route("/api/global-items", globalItemRoutes);
app.route("/api/manufacturers", manufacturerRoutes);
app.route("/api/onboarding", onboardingRoutes);
app.route("/api/tags", tagRoutes);
app.route("/api/exchange-rates", exchangeRateRoutes);

View File

@@ -22,10 +22,10 @@ function errorResult(message: string): ToolResult {
}
const catalogItemInputSchema = {
brand: z.string().describe("Brand or manufacturer name"),
manufacturerSlug: z.string().describe("Manufacturer slug (e.g. 'revelate-designs', 'apidura')"),
model: z
.string()
.describe("Model name — combined with brand forms the unique identifier"),
.describe("Model name — combined with manufacturerSlug forms the unique identifier"),
category: z
.string()
.optional()
@@ -80,7 +80,7 @@ export const catalogToolDefinitions = [
export function registerCatalogTools(db: Db) {
return {
upsert_catalog_item: async (args: {
brand: string;
manufacturerSlug: string;
model: string;
category?: string;
weightGrams?: number;
@@ -105,7 +105,7 @@ export function registerCatalogTools(db: Db) {
bulk_upsert_catalog: async (args: {
items: Array<{
brand: string;
manufacturerSlug: string;
model: string;
category?: string;
weightGrams?: number;

View File

@@ -0,0 +1,38 @@
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 };

View File

@@ -1,6 +1,6 @@
import { eq, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { categories, globalItems, items } from "../../db/schema.ts";
import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";
import { getOrCreateUncategorized } from "./category.service.ts";
type Db = typeof prodDb;
@@ -90,7 +90,7 @@ export async function exportItemsCsv(db: Db, userId: number): Promise<string> {
.select({
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
@@ -111,6 +111,7 @@ export async function exportItemsCsv(db: Db, userId: number): Promise<string> {
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(items.userId, userId));
const header =

View File

@@ -4,6 +4,7 @@ import {
globalItems,
globalItemTags,
items,
manufacturers,
setupItems,
setups,
tags,
@@ -86,14 +87,15 @@ export async function getRecentGlobalItems(
db: Db = prodDb,
limit = 8,
cursor?: string,
): Promise<CursorPage<typeof globalItems.$inferSelect>> {
): Promise<CursorPage<typeof globalItems.$inferSelect & { brand: string }>> {
const conditions = cursor
? [lt(globalItems.createdAt, new Date(cursor))]
: [];
const rows = await db
.select()
.select({ ...globalItems, brand: manufacturers.name })
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(globalItems.createdAt))
.limit(limit + 1);
@@ -160,7 +162,7 @@ export async function getPopularItemsByTags(
const rows = await db
.select({
id: globalItems.id,
brand: globalItems.brand,
brand: manufacturers.name,
model: globalItems.model,
category: globalItems.category,
weightGrams: globalItems.weightGrams,
@@ -170,6 +172,7 @@ export async function getPopularItemsByTags(
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
})
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id))
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
.leftJoin(items, eq(items.globalItemId, globalItems.id))

View File

@@ -1,17 +1,20 @@
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, tags } from "../../db/schema.ts";
import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts";
type Db = typeof prodDb;
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
/**
* Search global items by brand or model and/or tag names.
* Text search uses ILIKE for case-insensitive matching (PostgreSQL).
* Tag filtering uses AND logic -- items must have ALL specified tags.
* Escapes % and _ wildcard characters in user input.
*/
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,
@@ -23,7 +26,7 @@ export async function searchGlobalItems(
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escaped}%`;
conditions.push(
or(ilike(globalItems.brand, pattern), ilike(globalItems.model, pattern))!,
or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!,
);
}
@@ -43,24 +46,59 @@ export async function searchGlobalItems(
);
}
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 db.select().from(globalItems);
return baseQuery;
}
return db
.select()
.from(globalItems)
.where(and(...conditions));
return baseQuery.where(and(...conditions));
}
/**
* Get a single global item by ID with the count of user items referencing it
* via items.globalItemId.
*/
export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
const [item] = await db
.select()
.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;
@@ -73,10 +111,6 @@ export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
return { ...item, ownerCount: result?.ownerCount ?? 0 };
}
/**
* Sync tags for a global item: delete existing, re-insert provided tag names.
* Creates tags that don't exist yet (create-if-not-exists).
*/
async function syncGlobalItemTags(
tx: TxDb,
globalItemId: number,
@@ -97,15 +131,10 @@ async function syncGlobalItemTags(
}
}
/**
* Upsert a single global item by (brand, model).
* Creates if not exists, updates all non-key fields if exists.
* Tag sync: provided → sync; undefined → leave untouched; [] → clear all tags.
*/
export async function upsertGlobalItem(
db: Db,
data: {
brand: string;
manufacturerSlug: string;
model: string;
category?: string;
weightGrams?: number;
@@ -118,23 +147,25 @@ export async function upsertGlobalItem(
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.brand, data.brand),
eq(globalItems.manufacturerId, manufacturerId),
eq(globalItems.model, data.model),
),
);
const { tags: tagNames, ...itemData } = data;
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
manufacturerId,
model: itemData.model,
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
@@ -146,7 +177,7 @@ export async function upsertGlobalItem(
imageSourceUrl: itemData.imageSourceUrl ?? null,
})
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
target: [globalItems.manufacturerId, globalItems.model],
set: {
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
@@ -161,22 +192,17 @@ export async function upsertGlobalItem(
.returning();
if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames);
await syncGlobalItemTags(tx, item!.id, tagNames);
}
return { item, created: !existing };
return { item: item!, created: !existing };
});
}
/**
* Bulk upsert global items in a single transaction.
* Returns { created, updated, items } with accurate counts.
* Rolls back entirely if any item fails.
*/
export async function bulkUpsertGlobalItems(
db: Db,
itemsData: Array<{
brand: string;
manufacturerSlug: string;
model: string;
category?: string;
weightGrams?: number;
@@ -195,22 +221,24 @@ export async function bulkUpsertGlobalItems(
const resultItems = [];
for (const data of itemsData) {
const manufacturerId = await resolveManufacturerId(tx as unknown as Db, data.manufacturerSlug);
const [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(
and(
eq(globalItems.brand, data.brand),
eq(globalItems.manufacturerId, manufacturerId),
eq(globalItems.model, data.model),
),
);
const { tags: tagNames, ...itemData } = data;
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
manufacturerId,
model: itemData.model,
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
@@ -222,7 +250,7 @@ export async function bulkUpsertGlobalItems(
imageSourceUrl: itemData.imageSourceUrl ?? null,
})
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
target: [globalItems.manufacturerId, globalItems.model],
set: {
category: itemData.category ?? null,
weightGrams: itemData.weightGrams ?? null,
@@ -237,7 +265,7 @@ export async function bulkUpsertGlobalItems(
.returning();
if (tagNames !== undefined) {
await syncGlobalItemTags(tx, item.id, tagNames);
await syncGlobalItemTags(tx, item!.id, tagNames);
}
if (existing) {
@@ -245,7 +273,7 @@ export async function bulkUpsertGlobalItems(
} else {
created++;
}
resultItems.push(item);
resultItems.push(item!);
}
return { created, updated, items: resultItems };

View File

@@ -1,6 +1,6 @@
import { and, eq, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { categories, globalItems, items } from "../../db/schema.ts";
import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";
import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb;
@@ -11,7 +11,7 @@ export async function getAllItems(db: Db, userId: number) {
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
@@ -38,7 +38,7 @@ export async function getAllItems(db: Db, userId: number) {
globalItemId: items.globalItemId,
brand: sql<
string | null
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
>`COALESCE(${manufacturers.name}, ${items.brand})`.as("brand"),
dominantColor: items.dominantColor,
cropZoom: items.cropZoom,
cropX: items.cropX,
@@ -51,6 +51,7 @@ export async function getAllItems(db: Db, userId: number) {
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(items.userId, userId));
}
@@ -60,7 +61,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
@@ -87,7 +88,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
globalItemId: items.globalItemId,
brand: sql<
string | null
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
>`COALESCE(${manufacturers.name}, ${items.brand})`.as("brand"),
dominantColor: items.dominantColor,
cropZoom: items.cropZoom,
cropX: items.cropX,
@@ -100,6 +101,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
.from(items)
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(and(eq(items.id, id), eq(items.userId, userId)));
return row ?? null;
@@ -118,11 +120,12 @@ export async function createItem(
let name = data.name;
if (data.globalItemId) {
const [gi] = await db
.select({ brand: globalItems.brand, model: globalItems.model })
.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.brand} ${gi.model}`;
name = `${gi.name} ${gi.model}`;
}
}

View File

@@ -0,0 +1,42 @@
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!;
}

View File

@@ -4,6 +4,7 @@ import {
categories,
globalItems,
items,
manufacturers,
setupItems,
setups,
users,
@@ -97,7 +98,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
@@ -129,6 +130,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList };

View File

@@ -4,6 +4,7 @@ import {
categories,
globalItems,
items,
manufacturers,
setupItems,
setups,
} from "../../db/schema.ts";
@@ -79,7 +80,7 @@ export async function getSetupWithItems(
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
@@ -113,6 +114,7 @@ export async function getSetupWithItems(
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList };
@@ -131,7 +133,7 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
@@ -165,6 +167,7 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList };
@@ -185,14 +188,14 @@ export async function getSetupItemById(
id: items.id,
name: sql<string>`COALESCE(
CASE WHEN ${items.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${items.name}
END,
${items.name}
)`.as("name"),
brand: sql<
string | null
>`CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.brand} ELSE ${items.brand} END`.as(
>`CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${manufacturers.name} ELSE ${items.brand} END`.as(
"brand",
),
weightGrams: sql<number | null>`COALESCE(
@@ -221,6 +224,7 @@ export async function getSetupItemById(
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(and(eq(setupItems.setupId, setupId), eq(items.id, itemId)));
return row ?? null;

View File

@@ -4,6 +4,7 @@ import {
categories,
globalItems,
items,
manufacturers,
threadCandidates,
threads,
} from "../../db/schema.ts";
@@ -82,7 +83,7 @@ export async function getThreadWithCandidates(
threadId: threadCandidates.threadId,
name: sql<string>`COALESCE(
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
ELSE ${threadCandidates.name}
END,
${threadCandidates.name}
@@ -118,6 +119,7 @@ export async function getThreadWithCandidates(
.from(threadCandidates)
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(threadCandidates.threadId, threadId))
.orderBy(asc(threadCandidates.sortOrder));
@@ -367,10 +369,11 @@ export async function resolveThread(
if (candidate.globalItemId) {
// Reference item — link to global, personal fields only
const [gi] = await tx
.select()
.select({ name: manufacturers.name, model: globalItems.model })
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(eq(globalItems.id, candidate.globalItemId));
const fallbackName = gi ? `${gi.brand} ${gi.model}` : candidate.name;
const fallbackName = gi ? `${gi.name} ${gi.model}` : candidate.name;
insertValues = {
name: fallbackName,
globalItemId: candidate.globalItemId,

View File

@@ -125,7 +125,7 @@ export const searchGlobalItemsSchema = z.object({
// Catalog upsert schemas
export const upsertGlobalItemSchema = z.object({
brand: z.string().min(1, "Brand is required"),
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(),
@@ -146,6 +146,18 @@ export const bulkUpsertGlobalItemsSchema = z.object({
items: z.array(upsertGlobalItemSchema).min(1).max(100),
});
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(),
});
// Profile schemas
export const updateProfileSchema = z.object({
displayName: z.string().max(100).optional(),

View File

@@ -25,6 +25,8 @@ const TRUNCATE_TABLES = [
"setups",
"thread_candidates",
"threads",
"community_prices",
"market_prices",
"items",
"global_item_tags",
"global_items",
@@ -35,6 +37,7 @@ const TRUNCATE_TABLES = [
"api_keys",
"settings",
"categories",
"manufacturers",
"users",
];

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { manufacturers } from "../../src/db/schema.ts";
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.ts";
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.ts";
@@ -7,6 +8,16 @@ import { registerSetupTools } from "../../src/server/mcp/tools/setups.ts";
import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
import { createSecondTestUser, createTestDb } from "../helpers/db.ts";
async function insertManufacturer(db: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.onConflictDoUpdate({ target: manufacturers.slug, set: { name } })
.returning();
return row!;
}
function parseResult(result: {
content: Array<{ type: string; text: string }>;
}) {
@@ -256,15 +267,15 @@ describe("MCP Collection Summary Resource", () => {
describe("MCP Catalog Tools", () => {
test("upsert_catalog_item creates a new global item with created=true", async () => {
const { db } = await createTestDb();
await insertManufacturer(db, "Revelate Designs");
const tools = registerCatalogTools(db);
const result = await tools.upsert_catalog_item({
brand: "Revelate Designs",
manufacturerSlug: "revelate-designs",
model: "Terrapin System",
weightGrams: 235,
priceCents: 16500,
});
const data = parseResult(result);
expect(data.brand).toBe("Revelate Designs");
expect(data.model).toBe("Terrapin System");
expect(data.created).toBe(true);
expect(data.id).toBeDefined();
@@ -272,17 +283,18 @@ describe("MCP Catalog Tools", () => {
test("upsert_catalog_item updates existing item on brand+model match", async () => {
const { db } = await createTestDb();
await insertManufacturer(db, "Apidura");
const tools = registerCatalogTools(db);
// Create initial item
await tools.upsert_catalog_item({
brand: "Apidura",
manufacturerSlug: "apidura",
model: "Handlebar Pack",
});
// Update it
const result = await tools.upsert_catalog_item({
brand: "Apidura",
manufacturerSlug: "apidura",
model: "Handlebar Pack",
description: "Updated description",
weightGrams: 120,
@@ -295,10 +307,11 @@ describe("MCP Catalog Tools", () => {
test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => {
const { db } = await createTestDb();
await insertManufacturer(db, "MSR");
const tools = registerCatalogTools(db);
const result = await tools.upsert_catalog_item({
brand: "MSR",
manufacturerSlug: "msr",
model: "PocketRocket 2",
sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2",
imageCredit: "MSR Photography",
@@ -317,13 +330,16 @@ describe("MCP Catalog Tools", () => {
test("bulk_upsert_catalog processes array and returns created/updated counts", async () => {
const { db } = await createTestDb();
await insertManufacturer(db, "Revelate Designs");
await insertManufacturer(db, "Apidura");
await insertManufacturer(db, "MSR");
const tools = registerCatalogTools(db);
const result = await tools.bulk_upsert_catalog({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
{ brand: "MSR", model: "PocketRocket 2" },
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ manufacturerSlug: "apidura", model: "Handlebar Pack" },
{ manufacturerSlug: "msr", model: "PocketRocket 2" },
],
});
const data = parseResult(result);
@@ -335,18 +351,20 @@ describe("MCP Catalog Tools", () => {
test("bulk_upsert_catalog returns totalProcessed matching input length", async () => {
const { db } = await createTestDb();
await insertManufacturer(db, "Revelate Designs");
await insertManufacturer(db, "Apidura");
const tools = registerCatalogTools(db);
// Pre-create one item
await tools.upsert_catalog_item({
brand: "Revelate Designs",
manufacturerSlug: "revelate-designs",
model: "Terrapin System",
});
const result = await tools.bulk_upsert_catalog({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ manufacturerSlug: "apidura", model: "Handlebar Pack" },
],
});
const data = parseResult(result);

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { globalItems, setups } from "../../src/db/schema.ts";
import { globalItems, manufacturers, setups } from "../../src/db/schema.ts";
import { discoveryRoutes } from "../../src/server/routes/discovery.ts";
import { createTestDb } from "../helpers/db.ts";
@@ -20,17 +20,28 @@ async function createTestApp() {
return { app, db, userId };
}
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.onConflictDoUpdate({ target: manufacturers.slug, set: { name } })
.returning();
return row!;
}
async function insertGlobalItem(
db: TestDb["db"],
brand: string,
model: string,
category?: string,
) {
const m = await insertManufacturer(db, brand);
const [row] = await db
.insert(globalItems)
.values({ brand, model, category: category ?? "bags" })
.values({ manufacturerId: m.id, model, category: category ?? "bags" })
.returning();
return row;
return row!;
}
async function insertPublicSetup(
@@ -142,14 +153,16 @@ describe("Discovery Routes", () => {
const olderTime = new Date("2024-01-01T00:00:00Z");
const newerTime = new Date("2024-06-01T00:00:00Z");
const mA = await insertManufacturer(db, "Brand A");
const mB = await insertManufacturer(db, "Brand B");
await db.insert(globalItems).values({
brand: "Brand A",
manufacturerId: mA.id,
model: "Model A",
category: "bags",
createdAt: olderTime,
});
await db.insert(globalItems).values({
brand: "Brand B",
manufacturerId: mB.id,
model: "Model B",
category: "bags",
createdAt: newerTime,

View File

@@ -4,6 +4,7 @@ import {
globalItems,
globalItemTags,
items,
manufacturers,
tags,
} from "../../src/db/schema.ts";
import { globalItemRoutes } from "../../src/server/routes/global-items.ts";
@@ -25,16 +26,27 @@ async function createTestApp() {
return { app, db, userId };
}
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.onConflictDoUpdate({ target: manufacturers.slug, set: { name } })
.returning();
return row!;
}
async function insertGlobalItem(
db: TestDb["db"],
brand: string,
model: string,
) {
const m = await insertManufacturer(db, brand);
const [row] = await db
.insert(globalItems)
.values({ brand, model, category: "bags" })
.values({ manufacturerId: m.id, model, category: "bags" })
.returning();
return row;
return row!;
}
async function insertItem(
@@ -113,18 +125,18 @@ describe("Global Item Routes", () => {
describe("POST /api/global-items", () => {
it("returns 200 with item and created=true on new item", async () => {
await insertManufacturer(db, "Revelate Designs");
const res = await app.request("/api/global-items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brand: "Revelate Designs",
manufacturerSlug: "revelate-designs",
model: "Terrapin System",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.item.brand).toBe("Revelate Designs");
expect(body.item.model).toBe("Terrapin System");
expect(body.created).toBe(true);
});
@@ -136,7 +148,7 @@ describe("Global Item Routes", () => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brand: "Revelate Designs",
manufacturerSlug: "revelate-designs",
model: "Terrapin System",
description: "Updated description",
}),
@@ -148,7 +160,7 @@ describe("Global Item Routes", () => {
expect(body.item.description).toBe("Updated description");
});
it("returns 400 when brand is missing", async () => {
it("returns 400 when manufacturerSlug is missing", async () => {
const res = await app.request("/api/global-items", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -161,7 +173,7 @@ describe("Global Item Routes", () => {
const res = await app.request("/api/global-items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand: "Revelate Designs" }),
body: JSON.stringify({ manufacturerSlug: "revelate-designs" }),
});
expect(res.status).toBe(400);
});
@@ -169,13 +181,15 @@ describe("Global Item Routes", () => {
describe("POST /api/global-items/bulk", () => {
it("returns 200 with created/updated counts", async () => {
await insertManufacturer(db, "Revelate Designs");
await insertManufacturer(db, "Apidura");
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ manufacturerSlug: "apidura", model: "Handlebar Pack" },
],
}),
});
@@ -189,14 +203,15 @@ describe("Global Item Routes", () => {
it("returns correct counts for mix of new and existing items", async () => {
await insertGlobalItem(db, "Revelate Designs", "Terrapin System");
await insertManufacturer(db, "Apidura");
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ brand: "Apidura", model: "Handlebar Pack" },
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ manufacturerSlug: "apidura", model: "Handlebar Pack" },
],
}),
});
@@ -218,7 +233,7 @@ describe("Global Item Routes", () => {
it("returns 400 when items array exceeds 100", async () => {
const items = Array.from({ length: 101 }, (_, i) => ({
brand: `Brand${i}`,
manufacturerSlug: `brand${i}`,
model: `Model${i}`,
}));
const res = await app.request("/api/global-items/bulk", {
@@ -229,14 +244,14 @@ describe("Global Item Routes", () => {
expect(res.status).toBe(400);
});
it("returns 400 for invalid item in array (missing brand)", async () => {
it("returns 400 for invalid item in array (missing manufacturerSlug)", async () => {
const res = await app.request("/api/global-items/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{ brand: "Revelate Designs", model: "Terrapin System" },
{ model: "Invalid Item without brand" },
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
{ model: "Invalid Item without manufacturerSlug" },
],
}),
});

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import {
globalItems,
items,
manufacturers,
setupItems,
setups,
users,
@@ -16,19 +17,34 @@ import { createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [existing] = await db
.select()
.from(manufacturers)
.where(eq(manufacturers.slug, slug));
if (existing) return existing;
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.returning();
return row!;
}
async function insertGlobalItem(
db: TestDb["db"],
data: { brand: string; model: string; category?: string },
) {
const m = await insertManufacturer(db, data.brand);
const [row] = await db
.insert(globalItems)
.values({
brand: data.brand,
manufacturerId: m.id,
model: data.model,
category: data.category ?? null,
})
.returning();
return row;
return row!;
}
async function insertItem(db: TestDb["db"], userId: number, categoryId = 1) {

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm";
import * as schema from "../../src/db/schema.ts";
import {
globalItems,
globalItemTags,
@@ -17,10 +18,18 @@ import { createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
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: {
brand: string;
manufacturerId: number;
model: string;
category?: string;
weightGrams?: number;
@@ -30,14 +39,14 @@ async function insertGlobalItem(
const [row] = await db
.insert(globalItems)
.values({
brand: data.brand,
manufacturerId: data.manufacturerId,
model: data.model,
category: data.category ?? null,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
})
.returning();
return row;
return row!;
}
async function insertItem(
@@ -78,28 +87,20 @@ describe("Global Item Service", () => {
describe("searchGlobalItems", () => {
it("returns all global items when no query provided", async () => {
await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const results = await searchGlobalItems(db);
expect(results).toHaveLength(2);
});
it("returns items matching brand (case-insensitive)", async () => {
await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const results = await searchGlobalItems(db, "revelate");
expect(results).toHaveLength(1);
@@ -107,14 +108,10 @@ describe("Global Item Service", () => {
});
it("returns items matching model (case-insensitive)", async () => {
await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const results = await searchGlobalItems(db, "HANDLEBAR");
expect(results).toHaveLength(1);
@@ -122,42 +119,30 @@ describe("Global Item Service", () => {
});
it("does not match everything with wildcard chars", async () => {
await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const results = await searchGlobalItems(db, "100%");
expect(results).toHaveLength(0);
});
it("returns all items when no tags provided", async () => {
await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const results = await searchGlobalItems(db, undefined, undefined);
expect(results).toHaveLength(2);
});
it("filters by single tag", async () => {
const gi1 = await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
const _gi2 = await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m2 = await insertManufacturer(db, "Apidura", "apidura");
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
const _gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const tag = await insertTag(db, "ultralight");
await tagGlobalItem(db, gi1.id, tag.id);
@@ -168,14 +153,10 @@ describe("Global Item Service", () => {
});
it("filters by multiple tags with AND logic", async () => {
const gi1 = await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
const gi2 = await insertGlobalItem(db, {
brand: "Apidura",
model: "Handlebar Pack",
});
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m2 = await insertManufacturer(db, "Apidura", "apidura");
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
const gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const tagUL = await insertTag(db, "ultralight");
const tagBP = await insertTag(db, "bikepacking");
@@ -194,14 +175,9 @@ describe("Global Item Service", () => {
});
it("combines text search and tag filtering", async () => {
const gi1 = await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Terrapin System",
});
const gi2 = await insertGlobalItem(db, {
brand: "Revelate Designs",
model: "Spinelock",
});
const m = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const gi1 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Terrapin System" });
const gi2 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Spinelock" });
const tag = await insertTag(db, "bikepacking");
await tagGlobalItem(db, gi1.id, tag.id);
@@ -216,10 +192,8 @@ describe("Global Item Service", () => {
describe("getGlobalItemWithOwnerCount", () => {
it("returns item with ownerCount 0 when no items reference it", async () => {
const gi = await insertGlobalItem(db, {
brand: "MSR",
model: "PocketRocket 2",
});
const m = await insertManufacturer(db, "MSR", "msr");
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
const result = await getGlobalItemWithOwnerCount(db, gi.id);
expect(result).not.toBeNull();
@@ -228,10 +202,8 @@ describe("Global Item Service", () => {
});
it("returns ownerCount matching number of items with globalItemId", async () => {
const gi = await insertGlobalItem(db, {
brand: "MSR",
model: "PocketRocket 2",
});
const m = await insertManufacturer(db, "MSR", "msr");
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
await insertItem(db, "My Stove", userId, { globalItemId: gi.id });
await insertItem(db, "Another Stove", userId, {
@@ -269,8 +241,9 @@ describe("Global Item Service", () => {
describe("upsert operations", () => {
it("upsertGlobalItem creates new item and returns { item, created: true }", async () => {
await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const result = await upsertGlobalItem(db, {
brand: "Revelate Designs",
manufacturerSlug: "revelate-designs",
model: "Terrapin System",
category: "Bags",
weightGrams: 210,
@@ -278,19 +251,19 @@ describe("Global Item Service", () => {
expect(result.created).toBe(true);
expect(result.item.id).toBeDefined();
expect(result.item.brand).toBe("Revelate Designs");
expect(result.item.model).toBe("Terrapin System");
});
it("upsertGlobalItem updates existing item on (brand, model) conflict and returns { item, created: false }", async () => {
it("upsertGlobalItem updates existing item on (manufacturerId, model) conflict and returns { item, created: false }", async () => {
await insertManufacturer(db, "MSR", "msr");
await upsertGlobalItem(db, {
brand: "MSR",
manufacturerSlug: "msr",
model: "PocketRocket 2",
weightGrams: 83,
});
const second = await upsertGlobalItem(db, {
brand: "MSR",
manufacturerSlug: "msr",
model: "PocketRocket 2",
weightGrams: 90,
});
@@ -304,8 +277,9 @@ describe("Global Item Service", () => {
});
it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => {
await insertManufacturer(db, "Apidura", "apidura");
const result = await upsertGlobalItem(db, {
brand: "Apidura",
manufacturerSlug: "apidura",
model: "Handlebar Pack",
sourceUrl: "https://apidura.com/shop/handlebar-pack/",
imageCredit: "Apidura Ltd",
@@ -322,8 +296,9 @@ describe("Global Item Service", () => {
});
it("upsertGlobalItem with tags creates tags and links them", async () => {
await insertManufacturer(db, "Therm-a-Rest", "therm-a-rest");
const result = await upsertGlobalItem(db, {
brand: "Therm-a-Rest",
manufacturerSlug: "therm-a-rest",
model: "NeoAir XLite",
tags: ["sleeping-pad", "ultralight"],
});
@@ -342,16 +317,17 @@ describe("Global Item Service", () => {
});
it("upsertGlobalItem without tags leaves existing tags untouched", async () => {
await insertManufacturer(db, "Sea to Summit", "sea-to-summit");
// Create item with tags
const first = await upsertGlobalItem(db, {
brand: "Sea to Summit",
manufacturerSlug: "sea-to-summit",
model: "Spark III",
tags: ["sleeping-bag"],
});
// Upsert without tags
await upsertGlobalItem(db, {
brand: "Sea to Summit",
manufacturerSlug: "sea-to-summit",
model: "Spark III",
weightGrams: 450,
});
@@ -366,16 +342,17 @@ describe("Global Item Service", () => {
});
it("upsertGlobalItem with empty tags array clears existing tags", async () => {
await insertManufacturer(db, "Big Agnes", "big-agnes");
// Create item with tags
const first = await upsertGlobalItem(db, {
brand: "Big Agnes",
manufacturerSlug: "big-agnes",
model: "Copper Spur HV UL2",
tags: ["tent", "ultralight"],
});
// Upsert with empty tags
await upsertGlobalItem(db, {
brand: "Big Agnes",
manufacturerSlug: "big-agnes",
model: "Copper Spur HV UL2",
tags: [],
});
@@ -390,10 +367,12 @@ describe("Global Item Service", () => {
});
it("bulkUpsertGlobalItems processes array and returns correct created/updated counts", async () => {
await insertManufacturer(db, "Petzl", "petzl");
await insertManufacturer(db, "Black Diamond", "black-diamond");
const result = await bulkUpsertGlobalItems(db, [
{ brand: "Petzl", model: "Actik Core", weightGrams: 87 },
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 },
{ brand: "Black Diamond", model: "Spot 350", weightGrams: 90 },
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 },
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 },
{ manufacturerSlug: "black-diamond", model: "Spot 350", weightGrams: 90 },
]);
expect(result.created).toBe(3);
@@ -402,16 +381,18 @@ describe("Global Item Service", () => {
});
it("bulkUpsertGlobalItems handles mix of new and existing items", async () => {
await insertManufacturer(db, "Petzl", "petzl");
await insertManufacturer(db, "Black Diamond", "black-diamond");
// Pre-insert one item
await upsertGlobalItem(db, {
brand: "Petzl",
manufacturerSlug: "petzl",
model: "Actik Core",
weightGrams: 87,
});
const result = await bulkUpsertGlobalItems(db, [
{ brand: "Petzl", model: "Actik Core", weightGrams: 90 }, // existing
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, // new
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 }, // new
]);
expect(result.created).toBe(1);

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { globalItems } from "../../src/db/schema.ts";
import { globalItems, manufacturers } from "../../src/db/schema.ts";
import {
createItem,
deleteItem,
@@ -170,6 +170,15 @@ describe("Item Service", () => {
});
describe("reference items (globalItemId)", () => {
async function insertManufacturer(testDb: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await testDb
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.returning();
return row;
}
async function insertGlobalItem(
testDb: any,
data: {
@@ -180,7 +189,14 @@ describe("Item Service", () => {
imageUrl?: string;
},
) {
const [row] = await testDb.insert(globalItems).values(data).returning();
const m = await insertManufacturer(testDb, data.brand);
const [row] = await testDb.insert(globalItems).values({
manufacturerId: m.id,
model: data.model,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
imageUrl: data.imageUrl ?? null,
}).returning();
return row;
}

View File

@@ -0,0 +1,72 @@
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");
});
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { globalItems } from "../../src/db/schema.ts";
import { globalItems, manufacturers } from "../../src/db/schema.ts";
import {
createCandidate,
createThread,
@@ -618,6 +618,15 @@ describe("Thread Service", () => {
});
describe("catalog-linked candidates (globalItemId)", () => {
async function insertManufacturer(testDb: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await testDb
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.returning();
return row;
}
async function insertGlobalItem(
testDb: any,
data: {
@@ -628,7 +637,14 @@ describe("Thread Service", () => {
imageUrl?: string;
},
) {
const [row] = await testDb.insert(globalItems).values(data).returning();
const m = await insertManufacturer(testDb, data.brand);
const [row] = await testDb.insert(globalItems).values({
manufacturerId: m.id,
model: data.model,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
imageUrl: data.imageUrl ?? null,
}).returning();
return row;
}