The updateItem function's TypeScript type was missing dominantColor, cropZoom, cropX, and cropY fields, causing crop settings to silently fail to save despite the Zod schema and DB schema supporting them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
222 lines
6.2 KiB
TypeScript
222 lines
6.2 KiB
TypeScript
import { and, eq, sql } from "drizzle-orm";
|
|
import type { db as prodDb } from "../../db/index.ts";
|
|
import { categories, globalItems, items } from "../../db/schema.ts";
|
|
import type { CreateItem } from "../../shared/types.ts";
|
|
|
|
type Db = typeof prodDb;
|
|
|
|
export async function getAllItems(db: Db, userId: number) {
|
|
return db
|
|
.select({
|
|
id: items.id,
|
|
name: sql<string>`COALESCE(
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
|
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
|
ELSE ${items.name}
|
|
END,
|
|
${items.name}
|
|
)`.as("name"),
|
|
weightGrams: sql<number | null>`COALESCE(
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
|
|
${items.weightGrams}
|
|
)`.as("weight_grams"),
|
|
priceCents: sql<number | null>`COALESCE(
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
|
|
${items.priceCents}
|
|
)`.as("price_cents"),
|
|
purchasePriceCents: items.purchasePriceCents,
|
|
quantity: items.quantity,
|
|
categoryId: items.categoryId,
|
|
notes: items.notes,
|
|
productUrl: items.productUrl,
|
|
imageFilename: sql<string | null>`COALESCE(
|
|
${items.imageFilename},
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
|
|
)`.as("image_filename"),
|
|
imageSourceUrl: items.imageSourceUrl,
|
|
globalItemId: items.globalItemId,
|
|
brand: sql<
|
|
string | null
|
|
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
|
createdAt: items.createdAt,
|
|
updatedAt: items.updatedAt,
|
|
categoryName: categories.name,
|
|
categoryIcon: categories.icon,
|
|
})
|
|
.from(items)
|
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
|
.where(eq(items.userId, userId));
|
|
}
|
|
|
|
export async function getItemById(db: Db, userId: number, id: number) {
|
|
const [row] = await db
|
|
.select({
|
|
id: items.id,
|
|
name: sql<string>`COALESCE(
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL
|
|
THEN ${globalItems.brand} || ' ' || ${globalItems.model}
|
|
ELSE ${items.name}
|
|
END,
|
|
${items.name}
|
|
)`.as("name"),
|
|
weightGrams: sql<number | null>`COALESCE(
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.weightGrams} ELSE NULL END,
|
|
${items.weightGrams}
|
|
)`.as("weight_grams"),
|
|
priceCents: sql<number | null>`COALESCE(
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.priceCents} ELSE NULL END,
|
|
${items.priceCents}
|
|
)`.as("price_cents"),
|
|
purchasePriceCents: items.purchasePriceCents,
|
|
quantity: items.quantity,
|
|
categoryId: items.categoryId,
|
|
notes: items.notes,
|
|
productUrl: items.productUrl,
|
|
imageFilename: sql<string | null>`COALESCE(
|
|
${items.imageFilename},
|
|
CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${globalItems.imageUrl} ELSE NULL END
|
|
)`.as("image_filename"),
|
|
imageSourceUrl: items.imageSourceUrl,
|
|
globalItemId: items.globalItemId,
|
|
brand: sql<
|
|
string | null
|
|
>`COALESCE(${globalItems.brand}, ${items.brand})`.as("brand"),
|
|
createdAt: items.createdAt,
|
|
updatedAt: items.updatedAt,
|
|
categoryName: categories.name,
|
|
categoryIcon: categories.icon,
|
|
})
|
|
.from(items)
|
|
.innerJoin(categories, eq(items.categoryId, categories.id))
|
|
.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
|
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
|
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function createItem(
|
|
db: Db,
|
|
userId: number,
|
|
data: Partial<CreateItem> & {
|
|
name: string;
|
|
categoryId: number;
|
|
imageFilename?: string;
|
|
},
|
|
) {
|
|
// For reference items, look up global item for fallback name (items.name is NOT NULL)
|
|
let name = data.name;
|
|
if (data.globalItemId) {
|
|
const [gi] = await db
|
|
.select({ brand: globalItems.brand, model: globalItems.model })
|
|
.from(globalItems)
|
|
.where(eq(globalItems.id, data.globalItemId));
|
|
if (gi) {
|
|
name = `${gi.brand} ${gi.model}`;
|
|
}
|
|
}
|
|
|
|
const [row] = await db
|
|
.insert(items)
|
|
.values({
|
|
name,
|
|
weightGrams: data.weightGrams ?? null,
|
|
priceCents: data.priceCents ?? null,
|
|
quantity: data.quantity ?? 1,
|
|
categoryId: data.categoryId,
|
|
userId,
|
|
notes: data.notes ?? null,
|
|
productUrl: data.productUrl ?? null,
|
|
imageFilename: data.imageFilename ?? null,
|
|
imageSourceUrl: data.imageSourceUrl ?? null,
|
|
globalItemId: data.globalItemId ?? null,
|
|
purchasePriceCents: data.purchasePriceCents ?? null,
|
|
})
|
|
.returning();
|
|
|
|
return row;
|
|
}
|
|
|
|
export async function updateItem(
|
|
db: Db,
|
|
userId: number,
|
|
id: number,
|
|
data: Partial<{
|
|
name: string;
|
|
weightGrams: number;
|
|
priceCents: number;
|
|
quantity: number;
|
|
categoryId: number;
|
|
notes: string;
|
|
productUrl: string;
|
|
imageFilename: string;
|
|
imageSourceUrl: string;
|
|
globalItemId: number;
|
|
purchasePriceCents: number;
|
|
brand: string;
|
|
dominantColor: string | null;
|
|
cropZoom: number | null;
|
|
cropX: number | null;
|
|
cropY: number | null;
|
|
}>,
|
|
) {
|
|
// Check if item exists and belongs to user
|
|
const [existing] = await db
|
|
.select({ id: items.id })
|
|
.from(items)
|
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
|
|
|
if (!existing) return null;
|
|
|
|
const [row] = await db
|
|
.update(items)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(and(eq(items.id, id), eq(items.userId, userId)))
|
|
.returning();
|
|
|
|
return row;
|
|
}
|
|
|
|
export async function duplicateItem(db: Db, userId: number, id: number) {
|
|
const [source] = await db
|
|
.select()
|
|
.from(items)
|
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
|
|
|
if (!source) return null;
|
|
|
|
const [row] = await db
|
|
.insert(items)
|
|
.values({
|
|
name: `${source.name} (copy)`,
|
|
weightGrams: source.weightGrams,
|
|
priceCents: source.priceCents,
|
|
categoryId: source.categoryId,
|
|
userId,
|
|
notes: source.notes,
|
|
productUrl: source.productUrl,
|
|
imageFilename: source.imageFilename,
|
|
imageSourceUrl: source.imageSourceUrl,
|
|
quantity: source.quantity,
|
|
globalItemId: source.globalItemId,
|
|
purchasePriceCents: source.purchasePriceCents,
|
|
})
|
|
.returning();
|
|
|
|
return row;
|
|
}
|
|
|
|
export async function deleteItem(db: Db, userId: number, id: number) {
|
|
// Get item first (for image cleanup info)
|
|
const [item] = await db
|
|
.select()
|
|
.from(items)
|
|
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
|
|
|
if (!item) return null;
|
|
|
|
await db.delete(items).where(and(eq(items.id, id), eq(items.userId, userId)));
|
|
|
|
return item;
|
|
}
|