[wip] bugs to work out, but backend should support inventory

This commit is contained in:
2026-04-02 19:24:51 -04:00
parent 38f041d86f
commit 3be17fe84c
8 changed files with 240 additions and 100 deletions

View File

@@ -88,6 +88,25 @@ export const createSkuCollection = async () => {
console.log(chalk.green('Collection "skus" created successfully.')); console.log(chalk.green('Collection "skus" created successfully.'));
} }
// Delete and recreate the 'inventory' index
export const createInventoryCollection = async () => {
try {
await client.collections('inventories').delete();
} catch (error) {
// Ignore error, just means collection doesn't exist
}
await client.collections().create({
name: 'inventories',
fields: [
{ name: 'id', type: 'string' },
{ name: 'userId', type: 'string' },
{ name: 'catalogName', type: 'string' },
{ name: 'card_id', type: 'string', reference: 'cards.id' },
]
});
console.log(chalk.green('Collection "inventories" created successfully.'));
}
export const upsertCardCollection = async (db:DBInstance) => { export const upsertCardCollection = async (db:DBInstance) => {
const pokemon = await db.query.cards.findMany({ const pokemon = await db.query.cards.findMany({
@@ -131,6 +150,17 @@ export const upsertSkuCollection = async (db:DBInstance) => {
console.log(chalk.green('Collection "skus" indexed successfully.')); console.log(chalk.green('Collection "skus" indexed successfully.'));
} }
export const upsertInventoryCollection = async (db:DBInstance) => {
const inv = await db.query.inventory.findMany();
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
card_id: i.cardId.toString(),
})), { action: 'upsert' });
console.log(chalk.green('Collection "inventories" indexed successfully.'));
}

View File

@@ -5,7 +5,10 @@ import * as Indexing from './pokemon-helper.ts';
//await Indexing.createCardCollection(); //await Indexing.createCardCollection();
//await Indexing.createSkuCollection(); //await Indexing.createSkuCollection();
await Indexing.upsertCardCollection(db); await Indexing.createInventoryCollection();
await Indexing.upsertSkuCollection(db);
//await Indexing.upsertCardCollection(db);
//await Indexing.upsertSkuCollection(db);
await Indexing.upsertInventoryCollection(db);
await ClosePool(); await ClosePool();
console.log(chalk.green('Pokémon reindex complete.')); console.log(chalk.green('Pokémon reindex complete.'));

View File

@@ -21,9 +21,21 @@ export const relations = defineRelations(schema, (r) => ({
}), }),
history: r.many.priceHistory(), history: r.many.priceHistory(),
latestSales: r.many.salesHistory(), latestSales: r.many.salesHistory(),
inventories: r.many.inventory(),
},
inventory: {
card: r.one.cards({
from: r.inventory.cardId,
to: r.cards.cardId,
}),
sku: r.one.skus({
from: [r.inventory.cardId, r.inventory.condition],
to: [r.skus.cardId, r.skus.condition],
}),
}, },
cards: { cards: {
prices: r.many.skus(), prices: r.many.skus(),
inventories: r.many.inventory(),
set: r.one.sets({ set: r.one.sets({
from: r.cards.setId, from: r.cards.setId,
to: r.sets.setId, to: r.sets.setId,

View File

@@ -1,5 +1,5 @@
//import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core" //import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uniqueIndex, primaryKey } from "drizzle-orm/pg-core"; import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uuid, primaryKey } from "drizzle-orm/pg-core";
export const pokeSchema = pgSchema("pokemon"); export const pokeSchema = pgSchema("pokemon");
@@ -98,6 +98,7 @@ export const skus = pokeSchema.table('skus', {
}, },
(table) => [ (table) => [
index('idx_product_id_condition').on(table.productId, table.variant, table.condition), index('idx_product_id_condition').on(table.productId, table.variant, table.condition),
index('idx_card_id_condition').on(table.cardId, table.condition),
]); ]);
export const priceHistory = pokeSchema.table('price_history', { export const priceHistory = pokeSchema.table('price_history', {
@@ -124,6 +125,20 @@ export const salesHistory = pokeSchema.table('sales_history',{
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] }) primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
]); ]);
export const inventory = pokeSchema.table('inventory',{
inventoryId: uuid().primaryKey().notNull().defaultRandom(),
userId: varchar({ length: 100 }).notNull(),
catalogName: varchar({ length: 100 }),
cardId: integer().notNull(),
condition: varchar({ length: 255 }).notNull(),
quantity: integer(),
purchasePrice: integer(),
note: varchar({ length:255 })
},
(table) => [
index('idx_userid_cardid').on(table.userId, table.cardId)
]);
export const processingSkus = pokeSchema.table('processing_skus', { export const processingSkus = pokeSchema.table('processing_skus', {
skuId: integer().primaryKey(), skuId: integer().primaryKey(),
}); });

161
src/pages/api/inventory.ts Normal file
View File

@@ -0,0 +1,161 @@
import type { APIRoute } from 'astro';
import { db } from '../../db/index';
import { inventory } from '../../db/schema';
import { client } from '../../db/typesense';
import { eq } from 'drizzle-orm';
const GainLoss = (purchasePrice:any, marketPrice:any) => {
if (!purchasePrice || !marketPrice) return '<div class="fs-5 fw-semibold">N/A</div>';
const pp = Number(purchasePrice);
const mp = Number(marketPrice);
if (pp === mp) return '<div class="fs-5 fw-semibold text-warning">-</div>';
if (pp > mp) return `<div class="fs-5 fw-semibold text-critical">-$${pp-mp}</div>`;
return `<div class="fs-5 fw-semibold text-success">+$${mp-pp}</div>`;
}
const getInventory = async (userId:string, cardId:number) => {
const inventories = await db.query.inventory.findMany({
where: { userId:userId, cardId:cardId, },
with: { card: true, sku: true, }
});
const invHtml = inventories.map(inv => {
return `
<article class="border rounded-4 p-2 bg-body-tertiary inventory-entry-card">
<div class="d-flex flex-column gap-2">
<!-- Top row -->
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="min-w-0 flex-grow-1">
<div class="fw-semibold fs-5 text-body mb-1">${inv.condition}</div>
</div>
</div>
<!-- Middle row -->
<div class="row g-2">
<div class="col-4">
<div class="small text-secondary">Purchase price</div>
<div class="fs-5 fw-semibold">$${inv.purchasePrice}</div>
</div>
<div class="col-4">
<div class="small text-secondary">Market price</div>
<div class="fs-5 text-success">$${inv.sku?.marketPrice}</div>
</div>
<div class="col-4">
<div class="small text-secondary">Gain / loss</div>
${GainLoss(inv.purchasePrice, inv.sku?.marketPrice)}
</div>
</div>
<!-- Bottom row -->
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div class="d-flex align-items-center gap-2">
<span class="small text-secondary">Qty</span>
<div class="btn-group" role="group" aria-label="Quantity controls">
<button type="button" class="btn btn-outline-secondary btn-sm"></button>
<button type="button" class="btn btn-outline-secondary btn-sm" tabindex="-1">${inv.quantity}</button>
<button type="button" class="btn btn-outline-secondary btn-sm">+</button>
</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
<button type="button" class="btn btn-sm btn-outline-danger">Remove</button>
</div>
</div>
</div>
</article>`;
});
return new Response(
invHtml.join(''),
{
status: 200,
headers: { 'Content-Type': 'text/html' },
}
);
}
const addToInventory = async (userId:string, cardId:number, condition:string, purchasePrice:number, quantity:number, note:string, catalogName:string) => {
// First add to database
const inv = await db.insert(inventory).values({
userId: userId,
cardId: cardId,
catalogName: catalogName,
condition: condition,
purchasePrice: purchasePrice,
quantity: quantity,
note: note,
}).returning();
// And then add to Typesense
await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId,
userId: i.userId,
catalogName: i.catalogName,
card_id: i.cardId.toString(),
})));
}
const removeFromInventory = async (inventoryId:string) => {
await db.delete(inventory).where(eq( inventory.inventoryId, inventoryId ));
await client.collections('inventories').documents(inventoryId).delete();
}
const updateInventory = async (inventoryId:string, quantity:number, purchasePrice:number, note:string) => {
// Update in database
await db.update(inventory).set({
quantity: quantity,
purchasePrice: purchasePrice,
note: note,
}).where(eq( inventory.inventoryId, inventoryId ));
// There is no need to update Typesense for these fields as they are not indexed
}
export const POST: APIRoute = async ({ request, locals }) => {
// Access form data from the request body
const formData = await request.formData();
const action = formData.get('action');
const cardId = Number(formData.get('cardId')) || 0;
const { userId } = locals.auth();
switch (action) {
case 'add':
const condition = formData.get('condition')?.toString() || 'Unknown';
const purchasePrice = Number(formData.get('purchasePrice')) || 0;
const quantity = Number(formData.get('quantity')) || 1;
const note = formData.get('note')?.toString() || '';
const catalogName = formData.get('catalogName')?.toString() || 'Default';
await addToInventory(userId!, cardId, condition, purchasePrice, quantity, note, catalogName);
//return await getInventory(cardId);
break;
case 'remove':
const inventoryId = formData.get('inventoryId')?.toString() || '';
await removeFromInventory(inventoryId);
break;
case 'update':
const invId = formData.get('inventoryId')?.toString() || '';
const qty = Number(formData.get('quantity')) || 1;
const price = Number(formData.get('purchasePrice')) || 0;
const invNote = formData.get('note')?.toString() || '';
await updateInventory(invId, qty, price, invNote);
break;
default:
return new Response(
'Invalid action',
{
status: 400,
headers: { 'Content-Type': 'text/html' }
}
);
}
// Always return current inventory
return getInventory(userId!, cardId);
};

