[feat] bulk import allows date, and quantity is split into multiple entries
This commit is contained in:
@@ -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<string, string> = {
|
||||
'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<Record<string, string>[]> =>
|
||||
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<string, string>) => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(row)) out[k.trim().toLowerCase()] = v;
|
||||
return out;
|
||||
};
|
||||
|
||||
const findCardId = async (name: string, set: string): Promise<number | undefined> => {
|
||||
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<string, string>[], catalogName: string) => {
|
||||
const skipped: { row: number; name: string; reason: string }[] = [];
|
||||
const tsDocs: ReturnType<typeof inventoryTsDoc>[] = [];
|
||||
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);
|
||||
|
||||
@@ -144,15 +144,19 @@ const catalogs = catalogRows
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-secondary mb-3">
|
||||
<p class="small text-secondary mb-2">
|
||||
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
||||
</p>
|
||||
<p class="small text-secondary mb-3">
|
||||
Optionally add a <code>date</code> column (e.g. <code>2026-07-16</code>) to record when each card was acquired — it’s used as the entry’s added/purchase date. Rows without a date default to today.
|
||||
</p>
|
||||
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
|
||||
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
|
||||
<div id="csvPreview" class="mt-3 d-none">
|
||||
<p class="small text-secondary fw-semibold mb-1">Preview</p>
|
||||
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
|
||||
<div class="border border-secondary rounded p-2 small text-secondary overflow-auto" id="csvPreviewContent" style="max-height: 12rem;">—</div>
|
||||
</div>
|
||||
<div id="csvImportResult" class="mt-3 d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
|
||||
@@ -182,6 +186,85 @@ const catalogs = catalogRows
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Bulk CSV import ──────────────────────────────────────────────────────
|
||||
(function () {
|
||||
const importModal = document.getElementById('bulkImportModal');
|
||||
const fileInput = document.getElementById('csvFileInput');
|
||||
const uploadBtn = document.getElementById('csvUploadBtn');
|
||||
const preview = document.getElementById('csvPreview');
|
||||
const previewContent = document.getElementById('csvPreviewContent');
|
||||
const result = document.getElementById('csvImportResult');
|
||||
if (!importModal || !fileInput || !uploadBtn) return;
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
let selectedFile = null;
|
||||
let didImport = false;
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
selectedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
|
||||
result.classList.add('d-none');
|
||||
if (!selectedFile) { preview.classList.add('d-none'); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const lines = String(reader.result || '').split(/\r?\n/).filter((l) => l.trim()).slice(0, 6);
|
||||
previewContent.innerHTML = lines
|
||||
.map((l, i) => `<div class="${i === 0 ? 'text-light fw-semibold' : ''}">${esc(l)}</div>`)
|
||||
.join('');
|
||||
preview.classList.remove('d-none');
|
||||
};
|
||||
reader.readAsText(selectedFile);
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
if (!selectedFile) { alert('Choose a CSV file first.'); return; }
|
||||
const original = uploadBtn.textContent;
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Importing…';
|
||||
result.classList.add('d-none');
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('action', 'import');
|
||||
body.append('file', selectedFile);
|
||||
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
result.className = 'mt-3 alert alert-danger py-2 small';
|
||||
result.textContent = data.error || 'Import failed.';
|
||||
result.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
didImport = data.imported > 0;
|
||||
const skipped = data.skipped || [];
|
||||
let html = `<div class="fw-semibold ${data.imported > 0 ? 'text-success' : ''}">Imported ${data.imported} card${data.imported === 1 ? '' : 's'}.</div>`;
|
||||
if (skipped.length) {
|
||||
html += `<div class="mt-1 text-warning fw-semibold">Skipped ${skipped.length}:</div>`;
|
||||
html += '<ul class="mb-0 ps-3">' + skipped
|
||||
.map((s) => `<li>Row ${s.row}${s.name ? ' (' + esc(s.name) + ')' : ''}: ${esc(s.reason)}</li>`)
|
||||
.join('') + '</ul>';
|
||||
}
|
||||
result.className = 'mt-3 alert alert-dark py-2 small overflow-auto';
|
||||
result.style.maxHeight = '14rem';
|
||||
result.innerHTML = html;
|
||||
result.classList.remove('d-none');
|
||||
uploadBtn.textContent = didImport ? 'Done' : 'Upload';
|
||||
} catch (e) {
|
||||
result.className = 'mt-3 alert alert-danger py-2 small';
|
||||
result.textContent = 'Import failed.';
|
||||
result.classList.remove('d-none');
|
||||
} finally {
|
||||
uploadBtn.disabled = false;
|
||||
if (uploadBtn.textContent === 'Importing…') uploadBtn.textContent = original;
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh the dashboard (grid, catalogs, totals) after a successful import.
|
||||
importModal.addEventListener('hidden.bs.modal', () => {
|
||||
if (didImport) window.location.reload();
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
const modal = document.getElementById('inventoryModal');
|
||||
if (!modal) return;
|
||||
|
||||
Reference in New Issue
Block a user