[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 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,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) => {
|
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||||
// First add to database
|
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
|
||||||
const inv = await db.insert(inventory).values({
|
const count = Math.max(1, Math.floor(quantity));
|
||||||
userId: userId,
|
const inv = await db.insert(inventory).values(
|
||||||
skuId: skuId,
|
Array.from({ length: count }, () => ({
|
||||||
catalogName: catalogName,
|
userId: userId,
|
||||||
purchasePrice: purchasePrice.toFixed(2),
|
skuId: skuId,
|
||||||
quantity: quantity,
|
catalogName: catalogName,
|
||||||
note: note,
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
}).returning();
|
quantity: 1,
|
||||||
|
note: note,
|
||||||
|
}))
|
||||||
|
).returning();
|
||||||
// Get card details from the database to add to Typesense
|
// Get card details from the database to add to Typesense
|
||||||
const card = await db.query.cards.findFirst({
|
const card = await db.query.cards.findFirst({
|
||||||
where: { cardId: cardId },
|
where: { cardId: cardId },
|
||||||
@@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// And then add to Typesense for searching
|
// And then add to Typesense for searching
|
||||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
|
||||||
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(' '),
|
|
||||||
})));
|
|
||||||
} 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);
|
||||||
|
|||||||
@@ -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 — 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>
|
<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) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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;
|
||||||
|
|||||||
Reference in New Issue
Block a user