diff --git a/src/pages/api/inventory.ts b/src/pages/api/inventory.ts index 9d814fc..9c9edca 100644 --- a/src/pages/api/inventory.ts +++ b/src/pages/api/inventory.ts @@ -1,4 +1,5 @@ import type { APIRoute } from 'astro'; +import { parse } from 'csv'; import { db } from '../../db/index'; import { inventory, priceHistory } from '../../db/schema'; import { client } from '../../db/typesense'; @@ -89,16 +90,42 @@ const getInventory = async (userId: string, cardId: number) => { } +// Build the Typesense document for an inventory row + its card details. +const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({ + id: i.inventoryId, + userId: i.userId, + catalogName: i.catalogName, + sku_id: i.skuId.toString(), + purchasePrice: DollarToInt(i.purchasePrice), + productLineName: card?.productLineName, + rarityName: card?.rarityName, + setName: card?.set?.setName || "", + cardType: card?.cardType || "", + energyType: card?.energyType || "", + card_id: card?.cardId.toString() || "", + content: [ + card?.productName, + card?.productLineName, + card?.set?.setName || "", + card?.number, + card?.rarityName, + card?.artist || "" + ].join(' '), +}); + const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => { - // First add to database - const inv = await db.insert(inventory).values({ - userId: userId, - skuId: skuId, - catalogName: catalogName, - purchasePrice: purchasePrice.toFixed(2), - quantity: quantity, - note: note, - }).returning(); + // Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1. + const count = Math.max(1, Math.floor(quantity)); + const inv = await db.insert(inventory).values( + Array.from({ length: count }, () => ({ + userId: userId, + skuId: skuId, + catalogName: catalogName, + purchasePrice: purchasePrice.toFixed(2), + quantity: 1, + note: note, + })) + ).returning(); // Get card details from the database to add to Typesense const card = await db.query.cards.findFirst({ where: { cardId: cardId }, @@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur try { // And then add to Typesense for searching - await client.collections('inventories').documents().import(inv.map(i => ({ - id: i.inventoryId, - userId: i.userId, - catalogName: i.catalogName, - sku_id: i.skuId.toString(), - purchasePrice: DollarToInt(i.purchasePrice), - productLineName: card?.productLineName, - rarityName: card?.rarityName, - setName: card?.set?.setName || "", - cardType: card?.cardType || "", - energyType: card?.energyType || "", - card_id: card?.cardId.toString() || "", - content: [ - card?.productName, - card?.productLineName, - card?.set?.setName || "", - card?.number, - card?.rarityName, - card?.artist || "" - ].join(' '), - }))); + await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card))); } catch (error) { console.error('Error adding inventory to Typesense:', error); } @@ -154,6 +161,123 @@ const updateInventory = async (inventoryId: string, quantity: number, purchasePr } } +// ── Bulk CSV import ───────────────────────────────────────────────────────── +// Expected columns (header row, case-insensitive): name, set, condition, qty, +// price, market. An optional `date` column is used as the inventory createdAt; +// rows without a (valid) date default to the current time. +const CONDITION_MAP: Record = { + 'nm': 'Near Mint', 'near mint': 'Near Mint', 'mint': 'Near Mint', + 'lp': 'Lightly Played', 'lightly played': 'Lightly Played', + 'mp': 'Moderately Played', 'moderately played': 'Moderately Played', + 'hp': 'Heavily Played', 'heavily played': 'Heavily Played', + 'dmg': 'Damaged', 'dm': 'Damaged', 'damaged': 'Damaged', +}; + +const normalizeCondition = (value?: string) => { + const key = (value || '').trim().toLowerCase(); + return CONDITION_MAP[key] || 'Near Mint'; +}; + +// Parse an optional date cell into a Date, or null when empty/invalid. +const parseImportDate = (value?: string): Date | null => { + const raw = (value || '').trim(); + if (!raw) return null; + const date = new Date(raw); + return isNaN(date.getTime()) ? null : date; +}; + +const parseCsv = (text: string): Promise[]> => + new Promise((resolve, reject) => { + parse(text, { columns: true, trim: true, skip_empty_lines: true, bom: true }, + (err, records) => (err ? reject(err) : resolve(records))); + }); + +// Case-insensitive column lookup for a parsed CSV row. +const lowerKeys = (row: Record) => { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) out[k.trim().toLowerCase()] = v; + return out; +}; + +const findCardId = async (name: string, set: string): Promise => { + const escapedSet = set.replace(/`/g, ''); + let result = await client.collections('cards').documents().search({ + q: name, query_by: 'productName', per_page: 1, + ...(escapedSet ? { filter_by: `setName:\`${escapedSet}\`` } : {}), + }); + // Fall back to a name-only match if the set filter found nothing. + if (!result.hits?.length && escapedSet) { + result = await client.collections('cards').documents().search({ + q: name, query_by: 'productName', per_page: 1, + }); + } + return result.hits?.[0]?.document?.cardId as number | undefined; +}; + +const importInventory = async (userId: string, rows: Record[], catalogName: string) => { + const skipped: { row: number; name: string; reason: string }[] = []; + const tsDocs: ReturnType[] = []; + let imported = 0; + + for (let i = 0; i < rows.length; i++) { + const rowNumber = i + 2; // account for header row (1-indexed for humans) + const row = lowerKeys(rows[i]); + const name = (row.name || '').trim(); + const set = (row.set || '').trim(); + + if (!name) { skipped.push({ row: rowNumber, name, reason: 'Missing card name' }); continue; } + + const createdAt = parseImportDate(row.date); + if ((row.date || '').trim() && !createdAt) { + skipped.push({ row: rowNumber, name, reason: `Invalid date "${row.date.trim()}"` }); + continue; + } + + const condition = normalizeCondition(row.condition); + const count = Math.max(1, parseInt(row.qty, 10) || 1); + const purchasePrice = parseFloat(String(row.price ?? '').replace(/[^0-9.]/g, '')) || 0; + + const cardId = await findCardId(name, set); + if (!cardId) { skipped.push({ row: rowNumber, name, reason: 'Card not found' }); continue; } + + const sku = await db.query.skus.findFirst({ + where: { cardId: cardId, condition: condition }, + columns: { skuId: true }, + }); + if (!sku?.skuId) { skipped.push({ row: rowNumber, name, reason: `No "${condition}" SKU for card` }); continue; } + + // One row per copy: a qty of 5 becomes 5 separate entries of qty 1. + const inserted = await db.insert(inventory).values( + Array.from({ length: count }, () => ({ + userId, + skuId: sku.skuId, + catalogName, + purchasePrice: purchasePrice.toFixed(2), + quantity: 1, + note: '', + ...(createdAt ? { createdAt } : {}), + })) + ).returning(); + + const card = await db.query.cards.findFirst({ + where: { cardId: cardId }, + with: { set: true }, + }); + for (const entry of inserted) tsDocs.push(inventoryTsDoc(entry, card)); + imported += inserted.length; + } + + if (tsDocs.length) { + try { + await client.collections('inventories').documents().import(tsDocs); + } catch (error) { + console.error('Error importing inventory to Typesense:', error); + } + } + + return { imported, skipped }; +}; + export const POST: APIRoute = async ({ request, locals }) => { // Access form data from the request body const formData = await request.formData(); @@ -179,6 +303,33 @@ export const POST: APIRoute = async ({ request, locals }) => { await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName); break; + case 'import': { + if (!userId) { + return new Response(JSON.stringify({ error: 'Not authenticated' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + } + const file = formData.get('file') as File | null; + if (!file) { + return new Response(JSON.stringify({ error: 'No file uploaded' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + const importCatalog = formData.get('catalogName')?.toString()?.trim() || 'Default'; + try { + const rows = await parseCsv(await file.text()); + const summary = await importInventory(userId, rows, importCatalog); + return new Response(JSON.stringify(summary), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('Error importing inventory CSV:', error); + return new Response(JSON.stringify({ error: 'Could not parse CSV file' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + } + case 'remove': const inventoryId = formData.get('inventoryId')?.toString() || ''; await removeFromInventory(inventoryId); diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 19db54f..9fc42f3 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -144,15 +144,19 @@ const catalogs = catalogRows