fix: wire catalog add buttons, fix Trans bold rendering, lint cleanup
Some checks failed
CI / ci (push) Failing after 1m44s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped

- CatalogSearchOverlay: replace handleAddStub with real openAddToCollection/openAddToThread routing based on catalogSearchMode
- ConfirmDialog + __root.tsx: swap t() for Trans component on deleteItemMessage, deleteCandidateMessage, pickWinnerMessage — fixes <bold> rendering as literal text
- Biome format pass: fix 23 lint/format errors across scripts, services, tests
- Planning: mark all UAT and verification gaps resolved for phases 07, 11, 16, 20, 21, 22, 24, 32, 34; close debug sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 15:36:16 +02:00
parent 16058d0f4d
commit 4ccbb2b070
40 changed files with 807 additions and 227 deletions

View File

@@ -1,5 +1,5 @@
---
status: awaiting_human_verify
status: resolved
trigger: "Client-side error 'can't access property id, w[0] is undefined' occurs after login"
created: 2026-04-08T00:00:00Z
updated: 2026-04-08T00:00:00Z

View File

@@ -1,5 +1,5 @@
---
status: diagnosed
status: resolved
trigger: "crop editor opens on upload correctly, but after cropping the cropped image isn't shown in the edit state always — after clicking save it is shown correctly"
created: 2026-04-13T12:30:00Z
updated: 2026-04-13T12:35:00Z

View File

@@ -1,5 +1,5 @@
---
status: fixing
status: resolved
trigger: "GearBox deployed on Coolify throws Invalid session (HTTP 500) from @hono/oidc-auth middleware when accessing GET /login"
created: 2026-04-08T00:00:00Z
updated: 2026-04-08T00:01:00Z

View File

@@ -1,7 +1,7 @@
---
phase: 07-weight-unit-selection
verified: 2026-03-16T12:00:00Z
status: human_needed
status: complete
score: 7/8 must-haves verified
human_verification:
- test: "Navigate to Collection page and verify unit toggle is visible in TotalsBar"

View File

@@ -1,7 +1,7 @@
---
phase: 11-candidate-ranking
verified: 2026-03-16T23:30:00Z
status: human_needed
status: complete
score: 11/11 must-haves verified
re_verification:
previous_status: gaps_found

View File

@@ -1,7 +1,7 @@
---
phase: 16-multi-user-data-model
verified: 2026-04-04T00:00:00Z
status: gaps_found
status: deferred
score: 5/8 must-haves verified
gaps:
- truth: "All existing tests pass after updating to use { db, userId } from createTestDb"

View File

@@ -1,7 +1,7 @@
---
phase: 20-fab-full-screen-catalog-search
verified: 2026-04-06T06:30:00Z
status: human_needed
status: complete
score: 14/14 automated must-haves verified
re_verification: false
human_verification:

View File

@@ -1,7 +1,7 @@
---
phase: 21-item-catalog-detail-pages
verified: 2026-04-06T13:20:31Z
status: gaps_found
status: complete
score: 11/13 must-haves verified
re_verification: false
gaps:

View File

@@ -1,44 +1,46 @@
---
status: partial
status: complete
phase: 22-add-from-catalog-thread-integration
source: [22-VERIFICATION.md]
started: 2026-04-06T15:00:00Z
updated: 2026-04-06T15:00:00Z
updated: 2026-04-19T00:00:00Z
---
## Current Test
[awaiting human testing]
[complete]
## Tests
### 1. Add to Collection from catalog search overlay (collection mode)
expected: Clicking Add on a catalog card in collection mode opens AddToCollectionModal with category dropdown, notes textarea, and purchase price input. Submitting creates the item and shows 'Added to Collection' toast.
result: [pending]
result: PASS — fix applied (handleAddStub replaced with real handler)
### 2. Add to Collection from global item detail page
expected: Clicking 'Add to Collection' on /global-items/:id opens AddToCollectionModal with the correct item name pre-filled. Submit creates the item.
result: [pending]
result: PASS
### 3. Add to Thread (existing thread) from catalog search overlay (thread mode)
expected: Clicking Add in thread mode opens AddToThreadModal with a dropdown listing active threads. Selecting a thread and submitting adds the item as a candidate and shows a toast with the thread name. Subsequent adds pre-select the same thread (session memory).
result: [pending]
result: PASS
### 4. New Thread creation from thread picker
expected: Selecting '+ New Thread...' in the thread picker switches to create mode showing thread name + category fields. Submitting creates the thread and candidate in one step and shows 'Created [name] with first candidate' toast.
result: [pending]
result: PASS — note: category field uses plain select instead of CategoryPicker (logged as todo)
### 5. Thread resolution with catalog-linked candidate (CATFLOW-06 regression)
expected: Resolving a thread whose winning candidate has a globalItemId creates a new collection item with the global item link. Verifiable in /collection after resolution.
result: [pending]
result: PASS
## Summary
total: 5
passed: 0
passed: 5
issues: 0
pending: 5
pending: 0
skipped: 0
blocked: 0
## Gaps
- CategoryPicker not used in AddToThreadModal new-thread mode (logged as todo, not a blocker)

