Files
GearBox/src/server/services/global-item.service.ts
Jean-Luc Makiola c8ebbf8139 feat(25-01): Zod schemas, upsert service functions, passing tests
- Add upsertGlobalItemSchema and bulkUpsertGlobalItemsSchema to schemas.ts
- Add UpsertGlobalItemInput and BulkUpsertGlobalItemsInput types to types.ts
- Implement upsertGlobalItem with onConflictDoUpdate and tag sync
- Implement bulkUpsertGlobalItems processing array in single transaction
- Fix migration 0003 to only add new columns + unique constraint
- All 21 tests pass including 8 new upsert operation tests
2026-04-10 10:58:36 +02:00

254 lines
6.5 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, 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.
*/
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(globalItems.brand, 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}
)`,
);
}
if (conditions.length === 0) {
return db.select().from(globalItems);
}
return db
.select()
.from(globalItems)
.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()
.from(globalItems)
.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 };
}
/**
* 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,
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 });
}
}
/**
* 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;
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) => {
const [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(
and(
eq(globalItems.brand, data.brand),
eq(globalItems.model, data.model),
),
);
const { tags: tagNames, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
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.brand, 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, 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;
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 [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(
and(
eq(globalItems.brand, data.brand),
eq(globalItems.model, data.model),
),
);
const { tags: tagNames, ...itemData } = data;
const [item] = await tx
.insert(globalItems)
.values({
brand: itemData.brand,
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.brand, 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 };
});
}