feat(18-02): implement global item service, seed script, and seed integration

- searchGlobalItems with LIKE-based case-insensitive search and wildcard escaping
- getGlobalItemWithOwnerCount with owner count from junction table
- linkItemToGlobal/unlinkItemFromGlobal for item-global linking
- seedGlobalItems idempotent seed from JSON catalog
- Integrated seed into seedDefaults startup
This commit is contained in:
2026-04-05 13:06:07 +02:00
parent 3a6876f7e8
commit 60dd9f4934
3 changed files with 110 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { db as prodDb } from "./index.ts";
import { globalItems } from "./schema.ts";
import seedData from "./global-items-seed.json";
type Db = typeof prodDb;
/**
* Seed the global items table with initial bikepacking gear data.
* Idempotent: skips if any rows already exist.
*/
export function seedGlobalItems(db: Db = prodDb) {
const existing = db.select().from(globalItems).limit(1).all();
if (existing.length > 0) return;
for (const item of seedData) {
db.insert(globalItems)
.values({
brand: item.brand,
model: item.model,
category: item.category ?? null,
weightGrams: item.weightGrams ?? null,
priceCents: item.priceCents ?? null,
description: item.description ?? null,
})
.run();
}
}

View File

@@ -1,5 +1,6 @@
import { db } from "./index.ts";
import { categories } from "./schema.ts";
import { seedGlobalItems } from "./seed-global-items.ts";
export function seedDefaults() {
const existing = db.select().from(categories).all();
@@ -11,4 +12,7 @@ export function seedDefaults() {
})
.run();
}
// Seed global items catalog
seedGlobalItems(db);
}

View File

@@ -0,0 +1,79 @@
import { count, eq, like, or, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { globalItems, itemGlobalLinks } from "../../db/schema.ts";
type Db = typeof prodDb;
/**
* Search global items by brand or model. SQLite LIKE is case-insensitive for ASCII.
* Escapes % and _ wildcard characters in user input.
*/
export function searchGlobalItems(db: Db = prodDb, query?: string) {
if (!query) {
return db.select().from(globalItems).all();
}
// Escape SQL LIKE wildcards
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escaped}%`;
return db
.select()
.from(globalItems)
.where(
or(
like(globalItems.brand, pattern),
like(globalItems.model, pattern),
),
)
.all();
}
/**
* Get a single global item by ID with the count of user items linked to it.
*/
export function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
const item = db
.select()
.from(globalItems)
.where(eq(globalItems.id, id))
.get();
if (!item) return null;
const result = db
.select({ ownerCount: count() })
.from(itemGlobalLinks)
.where(eq(itemGlobalLinks.globalItemId, id))
.get();
return { ...item, ownerCount: result?.ownerCount ?? 0 };
}
/**
* Link a user's item to a global item. Throws on duplicate (unique constraint on itemId).
*/
export function linkItemToGlobal(
db: Db = prodDb,
itemId: number,
globalItemId: number,
) {
return db
.insert(itemGlobalLinks)
.values({ itemId, globalItemId })
.returning()
.get();
}
/**
* Remove the link between a user's item and any global item.
*/
export function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) {
const result = db
.delete(itemGlobalLinks)
.where(eq(itemGlobalLinks.itemId, itemId))
.returning()
.all();
return result.length;
}