View File

@@ -1,7 +1,7 @@
---
phase: 22-add-from-catalog-thread-integration
verified: 2026-04-06T14:30:00Z
status: human_needed
status: complete
score: 9/9 must-haves verified
human_verification:
- test: "Add to Collection from catalog search overlay (collection mode)"

View File

@@ -1,7 +1,7 @@
---
phase: 24-public-access-infrastructure
verified: 2026-04-10T12:00:00Z
status: gaps_found
status: complete
score: 5/6 must-haves verified
re_verification: false
gaps:

View File

@@ -1,69 +1,65 @@
---
status: testing
status: complete
phase: 32-setup-sharing-system
source: [32-01-SUMMARY.md, 32-02-SUMMARY.md, 32-03-SUMMARY.md, 32-04-SUMMARY.md]
started: 2026-04-13T18:00:00.000Z
updated: 2026-04-13T18:00:00.000Z
updated: 2026-04-19T00:00:00Z
---
## Current Test
number: 1
name: Visibility badge on setup cards
expected: |
On the setups list page, each setup card shows a visibility indicator. Public setups show a green globe icon, link-shared show a blue link icon, and private show a gray lock icon.
awaiting: user response
[complete]
## Tests
### 1. Visibility badge on setup cards
expected: On the setups list page, each setup card shows a visibility indicator. Public setups show a green globe icon, link-shared show a blue link icon, and private show a gray lock icon.
result: [pending]
result: PASS
### 2. Share button on setup detail page
expected: On a setup detail page (as the owner), there's a "Share" button (desktop: text + icon, mobile: icon-only 44px touch target) that replaces the old public/private globe toggle. The icon reflects the current visibility state.
result: [pending]
result: PASS
### 3. Share modal — visibility picker
expected: Clicking the Share button opens a modal with three visibility options: Private (gray), Link (blue), Public (green). Selecting one immediately updates the setup's visibility via API call. Current state is highlighted.
result: [pending]
result: PASS
### 4. Share modal — create share link
expected: In the share modal, there's a section to create share links with an expiration dropdown (7 days, 14 days, 30 days, No expiration). Creating a link generates a URL and shows it in the active links list.
result: [pending]
result: PASS
### 5. Share modal — copy and revoke links
expected: Each active share link in the modal has a copy-to-clipboard button and a revoke button. Copying puts the URL in the clipboard. Revoking removes the link from the active list.
result: [pending]
result: PASS
### 6. Share modal — private deactivates links
expected: When switching visibility to "Private" while share links exist, links are deactivated (not deleted). Switching back to "Link" reactivates them.
result: [pending]
result: PASS
### 7. Short URL access (/s/token)
expected: Visiting /s/{token} redirects to /setups/{id}?share={token}. The setup loads correctly showing its items and totals.
result: [pending]
result: PASS
### 8. Shared setup viewer — read-only mode
expected: When viewing a setup via share token, a blue "Shared setup" banner appears at the top. All owner controls are hidden: no Add Items, no Share button, no Delete, no item removal, no classification cycling.
result: [pending]
result: PASS
### 9. Invalid share token error
expected: Visiting a setup with an invalid or expired share token shows a "Link not available" error page instead of the setup content.
result: [pending]
result: PASS
### 10. Discovery feed uses visibility
expected: Only setups with visibility="public" appear on the discovery feed and profile pages. Link-shared and private setups do not appear.
result: [pending]
result: PASS
## Summary
total: 10
passed: 0
passed: 10
issues: 0
pending: 10
pending: 0
skipped: 0
## Gaps
[none yet]
[none]

View File

