Files
GearBox/src/server/services/global-item.service.ts
Jean-Luc Makiola e044547121
Some checks failed
CI / ci (push) Failing after 1m41s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: fix lint errors — auto-format, isNaN, unused imports, button type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:54:37 +02:00

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 };
});
}