- 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
254 lines
6.5 KiB
TypeScript
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 };
|
|
});
|
|
}
|