[feat] bulk import allows date, and quantity is split into multiple entries

This commit is contained in:
Thad Miller
2026-07-16 15:59:25 -04:00
parent a265aea424
commit b3799a5318
2 changed files with 266 additions and 32 deletions

View File

@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { parse } from 'csv';
import { db } from '../../db/index'; import { db } from '../../db/index';
import { inventory, priceHistory } from '../../db/schema'; import { inventory, priceHistory } from '../../db/schema';
import { client } from '../../db/typesense'; import { client } from '../../db/typesense';
@@ -89,25 +90,8 @@ const getInventory = async (userId: string, cardId: number) => {
} }
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => { // Build the Typesense document for an inventory row + its card details.
// First add to database const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({
const inv = await db.insert(inventory).values({
userId: userId,
skuId: skuId,
catalogName: catalogName,
purchasePrice: purchasePrice.toFixed(2),
quantity: quantity,
note: note,
}).returning();
// Get card details from the database to add to Typesense
const card = await db.query.cards.findFirst({
where: { cardId: cardId },
with: { set: true },
});
try {
// And then add to Typesense for searching
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId, id: i.inventoryId,
userId: i.userId, userId: i.userId,
catalogName: i.catalogName, catalogName: i.catalogName,
@@ -127,7 +111,30 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
card?.rarityName, card?.rarityName,
card?.artist || "" card?.artist || ""
].join(' '), ].join(' '),
}))); });
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
// 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 },
with: { set: true },
});
try {
// And then add to Typesense for searching
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
} catch (error) { } catch (error) {
console.error('Error adding inventory to Typesense:', 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 }) => { export const POST: APIRoute = async ({ request, locals }) => {
// Access form data from the request body // Access form data from the request body
const formData = await request.formData(); 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); await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
break; 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': case 'remove':
const inventoryId = formData.get('inventoryId')?.toString() || ''; const inventoryId = formData.get('inventoryId')?.toString() || '';
await removeFromInventory(inventoryId); await removeFromInventory(inventoryId);

View File

@@ -144,15 +144,19 @@ const catalogs = catalogRows
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <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>. Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
</p> </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 &mdash; it&rsquo;s used as the entry&rsquo;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> <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" /> <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"> <div id="csvPreview" class="mt-3 d-none">
<p class="small text-secondary fw-semibold mb-1">Preview</p> <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>
<div id="csvImportResult" class="mt-3 d-none"></div>
</div> </div>
<div class="modal-footer border-secondary"> <div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button> <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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 () { (function () {
const modal = document.getElementById('inventoryModal'); const modal = document.getElementById('inventoryModal');
if (!modal) return; if (!modal) return;