View File

@@ -1028,7 +1028,7 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
document.getElementById("catalogList")?.appendChild(li); document.getElementById("catalogList")?.appendChild(li);
input.value = ""; input.value = "";
bootstrap.Modal.getInstance(document.getElementById("newCatalogModal"))?.hide(); //bootstrap.Modal.getInstance(document.getElementById("newCatalogModal"))?.hide();
}); });
document.getElementById("csvFileInput")?.addEventListener("change", (e) => { document.getElementById("csvFileInput")?.addEventListener("change", (e) => {

View File

@@ -169,7 +169,6 @@ const altSearchUrl = (card: any) => {
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`; return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
}; };
--- ---
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl"> <div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content" data-card-id={card?.cardId}> <div class="modal-content" data-card-id={card?.cardId}>
<div class="modal-header border-0"> <div class="modal-header border-0">
@@ -496,105 +495,22 @@ const altSearchUrl = (card: any) => {
<div class="alert alert-dark border-0 rounded-4 d-none" id="inventoryEmptyState"> <div class="alert alert-dark border-0 rounded-4 d-none" id="inventoryEmptyState">
<div class="fw-medium mb-1">No inventory entries yet</div> <div class="fw-medium mb-1">No inventory entries yet</div>
<div class="text-secondary small"> <div class="text-secondary small">
Once you add copies of this card, theyll show up here. Once you add copies of this card, they'll show up here.
</div> </div>
</div> </div>
<!-- Inventory list --> <!-- Inventory list -->
<div class="d-flex flex-column gap-3" id="inventoryEntryList"> <div class="d-flex flex-column gap-3" id="inventoryEntryList" hx-post="/api/inventory" hx-trigger="intersect once" hx-target="this" hx-vals=`{"cardId": ${cardId}}`>
<span>Loading...</span>
<!-- Inventory card -->
<article class="border rounded-4 p-2 bg-body-tertiary inventory-entry-card">
<div class="d-flex flex-column gap-2">
<!-- Top row -->
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="min-w-0 flex-grow-1">
<div class="fw-semibold fs-5 text-body mb-1">Near Mint</div>
</div>
</div>
<!-- Middle row -->
<div class="row g-2">
<div class="col-4">
<div class="small text-secondary">Purchase price</div>
<div class="fs-5 fw-semibold">$8.50</div>
</div>
<div class="col-4">
<div class="small text-secondary">Market price</div>
<div class="fs-5 text-success">$10.25</div>
</div>
<div class="col-4">
<div class="small text-secondary">Gain / loss</div>
<div class="fs-5 fw-semibold text-success">+$1.75</div>
</div>
</div>
<!-- Bottom row -->
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div class="d-flex align-items-center gap-2">
<span class="small text-secondary">Qty</span>
<div class="btn-group" role="group" aria-label="Quantity controls">
<button type="button" class="btn btn-outline-secondary btn-sm"></button>
<button type="button" class="btn btn-outline-secondary btn-sm" tabindex="-1">2</button>
<button type="button" class="btn btn-outline-secondary btn-sm">+</button>
</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
<button type="button" class="btn btn-sm btn-outline-danger">Remove</button>
</div>
</div>
</div>
</article>
<!-- Inventory card -->
<article class="border rounded-4 p-2 bg-body-tertiary inventory-entry-card">
<div class="d-flex flex-column gap-2">
<!-- Top row -->
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="min-w-0 flex-grow-1">
<div class="fw-semibold fs-5 text-body mb-1">Lightly Played</div>
</div>
</div>
<div class="row g-2">
<div class="col-4">
<div class="small text-secondary">Purchase price</div>
<div class="fs-5 fw-semibold">$6.00</div>
</div>
<div class="col-4">
<div class="small text-secondary">Market price</div>
<div class="fs-5 text-success">$8.00</div>
</div>
<div class="col-4">
<div class="small text-secondary">Gain / loss</div>
<div class="fs-5 fw-semibold text-success">+$4.00</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div class="d-flex align-items-center gap-2">
<span class="small text-secondary">Qty</span>
<div class="btn-group" role="group" aria-label="Quantity controls">
<button type="button" class="btn btn-outline-secondary btn-sm"></button>
<button type="button" class="btn btn-outline-secondary btn-sm" tabindex="-1">2</button>
<button type="button" class="btn btn-outline-secondary btn-sm">+</button>
</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
<button type="button" class="btn btn-sm btn-outline-danger">Remove</button>
</div>
</div>
</div>
</article>
</div> </div>
<script is:inline>
console.log('Setting up inventory tooltips');
document.addEventListener('DOMContentLoaded', () => {
console.log('Initializing tooltips');
htmx.process(document.getElementById('inventoryEntryList'));
console.log('Tooltips initialized');
});
</script>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -234,6 +234,8 @@ const facets = searchResults.results.slice(1).map((result: any) => {
} }
</div> </div>
<script define:vars={{ totalHits, filters, facets }} is:inline> <script define:vars={{ totalHits, filters, facets }} is:inline>
import { c } from "@clerk/shared/index-Cx9VOot7";
// Filter the facet values to make things like Set easier to find // Filter the facet values to make things like Set easier to find
const facetfilters = document.querySelectorAll('.facet-filter'); const facetfilters = document.querySelectorAll('.facet-filter');
@@ -260,7 +262,8 @@ const facets = searchResults.results.slice(1).map((result: any) => {
document.getElementById('searchform').dispatchEvent(new Event('submit', {bubbles:true, cancelable:true})); document.getElementById('searchform').dispatchEvent(new Event('submit', {bubbles:true, cancelable:true}));
} }
document.getElementById('clear-filters').addEventListener('click', (e) => clearAllFilters(e)); document.getElementById('clear-filters').addEventListener('click', (e) => clearAllFilters(e));
document.getElementById('clear-all-filters').addEventListener('click', (e) => clearAllFilters(e)); const clearAllBtn = document.getElementById('clear-all-filters');
if (clearAllBtn) clearAllBtn.addEventListener('click', (e) => clearAllFilters(e));
// Remove single facet value // Remove single facet value
for (const li of document.querySelectorAll('.remove-filter')) { for (const li of document.querySelectorAll('.remove-filter')) {