529 lines
14 KiB
TypeScript
529 lines
14 KiB
TypeScript
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";
|
|
|
|
type Db = typeof prodDb;
|
|
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
|
|
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,
|
|
tagNames?: string[],
|
|
) {
|
|
const conditions: SQL[] = [];
|
|
|
|
if (query) {
|
|
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
const pattern = `%${escaped}%`;
|
|
conditions.push(
|
|
or(
|
|
ilike(manufacturers.name, pattern),
|
|
ilike(globalItems.model, pattern),
|
|
)!,
|
|
);
|
|
}
|
|
|
|
if (tagNames && tagNames.length > 0) {
|
|
conditions.push(
|
|
sql`${globalItems.id} IN (
|
|
SELECT ${globalItemTags.globalItemId}
|
|
FROM ${globalItemTags}
|
|
JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId}
|
|
WHERE ${tags.name} IN (${sql.join(
|
|
tagNames.map((t) => sql`${t}`),
|
|
sql`, `,
|
|
)})
|
|
GROUP BY ${globalItemTags.globalItemId}
|
|
HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length}
|
|
)`,
|
|
);
|
|
}
|
|
|
|
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 baseQuery;
|
|
}
|
|
|
|
return baseQuery.where(and(...conditions));
|
|
}
|
|
|
|
export async function listGlobalItemsForAdmin(
|
|
db: Db,
|
|
opts: {
|
|
query?: string;
|
|
tagNames?: string[];
|
|
offset?: number;
|
|
limit?: number;
|
|
} = {},
|
|
) {
|
|
const { query, tagNames, offset = 0, limit = 50 } = opts;
|
|
const conditions: SQL[] = [];
|
|
|
|
if (query) {
|
|
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
const pattern = `%${escaped}%`;
|
|
conditions.push(
|
|
or(
|
|
ilike(manufacturers.name, pattern),
|
|
ilike(globalItems.model, pattern),
|
|
)!,
|
|
);
|
|
}
|
|
|
|
if (tagNames && tagNames.length > 0) {
|
|
conditions.push(
|
|
sql`${globalItems.id} IN (
|
|
SELECT ${globalItemTags.globalItemId}
|
|
FROM ${globalItemTags}
|
|
JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId}
|
|
WHERE ${tags.name} IN (${sql.join(
|
|
tagNames.map((t) => sql`${t}`),
|
|
sql`, `,
|
|
)})
|
|
GROUP BY ${globalItemTags.globalItemId}
|
|
HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length}
|
|
)`,
|
|
);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
// 1. Total count
|
|
const [{ total }] = await db
|
|
.select({ total: count() })
|
|
.from(globalItems)
|
|
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
|
|
.where(whereClause);
|
|
|
|
// 2. Paginated items
|
|
const pageItems = await 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))
|
|
.where(whereClause)
|
|
.orderBy(manufacturers.name, globalItems.model)
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
if (pageItems.length === 0) {
|
|
return { items: [], total: total ?? 0, hasMore: false, nextOffset: offset };
|
|
}
|
|
|
|
const ids = pageItems.map((i) => i.id);
|
|
|
|
// 3. Batch fetch tags for this page
|
|
const tagRows = await db
|
|
.select({
|
|
globalItemId: globalItemTags.globalItemId,
|
|
name: tags.name,
|
|
})
|
|
.from(globalItemTags)
|
|
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
|
|
.where(
|
|
sql`${globalItemTags.globalItemId} IN (${sql.join(
|
|
ids.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
)})`,
|
|
);
|
|
|
|
const tagsByItemId = new Map<number, string[]>();
|
|
for (const row of tagRows) {
|
|
const list = tagsByItemId.get(row.globalItemId) ?? [];
|
|
list.push(row.name);
|
|
tagsByItemId.set(row.globalItemId, list);
|
|
}
|
|
|
|
// 4. Batch fetch owner counts for this page
|
|
const ownerRows = await db
|
|
.select({
|
|
globalItemId: items.globalItemId,
|
|
ownerCount: count(),
|
|
})
|
|
.from(items)
|
|
.where(
|
|
sql`${items.globalItemId} IN (${sql.join(
|
|
ids.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
)})`,
|
|
)
|
|
.groupBy(items.globalItemId);
|
|
|
|
const ownerCountById = new Map<number, number>();
|
|
for (const row of ownerRows) {
|
|
if (row.globalItemId != null) {
|
|
ownerCountById.set(row.globalItemId, row.ownerCount);
|
|
}
|
|
}
|
|
|
|
const enriched = pageItems.map((item) => ({
|
|
...item,
|
|
tags: tagsByItemId.get(item.id) ?? [],
|
|
ownerCount: ownerCountById.get(item.id) ?? 0,
|
|
}));
|
|
|
|
const nextOffset = offset + limit;
|
|
return {
|
|
items: enriched,
|
|
total: total ?? 0,
|
|
hasMore: nextOffset < (total ?? 0),
|
|
nextOffset,
|
|
};
|
|
}
|
|
|
|
export async function updateGlobalItemById(
|
|
db: Db,
|
|
id: number,
|
|
data: {
|
|
manufacturerId?: number;
|
|
model?: string;
|
|
category?: string | null;
|
|
weightGrams?: number | null;
|
|
priceCents?: number | null;
|
|
imageUrl?: string | null;
|
|
description?: string | null;
|
|
sourceUrl?: string | null;
|
|
imageCredit?: string | null;
|
|
imageSourceUrl?: string | null;
|
|
tags?: string[];
|
|
},
|
|
) {
|
|
return await db.transaction(async (tx) => {
|
|
const { tags: tagNames, ...fields } = data;
|
|
|
|
// Build partial update — only set provided fields
|
|
const updateSet: Record<string, unknown> = {};
|
|
if (fields.manufacturerId !== undefined)
|
|
updateSet.manufacturerId = fields.manufacturerId;
|
|
if (fields.model !== undefined) updateSet.model = fields.model;
|
|
if ("category" in fields) updateSet.category = fields.category ?? null;
|
|
if ("weightGrams" in fields)
|
|
updateSet.weightGrams = fields.weightGrams ?? null;
|
|
if ("priceCents" in fields)
|
|
updateSet.priceCents = fields.priceCents ?? null;
|
|
if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? null;
|
|
if ("description" in fields)
|
|
updateSet.description = fields.description ?? null;
|
|
if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null;
|
|
if ("imageCredit" in fields)
|
|
updateSet.imageCredit = fields.imageCredit ?? null;
|
|
if ("imageSourceUrl" in fields)
|
|
updateSet.imageSourceUrl = fields.imageSourceUrl ?? null;
|
|
|
|
let item: typeof globalItems.$inferSelect | undefined;
|
|
if (Object.keys(updateSet).length > 0) {
|
|
const [updated] = await tx
|
|
.update(globalItems)
|
|
.set(updateSet)
|
|
.where(eq(globalItems.id, id))
|
|
.returning();
|
|
item = updated;
|
|
} else {
|
|
const [existing] = await tx
|
|
.select()
|
|
.from(globalItems)
|
|
.where(eq(globalItems.id, id));
|
|
item = existing;
|
|
}
|
|
|
|
if (!item) return null;
|
|
|
|
if (tagNames !== undefined) {
|
|
await syncGlobalItemTags(tx, id, tagNames);
|
|
}
|
|
|
|
return item;
|
|
});
|
|
}
|
|
|
|
export async function deleteGlobalItem(db: Db, id: number) {
|
|
return await db.transaction(async (tx) => {
|
|
// 1. Verify item exists
|
|
const [existing] = await tx
|
|
.select({ id: globalItems.id })
|
|
.from(globalItems)
|
|
.where(eq(globalItems.id, id));
|
|
|
|
if (!existing) return false;
|
|
|
|
// 2. Nullify user item links (FK: items.globalItemId → globalItems.id, no cascade)
|
|
await tx
|
|
.update(items)
|
|
.set({ globalItemId: null })
|
|
.where(eq(items.globalItemId, id));
|
|
|
|
// 3. Remove tag associations (FK: globalItemTags.globalItemId → globalItems.id, no cascade)
|
|
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, id));
|
|
|
|
// 4. Delete the global item
|
|
await tx.delete(globalItems).where(eq(globalItems.id, id));
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) {
|
|
const [item] = await 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))
|
|
.where(eq(globalItems.id, id));
|
|
|
|
if (!item) return null;
|
|
|
|
const [result] = await db
|
|
.select({ ownerCount: count() })
|
|
.from(items)
|
|
.where(eq(items.globalItemId, id));
|
|
|
|
return { ...item, ownerCount: result?.ownerCount ?? 0 };
|
|
}
|
|
|
|
async function syncGlobalItemTags(
|
|
tx: TxDb,
|
|
globalItemId: number,
|
|
tagNames: string[],
|
|
) {
|
|
await tx
|
|
.delete(globalItemTags)
|
|
.where(eq(globalItemTags.globalItemId, globalItemId));
|
|
|
|
for (const name of tagNames) {
|
|
const [tag] = await tx
|
|
.insert(tags)
|
|
.values({ name })
|
|
.onConflictDoUpdate({ target: tags.name, set: { name } })
|
|
.returning({ id: tags.id });
|
|
|
|
await tx.insert(globalItemTags).values({ globalItemId, tagId: tag.id });
|
|
}
|
|
}
|
|
|
|
export async function upsertGlobalItem(
|
|
db: Db,
|
|
data: {
|
|
manufacturerSlug: string;
|
|
model: string;
|
|
category?: string;
|
|
weightGrams?: number;
|
|
priceCents?: number;
|
|
imageUrl?: string;
|
|
description?: string;
|
|
sourceUrl?: string;
|
|
imageCredit?: string;
|
|
imageSourceUrl?: string;
|
|
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.manufacturerId, manufacturerId),
|
|
eq(globalItems.model, data.model),
|
|
),
|
|
);
|
|
|
|
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
|
|
|
|
const [item] = await tx
|
|
.insert(globalItems)
|
|
.values({
|
|
manufacturerId,
|
|
model: itemData.model,
|
|
category: itemData.category ?? null,
|
|
weightGrams: itemData.weightGrams ?? null,
|
|
priceCents: itemData.priceCents ?? null,
|
|
imageUrl: itemData.imageUrl ?? null,
|
|
description: itemData.description ?? null,
|
|
sourceUrl: itemData.sourceUrl ?? null,
|
|
imageCredit: itemData.imageCredit ?? null,
|
|
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [globalItems.manufacturerId, globalItems.model],
|
|
set: {
|
|
category: itemData.category ?? null,
|
|
weightGrams: itemData.weightGrams ?? null,
|
|
priceCents: itemData.priceCents ?? null,
|
|
imageUrl: itemData.imageUrl ?? null,
|
|
description: itemData.description ?? null,
|
|
sourceUrl: itemData.sourceUrl ?? null,
|
|
imageCredit: itemData.imageCredit ?? null,
|
|
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
|
},
|
|
})
|
|
.returning();
|
|
|
|
if (tagNames !== undefined) {
|
|
await syncGlobalItemTags(tx, item!.id, tagNames);
|
|
}
|
|
|
|
return { item: item!, created: !existing };
|
|
});
|
|
}
|
|
|
|
export async function bulkUpsertGlobalItems(
|
|
db: Db,
|
|
itemsData: Array<{
|
|
manufacturerSlug: string;
|
|
model: string;
|
|
category?: string;
|
|
weightGrams?: number;
|
|
priceCents?: number;
|
|
imageUrl?: string;
|
|
description?: string;
|
|
sourceUrl?: string;
|
|
imageCredit?: string;
|
|
imageSourceUrl?: string;
|
|
tags?: string[];
|
|
}>,
|
|
) {
|
|
return await db.transaction(async (tx) => {
|
|
let created = 0;
|
|
let updated = 0;
|
|
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.manufacturerId, manufacturerId),
|
|
eq(globalItems.model, data.model),
|
|
),
|
|
);
|
|
|
|
const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data;
|
|
|
|
const [item] = await tx
|
|
.insert(globalItems)
|
|
.values({
|
|
manufacturerId,
|
|
model: itemData.model,
|
|
category: itemData.category ?? null,
|
|
weightGrams: itemData.weightGrams ?? null,
|
|
priceCents: itemData.priceCents ?? null,
|
|
imageUrl: itemData.imageUrl ?? null,
|
|
description: itemData.description ?? null,
|
|
sourceUrl: itemData.sourceUrl ?? null,
|
|
imageCredit: itemData.imageCredit ?? null,
|
|
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [globalItems.manufacturerId, globalItems.model],
|
|
set: {
|
|
category: itemData.category ?? null,
|
|
weightGrams: itemData.weightGrams ?? null,
|
|
priceCents: itemData.priceCents ?? null,
|
|
imageUrl: itemData.imageUrl ?? null,
|
|
description: itemData.description ?? null,
|
|
sourceUrl: itemData.sourceUrl ?? null,
|
|
imageCredit: itemData.imageCredit ?? null,
|
|
imageSourceUrl: itemData.imageSourceUrl ?? null,
|
|
},
|
|
})
|
|
.returning();
|
|
|
|
if (tagNames !== undefined) {
|
|
await syncGlobalItemTags(tx, item!.id, tagNames);
|
|
}
|
|
|
|
if (existing) {
|
|
updated++;
|
|
} else {
|
|
created++;
|
|
}
|
|
resultItems.push(item!);
|
|
}
|
|
|
|
return { created, updated, items: resultItems };
|
|
});
|
|
}
|