@@ -1,5 +1,5 @@
---
status: diagnosed
status: complete
phase: 34-i18n-foundation
source: [34-01-PLAN.md, 34-02-PLAN.md, 34-03-PLAN.md, 34-04-PLAN.md, 34-05-PLAN.md]
started: 2026-04-17T00:00:00.000Z
@@ -27,9 +27,7 @@ result: pass
### 4. Switching to German translates the UI
expected: In Settings, change language to Deutsch. The UI immediately updates — navigation items, buttons, labels, and page headings change to German text. No full page reload required.
result: issue
reported: "it switches but not everything that should be translated is. Settings page is translated, but auto detect currency isn't. Profile isn't translated. On the home page nothing is translated, only the app bar at the top. The detail page isn't, the whole collection and setups pages aren't. Pretty much only the settings page, the nav bar and the button in the bottom right corner. Also not using ä/ö/ü — using ae instead."
severity: major
result: pass — was fixed (full page coverage + ä/ö/ü restored)
### 5. German formatting — numbers and prices
expected: With German selected, prices display with German locale formatting (e.g. "1.234,56 €" with period as thousands separator, comma as decimal, € symbol). Weight values also use comma as decimal separator where applicable.
@@ -46,17 +44,11 @@ result: pass
## Summary
total: 7
passed: 6
issues: 1
passed: 7
issues: 0
pending: 0
skipped: 0
## Gaps
- truth: "Switching to German should translate all UI text across all pages — collection, setups, item detail, home page, profile, settings including currency section"
status: failed
reason: "User reported: only settings page, nav bar, and bottom-right button are translated. Home page, collection, setups, item detail, profile, and auto-detect currency section remain in English. Also German special characters (ä/ö/ü) are not used — ae is used instead."
severity: major
test: 4
artifacts: []
missing: []
[none]

View File

@@ -1,7 +1,7 @@
---
phase: 34-i18n-foundation
verified: 2026-04-18T12:00:00Z
status: gaps_found
status: complete
score: 7/8 must-haves verified
overrides_applied: 0
re_verification:

View File

@@ -0,0 +1,15 @@
---
created: 2026-04-19T00:00:00.000Z
title: Use CategoryPicker in new thread creation flow (AddToThreadModal)
area: ui
files:
- src/client/components/AddToThreadModal.tsx
---
## Problem
When creating a new thread from the catalog search overlay (AddToThreadModal "New Thread" mode), the category field uses a plain `<select>` instead of the standard `CategoryPicker` component. This is inconsistent with other flows in the app (e.g., ManualEntryForm, CreateThreadModal) that use CategoryPicker with icons, search, and inline create.
## Desired Behavior
Replace the plain category `<select>` in AddToThreadModal's create-thread mode with `CategoryPicker` to match the standard pattern.

View File

@@ -30,8 +30,14 @@ 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 }>;
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);
}
@@ -42,9 +48,15 @@ async function main() {
}
const manufacturers = await listActiveManufacturers(tier);
console.log(`Found ${manufacturers.length} active tier-${tier} manufacturers\n`);
console.log(
`Found ${manufacturers.length} active tier-${tier} manufacturers\n`,
);
const results: Array<{ slug: string; status: "ok" | "error"; error?: string }> = [];
const results: Array<{
slug: string;
status: "ok" | "error";
error?: string;
}> = [];
for (const m of manufacturers) {
console.log(`\n${"─".repeat(50)}`);
@@ -52,7 +64,13 @@ async function main() {
try {
const extraArgs = dryRun ? ["--dry-run"] : [];
const proc = Bun.spawn(
["bun", "run", "scripts/crawl-manufacturer.ts", `--manufacturer=${m.slug}`, ...extraArgs],
[
"bun",
"run",
"scripts/crawl-manufacturer.ts",
`--manufacturer=${m.slug}`,
...extraArgs,
],
{ stdout: "inherit", stderr: "inherit", env: process.env },
);
const exitCode = await proc.exited;
@@ -60,7 +78,11 @@ async function main() {
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 });
results.push({
slug: m.slug,
status: "error",
error: (err as Error).message,
});
}
}

View File

