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