Compare commits
17 Commits
bea386e7db
...
16058d0f4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 16058d0f4d | |||
| 065b262b5b | |||
| 44602d409e | |||
| 3d2911cedc | |||
| b2a725a646 | |||
| 44b1eac0ba | |||
| 0b4715b80c | |||
| a508773809 | |||
| 2924c2269c | |||
| 12b3f8e380 | |||
| 5037350aa0 | |||
| 8ff680ef92 | |||
| f868bbdecf | |||
| ec27df1d0f | |||
| 8c1b19f07d | |||
| 7de3e9e957 | |||
| 2cb83a63f1 |
7
bun.lock
7
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "gearbox",
|
"name": "gearbox",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
"@aws-sdk/client-s3": "^3.1024.0",
|
"@aws-sdk/client-s3": "^3.1024.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1024.0",
|
"@aws-sdk/s3-request-presigner": "^3.1024.0",
|
||||||
"@hono/oidc-auth": "^1.8.1",
|
"@hono/oidc-auth": "^1.8.1",
|
||||||
@@ -53,6 +54,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
"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-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -429,48 +429,7 @@ main().catch((err) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Add market prices upsert after the bulk upsert**
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
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**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add scripts/crawl-manufacturer.ts
|
git add scripts/crawl-manufacturer.ts
|
||||||
|
|||||||
12
drizzle-pg/0007_steady_sasquatch.sql
Normal file
12
drizzle-pg/0007_steady_sasquatch.sql
Normal 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")
|
||||||
|
);
|
||||||
4
drizzle-pg/0008_productive_tyrannus.sql
Normal file
4
drizzle-pg/0008_productive_tyrannus.sql
Normal 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");
|
||||||
1683
drizzle-pg/meta/0007_snapshot.json
Normal file
1683
drizzle-pg/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1697
drizzle-pg/meta/0008_snapshot.json
Normal file
1697
drizzle-pg/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,20 @@
|
|||||||
"when": 1776096142720,
|
"when": 1776096142720,
|
||||||
"tag": "0006_remarkable_susan_delgado",
|
"tag": "0006_remarkable_susan_delgado",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
"test:e2e:ui": "bunx playwright test --ui",
|
"test:e2e:ui": "bunx playwright test --ui",
|
||||||
"lint": "bunx @biomejs/biome check .",
|
"lint": "bunx @biomejs/biome check .",
|
||||||
"db:seed:dev": "bun run src/db/dev-seed.ts",
|
"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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.7",
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
"@aws-sdk/client-s3": "^3.1024.0",
|
"@aws-sdk/client-s3": "^3.1024.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1024.0",
|
"@aws-sdk/s3-request-presigner": "^3.1024.0",
|
||||||
"@hono/oidc-auth": "^1.8.1",
|
"@hono/oidc-auth": "^1.8.1",
|
||||||
|
|||||||
84
scripts/crawl-all.ts
Normal file
84
scripts/crawl-all.ts
Normal 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);
|
||||||
|
});
|
||||||
308
scripts/crawl-manufacturer.ts
Normal file
308
scripts/crawl-manufacturer.ts
Normal 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);
|
||||||
|
});
|
||||||
20
scripts/taxonomy/categories.ts
Normal file
20
scripts/taxonomy/categories.ts
Normal 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
31
scripts/taxonomy/tags.ts
Normal 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];
|
||||||
@@ -17,13 +17,34 @@ export const DEV_CATEGORIES = [
|
|||||||
{ name: "Navigation", icon: "compass" },
|
{ name: "Navigation", icon: "compass" },
|
||||||
] as const;
|
] 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 ───────────────────────────────────────────────────
|
// ── Global Items ───────────────────────────────────────────────────
|
||||||
// Index positions are referenced by user items, thread candidates, and tag assignments.
|
// Index positions are referenced by user items, thread candidates, and tag assignments.
|
||||||
|
|
||||||
export const DEV_GLOBAL_ITEMS = [
|
export const DEV_GLOBAL_ITEMS = [
|
||||||
// Bags (indices 0-5)
|
// Bags (indices 0-5)
|
||||||
{
|
{
|
||||||
brand: "Revelate Designs",
|
manufacturerSlug: "revelate-designs",
|
||||||
model: "Terrapin System",
|
model: "Terrapin System",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
weightGrams: 529,
|
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.",
|
"Waterproof saddle bag with 14L capacity, roll-top closure, and integrated seat bag mount.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Apidura",
|
manufacturerSlug: "apidura",
|
||||||
model: "Expedition Handlebar Pack",
|
model: "Expedition Handlebar Pack",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
weightGrams: 300,
|
weightGrams: 300,
|
||||||
@@ -41,7 +62,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"14L waterproof handlebar roll bag with internal dry bag and accessory pocket.",
|
"14L waterproof handlebar roll bag with internal dry bag and accessory pocket.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Ortlieb",
|
manufacturerSlug: "ortlieb",
|
||||||
model: "Frame-Pack RC",
|
model: "Frame-Pack RC",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
weightGrams: 250,
|
weightGrams: 250,
|
||||||
@@ -50,7 +71,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"6L waterproof roll-closure frame bag with TIZIP zipper for full-frame bikes.",
|
"6L waterproof roll-closure frame bag with TIZIP zipper for full-frame bikes.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Rockgeist",
|
manufacturerSlug: "rockgeist",
|
||||||
model: "BarJam",
|
model: "BarJam",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
weightGrams: 142,
|
weightGrams: 142,
|
||||||
@@ -59,7 +80,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Ultralight handlebar harness with side-loading dry bag compatibility.",
|
"Ultralight handlebar harness with side-loading dry bag compatibility.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Oveja Negra",
|
manufacturerSlug: "oveja-negra",
|
||||||
model: "Superwedgie",
|
model: "Superwedgie",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
weightGrams: 170,
|
weightGrams: 170,
|
||||||
@@ -68,7 +89,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Half-frame bag with easy-access zipper and internal organization.",
|
"Half-frame bag with easy-access zipper and internal organization.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Apidura",
|
manufacturerSlug: "apidura",
|
||||||
model: "Racing Top Tube Pack",
|
model: "Racing Top Tube Pack",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
weightGrams: 72,
|
weightGrams: 72,
|
||||||
@@ -79,7 +100,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Shelter (indices 6-9)
|
// Shelter (indices 6-9)
|
||||||
{
|
{
|
||||||
brand: "Zpacks",
|
manufacturerSlug: "zpacks",
|
||||||
model: "Duplex",
|
model: "Duplex",
|
||||||
category: "shelter",
|
category: "shelter",
|
||||||
weightGrams: 539,
|
weightGrams: 539,
|
||||||
@@ -88,7 +109,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Dyneema Composite Fabric two-person trekking pole shelter, freestanding with optional poles.",
|
"Dyneema Composite Fabric two-person trekking pole shelter, freestanding with optional poles.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Tarptent",
|
manufacturerSlug: "tarptent",
|
||||||
model: "Stratospire Li",
|
model: "Stratospire Li",
|
||||||
category: "shelter",
|
category: "shelter",
|
||||||
weightGrams: 737,
|
weightGrams: 737,
|
||||||
@@ -97,7 +118,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Two-person double-wall tent in Dyneema with dual vestibules and excellent ventilation.",
|
"Two-person double-wall tent in Dyneema with dual vestibules and excellent ventilation.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Durston",
|
manufacturerSlug: "durston",
|
||||||
model: "X-Mid 1 Solid",
|
model: "X-Mid 1 Solid",
|
||||||
category: "shelter",
|
category: "shelter",
|
||||||
weightGrams: 880,
|
weightGrams: 880,
|
||||||
@@ -106,7 +127,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Single-wall silpoly trekking pole tent with symmetrical design and two vestibules.",
|
"Single-wall silpoly trekking pole tent with symmetrical design and two vestibules.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Big Agnes",
|
manufacturerSlug: "big-agnes",
|
||||||
model: "Copper Spur HV UL1",
|
model: "Copper Spur HV UL1",
|
||||||
category: "shelter",
|
category: "shelter",
|
||||||
weightGrams: 936,
|
weightGrams: 936,
|
||||||
@@ -117,7 +138,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Sleep System (indices 10-14)
|
// Sleep System (indices 10-14)
|
||||||
{
|
{
|
||||||
brand: "Enlightened Equipment",
|
manufacturerSlug: "enlightened-equipment",
|
||||||
model: "Enigma 20F",
|
model: "Enigma 20F",
|
||||||
category: "sleep",
|
category: "sleep",
|
||||||
weightGrams: 567,
|
weightGrams: 567,
|
||||||
@@ -126,7 +147,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"20F down quilt with 850FP DownTek water-resistant fill, sewn footbox option.",
|
"20F down quilt with 850FP DownTek water-resistant fill, sewn footbox option.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Therm-a-Rest",
|
manufacturerSlug: "therm-a-rest",
|
||||||
model: "NeoAir XLite NXT",
|
model: "NeoAir XLite NXT",
|
||||||
category: "sleep",
|
category: "sleep",
|
||||||
weightGrams: 354,
|
weightGrams: 354,
|
||||||
@@ -135,7 +156,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"R-value 4.5 ultralight inflatable sleeping pad with ThermaCapture reflective technology.",
|
"R-value 4.5 ultralight inflatable sleeping pad with ThermaCapture reflective technology.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Nemo",
|
manufacturerSlug: "nemo",
|
||||||
model: "Tensor Insulated Regular",
|
model: "Tensor Insulated Regular",
|
||||||
category: "sleep",
|
category: "sleep",
|
||||||
weightGrams: 425,
|
weightGrams: 425,
|
||||||
@@ -144,7 +165,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"R-value 4.2 insulated sleeping pad with Spaceframe baffles for stability.",
|
"R-value 4.2 insulated sleeping pad with Spaceframe baffles for stability.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Sea to Summit",
|
manufacturerSlug: "sea-to-summit",
|
||||||
model: "Aeros Premium Pillow",
|
model: "Aeros Premium Pillow",
|
||||||
category: "sleep",
|
category: "sleep",
|
||||||
weightGrams: 79,
|
weightGrams: 79,
|
||||||
@@ -153,7 +174,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Brushed 50D polyester inflatable pillow with multifunctional valve.",
|
"Brushed 50D polyester inflatable pillow with multifunctional valve.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Western Mountaineering",
|
manufacturerSlug: "western-mountaineering",
|
||||||
model: "NanoLite 22F",
|
model: "NanoLite 22F",
|
||||||
category: "sleep",
|
category: "sleep",
|
||||||
weightGrams: 510,
|
weightGrams: 510,
|
||||||
@@ -164,7 +185,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Cooking (indices 15-19)
|
// Cooking (indices 15-19)
|
||||||
{
|
{
|
||||||
brand: "BRS",
|
manufacturerSlug: "brs",
|
||||||
model: "BRS-3000T",
|
model: "BRS-3000T",
|
||||||
category: "cooking",
|
category: "cooking",
|
||||||
weightGrams: 25,
|
weightGrams: 25,
|
||||||
@@ -173,7 +194,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Ultralight titanium canister stove, 25g with piezo ignition, 2700W output.",
|
"Ultralight titanium canister stove, 25g with piezo ignition, 2700W output.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Soto",
|
manufacturerSlug: "soto",
|
||||||
model: "WindMaster",
|
model: "WindMaster",
|
||||||
category: "cooking",
|
category: "cooking",
|
||||||
weightGrams: 67,
|
weightGrams: 67,
|
||||||
@@ -182,7 +203,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Micro-regulator stove with concave burner head for excellent wind resistance.",
|
"Micro-regulator stove with concave burner head for excellent wind resistance.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Toaks",
|
manufacturerSlug: "toaks",
|
||||||
model: "Light Titanium 750ml",
|
model: "Light Titanium 750ml",
|
||||||
category: "cooking",
|
category: "cooking",
|
||||||
weightGrams: 86,
|
weightGrams: 86,
|
||||||
@@ -191,7 +212,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Titanium pot with graduated measurements, lid, and folding bail handle.",
|
"Titanium pot with graduated measurements, lid, and folding bail handle.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Snow Peak",
|
manufacturerSlug: "snow-peak",
|
||||||
model: "Ti-Mini Solo Combo",
|
model: "Ti-Mini Solo Combo",
|
||||||
category: "cooking",
|
category: "cooking",
|
||||||
weightGrams: 198,
|
weightGrams: 198,
|
||||||
@@ -200,7 +221,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Titanium cookset with 850ml pot, lid/pan, and nesting mug for solo cooking.",
|
"Titanium cookset with 850ml pot, lid/pan, and nesting mug for solo cooking.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "MSR",
|
manufacturerSlug: "msr",
|
||||||
model: "PocketRocket Deluxe",
|
model: "PocketRocket Deluxe",
|
||||||
category: "cooking",
|
category: "cooking",
|
||||||
weightGrams: 83,
|
weightGrams: 83,
|
||||||
@@ -211,7 +232,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Lighting (indices 20-22)
|
// Lighting (indices 20-22)
|
||||||
{
|
{
|
||||||
brand: "Nitecore",
|
manufacturerSlug: "nitecore",
|
||||||
model: "NU25 UL",
|
model: "NU25 UL",
|
||||||
category: "lighting",
|
category: "lighting",
|
||||||
weightGrams: 28,
|
weightGrams: 28,
|
||||||
@@ -220,7 +241,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Rechargeable ultralight headlamp with 400 lumens, red/high-CRI aux LEDs.",
|
"Rechargeable ultralight headlamp with 400 lumens, red/high-CRI aux LEDs.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Lezyne",
|
manufacturerSlug: "lezyne",
|
||||||
model: "Lite Drive 1200+",
|
model: "Lite Drive 1200+",
|
||||||
category: "lighting",
|
category: "lighting",
|
||||||
weightGrams: 176,
|
weightGrams: 176,
|
||||||
@@ -229,7 +250,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"1200 lumen USB-C rechargeable bike light with MOR optical lens design.",
|
"1200 lumen USB-C rechargeable bike light with MOR optical lens design.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Fenix",
|
manufacturerSlug: "fenix",
|
||||||
model: "HL60R",
|
model: "HL60R",
|
||||||
category: "lighting",
|
category: "lighting",
|
||||||
weightGrams: 134,
|
weightGrams: 134,
|
||||||
@@ -240,7 +261,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Tools & Repair (indices 23-25)
|
// Tools & Repair (indices 23-25)
|
||||||
{
|
{
|
||||||
brand: "Park Tool",
|
manufacturerSlug: "park-tool",
|
||||||
model: "IB-3",
|
model: "IB-3",
|
||||||
category: "tools",
|
category: "tools",
|
||||||
weightGrams: 175,
|
weightGrams: 175,
|
||||||
@@ -249,7 +270,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Folding hex/Torx multi-tool with 3-6mm hex, T25, Phillips and flathead.",
|
"Folding hex/Torx multi-tool with 3-6mm hex, T25, Phillips and flathead.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Lezyne",
|
manufacturerSlug: "lezyne",
|
||||||
model: "CNC Chain Breaker",
|
model: "CNC Chain Breaker",
|
||||||
category: "tools",
|
category: "tools",
|
||||||
weightGrams: 28,
|
weightGrams: 28,
|
||||||
@@ -258,7 +279,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"CNC-machined aluminum chain tool compatible with 8-12 speed chains.",
|
"CNC-machined aluminum chain tool compatible with 8-12 speed chains.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Gorilla Tape",
|
manufacturerSlug: "gorilla-tape",
|
||||||
model: "Mini Duct Tape Roll",
|
model: "Mini Duct Tape Roll",
|
||||||
category: "tools",
|
category: "tools",
|
||||||
weightGrams: 30,
|
weightGrams: 30,
|
||||||
@@ -268,7 +289,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Clothing (indices 26-28)
|
// Clothing (indices 26-28)
|
||||||
{
|
{
|
||||||
brand: "Patagonia",
|
manufacturerSlug: "patagonia",
|
||||||
model: "R1 Air Full-Zip",
|
model: "R1 Air Full-Zip",
|
||||||
category: "clothing",
|
category: "clothing",
|
||||||
weightGrams: 266,
|
weightGrams: 266,
|
||||||
@@ -277,7 +298,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Breathable midlayer fleece with open-knit R1 Air fabric for high-output activities.",
|
"Breathable midlayer fleece with open-knit R1 Air fabric for high-output activities.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Frogg Toggs",
|
manufacturerSlug: "frogg-toggs",
|
||||||
model: "Ultra-Lite2 Rain Suit",
|
model: "Ultra-Lite2 Rain Suit",
|
||||||
category: "clothing",
|
category: "clothing",
|
||||||
weightGrams: 340,
|
weightGrams: 340,
|
||||||
@@ -286,7 +307,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"Budget ultralight rain jacket and pants set, DriPore breathable material.",
|
"Budget ultralight rain jacket and pants set, DriPore breathable material.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Buff",
|
manufacturerSlug: "buff",
|
||||||
model: "Merino Wool Multifunctional",
|
model: "Merino Wool Multifunctional",
|
||||||
category: "clothing",
|
category: "clothing",
|
||||||
weightGrams: 43,
|
weightGrams: 43,
|
||||||
@@ -297,7 +318,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Water (indices 29-31)
|
// Water (indices 29-31)
|
||||||
{
|
{
|
||||||
brand: "Sawyer",
|
manufacturerSlug: "sawyer",
|
||||||
model: "Squeeze SP129",
|
model: "Squeeze SP129",
|
||||||
category: "water",
|
category: "water",
|
||||||
weightGrams: 85,
|
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.",
|
"0.1 micron hollow-fiber water filter with high flow rate and backflush capability.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Katadyn",
|
manufacturerSlug: "katadyn",
|
||||||
model: "BeFree 1L",
|
model: "BeFree 1L",
|
||||||
category: "water",
|
category: "water",
|
||||||
weightGrams: 63,
|
weightGrams: 63,
|
||||||
@@ -315,7 +336,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
"EZ-Clean membrane filter with collapsible Hydrapak flask, 2L/min flow rate.",
|
"EZ-Clean membrane filter with collapsible Hydrapak flask, 2L/min flow rate.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "HydraPak",
|
manufacturerSlug: "hydrapak",
|
||||||
model: "Seeker 2L",
|
model: "Seeker 2L",
|
||||||
category: "water",
|
category: "water",
|
||||||
weightGrams: 76,
|
weightGrams: 76,
|
||||||
@@ -326,7 +347,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Electronics (indices 32-33)
|
// Electronics (indices 32-33)
|
||||||
{
|
{
|
||||||
brand: "Anker",
|
manufacturerSlug: "anker",
|
||||||
model: "Nano Power Bank 10000 PD",
|
model: "Nano Power Bank 10000 PD",
|
||||||
category: "electronics",
|
category: "electronics",
|
||||||
weightGrams: 220,
|
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.",
|
"10000mAh 30W USB-C PD power bank with built-in display and passthrough charging.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Garmin",
|
manufacturerSlug: "garmin",
|
||||||
model: "inReach Mini 2",
|
model: "inReach Mini 2",
|
||||||
category: "electronics",
|
category: "electronics",
|
||||||
weightGrams: 100,
|
weightGrams: 100,
|
||||||
@@ -346,7 +367,7 @@ export const DEV_GLOBAL_ITEMS = [
|
|||||||
|
|
||||||
// Navigation (indices 34-35)
|
// Navigation (indices 34-35)
|
||||||
{
|
{
|
||||||
brand: "Wahoo",
|
manufacturerSlug: "wahoo",
|
||||||
model: "ELEMNT BOLT V2",
|
model: "ELEMNT BOLT V2",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
weightGrams: 68,
|
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.",
|
"GPS cycling computer with color display, turn-by-turn navigation, and smart trainer integration.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
brand: "Ortlieb",
|
manufacturerSlug: "ortlieb",
|
||||||
model: "Ultimate Six Classic",
|
model: "Ultimate Six Classic",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
weightGrams: 500,
|
weightGrams: 500,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { and, eq, like, sql } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
DEV_CATEGORIES,
|
DEV_CATEGORIES,
|
||||||
DEV_GLOBAL_ITEMS,
|
DEV_GLOBAL_ITEMS,
|
||||||
|
DEV_MANUFACTURERS,
|
||||||
DEV_MARKET_PRICES,
|
DEV_MARKET_PRICES,
|
||||||
DEV_SETTINGS,
|
DEV_SETTINGS,
|
||||||
DEV_SETUPS,
|
DEV_SETUPS,
|
||||||
@@ -79,9 +80,12 @@ async function seedDevData(database: Db = db) {
|
|||||||
await clearDevData(database);
|
await clearDevData(database);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── 1. Seed global items and tags ──────────────────────────
|
// ── 1. Seed global items, tags, and dev-specific manufacturers ─
|
||||||
await seedGlobalItems(database);
|
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 ─────────────────────────────────────
|
// ── 2. Insert dev user ─────────────────────────────────────
|
||||||
const [user] = await database
|
const [user] = await database
|
||||||
@@ -123,20 +127,29 @@ async function seedDevData(database: Db = db) {
|
|||||||
|
|
||||||
// ── 5. Insert global items and tag assignments ─────────────
|
// ── 5. Insert global items and tag assignments ─────────────
|
||||||
// DEV_GLOBAL_ITEMS may overlap with seed-global-items.json entries.
|
// 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
|
const existingGlobalItems = await database
|
||||||
.select()
|
.select()
|
||||||
.from(schema.globalItems);
|
.from(schema.globalItems);
|
||||||
const existingGlobalItemMap = new Map<string, number>();
|
const existingGlobalItemMap = new Map<string, number>();
|
||||||
for (const gi of existingGlobalItems) {
|
for (const gi of existingGlobalItems) {
|
||||||
existingGlobalItemMap.set(`${gi.brand}::${gi.model}`, gi.id);
|
existingGlobalItemMap.set(`${gi.manufacturerId}::${gi.model}`, gi.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalItemIds: number[] = [];
|
const globalItemIds: number[] = [];
|
||||||
let newGlobalCount = 0;
|
let newGlobalCount = 0;
|
||||||
|
|
||||||
for (const item of DEV_GLOBAL_ITEMS) {
|
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);
|
const existingId = existingGlobalItemMap.get(key);
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
globalItemIds.push(existingId);
|
globalItemIds.push(existingId);
|
||||||
@@ -144,7 +157,7 @@ async function seedDevData(database: Db = db) {
|
|||||||
const [inserted] = await database
|
const [inserted] = await database
|
||||||
.insert(schema.globalItems)
|
.insert(schema.globalItems)
|
||||||
.values({
|
.values({
|
||||||
brand: item.brand,
|
manufacturerId: mfId,
|
||||||
model: item.model,
|
model: item.model,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
weightGrams: item.weightGrams,
|
weightGrams: item.weightGrams,
|
||||||
@@ -154,7 +167,7 @@ async function seedDevData(database: Db = db) {
|
|||||||
.returning();
|
.returning();
|
||||||
if (!inserted)
|
if (!inserted)
|
||||||
throw new Error(
|
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);
|
globalItemIds.push(inserted.id);
|
||||||
newGlobalCount++;
|
newGlobalCount++;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"brand": "Revelate Designs",
|
"manufacturerSlug": "revelate-designs",
|
||||||
"model": "Terrapin System",
|
"model": "Terrapin System",
|
||||||
"category": "bags",
|
"category": "bags",
|
||||||
"weightGrams": 529,
|
"weightGrams": 529,
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"description": "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated Revelate seat bag mount."
|
"description": "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated Revelate seat bag mount."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Apidura",
|
"manufacturerSlug": "apidura",
|
||||||
"model": "Expedition Handlebar Pack",
|
"model": "Expedition Handlebar Pack",
|
||||||
"category": "bags",
|
"category": "bags",
|
||||||
"weightGrams": 300,
|
"weightGrams": 300,
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket."
|
"description": "14L waterproof handlebar roll bag with internal dry bag and accessory pocket."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Ortlieb",
|
"manufacturerSlug": "ortlieb",
|
||||||
"model": "Frame-Pack Toptube",
|
"model": "Frame-Pack Toptube",
|
||||||
"category": "bags",
|
"category": "bags",
|
||||||
"weightGrams": 180,
|
"weightGrams": 180,
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"description": "4L waterproof top-tube bag with magnetic closure and reflective details."
|
"description": "4L waterproof top-tube bag with magnetic closure and reflective details."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Revelate Designs",
|
"manufacturerSlug": "revelate-designs",
|
||||||
"model": "Tangle Frame Bag",
|
"model": "Tangle Frame Bag",
|
||||||
"category": "bags",
|
"category": "bags",
|
||||||
"weightGrams": 170,
|
"weightGrams": 170,
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"description": "Full-frame bag with water-resistant construction and multiple internal pockets."
|
"description": "Full-frame bag with water-resistant construction and multiple internal pockets."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Big Agnes",
|
"manufacturerSlug": "big-agnes",
|
||||||
"model": "Copper Spur HV UL1",
|
"model": "Copper Spur HV UL1",
|
||||||
"category": "shelters",
|
"category": "shelters",
|
||||||
"weightGrams": 879,
|
"weightGrams": 879,
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"description": "Ultralight 1-person freestanding tent with high-volume hub design and DAC Featherlite poles."
|
"description": "Ultralight 1-person freestanding tent with high-volume hub design and DAC Featherlite poles."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Tarptent",
|
"manufacturerSlug": "tarptent",
|
||||||
"model": "Protrail Li",
|
"model": "Protrail Li",
|
||||||
"category": "shelters",
|
"category": "shelters",
|
||||||
"weightGrams": 454,
|
"weightGrams": 454,
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"description": "Ultralight single-wall trekking pole shelter in Dyneema composite fabric."
|
"description": "Ultralight single-wall trekking pole shelter in Dyneema composite fabric."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Outdoor Research",
|
"manufacturerSlug": "outdoor-research",
|
||||||
"model": "Helium Bivy",
|
"model": "Helium Bivy",
|
||||||
"category": "shelters",
|
"category": "shelters",
|
||||||
"weightGrams": 510,
|
"weightGrams": 510,
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"description": "Waterproof breathable bivy sack with single-hoop pole and full-zip entry."
|
"description": "Waterproof breathable bivy sack with single-hoop pole and full-zip entry."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Sea to Summit",
|
"manufacturerSlug": "sea-to-summit",
|
||||||
"model": "Spark SP1",
|
"model": "Spark SP1",
|
||||||
"category": "sleep-systems",
|
"category": "sleep-systems",
|
||||||
"weightGrams": 375,
|
"weightGrams": 375,
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"description": "Ultralight 850+ fill down sleeping bag rated to 40F/4C with Ultra-Dry Down."
|
"description": "Ultralight 850+ fill down sleeping bag rated to 40F/4C with Ultra-Dry Down."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Nemo",
|
"manufacturerSlug": "nemo",
|
||||||
"model": "Tensor Ultralight Insulated Regular",
|
"model": "Tensor Ultralight Insulated Regular",
|
||||||
"category": "sleep-systems",
|
"category": "sleep-systems",
|
||||||
"weightGrams": 425,
|
"weightGrams": 425,
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"description": "3-inch thick insulated sleeping pad with R-value 4.2 and Spaceframe baffles."
|
"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",
|
"model": "NeoAir XLite NXT",
|
||||||
"category": "sleep-systems",
|
"category": "sleep-systems",
|
||||||
"weightGrams": 354,
|
"weightGrams": 354,
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"description": "Ultralight insulated air pad with R-value 4.5, Triangular Core Matrix, and WingLock valve."
|
"description": "Ultralight insulated air pad with R-value 4.5, Triangular Core Matrix, and WingLock valve."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "MSR",
|
"manufacturerSlug": "msr",
|
||||||
"model": "PocketRocket 2",
|
"model": "PocketRocket 2",
|
||||||
"category": "cooking",
|
"category": "cooking",
|
||||||
"weightGrams": 73,
|
"weightGrams": 73,
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"description": "Ultralight canister stove with adjustable flame control, boils 1L in 3.5 minutes."
|
"description": "Ultralight canister stove with adjustable flame control, boils 1L in 3.5 minutes."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Toaks",
|
"manufacturerSlug": "toaks",
|
||||||
"model": "Titanium 750ml Pot",
|
"model": "Titanium 750ml Pot",
|
||||||
"category": "cooking",
|
"category": "cooking",
|
||||||
"weightGrams": 103,
|
"weightGrams": 103,
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"description": "Ultralight titanium pot with lid and foldable handles, 750ml capacity."
|
"description": "Ultralight titanium pot with lid and foldable handles, 750ml capacity."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Katadyn",
|
"manufacturerSlug": "katadyn",
|
||||||
"model": "BeFree 1.0L",
|
"model": "BeFree 1.0L",
|
||||||
"category": "hydration",
|
"category": "hydration",
|
||||||
"weightGrams": 59,
|
"weightGrams": 59,
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
"description": "Ultralight hollow fiber water filter with 0.1 micron filtration and 1L soft flask."
|
"description": "Ultralight hollow fiber water filter with 0.1 micron filtration and 1L soft flask."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "HydraPak",
|
"manufacturerSlug": "hydrapak",
|
||||||
"model": "Seeker 2L",
|
"model": "Seeker 2L",
|
||||||
"category": "hydration",
|
"category": "hydration",
|
||||||
"weightGrams": 73,
|
"weightGrams": 73,
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
"description": "Collapsible 2L water storage with wide mouth and compatible with Katadyn BeFree filter."
|
"description": "Collapsible 2L water storage with wide mouth and compatible with Katadyn BeFree filter."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Nitecore",
|
"manufacturerSlug": "nitecore",
|
||||||
"model": "NU25 UL",
|
"model": "NU25 UL",
|
||||||
"category": "lighting",
|
"category": "lighting",
|
||||||
"weightGrams": 28,
|
"weightGrams": 28,
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
"description": "Ultralight USB-C rechargeable headlamp with 400 lumens max and red light mode."
|
"description": "Ultralight USB-C rechargeable headlamp with 400 lumens max and red light mode."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Exposure Lights",
|
"manufacturerSlug": "exposure-lights",
|
||||||
"model": "Revo Dynamo",
|
"model": "Revo Dynamo",
|
||||||
"category": "lighting",
|
"category": "lighting",
|
||||||
"weightGrams": 130,
|
"weightGrams": 130,
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"description": "Dynamo-powered front light with 800 lumens, built-in standlight, and USB charging output."
|
"description": "Dynamo-powered front light with 800 lumens, built-in standlight, and USB charging output."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Surly",
|
"manufacturerSlug": "surly",
|
||||||
"model": "24-Pack Rack",
|
"model": "24-Pack Rack",
|
||||||
"category": "racks",
|
"category": "racks",
|
||||||
"weightGrams": 750,
|
"weightGrams": 750,
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"description": "Front rack for bikepacking with 24-pack platform, fits most forks with mid-blade eyelets."
|
"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",
|
"model": "Anything Cage HD",
|
||||||
"category": "accessories",
|
"category": "accessories",
|
||||||
"weightGrams": 80,
|
"weightGrams": 80,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
doublePrecision,
|
doublePrecision,
|
||||||
integer,
|
integer,
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -20,6 +21,19 @@ export const users = pgTable("users", {
|
|||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
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 ──────────────────────────────────────────────────────
|
// ── Categories ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const categories = pgTable(
|
export const categories = pgTable(
|
||||||
@@ -163,7 +177,9 @@ export const globalItems = pgTable(
|
|||||||
"global_items",
|
"global_items",
|
||||||
{
|
{
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
brand: text("brand").notNull(),
|
manufacturerId: integer("manufacturer_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => manufacturers.id),
|
||||||
model: text("model").notNull(),
|
model: text("model").notNull(),
|
||||||
category: text("category"),
|
category: text("category"),
|
||||||
weightGrams: doublePrecision("weight_grams"),
|
weightGrams: doublePrecision("weight_grams"),
|
||||||
@@ -179,7 +195,7 @@ export const globalItems = pgTable(
|
|||||||
cropY: doublePrecision("crop_y"),
|
cropY: doublePrecision("crop_y"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [unique().on(table.brand, table.model)],
|
(table) => [unique().on(table.manufacturerId, table.model)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Tags ───────────────────────────────────────────────────────────
|
// ── Tags ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,81 +1,68 @@
|
|||||||
import seedData from "./global-items-seed.json";
|
import seedData from "./global-items-seed.json";
|
||||||
import { db as prodDb } from "./index.ts";
|
import { db as prodDb } from "./index.ts";
|
||||||
import { globalItems, tags } from "./schema.ts";
|
import { globalItems, manufacturers, tags } from "./schema.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
const SEED_TAGS = [
|
export const SEED_MANUFACTURERS = [
|
||||||
// Hobby / activity tags (used by onboarding hobby picker)
|
{ name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 },
|
||||||
"bikepacking",
|
{ name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 },
|
||||||
"cycling",
|
{ name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 },
|
||||||
"hiking",
|
{ name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 },
|
||||||
"backpacking",
|
{ name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 },
|
||||||
"camping",
|
{ name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 },
|
||||||
"climbing",
|
{ name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 },
|
||||||
"mountaineering",
|
{ name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 },
|
||||||
"road-cycling",
|
{ name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 },
|
||||||
"gravel",
|
{ name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 },
|
||||||
"running",
|
{ name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 },
|
||||||
"trail-running",
|
{ name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 },
|
||||||
// Bag types
|
{ name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 },
|
||||||
"handlebar-bag",
|
{ name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 },
|
||||||
"framebag",
|
{ name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 },
|
||||||
"saddlebag",
|
{ name: "Canyon", slug: "canyon", website: "https://canyon.com", country: "DE", tier: 1 },
|
||||||
"top-tube-bag",
|
{ name: "Specialized", slug: "specialized", website: "https://specialized.com", country: "US", tier: 1 },
|
||||||
"stem-bag",
|
{ name: "Trek", slug: "trek", website: "https://trekbikes.com", country: "US", tier: 1 },
|
||||||
"fork-bag",
|
{ name: "Salsa Cycles", slug: "salsa-cycles", website: "https://salsacycles.com", country: "US", tier: 1 },
|
||||||
"feed-bag",
|
{ name: "Surly", slug: "surly", website: "https://surlybikes.com", country: "US", tier: 1 },
|
||||||
"dry-bag",
|
// Additional manufacturers referenced in seed data
|
||||||
"stuff-sack",
|
{ name: "Nemo", slug: "nemo", website: "https://nemoequipment.com", country: "US", tier: 1 },
|
||||||
// Bike bags (parent)
|
{ name: "Therm-a-Rest", slug: "therm-a-rest", website: "https://thermarest.com", country: "US", tier: 1 },
|
||||||
"bike-bag",
|
{ name: "Toaks", slug: "toaks", website: "https://toaksoutdoor.com", country: "CN", tier: 1 },
|
||||||
// Shelter
|
{ name: "Katadyn", slug: "katadyn", website: "https://katadyn.com", country: "CH", tier: 1 },
|
||||||
"tent",
|
{ name: "HydraPak", slug: "hydrapak", website: "https://hydrapak.com", country: "US", tier: 1 },
|
||||||
"bivy",
|
{ name: "Nitecore", slug: "nitecore", website: "https://nitecore.com", country: "CN", tier: 1 },
|
||||||
"tarp",
|
{ name: "Outdoor Research", slug: "outdoor-research", website: "https://outdoorresearch.com", country: "US", tier: 1 },
|
||||||
"hammock",
|
{ name: "Exposure Lights", slug: "exposure-lights", website: "https://exposurelights.com", country: "GB", tier: 1 },
|
||||||
// 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",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
const SEED_TAGS = [
|
||||||
* Seed curated tags for outdoor/adventure gear.
|
"bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing",
|
||||||
* Idempotent: inserts only tags that don't already exist.
|
"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) {
|
export async function seedTags(db: Db = prodDb) {
|
||||||
const existing = await db.select().from(tags);
|
const existing = await db.select().from(tags);
|
||||||
const existingNames = new Set(existing.map((t) => t.name));
|
const existingNames = new Set(existing.map((t) => t.name));
|
||||||
|
|
||||||
for (const name of SEED_TAGS) {
|
for (const name of SEED_TAGS) {
|
||||||
if (!existingNames.has(name)) {
|
if (!existingNames.has(name)) {
|
||||||
await db.insert(tags).values({ 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) {
|
export async function seedGlobalItems(db: Db = prodDb) {
|
||||||
|
await seedManufacturers(db);
|
||||||
|
|
||||||
const existing = await db.select().from(globalItems).limit(1);
|
const existing = await db.select().from(globalItems).limit(1);
|
||||||
if (existing.length > 0) return;
|
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) {
|
for (const item of seedData) {
|
||||||
|
const manufacturerId = mfBySlug.get(item.manufacturerSlug);
|
||||||
|
if (!manufacturerId) continue;
|
||||||
|
|
||||||
await db.insert(globalItems).values({
|
await db.insert(globalItems).values({
|
||||||
brand: item.brand,
|
manufacturerId,
|
||||||
model: item.model,
|
model: item.model,
|
||||||
category: item.category ?? null,
|
category: item.category ?? null,
|
||||||
weightGrams: item.weightGrams ?? null,
|
weightGrams: item.weightGrams ?? null,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { communityPriceRoutes } from "./routes/community-prices.ts";
|
|||||||
import { discoveryRoutes } from "./routes/discovery.ts";
|
import { discoveryRoutes } from "./routes/discovery.ts";
|
||||||
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
|
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
|
||||||
import { globalItemRoutes } from "./routes/global-items.ts";
|
import { globalItemRoutes } from "./routes/global-items.ts";
|
||||||
|
import { manufacturerRoutes } from "./routes/manufacturers.ts";
|
||||||
import { imageRoutes } from "./routes/images.ts";
|
import { imageRoutes } from "./routes/images.ts";
|
||||||
import { itemRoutes } from "./routes/items.ts";
|
import { itemRoutes } from "./routes/items.ts";
|
||||||
import { marketPriceRoutes } from "./routes/market-prices.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/setups", setupRoutes);
|
||||||
app.route("/api/discovery", discoveryRoutes);
|
app.route("/api/discovery", discoveryRoutes);
|
||||||
app.route("/api/global-items", globalItemRoutes);
|
app.route("/api/global-items", globalItemRoutes);
|
||||||
|
app.route("/api/manufacturers", manufacturerRoutes);
|
||||||
app.route("/api/onboarding", onboardingRoutes);
|
app.route("/api/onboarding", onboardingRoutes);
|
||||||
app.route("/api/tags", tagRoutes);
|
app.route("/api/tags", tagRoutes);
|
||||||
app.route("/api/exchange-rates", exchangeRateRoutes);
|
app.route("/api/exchange-rates", exchangeRateRoutes);
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ function errorResult(message: string): ToolResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const catalogItemInputSchema = {
|
const catalogItemInputSchema = {
|
||||||
brand: z.string().describe("Brand or manufacturer name"),
|
manufacturerSlug: z.string().describe("Manufacturer slug (e.g. 'revelate-designs', 'apidura')"),
|
||||||
model: z
|
model: z
|
||||||
.string()
|
.string()
|
||||||
.describe("Model name — combined with brand forms the unique identifier"),
|
.describe("Model name — combined with manufacturerSlug forms the unique identifier"),
|
||||||
category: z
|
category: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -80,7 +80,7 @@ export const catalogToolDefinitions = [
|
|||||||
export function registerCatalogTools(db: Db) {
|
export function registerCatalogTools(db: Db) {
|
||||||
return {
|
return {
|
||||||
upsert_catalog_item: async (args: {
|
upsert_catalog_item: async (args: {
|
||||||
brand: string;
|
manufacturerSlug: string;
|
||||||
model: string;
|
model: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
weightGrams?: number;
|
weightGrams?: number;
|
||||||
@@ -105,7 +105,7 @@ export function registerCatalogTools(db: Db) {
|
|||||||
|
|
||||||
bulk_upsert_catalog: async (args: {
|
bulk_upsert_catalog: async (args: {
|
||||||
items: Array<{
|
items: Array<{
|
||||||
brand: string;
|
manufacturerSlug: string;
|
||||||
model: string;
|
model: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
weightGrams?: number;
|
weightGrams?: number;
|
||||||
|
|||||||
38
src/server/routes/manufacturers.ts
Normal file
38
src/server/routes/manufacturers.ts
Normal 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 };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import type { db as prodDb } from "../../db/index.ts";
|
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";
|
import { getOrCreateUncategorized } from "./category.service.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
@@ -90,7 +90,7 @@ export async function exportItemsCsv(db: Db, userId: number): Promise<string> {
|
|||||||
.select({
|
.select({
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${items.name}
|
ELSE ${items.name}
|
||||||
END,
|
END,
|
||||||
${items.name}
|
${items.name}
|
||||||
@@ -111,6 +111,7 @@ export async function exportItemsCsv(db: Db, userId: number): Promise<string> {
|
|||||||
.from(items)
|
.from(items)
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(items.userId, userId));
|
.where(eq(items.userId, userId));
|
||||||
|
|
||||||
const header =
|
const header =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
globalItems,
|
globalItems,
|
||||||
globalItemTags,
|
globalItemTags,
|
||||||
items,
|
items,
|
||||||
|
manufacturers,
|
||||||
setupItems,
|
setupItems,
|
||||||
setups,
|
setups,
|
||||||
tags,
|
tags,
|
||||||
@@ -86,14 +87,15 @@ export async function getRecentGlobalItems(
|
|||||||
db: Db = prodDb,
|
db: Db = prodDb,
|
||||||
limit = 8,
|
limit = 8,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
): Promise<CursorPage<typeof globalItems.$inferSelect>> {
|
): Promise<CursorPage<typeof globalItems.$inferSelect & { brand: string }>> {
|
||||||
const conditions = cursor
|
const conditions = cursor
|
||||||
? [lt(globalItems.createdAt, new Date(cursor))]
|
? [lt(globalItems.createdAt, new Date(cursor))]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select({ ...globalItems, brand: manufacturers.name })
|
||||||
.from(globalItems)
|
.from(globalItems)
|
||||||
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(conditions.length ? and(...conditions) : undefined)
|
.where(conditions.length ? and(...conditions) : undefined)
|
||||||
.orderBy(desc(globalItems.createdAt))
|
.orderBy(desc(globalItems.createdAt))
|
||||||
.limit(limit + 1);
|
.limit(limit + 1);
|
||||||
@@ -160,7 +162,7 @@ export async function getPopularItemsByTags(
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: globalItems.id,
|
id: globalItems.id,
|
||||||
brand: globalItems.brand,
|
brand: manufacturers.name,
|
||||||
model: globalItems.model,
|
model: globalItems.model,
|
||||||
category: globalItems.category,
|
category: globalItems.category,
|
||||||
weightGrams: globalItems.weightGrams,
|
weightGrams: globalItems.weightGrams,
|
||||||
@@ -170,6 +172,7 @@ export async function getPopularItemsByTags(
|
|||||||
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
|
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
|
||||||
})
|
})
|
||||||
.from(globalItems)
|
.from(globalItems)
|
||||||
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id))
|
.innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id))
|
||||||
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
|
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
|
||||||
.leftJoin(items, eq(items.globalItemId, globalItems.id))
|
.leftJoin(items, eq(items.globalItemId, globalItems.id))
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import type { SQL } from "drizzle-orm";
|
import type { SQL } from "drizzle-orm";
|
||||||
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
|
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
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 Db = typeof prodDb;
|
||||||
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||||
|
|
||||||
/**
|
async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise<number> {
|
||||||
* Search global items by brand or model and/or tag names.
|
const [m] = await (db as Db)
|
||||||
* Text search uses ILIKE for case-insensitive matching (PostgreSQL).
|
.select({ id: manufacturers.id })
|
||||||
* Tag filtering uses AND logic -- items must have ALL specified tags.
|
.from(manufacturers)
|
||||||
* Escapes % and _ wildcard characters in user input.
|
.where(eq(manufacturers.slug, slug));
|
||||||
*/
|
if (!m) throw new Error(`Manufacturer not found: ${slug}`);
|
||||||
|
return m.id;
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchGlobalItems(
|
export async function searchGlobalItems(
|
||||||
db: Db = prodDb,
|
db: Db = prodDb,
|
||||||
query?: string,
|
query?: string,
|
||||||
@@ -23,7 +26,7 @@ export async function searchGlobalItems(
|
|||||||
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||||
const pattern = `%${escaped}%`;
|
const pattern = `%${escaped}%`;
|
||||||
conditions.push(
|
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) {
|
if (conditions.length === 0) {
|
||||||
return db.select().from(globalItems);
|
return baseQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return baseQuery.where(and(...conditions));
|
||||||
.select()
|
|
||||||
.from(globalItems)
|
|
||||||
.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) {
|
export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
|
||||||
const [item] = await db
|
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)
|
.from(globalItems)
|
||||||
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(globalItems.id, id));
|
.where(eq(globalItems.id, id));
|
||||||
|
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
@@ -73,10 +111,6 @@ export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
|
|||||||
return { ...item, ownerCount: result?.ownerCount ?? 0 };
|
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(
|
async function syncGlobalItemTags(
|
||||||
tx: TxDb,
|
tx: TxDb,
|
||||||
globalItemId: number,
|
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(
|
export async function upsertGlobalItem(
|
||||||
db: Db,
|
db: Db,
|
||||||
data: {
|
data: {
|
||||||
brand: string;
|
manufacturerSlug: string;
|
||||||
model: string;
|
model: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
weightGrams?: number;
|
weightGrams?: number;
|
||||||
@@ -118,23 +147,25 @@ export async function upsertGlobalItem(
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug);
|
||||||
|
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
const [existing] = await tx
|
const [existing] = await tx
|
||||||
.select({ id: globalItems.id })
|
.select({ id: globalItems.id })
|
||||||
.from(globalItems)
|
.from(globalItems)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(globalItems.brand, data.brand),
|
eq(globalItems.manufacturerId, manufacturerId),
|
||||||
eq(globalItems.model, data.model),
|
eq(globalItems.model, data.model),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { tags: tagNames, ...itemData } = data;
|
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
|
||||||
|
|
||||||
const [item] = await tx
|
const [item] = await tx
|
||||||
.insert(globalItems)
|
.insert(globalItems)
|
||||||
.values({
|
.values({
|
||||||
brand: itemData.brand,
|
manufacturerId,
|
||||||
model: itemData.model,
|
model: itemData.model,
|
||||||
category: itemData.category ?? null,
|
category: itemData.category ?? null,
|
||||||
weightGrams: itemData.weightGrams ?? null,
|
weightGrams: itemData.weightGrams ?? null,
|
||||||
@@ -146,7 +177,7 @@ export async function upsertGlobalItem(
|
|||||||
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [globalItems.brand, globalItems.model],
|
target: [globalItems.manufacturerId, globalItems.model],
|
||||||
set: {
|
set: {
|
||||||
category: itemData.category ?? null,
|
category: itemData.category ?? null,
|
||||||
weightGrams: itemData.weightGrams ?? null,
|
weightGrams: itemData.weightGrams ?? null,
|
||||||
@@ -161,22 +192,17 @@ export async function upsertGlobalItem(
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (tagNames !== undefined) {
|
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(
|
export async function bulkUpsertGlobalItems(
|
||||||
db: Db,
|
db: Db,
|
||||||
itemsData: Array<{
|
itemsData: Array<{
|
||||||
brand: string;
|
manufacturerSlug: string;
|
||||||
model: string;
|
model: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
weightGrams?: number;
|
weightGrams?: number;
|
||||||
@@ -195,22 +221,24 @@ export async function bulkUpsertGlobalItems(
|
|||||||
const resultItems = [];
|
const resultItems = [];
|
||||||
|
|
||||||
for (const data of itemsData) {
|
for (const data of itemsData) {
|
||||||
|
const manufacturerId = await resolveManufacturerId(tx as unknown as Db, data.manufacturerSlug);
|
||||||
|
|
||||||
const [existing] = await tx
|
const [existing] = await tx
|
||||||
.select({ id: globalItems.id })
|
.select({ id: globalItems.id })
|
||||||
.from(globalItems)
|
.from(globalItems)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(globalItems.brand, data.brand),
|
eq(globalItems.manufacturerId, manufacturerId),
|
||||||
eq(globalItems.model, data.model),
|
eq(globalItems.model, data.model),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { tags: tagNames, ...itemData } = data;
|
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
|
||||||
|
|
||||||
const [item] = await tx
|
const [item] = await tx
|
||||||
.insert(globalItems)
|
.insert(globalItems)
|
||||||
.values({
|
.values({
|
||||||
brand: itemData.brand,
|
manufacturerId,
|
||||||
model: itemData.model,
|
model: itemData.model,
|
||||||
category: itemData.category ?? null,
|
category: itemData.category ?? null,
|
||||||
weightGrams: itemData.weightGrams ?? null,
|
weightGrams: itemData.weightGrams ?? null,
|
||||||
@@ -222,7 +250,7 @@ export async function bulkUpsertGlobalItems(
|
|||||||
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [globalItems.brand, globalItems.model],
|
target: [globalItems.manufacturerId, globalItems.model],
|
||||||
set: {
|
set: {
|
||||||
category: itemData.category ?? null,
|
category: itemData.category ?? null,
|
||||||
weightGrams: itemData.weightGrams ?? null,
|
weightGrams: itemData.weightGrams ?? null,
|
||||||
@@ -237,7 +265,7 @@ export async function bulkUpsertGlobalItems(
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (tagNames !== undefined) {
|
if (tagNames !== undefined) {
|
||||||
await syncGlobalItemTags(tx, item.id, tagNames);
|
await syncGlobalItemTags(tx, item!.id, tagNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -245,7 +273,7 @@ export async function bulkUpsertGlobalItems(
|
|||||||
} else {
|
} else {
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
resultItems.push(item);
|
resultItems.push(item!);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, updated, items: resultItems };
|
return { created, updated, items: resultItems };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import type { db as prodDb } from "../../db/index.ts";
|
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";
|
import type { CreateItem } from "../../shared/types.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
@@ -11,7 +11,7 @@ export async function getAllItems(db: Db, userId: number) {
|
|||||||
id: items.id,
|
id: items.id,
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${items.name}
|
ELSE ${items.name}
|
||||||
END,
|
END,
|
||||||
${items.name}
|
${items.name}
|
||||||
@@ -38,7 +38,7 @@ export async function getAllItems(db: Db, userId: number) {
|
|||||||
globalItemId: items.globalItemId,
|
globalItemId: items.globalItemId,
|
||||||
brand: sql<
|
brand: sql<
|
||||||
string | null
|
string | null
|
||||||
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
>`COALESCE(${manufacturers.name}, ${items.brand})`.as("brand"),
|
||||||
dominantColor: items.dominantColor,
|
dominantColor: items.dominantColor,
|
||||||
cropZoom: items.cropZoom,
|
cropZoom: items.cropZoom,
|
||||||
cropX: items.cropX,
|
cropX: items.cropX,
|
||||||
@@ -51,6 +51,7 @@ export async function getAllItems(db: Db, userId: number) {
|
|||||||
.from(items)
|
.from(items)
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(items.userId, userId));
|
.where(eq(items.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
|||||||
id: items.id,
|
id: items.id,
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${items.name}
|
ELSE ${items.name}
|
||||||
END,
|
END,
|
||||||
${items.name}
|
${items.name}
|
||||||
@@ -87,7 +88,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
|||||||
globalItemId: items.globalItemId,
|
globalItemId: items.globalItemId,
|
||||||
brand: sql<
|
brand: sql<
|
||||||
string | null
|
string | null
|
||||||
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
>`COALESCE(${manufacturers.name}, ${items.brand})`.as("brand"),
|
||||||
dominantColor: items.dominantColor,
|
dominantColor: items.dominantColor,
|
||||||
cropZoom: items.cropZoom,
|
cropZoom: items.cropZoom,
|
||||||
cropX: items.cropX,
|
cropX: items.cropX,
|
||||||
@@ -100,6 +101,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
|||||||
.from(items)
|
.from(items)
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.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)));
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||||
|
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
@@ -118,11 +120,12 @@ export async function createItem(
|
|||||||
let name = data.name;
|
let name = data.name;
|
||||||
if (data.globalItemId) {
|
if (data.globalItemId) {
|
||||||
const [gi] = await db
|
const [gi] = await db
|
||||||
.select({ brand: globalItems.brand, model: globalItems.model })
|
.select({ name: manufacturers.name, model: globalItems.model })
|
||||||
.from(globalItems)
|
.from(globalItems)
|
||||||
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(globalItems.id, data.globalItemId));
|
.where(eq(globalItems.id, data.globalItemId));
|
||||||
if (gi) {
|
if (gi) {
|
||||||
name = `${gi.brand} ${gi.model}`;
|
name = `${gi.name} ${gi.model}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
src/server/services/manufacturer.service.ts
Normal file
42
src/server/services/manufacturer.service.ts
Normal 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!;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
categories,
|
categories,
|
||||||
globalItems,
|
globalItems,
|
||||||
items,
|
items,
|
||||||
|
manufacturers,
|
||||||
setupItems,
|
setupItems,
|
||||||
setups,
|
setups,
|
||||||
users,
|
users,
|
||||||
@@ -97,7 +98,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
|
|||||||
id: items.id,
|
id: items.id,
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${items.name}
|
ELSE ${items.name}
|
||||||
END,
|
END,
|
||||||
${items.name}
|
${items.name}
|
||||||
@@ -129,6 +130,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
|
|||||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(setupItems.setupId, setupId));
|
.where(eq(setupItems.setupId, setupId));
|
||||||
|
|
||||||
return { ...setup, items: itemList };
|
return { ...setup, items: itemList };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
categories,
|
categories,
|
||||||
globalItems,
|
globalItems,
|
||||||
items,
|
items,
|
||||||
|
manufacturers,
|
||||||
setupItems,
|
setupItems,
|
||||||
setups,
|
setups,
|
||||||
} from "../../db/schema.ts";
|
} from "../../db/schema.ts";
|
||||||
@@ -79,7 +80,7 @@ export async function getSetupWithItems(
|
|||||||
id: items.id,
|
id: items.id,
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${items.name}
|
ELSE ${items.name}
|
||||||
END,
|
END,
|
||||||
${items.name}
|
${items.name}
|
||||||
@@ -113,6 +114,7 @@ export async function getSetupWithItems(
|
|||||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(setupItems.setupId, setupId));
|
.where(eq(setupItems.setupId, setupId));
|
||||||
|
|
||||||
return { ...setup, items: itemList };
|
return { ...setup, items: itemList };
|
||||||
@@ -131,7 +133,7 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
|
|||||||
id: items.id,
|
id: items.id,
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${items.name}
|
ELSE ${items.name}
|
||||||
END,
|
END,
|
||||||
${items.name}
|
${items.name}
|
||||||
@@ -165,6 +167,7 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
|
|||||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
||||||
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(setupItems.setupId, setupId));
|
.where(eq(setupItems.setupId, setupId));
|
||||||
|
|
||||||
return { ...setup, items: itemList };
|
return { ...setup, items: itemList };
|
||||||
@@ -185,14 +188,14 @@ export async function getSetupItemById(
|
|||||||
id: items.id,
|
id: items.id,
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${items.globalItemId} IS NOT NULL
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${items.name}
|
ELSE ${items.name}
|
||||||
END,
|
END,
|
||||||
${items.name}
|
${items.name}
|
||||||
)`.as("name"),
|
)`.as("name"),
|
||||||
brand: sql<
|
brand: sql<
|
||||||
string | null
|
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",
|
"brand",
|
||||||
),
|
),
|
||||||
weightGrams: sql<number | null>`COALESCE(
|
weightGrams: sql<number | null>`COALESCE(
|
||||||
@@ -221,6 +224,7 @@ export async function getSetupItemById(
|
|||||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(items.globalItemId, globalItems.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)));
|
.where(and(eq(setupItems.setupId, setupId), eq(items.id, itemId)));
|
||||||
|
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
categories,
|
categories,
|
||||||
globalItems,
|
globalItems,
|
||||||
items,
|
items,
|
||||||
|
manufacturers,
|
||||||
threadCandidates,
|
threadCandidates,
|
||||||
threads,
|
threads,
|
||||||
} from "../../db/schema.ts";
|
} from "../../db/schema.ts";
|
||||||
@@ -82,7 +83,7 @@ export async function getThreadWithCandidates(
|
|||||||
threadId: threadCandidates.threadId,
|
threadId: threadCandidates.threadId,
|
||||||
name: sql<string>`COALESCE(
|
name: sql<string>`COALESCE(
|
||||||
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
|
CASE WHEN ${threadCandidates.globalItemId} IS NOT NULL
|
||||||
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
THEN ${manufacturers.name} || ' ' || ${globalItems.model}
|
||||||
ELSE ${threadCandidates.name}
|
ELSE ${threadCandidates.name}
|
||||||
END,
|
END,
|
||||||
${threadCandidates.name}
|
${threadCandidates.name}
|
||||||
@@ -118,6 +119,7 @@ export async function getThreadWithCandidates(
|
|||||||
.from(threadCandidates)
|
.from(threadCandidates)
|
||||||
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
||||||
.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
|
.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
|
||||||
|
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(threadCandidates.threadId, threadId))
|
.where(eq(threadCandidates.threadId, threadId))
|
||||||
.orderBy(asc(threadCandidates.sortOrder));
|
.orderBy(asc(threadCandidates.sortOrder));
|
||||||
|
|
||||||
@@ -367,10 +369,11 @@ export async function resolveThread(
|
|||||||
if (candidate.globalItemId) {
|
if (candidate.globalItemId) {
|
||||||
// Reference item — link to global, personal fields only
|
// Reference item — link to global, personal fields only
|
||||||
const [gi] = await tx
|
const [gi] = await tx
|
||||||
.select()
|
.select({ name: manufacturers.name, model: globalItems.model })
|
||||||
.from(globalItems)
|
.from(globalItems)
|
||||||
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
||||||
.where(eq(globalItems.id, candidate.globalItemId));
|
.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 = {
|
insertValues = {
|
||||||
name: fallbackName,
|
name: fallbackName,
|
||||||
globalItemId: candidate.globalItemId,
|
globalItemId: candidate.globalItemId,
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const searchGlobalItemsSchema = z.object({
|
|||||||
|
|
||||||
// Catalog upsert schemas
|
// Catalog upsert schemas
|
||||||
export const upsertGlobalItemSchema = z.object({
|
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"),
|
model: z.string().min(1, "Model is required"),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
weightGrams: z.number().nonnegative().optional(),
|
weightGrams: z.number().nonnegative().optional(),
|
||||||
@@ -146,6 +146,18 @@ export const bulkUpsertGlobalItemsSchema = z.object({
|
|||||||
items: z.array(upsertGlobalItemSchema).min(1).max(100),
|
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
|
// Profile schemas
|
||||||
export const updateProfileSchema = z.object({
|
export const updateProfileSchema = z.object({
|
||||||
displayName: z.string().max(100).optional(),
|
displayName: z.string().max(100).optional(),
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const TRUNCATE_TABLES = [
|
|||||||
"setups",
|
"setups",
|
||||||
"thread_candidates",
|
"thread_candidates",
|
||||||
"threads",
|
"threads",
|
||||||
|
"community_prices",
|
||||||
|
"market_prices",
|
||||||
"items",
|
"items",
|
||||||
"global_item_tags",
|
"global_item_tags",
|
||||||
"global_items",
|
"global_items",
|
||||||
@@ -35,6 +37,7 @@ const TRUNCATE_TABLES = [
|
|||||||
"api_keys",
|
"api_keys",
|
||||||
"settings",
|
"settings",
|
||||||
"categories",
|
"categories",
|
||||||
|
"manufacturers",
|
||||||
"users",
|
"users",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { manufacturers } from "../../src/db/schema.ts";
|
||||||
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
|
import { getCollectionSummary } from "../../src/server/mcp/resources/collection.ts";
|
||||||
import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.ts";
|
import { registerCatalogTools } from "../../src/server/mcp/tools/catalog.ts";
|
||||||
import { registerCategoryTools } from "../../src/server/mcp/tools/categories.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 { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
|
||||||
import { createSecondTestUser, createTestDb } from "../helpers/db.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: {
|
function parseResult(result: {
|
||||||
content: Array<{ type: string; text: string }>;
|
content: Array<{ type: string; text: string }>;
|
||||||
}) {
|
}) {
|
||||||
@@ -256,15 +267,15 @@ describe("MCP Collection Summary Resource", () => {
|
|||||||
describe("MCP Catalog Tools", () => {
|
describe("MCP Catalog Tools", () => {
|
||||||
test("upsert_catalog_item creates a new global item with created=true", async () => {
|
test("upsert_catalog_item creates a new global item with created=true", async () => {
|
||||||
const { db } = await createTestDb();
|
const { db } = await createTestDb();
|
||||||
|
await insertManufacturer(db, "Revelate Designs");
|
||||||
const tools = registerCatalogTools(db);
|
const tools = registerCatalogTools(db);
|
||||||
const result = await tools.upsert_catalog_item({
|
const result = await tools.upsert_catalog_item({
|
||||||
brand: "Revelate Designs",
|
manufacturerSlug: "revelate-designs",
|
||||||
model: "Terrapin System",
|
model: "Terrapin System",
|
||||||
weightGrams: 235,
|
weightGrams: 235,
|
||||||
priceCents: 16500,
|
priceCents: 16500,
|
||||||
});
|
});
|
||||||
const data = parseResult(result);
|
const data = parseResult(result);
|
||||||
expect(data.brand).toBe("Revelate Designs");
|
|
||||||
expect(data.model).toBe("Terrapin System");
|
expect(data.model).toBe("Terrapin System");
|
||||||
expect(data.created).toBe(true);
|
expect(data.created).toBe(true);
|
||||||
expect(data.id).toBeDefined();
|
expect(data.id).toBeDefined();
|
||||||
@@ -272,17 +283,18 @@ describe("MCP Catalog Tools", () => {
|
|||||||
|
|
||||||
test("upsert_catalog_item updates existing item on brand+model match", async () => {
|
test("upsert_catalog_item updates existing item on brand+model match", async () => {
|
||||||
const { db } = await createTestDb();
|
const { db } = await createTestDb();
|
||||||
|
await insertManufacturer(db, "Apidura");
|
||||||
const tools = registerCatalogTools(db);
|
const tools = registerCatalogTools(db);
|
||||||
|
|
||||||
// Create initial item
|
// Create initial item
|
||||||
await tools.upsert_catalog_item({
|
await tools.upsert_catalog_item({
|
||||||
brand: "Apidura",
|
manufacturerSlug: "apidura",
|
||||||
model: "Handlebar Pack",
|
model: "Handlebar Pack",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update it
|
// Update it
|
||||||
const result = await tools.upsert_catalog_item({
|
const result = await tools.upsert_catalog_item({
|
||||||
brand: "Apidura",
|
manufacturerSlug: "apidura",
|
||||||
model: "Handlebar Pack",
|
model: "Handlebar Pack",
|
||||||
description: "Updated description",
|
description: "Updated description",
|
||||||
weightGrams: 120,
|
weightGrams: 120,
|
||||||
@@ -295,10 +307,11 @@ describe("MCP Catalog Tools", () => {
|
|||||||
|
|
||||||
test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => {
|
test("upsert_catalog_item includes attribution fields in result (SEED-03)", async () => {
|
||||||
const { db } = await createTestDb();
|
const { db } = await createTestDb();
|
||||||
|
await insertManufacturer(db, "MSR");
|
||||||
const tools = registerCatalogTools(db);
|
const tools = registerCatalogTools(db);
|
||||||
|
|
||||||
const result = await tools.upsert_catalog_item({
|
const result = await tools.upsert_catalog_item({
|
||||||
brand: "MSR",
|
manufacturerSlug: "msr",
|
||||||
model: "PocketRocket 2",
|
model: "PocketRocket 2",
|
||||||
sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2",
|
sourceUrl: "https://www.cascadedesigns.com/msr/pocket-rocket-2",
|
||||||
imageCredit: "MSR Photography",
|
imageCredit: "MSR Photography",
|
||||||
@@ -317,13 +330,16 @@ describe("MCP Catalog Tools", () => {
|
|||||||
|
|
||||||
test("bulk_upsert_catalog processes array and returns created/updated counts", async () => {
|
test("bulk_upsert_catalog processes array and returns created/updated counts", async () => {
|
||||||
const { db } = await createTestDb();
|
const { db } = await createTestDb();
|
||||||
|
await insertManufacturer(db, "Revelate Designs");
|
||||||
|
await insertManufacturer(db, "Apidura");
|
||||||
|
await insertManufacturer(db, "MSR");
|
||||||
const tools = registerCatalogTools(db);
|
const tools = registerCatalogTools(db);
|
||||||
|
|
||||||
const result = await tools.bulk_upsert_catalog({
|
const result = await tools.bulk_upsert_catalog({
|
||||||
items: [
|
items: [
|
||||||
{ brand: "Revelate Designs", model: "Terrapin System" },
|
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
|
||||||
{ brand: "Apidura", model: "Handlebar Pack" },
|
{ manufacturerSlug: "apidura", model: "Handlebar Pack" },
|
||||||
{ brand: "MSR", model: "PocketRocket 2" },
|
{ manufacturerSlug: "msr", model: "PocketRocket 2" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const data = parseResult(result);
|
const data = parseResult(result);
|
||||||
@@ -335,18 +351,20 @@ describe("MCP Catalog Tools", () => {
|
|||||||
|
|
||||||
test("bulk_upsert_catalog returns totalProcessed matching input length", async () => {
|
test("bulk_upsert_catalog returns totalProcessed matching input length", async () => {
|
||||||
const { db } = await createTestDb();
|
const { db } = await createTestDb();
|
||||||
|
await insertManufacturer(db, "Revelate Designs");
|
||||||
|
await insertManufacturer(db, "Apidura");
|
||||||
const tools = registerCatalogTools(db);
|
const tools = registerCatalogTools(db);
|
||||||
|
|
||||||
// Pre-create one item
|
// Pre-create one item
|
||||||
await tools.upsert_catalog_item({
|
await tools.upsert_catalog_item({
|
||||||
brand: "Revelate Designs",
|
manufacturerSlug: "revelate-designs",
|
||||||
model: "Terrapin System",
|
model: "Terrapin System",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await tools.bulk_upsert_catalog({
|
const result = await tools.bulk_upsert_catalog({
|
||||||
items: [
|
items: [
|
||||||
{ brand: "Revelate Designs", model: "Terrapin System" },
|
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
|
||||||
{ brand: "Apidura", model: "Handlebar Pack" },
|
{ manufacturerSlug: "apidura", model: "Handlebar Pack" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const data = parseResult(result);
|
const data = parseResult(result);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { Hono } from "hono";
|
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 { discoveryRoutes } from "../../src/server/routes/discovery.ts";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
@@ -20,17 +20,28 @@ async function createTestApp() {
|
|||||||
return { app, db, userId };
|
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(
|
async function insertGlobalItem(
|
||||||
db: TestDb["db"],
|
db: TestDb["db"],
|
||||||
brand: string,
|
brand: string,
|
||||||
model: string,
|
model: string,
|
||||||
category?: string,
|
category?: string,
|
||||||
) {
|
) {
|
||||||
|
const m = await insertManufacturer(db, brand);
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(globalItems)
|
.insert(globalItems)
|
||||||
.values({ brand, model, category: category ?? "bags" })
|
.values({ manufacturerId: m.id, model, category: category ?? "bags" })
|
||||||
.returning();
|
.returning();
|
||||||
return row;
|
return row!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertPublicSetup(
|
async function insertPublicSetup(
|
||||||
@@ -142,14 +153,16 @@ describe("Discovery Routes", () => {
|
|||||||
const olderTime = new Date("2024-01-01T00:00:00Z");
|
const olderTime = new Date("2024-01-01T00:00:00Z");
|
||||||
const newerTime = new Date("2024-06-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({
|
await db.insert(globalItems).values({
|
||||||
brand: "Brand A",
|
manufacturerId: mA.id,
|
||||||
model: "Model A",
|
model: "Model A",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
createdAt: olderTime,
|
createdAt: olderTime,
|
||||||
});
|
});
|
||||||
await db.insert(globalItems).values({
|
await db.insert(globalItems).values({
|
||||||
brand: "Brand B",
|
manufacturerId: mB.id,
|
||||||
model: "Model B",
|
model: "Model B",
|
||||||
category: "bags",
|
category: "bags",
|
||||||
createdAt: newerTime,
|
createdAt: newerTime,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
globalItems,
|
globalItems,
|
||||||
globalItemTags,
|
globalItemTags,
|
||||||
items,
|
items,
|
||||||
|
manufacturers,
|
||||||
tags,
|
tags,
|
||||||
} from "../../src/db/schema.ts";
|
} from "../../src/db/schema.ts";
|
||||||
import { globalItemRoutes } from "../../src/server/routes/global-items.ts";
|
import { globalItemRoutes } from "../../src/server/routes/global-items.ts";
|
||||||
@@ -25,16 +26,27 @@ async function createTestApp() {
|
|||||||
return { app, db, userId };
|
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(
|
async function insertGlobalItem(
|
||||||
db: TestDb["db"],
|
db: TestDb["db"],
|
||||||
brand: string,
|
brand: string,
|
||||||
model: string,
|
model: string,
|
||||||
) {
|
) {
|
||||||
|
const m = await insertManufacturer(db, brand);
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(globalItems)
|
.insert(globalItems)
|
||||||
.values({ brand, model, category: "bags" })
|
.values({ manufacturerId: m.id, model, category: "bags" })
|
||||||
.returning();
|
.returning();
|
||||||
return row;
|
return row!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertItem(
|
async function insertItem(
|
||||||
@@ -113,18 +125,18 @@ describe("Global Item Routes", () => {
|
|||||||
|
|
||||||
describe("POST /api/global-items", () => {
|
describe("POST /api/global-items", () => {
|
||||||
it("returns 200 with item and created=true on new item", async () => {
|
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", {
|
const res = await app.request("/api/global-items", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
brand: "Revelate Designs",
|
manufacturerSlug: "revelate-designs",
|
||||||
model: "Terrapin System",
|
model: "Terrapin System",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.item.brand).toBe("Revelate Designs");
|
|
||||||
expect(body.item.model).toBe("Terrapin System");
|
expect(body.item.model).toBe("Terrapin System");
|
||||||
expect(body.created).toBe(true);
|
expect(body.created).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -136,7 +148,7 @@ describe("Global Item Routes", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
brand: "Revelate Designs",
|
manufacturerSlug: "revelate-designs",
|
||||||
model: "Terrapin System",
|
model: "Terrapin System",
|
||||||
description: "Updated description",
|
description: "Updated description",
|
||||||
}),
|
}),
|
||||||
@@ -148,7 +160,7 @@ describe("Global Item Routes", () => {
|
|||||||
expect(body.item.description).toBe("Updated description");
|
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", {
|
const res = await app.request("/api/global-items", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -161,7 +173,7 @@ describe("Global Item Routes", () => {
|
|||||||
const res = await app.request("/api/global-items", {
|
const res = await app.request("/api/global-items", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ brand: "Revelate Designs" }),
|
body: JSON.stringify({ manufacturerSlug: "revelate-designs" }),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
@@ -169,13 +181,15 @@ describe("Global Item Routes", () => {
|
|||||||
|
|
||||||
describe("POST /api/global-items/bulk", () => {
|
describe("POST /api/global-items/bulk", () => {
|
||||||
it("returns 200 with created/updated counts", async () => {
|
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", {
|
const res = await app.request("/api/global-items/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
items: [
|
items: [
|
||||||
{ brand: "Revelate Designs", model: "Terrapin System" },
|
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
|
||||||
{ brand: "Apidura", model: "Handlebar Pack" },
|
{ 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 () => {
|
it("returns correct counts for mix of new and existing items", async () => {
|
||||||
await insertGlobalItem(db, "Revelate Designs", "Terrapin System");
|
await insertGlobalItem(db, "Revelate Designs", "Terrapin System");
|
||||||
|
await insertManufacturer(db, "Apidura");
|
||||||
|
|
||||||
const res = await app.request("/api/global-items/bulk", {
|
const res = await app.request("/api/global-items/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
items: [
|
items: [
|
||||||
{ brand: "Revelate Designs", model: "Terrapin System" },
|
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
|
||||||
{ brand: "Apidura", model: "Handlebar Pack" },
|
{ manufacturerSlug: "apidura", model: "Handlebar Pack" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -218,7 +233,7 @@ describe("Global Item Routes", () => {
|
|||||||
|
|
||||||
it("returns 400 when items array exceeds 100", async () => {
|
it("returns 400 when items array exceeds 100", async () => {
|
||||||
const items = Array.from({ length: 101 }, (_, i) => ({
|
const items = Array.from({ length: 101 }, (_, i) => ({
|
||||||
brand: `Brand${i}`,
|
manufacturerSlug: `brand${i}`,
|
||||||
model: `Model${i}`,
|
model: `Model${i}`,
|
||||||
}));
|
}));
|
||||||
const res = await app.request("/api/global-items/bulk", {
|
const res = await app.request("/api/global-items/bulk", {
|
||||||
@@ -229,14 +244,14 @@ describe("Global Item Routes", () => {
|
|||||||
expect(res.status).toBe(400);
|
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", {
|
const res = await app.request("/api/global-items/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
items: [
|
items: [
|
||||||
{ brand: "Revelate Designs", model: "Terrapin System" },
|
{ manufacturerSlug: "revelate-designs", model: "Terrapin System" },
|
||||||
{ model: "Invalid Item without brand" },
|
{ model: "Invalid Item without manufacturerSlug" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
globalItems,
|
globalItems,
|
||||||
items,
|
items,
|
||||||
|
manufacturers,
|
||||||
setupItems,
|
setupItems,
|
||||||
setups,
|
setups,
|
||||||
users,
|
users,
|
||||||
@@ -16,19 +17,34 @@ import { createTestDb } from "../helpers/db.ts";
|
|||||||
|
|
||||||
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
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(
|
async function insertGlobalItem(
|
||||||
db: TestDb["db"],
|
db: TestDb["db"],
|
||||||
data: { brand: string; model: string; category?: string },
|
data: { brand: string; model: string; category?: string },
|
||||||
) {
|
) {
|
||||||
|
const m = await insertManufacturer(db, data.brand);
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(globalItems)
|
.insert(globalItems)
|
||||||
.values({
|
.values({
|
||||||
brand: data.brand,
|
manufacturerId: m.id,
|
||||||
model: data.model,
|
model: data.model,
|
||||||
category: data.category ?? null,
|
category: data.category ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return row;
|
return row!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertItem(db: TestDb["db"], userId: number, categoryId = 1) {
|
async function insertItem(db: TestDb["db"], userId: number, categoryId = 1) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as schema from "../../src/db/schema.ts";
|
||||||
import {
|
import {
|
||||||
globalItems,
|
globalItems,
|
||||||
globalItemTags,
|
globalItemTags,
|
||||||
@@ -17,10 +18,18 @@ import { createTestDb } from "../helpers/db.ts";
|
|||||||
|
|
||||||
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
|
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(
|
async function insertGlobalItem(
|
||||||
db: TestDb["db"],
|
db: TestDb["db"],
|
||||||
data: {
|
data: {
|
||||||
brand: string;
|
manufacturerId: number;
|
||||||
model: string;
|
model: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
weightGrams?: number;
|
weightGrams?: number;
|
||||||
@@ -30,14 +39,14 @@ async function insertGlobalItem(
|
|||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(globalItems)
|
.insert(globalItems)
|
||||||
.values({
|
.values({
|
||||||
brand: data.brand,
|
manufacturerId: data.manufacturerId,
|
||||||
model: data.model,
|
model: data.model,
|
||||||
category: data.category ?? null,
|
category: data.category ?? null,
|
||||||
weightGrams: data.weightGrams ?? null,
|
weightGrams: data.weightGrams ?? null,
|
||||||
priceCents: data.priceCents ?? null,
|
priceCents: data.priceCents ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return row;
|
return row!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertItem(
|
async function insertItem(
|
||||||
@@ -78,28 +87,20 @@ describe("Global Item Service", () => {
|
|||||||
|
|
||||||
describe("searchGlobalItems", () => {
|
describe("searchGlobalItems", () => {
|
||||||
it("returns all global items when no query provided", async () => {
|
it("returns all global items when no query provided", async () => {
|
||||||
await insertGlobalItem(db, {
|
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||||
model: "Terrapin System",
|
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||||
});
|
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||||
await insertGlobalItem(db, {
|
|
||||||
brand: "Apidura",
|
|
||||||
model: "Handlebar Pack",
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await searchGlobalItems(db);
|
const results = await searchGlobalItems(db);
|
||||||
expect(results).toHaveLength(2);
|
expect(results).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns items matching brand (case-insensitive)", async () => {
|
it("returns items matching brand (case-insensitive)", async () => {
|
||||||
await insertGlobalItem(db, {
|
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||||
model: "Terrapin System",
|
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||||
});
|
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||||
await insertGlobalItem(db, {
|
|
||||||
brand: "Apidura",
|
|
||||||
model: "Handlebar Pack",
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await searchGlobalItems(db, "revelate");
|
const results = await searchGlobalItems(db, "revelate");
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
@@ -107,14 +108,10 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns items matching model (case-insensitive)", async () => {
|
it("returns items matching model (case-insensitive)", async () => {
|
||||||
await insertGlobalItem(db, {
|
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||||
model: "Terrapin System",
|
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||||
});
|
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||||
await insertGlobalItem(db, {
|
|
||||||
brand: "Apidura",
|
|
||||||
model: "Handlebar Pack",
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await searchGlobalItems(db, "HANDLEBAR");
|
const results = await searchGlobalItems(db, "HANDLEBAR");
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
@@ -122,42 +119,30 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not match everything with wildcard chars", async () => {
|
it("does not match everything with wildcard chars", async () => {
|
||||||
await insertGlobalItem(db, {
|
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||||
model: "Terrapin System",
|
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||||
});
|
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||||
await insertGlobalItem(db, {
|
|
||||||
brand: "Apidura",
|
|
||||||
model: "Handlebar Pack",
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await searchGlobalItems(db, "100%");
|
const results = await searchGlobalItems(db, "100%");
|
||||||
expect(results).toHaveLength(0);
|
expect(results).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns all items when no tags provided", async () => {
|
it("returns all items when no tags provided", async () => {
|
||||||
await insertGlobalItem(db, {
|
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||||
model: "Terrapin System",
|
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||||
});
|
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||||
await insertGlobalItem(db, {
|
|
||||||
brand: "Apidura",
|
|
||||||
model: "Handlebar Pack",
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await searchGlobalItems(db, undefined, undefined);
|
const results = await searchGlobalItems(db, undefined, undefined);
|
||||||
expect(results).toHaveLength(2);
|
expect(results).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by single tag", async () => {
|
it("filters by single tag", async () => {
|
||||||
const gi1 = await insertGlobalItem(db, {
|
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||||
model: "Terrapin System",
|
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||||
});
|
const _gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||||
const _gi2 = await insertGlobalItem(db, {
|
|
||||||
brand: "Apidura",
|
|
||||||
model: "Handlebar Pack",
|
|
||||||
});
|
|
||||||
|
|
||||||
const tag = await insertTag(db, "ultralight");
|
const tag = await insertTag(db, "ultralight");
|
||||||
await tagGlobalItem(db, gi1.id, tag.id);
|
await tagGlobalItem(db, gi1.id, tag.id);
|
||||||
@@ -168,14 +153,10 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("filters by multiple tags with AND logic", async () => {
|
it("filters by multiple tags with AND logic", async () => {
|
||||||
const gi1 = await insertGlobalItem(db, {
|
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const m2 = await insertManufacturer(db, "Apidura", "apidura");
|
||||||
model: "Terrapin System",
|
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
|
||||||
});
|
const gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
|
||||||
const gi2 = await insertGlobalItem(db, {
|
|
||||||
brand: "Apidura",
|
|
||||||
model: "Handlebar Pack",
|
|
||||||
});
|
|
||||||
|
|
||||||
const tagUL = await insertTag(db, "ultralight");
|
const tagUL = await insertTag(db, "ultralight");
|
||||||
const tagBP = await insertTag(db, "bikepacking");
|
const tagBP = await insertTag(db, "bikepacking");
|
||||||
@@ -194,14 +175,9 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("combines text search and tag filtering", async () => {
|
it("combines text search and tag filtering", async () => {
|
||||||
const gi1 = await insertGlobalItem(db, {
|
const m = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
brand: "Revelate Designs",
|
const gi1 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Terrapin System" });
|
||||||
model: "Terrapin System",
|
const gi2 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Spinelock" });
|
||||||
});
|
|
||||||
const gi2 = await insertGlobalItem(db, {
|
|
||||||
brand: "Revelate Designs",
|
|
||||||
model: "Spinelock",
|
|
||||||
});
|
|
||||||
|
|
||||||
const tag = await insertTag(db, "bikepacking");
|
const tag = await insertTag(db, "bikepacking");
|
||||||
await tagGlobalItem(db, gi1.id, tag.id);
|
await tagGlobalItem(db, gi1.id, tag.id);
|
||||||
@@ -216,10 +192,8 @@ describe("Global Item Service", () => {
|
|||||||
|
|
||||||
describe("getGlobalItemWithOwnerCount", () => {
|
describe("getGlobalItemWithOwnerCount", () => {
|
||||||
it("returns item with ownerCount 0 when no items reference it", async () => {
|
it("returns item with ownerCount 0 when no items reference it", async () => {
|
||||||
const gi = await insertGlobalItem(db, {
|
const m = await insertManufacturer(db, "MSR", "msr");
|
||||||
brand: "MSR",
|
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
|
||||||
model: "PocketRocket 2",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await getGlobalItemWithOwnerCount(db, gi.id);
|
const result = await getGlobalItemWithOwnerCount(db, gi.id);
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
@@ -228,10 +202,8 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns ownerCount matching number of items with globalItemId", async () => {
|
it("returns ownerCount matching number of items with globalItemId", async () => {
|
||||||
const gi = await insertGlobalItem(db, {
|
const m = await insertManufacturer(db, "MSR", "msr");
|
||||||
brand: "MSR",
|
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
|
||||||
model: "PocketRocket 2",
|
|
||||||
});
|
|
||||||
|
|
||||||
await insertItem(db, "My Stove", userId, { globalItemId: gi.id });
|
await insertItem(db, "My Stove", userId, { globalItemId: gi.id });
|
||||||
await insertItem(db, "Another Stove", userId, {
|
await insertItem(db, "Another Stove", userId, {
|
||||||
@@ -269,8 +241,9 @@ describe("Global Item Service", () => {
|
|||||||
|
|
||||||
describe("upsert operations", () => {
|
describe("upsert operations", () => {
|
||||||
it("upsertGlobalItem creates new item and returns { item, created: true }", async () => {
|
it("upsertGlobalItem creates new item and returns { item, created: true }", async () => {
|
||||||
|
await insertManufacturer(db, "Revelate Designs", "revelate-designs");
|
||||||
const result = await upsertGlobalItem(db, {
|
const result = await upsertGlobalItem(db, {
|
||||||
brand: "Revelate Designs",
|
manufacturerSlug: "revelate-designs",
|
||||||
model: "Terrapin System",
|
model: "Terrapin System",
|
||||||
category: "Bags",
|
category: "Bags",
|
||||||
weightGrams: 210,
|
weightGrams: 210,
|
||||||
@@ -278,19 +251,19 @@ describe("Global Item Service", () => {
|
|||||||
|
|
||||||
expect(result.created).toBe(true);
|
expect(result.created).toBe(true);
|
||||||
expect(result.item.id).toBeDefined();
|
expect(result.item.id).toBeDefined();
|
||||||
expect(result.item.brand).toBe("Revelate Designs");
|
|
||||||
expect(result.item.model).toBe("Terrapin System");
|
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, {
|
await upsertGlobalItem(db, {
|
||||||
brand: "MSR",
|
manufacturerSlug: "msr",
|
||||||
model: "PocketRocket 2",
|
model: "PocketRocket 2",
|
||||||
weightGrams: 83,
|
weightGrams: 83,
|
||||||
});
|
});
|
||||||
|
|
||||||
const second = await upsertGlobalItem(db, {
|
const second = await upsertGlobalItem(db, {
|
||||||
brand: "MSR",
|
manufacturerSlug: "msr",
|
||||||
model: "PocketRocket 2",
|
model: "PocketRocket 2",
|
||||||
weightGrams: 90,
|
weightGrams: 90,
|
||||||
});
|
});
|
||||||
@@ -304,8 +277,9 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => {
|
it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => {
|
||||||
|
await insertManufacturer(db, "Apidura", "apidura");
|
||||||
const result = await upsertGlobalItem(db, {
|
const result = await upsertGlobalItem(db, {
|
||||||
brand: "Apidura",
|
manufacturerSlug: "apidura",
|
||||||
model: "Handlebar Pack",
|
model: "Handlebar Pack",
|
||||||
sourceUrl: "https://apidura.com/shop/handlebar-pack/",
|
sourceUrl: "https://apidura.com/shop/handlebar-pack/",
|
||||||
imageCredit: "Apidura Ltd",
|
imageCredit: "Apidura Ltd",
|
||||||
@@ -322,8 +296,9 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("upsertGlobalItem with tags creates tags and links them", async () => {
|
it("upsertGlobalItem with tags creates tags and links them", async () => {
|
||||||
|
await insertManufacturer(db, "Therm-a-Rest", "therm-a-rest");
|
||||||
const result = await upsertGlobalItem(db, {
|
const result = await upsertGlobalItem(db, {
|
||||||
brand: "Therm-a-Rest",
|
manufacturerSlug: "therm-a-rest",
|
||||||
model: "NeoAir XLite",
|
model: "NeoAir XLite",
|
||||||
tags: ["sleeping-pad", "ultralight"],
|
tags: ["sleeping-pad", "ultralight"],
|
||||||
});
|
});
|
||||||
@@ -342,16 +317,17 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("upsertGlobalItem without tags leaves existing tags untouched", async () => {
|
it("upsertGlobalItem without tags leaves existing tags untouched", async () => {
|
||||||
|
await insertManufacturer(db, "Sea to Summit", "sea-to-summit");
|
||||||
// Create item with tags
|
// Create item with tags
|
||||||
const first = await upsertGlobalItem(db, {
|
const first = await upsertGlobalItem(db, {
|
||||||
brand: "Sea to Summit",
|
manufacturerSlug: "sea-to-summit",
|
||||||
model: "Spark III",
|
model: "Spark III",
|
||||||
tags: ["sleeping-bag"],
|
tags: ["sleeping-bag"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upsert without tags
|
// Upsert without tags
|
||||||
await upsertGlobalItem(db, {
|
await upsertGlobalItem(db, {
|
||||||
brand: "Sea to Summit",
|
manufacturerSlug: "sea-to-summit",
|
||||||
model: "Spark III",
|
model: "Spark III",
|
||||||
weightGrams: 450,
|
weightGrams: 450,
|
||||||
});
|
});
|
||||||
@@ -366,16 +342,17 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("upsertGlobalItem with empty tags array clears existing tags", async () => {
|
it("upsertGlobalItem with empty tags array clears existing tags", async () => {
|
||||||
|
await insertManufacturer(db, "Big Agnes", "big-agnes");
|
||||||
// Create item with tags
|
// Create item with tags
|
||||||
const first = await upsertGlobalItem(db, {
|
const first = await upsertGlobalItem(db, {
|
||||||
brand: "Big Agnes",
|
manufacturerSlug: "big-agnes",
|
||||||
model: "Copper Spur HV UL2",
|
model: "Copper Spur HV UL2",
|
||||||
tags: ["tent", "ultralight"],
|
tags: ["tent", "ultralight"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upsert with empty tags
|
// Upsert with empty tags
|
||||||
await upsertGlobalItem(db, {
|
await upsertGlobalItem(db, {
|
||||||
brand: "Big Agnes",
|
manufacturerSlug: "big-agnes",
|
||||||
model: "Copper Spur HV UL2",
|
model: "Copper Spur HV UL2",
|
||||||
tags: [],
|
tags: [],
|
||||||
});
|
});
|
||||||
@@ -390,10 +367,12 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("bulkUpsertGlobalItems processes array and returns correct created/updated counts", async () => {
|
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, [
|
const result = await bulkUpsertGlobalItems(db, [
|
||||||
{ brand: "Petzl", model: "Actik Core", weightGrams: 87 },
|
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 },
|
||||||
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 },
|
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 },
|
||||||
{ brand: "Black Diamond", model: "Spot 350", weightGrams: 90 },
|
{ manufacturerSlug: "black-diamond", model: "Spot 350", weightGrams: 90 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result.created).toBe(3);
|
expect(result.created).toBe(3);
|
||||||
@@ -402,16 +381,18 @@ describe("Global Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("bulkUpsertGlobalItems handles mix of new and existing items", async () => {
|
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
|
// Pre-insert one item
|
||||||
await upsertGlobalItem(db, {
|
await upsertGlobalItem(db, {
|
||||||
brand: "Petzl",
|
manufacturerSlug: "petzl",
|
||||||
model: "Actik Core",
|
model: "Actik Core",
|
||||||
weightGrams: 87,
|
weightGrams: 87,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await bulkUpsertGlobalItems(db, [
|
const result = await bulkUpsertGlobalItems(db, [
|
||||||
{ brand: "Petzl", model: "Actik Core", weightGrams: 90 }, // existing
|
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing
|
||||||
{ brand: "Black Diamond", model: "Spot 400", weightGrams: 95 }, // new
|
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 }, // new
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result.created).toBe(1);
|
expect(result.created).toBe(1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { globalItems } from "../../src/db/schema.ts";
|
import { globalItems, manufacturers } from "../../src/db/schema.ts";
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
@@ -170,6 +170,15 @@ describe("Item Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("reference items (globalItemId)", () => {
|
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(
|
async function insertGlobalItem(
|
||||||
testDb: any,
|
testDb: any,
|
||||||
data: {
|
data: {
|
||||||
@@ -180,7 +189,14 @@ describe("Item Service", () => {
|
|||||||
imageUrl?: string;
|
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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
tests/services/manufacturer.service.test.ts
Normal file
72
tests/services/manufacturer.service.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "bun:test";
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { globalItems } from "../../src/db/schema.ts";
|
import { globalItems, manufacturers } from "../../src/db/schema.ts";
|
||||||
import {
|
import {
|
||||||
createCandidate,
|
createCandidate,
|
||||||
createThread,
|
createThread,
|
||||||
@@ -618,6 +618,15 @@ describe("Thread Service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("catalog-linked candidates (globalItemId)", () => {
|
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(
|
async function insertGlobalItem(
|
||||||
testDb: any,
|
testDb: any,
|
||||||
data: {
|
data: {
|
||||||
@@ -628,7 +637,14 @@ describe("Thread Service", () => {
|
|||||||
imageUrl?: string;
|
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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user