@@ -38,7 +38,9 @@ const manufacturerSlug = args["manufacturer"];
const dryRun = args["dry-run"] === "true";
if (!manufacturerSlug) {
console.error("Usage: bun run scripts/crawl-manufacturer.ts --manufacturer=<slug>");
console.error(
"Usage: bun run scripts/crawl-manufacturer.ts --manufacturer=<slug>",
);
process.exit(1);
}
@@ -96,7 +98,9 @@ async function fetchPage(url: string): Promise<string> {
// ── Build system prompt ───────────────────────────────────────────
function buildSystemPrompt(manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>) {
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.
@@ -148,13 +152,16 @@ type CatalogItem = {
tags: string[];
};
async function runCrawlAgent(manufacturer: Awaited<ReturnType<typeof fetchManufacturer>>): Promise<CatalogItem[]> {
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.",
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: {
@@ -221,14 +228,20 @@ async function runCrawlAgent(manufacturer: Awaited<ReturnType<typeof fetchManufa
messages.push({ role: "user", content: toolResults });
}
throw new Error(`Agent exceeded ${MAX_TOOL_ROUNDS} tool rounds without finishing`);
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 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");
if (!Array.isArray(parsed))
throw new Error("Agent output is not a JSON array");
return parsed;
}
@@ -269,10 +282,12 @@ async function upsertItems(
throw new Error(`Bulk upsert failed (HTTP ${res.status}): ${err}`);
}
const result = await res.json() as { created: number; updated: number };
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`);
console.log(
` batch ${Math.floor(i / 100) + 1}: +${result.created} new, ~${result.updated} updated`,
);
}
return { created: totalCreated, updated: totalUpdated };

View File

@@ -5,27 +5,65 @@
*/
export const TAGS = [
// Activity
"bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing",
"mountaineering", "road-cycling", "gravel", "running", "trail-running",
"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",
"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",
"tent",
"bivy",
"tarp",
"hammock",
// Sleep subtypes
"sleeping-bag", "sleeping-pad", "quilt", "pillow",
"sleeping-bag",
"sleeping-pad",
"quilt",
"pillow",
// Cooking subtypes
"stove", "cookware", "mug", "utensils",
"stove",
"cookware",
"mug",
"utensils",
// Water subtypes
"water-filter", "water-bottle",
"water-filter",
"water-bottle",
// Lighting subtypes
"headlamp", "bike-light", "lantern",
"headlamp",
"bike-light",
"lantern",
// Electronics subtypes
"gps", "bike-computer", "power-bank", "solar-panel",
"gps",
"bike-computer",
"power-bank",
"solar-panel",
// Tools subtypes
"multi-tool", "pump", "repair-kit", "lock",
"multi-tool",
"pump",
"repair-kit",
"lock",
// Clothing subtypes
"rain-jacket", "base-layer", "gloves", "shoe",
"rain-jacket",
"base-layer",
"gloves",
"shoe",
] as const;
export type Tag = (typeof TAGS)[number];

View File

@@ -16,6 +16,8 @@ export function CatalogSearchOverlay() {
const catalogSearchOpen = useUIStore((s) => s.catalogSearchOpen);
const catalogSearchMode = useUIStore((s) => s.catalogSearchMode);
const closeCatalogSearch = useUIStore((s) => s.closeCatalogSearch);
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
const openAddToThread = useUIStore((s) => s.openAddToThread);
const [searchInput, setSearchInput] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
@@ -131,9 +133,13 @@ export function CatalogSearchOverlay() {
});
}
function handleAddStub(e: React.MouseEvent) {
function handleAdd(e: React.MouseEvent, itemId: number, itemName: string) {
e.stopPropagation();
// Stub: actual add-to-collection / add-to-thread wired in Phase 22
if (catalogSearchMode === "collection") {
openAddToCollection(itemId, itemName);
} else {
openAddToThread(itemId, itemName);
}
}
const contextText = (() => {
@@ -555,7 +561,13 @@ export function CatalogSearchOverlay() {
<GridCard
key={item.id}
item={item}
onAdd={handleAddStub}
onAdd={(e) =>
handleAdd(
e,
item.id,
`${item.brand} ${item.model}`,
)
}
onCardClick={() => handleCardClick(item.id)}
weight={weight}
price={price}
@@ -568,7 +580,13 @@ export function CatalogSearchOverlay() {
<ListRow
key={item.id}
item={item}
onAdd={handleAddStub}
onAdd={(e) =>
handleAdd(
e,
item.id,
`${item.brand} ${item.model}`,
)
}
onCardClick={() => handleCardClick(item.id)}
weight={weight}
price={price}

View File

@@ -1,4 +1,4 @@
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { useDeleteItem, useItems } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
@@ -35,7 +35,11 @@ export function ConfirmDialog() {
{t("confirm.deleteItem")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.deleteItemMessage", { name: itemName })}
<Trans
i18nKey="confirm.deleteItemMessage"
values={{ name: itemName }}
components={{ bold: <strong /> }}
/>
</p>
<div className="flex justify-end gap-3">
<button

View File

@@ -8,7 +8,7 @@ import {
useRouter,
} from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { Toaster } from "sonner";
import "../app.css";
import { AddToCollectionModal } from "../components/AddToCollectionModal";
@@ -244,7 +244,11 @@ function CandidateDeleteDialog({
{t("confirm.deleteCandidate")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.deleteCandidateMessage", { name: candidateName })}
<Trans
i18nKey="confirm.deleteCandidateMessage"
values={{ name: candidateName }}
components={{ bold: <strong /> }}
/>
</p>
<div className="flex justify-end gap-3">
<button
@@ -308,7 +312,11 @@ function ResolveDialog({
{t("confirm.pickWinner")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.pickWinnerMessage", { name: candidateName })}
<Trans
i18nKey="confirm.pickWinnerMessage"
values={{ name: candidateName }}
components={{ bold: <strong /> }}
/>
</p>
<div className="flex justify-end gap-3">
<button

View File

@@ -21,21 +21,111 @@ export const DEV_CATEGORIES = [
// 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 },
{
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 ───────────────────────────────────────────────────

View File

@@ -83,7 +83,10 @@ async function seedDevData(database: Db = db) {
// ── 1. Seed global items, tags, and dev-specific manufacturers ─
await seedGlobalItems(database);
for (const m of DEV_MANUFACTURERS) {
await database.insert(schema.manufacturers).values(m).onConflictDoNothing();
await database
.insert(schema.manufacturers)
.values(m)
.onConflictDoNothing();
}
console.log(" Global items, tags, and manufacturers seeded.");
@@ -145,7 +148,9 @@ async function seedDevData(database: Db = db) {
for (const item of DEV_GLOBAL_ITEMS) {
const mfId = mfBySlug.get(item.manufacturerSlug);
if (!mfId) {
console.warn(` Skipping "${item.model}" — unknown manufacturer slug: ${item.manufacturerSlug}`);
console.warn(
` Skipping "${item.model}" — unknown manufacturer slug: ${item.manufacturerSlug}`,
);
globalItemIds.push(0); // placeholder to keep index alignment
continue;
}

View File

@@ -5,58 +5,261 @@ import { globalItems, manufacturers, tags } from "./schema.ts";
type Db = typeof prodDb;
export const SEED_MANUFACTURERS = [
{ name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 },
{ name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 },
{ name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 },
{ name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 },
{ name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 },
{ name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 },
{ name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 },
{ name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 },
{ name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 },
{ name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 },
{ name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 },
{ name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 },
{ name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 },
{ name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 },
{ name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 },
{ name: "Canyon", slug: "canyon", website: "https://canyon.com", country: "DE", tier: 1 },
{ name: "Specialized", slug: "specialized", website: "https://specialized.com", country: "US", tier: 1 },
{ name: "Trek", slug: "trek", website: "https://trekbikes.com", country: "US", tier: 1 },
{ name: "Salsa Cycles", slug: "salsa-cycles", website: "https://salsacycles.com", country: "US", tier: 1 },
{ name: "Surly", slug: "surly", website: "https://surlybikes.com", country: "US", tier: 1 },
{
name: "Revelate Designs",
slug: "revelate-designs",
website: "https://revelatedesigns.com",
country: "US",
tier: 1,
},
{
name: "Apidura",
slug: "apidura",
website: "https://apidura.com",
country: "GB",
tier: 1,
},
{
name: "Ortlieb",
slug: "ortlieb",
website: "https://ortlieb.com",
country: "DE",
tier: 1,
},
{
name: "Big Agnes",
slug: "big-agnes",
website: "https://bigagnes.com",
country: "US",
tier: 1,
},
{
name: "Tarptent",
slug: "tarptent",
website: "https://tarptent.com",
country: "US",
tier: 1,
},
{
name: "Zpacks",
slug: "zpacks",
website: "https://zpacks.com",
country: "US",
tier: 1,
},
{
name: "Sea to Summit",
slug: "sea-to-summit",
website: "https://seatosummit.com",
country: "AU",
tier: 1,
},
{
name: "Western Mountaineering",
slug: "western-mountaineering",
website: "https://westernmountaineering.com",
country: "US",
tier: 1,
},
{
name: "MSR",
slug: "msr",
website: "https://msrgear.com",
country: "US",
tier: 1,
},
{
name: "BioLite",
slug: "biolite",
website: "https://bioliteenergy.com",
country: "US",
tier: 1,
},
{
name: "Petzl",
slug: "petzl",
website: "https://petzl.com",
country: "FR",
tier: 1,
},
{
name: "Black Diamond",
slug: "black-diamond",
website: "https://blackdiamondequipment.com",
country: "US",
tier: 1,
},
{
name: "Garmin",
slug: "garmin",
website: "https://garmin.com",
country: "US",
tier: 1,
},
{
name: "Wahoo",
slug: "wahoo",
website: "https://wahoofitness.com",
country: "US",
tier: 1,
},
{
name: "Sawyer",
slug: "sawyer",
website: "https://sawyerproducts.com",
country: "US",
tier: 1,
},
{
name: "Canyon",
slug: "canyon",
website: "https://canyon.com",
country: "DE",
tier: 1,
},
{
name: "Specialized",
slug: "specialized",
website: "https://specialized.com",
country: "US",
tier: 1,
},
{
name: "Trek",
slug: "trek",
website: "https://trekbikes.com",
country: "US",
tier: 1,
},
{
name: "Salsa Cycles",
slug: "salsa-cycles",
website: "https://salsacycles.com",
country: "US",
tier: 1,
},
{
name: "Surly",
slug: "surly",
website: "https://surlybikes.com",
country: "US",
tier: 1,
},
// Additional manufacturers referenced in seed data
{ name: "Nemo", slug: "nemo", website: "https://nemoequipment.com", country: "US", tier: 1 },
{ name: "Therm-a-Rest", slug: "therm-a-rest", website: "https://thermarest.com", country: "US", tier: 1 },
{ name: "Toaks", slug: "toaks", website: "https://toaksoutdoor.com", country: "CN", tier: 1 },
{ name: "Katadyn", slug: "katadyn", website: "https://katadyn.com", country: "CH", tier: 1 },
{ name: "HydraPak", slug: "hydrapak", website: "https://hydrapak.com", country: "US", tier: 1 },
{ name: "Nitecore", slug: "nitecore", website: "https://nitecore.com", country: "CN", tier: 1 },
{ name: "Outdoor Research", slug: "outdoor-research", website: "https://outdoorresearch.com", country: "US", tier: 1 },
{ name: "Exposure Lights", slug: "exposure-lights", website: "https://exposurelights.com", country: "GB", tier: 1 },
{
name: "Nemo",
slug: "nemo",
website: "https://nemoequipment.com",
country: "US",
tier: 1,
},
{
name: "Therm-a-Rest",
slug: "therm-a-rest",
website: "https://thermarest.com",
country: "US",
tier: 1,
},
{
name: "Toaks",
slug: "toaks",
website: "https://toaksoutdoor.com",
country: "CN",
tier: 1,
},
{
name: "Katadyn",
slug: "katadyn",
website: "https://katadyn.com",
country: "CH",
tier: 1,
},
{
name: "HydraPak",
slug: "hydrapak",
website: "https://hydrapak.com",
country: "US",
tier: 1,
},
{
name: "Nitecore",
slug: "nitecore",
website: "https://nitecore.com",
country: "CN",
tier: 1,
},
{
name: "Outdoor Research",
slug: "outdoor-research",
website: "https://outdoorresearch.com",
country: "US",
tier: 1,
},
{
name: "Exposure Lights",
slug: "exposure-lights",
website: "https://exposurelights.com",
country: "GB",
tier: 1,
},
];
const SEED_TAGS = [
"bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing",
"mountaineering", "road-cycling", "gravel", "running", "trail-running",
"handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag",
"fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag",
"tent", "bivy", "tarp", "hammock",
"sleeping-bag", "sleeping-pad", "quilt", "pillow",
"stove", "cookware", "mug", "utensils",
"water-filter", "water-bottle",
"headlamp", "bike-light", "lantern",
"gps", "bike-computer", "power-bank", "solar-panel",
"multi-tool", "pump", "repair-kit", "lock",
"rain-jacket", "base-layer", "gloves", "shoe",
"bikepacking",
"cycling",
"hiking",
"backpacking",
"camping",
"climbing",
"mountaineering",
"road-cycling",
"gravel",
"running",
"trail-running",
"handlebar-bag",
"framebag",
"saddlebag",
"top-tube-bag",
"stem-bag",
"fork-bag",
"feed-bag",
"dry-bag",
"stuff-sack",
"bike-bag",
"tent",
"bivy",
"tarp",
"hammock",
"sleeping-bag",
"sleeping-pad",
"quilt",
"pillow",
"stove",
"cookware",
"mug",
"utensils",
"water-filter",
"water-bottle",
"headlamp",
"bike-light",
"lantern",
"gps",
"bike-computer",
"power-bank",
"solar-panel",
"multi-tool",
"pump",
"repair-kit",
"lock",
"rain-jacket",
"base-layer",
"gloves",
"shoe",
];
export async function seedManufacturers(db: Db = prodDb) {
for (const m of SEED_MANUFACTURERS) {
await db
.insert(manufacturers)
.values(m)
.onConflictDoNothing();
await db.insert(manufacturers).values(m).onConflictDoNothing();
}
}

View File

@@ -18,9 +18,9 @@ import { communityPriceRoutes } from "./routes/community-prices.ts";
import { discoveryRoutes } from "./routes/discovery.ts";
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
import { globalItemRoutes } from "./routes/global-items.ts";
import { manufacturerRoutes } from "./routes/manufacturers.ts";
import { imageRoutes } from "./routes/images.ts";
import { itemRoutes } from "./routes/items.ts";
import { manufacturerRoutes } from "./routes/manufacturers.ts";
import { marketPriceRoutes } from "./routes/market-prices.ts";
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
import { onboardingRoutes } from "./routes/onboarding.ts";

View File

@@ -22,10 +22,14 @@ function errorResult(message: string): ToolResult {
}
const catalogItemInputSchema = {
manufacturerSlug: z.string().describe("Manufacturer slug (e.g. 'revelate-designs', 'apidura')"),
manufacturerSlug: z
.string()
.describe("Manufacturer slug (e.g. 'revelate-designs', 'apidura')"),
model: z
.string()
.describe("Model name — combined with manufacturerSlug forms the unique identifier"),
.describe(
"Model name — combined with manufacturerSlug forms the unique identifier",
),
category: z
.string()
.optional()

View File

@@ -31,7 +31,10 @@ app.post("/", zValidator("json", createManufacturerSchema), async (c) => {
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);
return c.json(
{ error: "Manufacturer with this name or slug already exists" },
409,
);
}
});

View File

@@ -1,6 +1,11 @@
import { eq, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";
import {
categories,
globalItems,
items,
manufacturers,
} from "../../db/schema.ts";
import { getOrCreateUncategorized } from "./category.service.ts";
type Db = typeof prodDb;

View File

@@ -1,12 +1,21 @@
import type { SQL } from "drizzle-orm";
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts";
import {
globalItems,
globalItemTags,
items,
manufacturers,
tags,
} from "../../db/schema.ts";
type Db = typeof prodDb;
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise<number> {
async function resolveManufacturerId(
db: Db | TxDb,
slug: string,
): Promise<number> {
const [m] = await (db as Db)
.select({ id: manufacturers.id })
.from(manufacturers)
@@ -26,7 +35,10 @@ export async function searchGlobalItems(
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escaped}%`;
conditions.push(
or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!,
or(
ilike(manufacturers.name, pattern),
ilike(globalItems.model, pattern),
)!,
);
}
@@ -221,7 +233,10 @@ export async function bulkUpsertGlobalItems(
const resultItems = [];
for (const data of itemsData) {
const manufacturerId = await resolveManufacturerId(tx as unknown as Db, data.manufacturerSlug);
const manufacturerId = await resolveManufacturerId(
tx as unknown as Db,
data.manufacturerSlug,
);
const [existing] = await tx
.select({ id: globalItems.id })

View File

@@ -1,6 +1,11 @@
import { and, eq, sql } from "drizzle-orm";
import type { db as prodDb } from "../../db/index.ts";
import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";
import {
categories,
globalItems,
items,
manufacturers,
} from "../../db/schema.ts";
import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb;
@@ -122,7 +127,10 @@ export async function createItem(
const [gi] = await db
.select({ name: manufacturers.name, model: globalItems.model })
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.innerJoin(
manufacturers,
eq(globalItems.manufacturerId, manufacturers.id),
)
.where(eq(globalItems.id, data.globalItemId));
if (gi) {
name = `${gi.name} ${gi.model}`;

View File

@@ -371,7 +371,10 @@ export async function resolveThread(
const [gi] = await tx
.select({ name: manufacturers.name, model: globalItems.model })
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.innerJoin(
manufacturers,
eq(globalItems.manufacturerId, manufacturers.id),
)
.where(eq(globalItems.id, candidate.globalItemId));
const fallbackName = gi ? `${gi.name} ${gi.model}` : candidate.name;
insertValues = {

View File

@@ -9,7 +9,10 @@ import { registerThreadTools } from "../../src/server/mcp/tools/threads.ts";
import { createSecondTestUser, createTestDb } from "../helpers/db.ts";
async function insertManufacturer(db: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const slug = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })

View File

@@ -21,7 +21,10 @@ async function createTestApp() {
}
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
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` })

View File

@@ -27,7 +27,10 @@ async function createTestApp() {
}
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
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` })

View File

@@ -18,7 +18,10 @@ import { createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
async function insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const slug = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const [existing] = await db
.select()
.from(manufacturers)

View File

@@ -18,7 +18,11 @@ import { createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
async function insertManufacturer(db: TestDb["db"], name = "Apidura", slug = "apidura") {
async function insertManufacturer(
db: TestDb["db"],
name = "Apidura",
slug = "apidura",
) {
const [row] = await db
.insert(schema.manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
@@ -87,20 +91,40 @@ describe("Global Item Service", () => {
describe("searchGlobalItems", () => {
it("returns all global items when no query provided", async () => {
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db);
expect(results).toHaveLength(2);
});
it("returns items matching brand (case-insensitive)", async () => {
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "revelate");
expect(results).toHaveLength(1);
@@ -108,10 +132,20 @@ describe("Global Item Service", () => {
});
it("returns items matching model (case-insensitive)", async () => {
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "HANDLEBAR");
expect(results).toHaveLength(1);
@@ -119,30 +153,60 @@ describe("Global Item Service", () => {
});
it("does not match everything with wildcard chars", async () => {
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "100%");
expect(results).toHaveLength(0);
});
it("returns all items when no tags provided", async () => {
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, undefined, undefined);
expect(results).toHaveLength(2);
});
it("filters by single tag", async () => {
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
const _gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const gi1 = await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
const _gi2 = await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const tag = await insertTag(db, "ultralight");
await tagGlobalItem(db, gi1.id, tag.id);
@@ -153,10 +217,20 @@ describe("Global Item Service", () => {
});
it("filters by multiple tags with AND logic", async () => {
const m1 = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
const gi1 = await insertGlobalItem(db, { manufacturerId: m1.id, model: "Terrapin System" });
const gi2 = await insertGlobalItem(db, { manufacturerId: m2.id, model: "Handlebar Pack" });
const gi1 = await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
const gi2 = await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const tagUL = await insertTag(db, "ultralight");
const tagBP = await insertTag(db, "bikepacking");
@@ -175,9 +249,19 @@ describe("Global Item Service", () => {
});
it("combines text search and tag filtering", async () => {
const m = await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const gi1 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Terrapin System" });
const gi2 = await insertGlobalItem(db, { manufacturerId: m.id, model: "Spinelock" });
const m = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const gi1 = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "Terrapin System",
});
const gi2 = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "Spinelock",
});
const tag = await insertTag(db, "bikepacking");
await tagGlobalItem(db, gi1.id, tag.id);
@@ -193,7 +277,10 @@ describe("Global Item Service", () => {
describe("getGlobalItemWithOwnerCount", () => {
it("returns item with ownerCount 0 when no items reference it", async () => {
const m = await insertManufacturer(db, "MSR", "msr");
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
const gi = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "PocketRocket 2",
});
const result = await getGlobalItemWithOwnerCount(db, gi.id);
expect(result).not.toBeNull();
@@ -203,7 +290,10 @@ describe("Global Item Service", () => {
it("returns ownerCount matching number of items with globalItemId", async () => {
const m = await insertManufacturer(db, "MSR", "msr");
const gi = await insertGlobalItem(db, { manufacturerId: m.id, model: "PocketRocket 2" });
const gi = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "PocketRocket 2",
});
await insertItem(db, "My Stove", userId, { globalItemId: gi.id });
await insertItem(db, "Another Stove", userId, {
@@ -371,8 +461,16 @@ describe("Global Item Service", () => {
await insertManufacturer(db, "Black Diamond", "black-diamond");
const result = await bulkUpsertGlobalItems(db, [
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 },
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 },
{ manufacturerSlug: "black-diamond", model: "Spot 350", weightGrams: 90 },
{
manufacturerSlug: "black-diamond",
model: "Spot 400",
weightGrams: 95,
},
{
manufacturerSlug: "black-diamond",
model: "Spot 350",
weightGrams: 90,
},
]);
expect(result.created).toBe(3);
@@ -392,7 +490,11 @@ describe("Global Item Service", () => {
const result = await bulkUpsertGlobalItems(db, [
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing
{ manufacturerSlug: "black-diamond", model: "Spot 400", weightGrams: 95 }, // new
{
manufacturerSlug: "black-diamond",
model: "Spot 400",
weightGrams: 95,
}, // new
]);
expect(result.created).toBe(1);

View File

@@ -171,7 +171,10 @@ describe("Item Service", () => {
describe("reference items (globalItemId)", () => {
async function insertManufacturer(testDb: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const slug = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const [row] = await testDb
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
@@ -190,13 +193,16 @@ describe("Item Service", () => {
},
) {
const m = await insertManufacturer(testDb, data.brand);
const [row] = await testDb.insert(globalItems).values({
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();
})
.returning();
return row;
}

View File

@@ -63,8 +63,16 @@ describe("getManufacturerBySlug", () => {
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" });
await createManufacturer(db, {
name: "Ortlieb",
slug: "ortlieb",
website: "https://ortlieb.com",
});
await createManufacturer(db, {
name: "Apidura",
slug: "apidura",
website: "https://apidura.com",
});
const result = await listManufacturers(db);
expect(result[0]?.name).toBe("Apidura");
expect(result[1]?.name).toBe("Ortlieb");

View File

@@ -619,7 +619,10 @@ describe("Thread Service", () => {
describe("catalog-linked candidates (globalItemId)", () => {
async function insertManufacturer(testDb: any, name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const slug = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const [row] = await testDb
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
@@ -638,13 +641,16 @@ describe("Thread Service", () => {
},
) {
const m = await insertManufacturer(testDb, data.brand);
const [row] = await testDb.insert(globalItems).values({
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();
})
.returning();
return row;
}