From 29b3aa0323e6f52fdbbfb60953dd5eb2970c0df3 Mon Sep 17 00:00:00 2001 From: code181 Date: Mon, 9 Nov 2026 10:40:26 -0500 Subject: [PATCH] Add Shopify product match status and last sync timestamps --- backend/db/schema.sql | 4 +- backend/src/routes/products.js | 44 ++++++++++++++++++-- backend/src/services/sampleData/shopify.json | 24 +++++++---- frontend/src/pages/Products.jsx | 39 ++++++++++++++++- 4 files changed, 96 insertions(+), 15 deletions(-) diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 3406682..cdbf194 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -54,12 +54,14 @@ CREATE TABLE IF NOT EXISTS products ( shopify_inventory_item_id TEXT, manufacturer_id INTEGER REFERENCES manufacturers(id) ON DELETE SET NULL, unit_cost NUMERIC(10,2), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_synced_at TIMESTAMPTZ ); -- For databases created before the column existed: ALTER TABLE products ADD COLUMN IF NOT EXISTS shopify_inventory_item_id TEXT; ALTER TABLE products ADD COLUMN IF NOT EXISTS unit_cost NUMERIC(10,2); +ALTER TABLE products ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS reorder_thresholds ( id SERIAL PRIMARY KEY, diff --git a/backend/src/routes/products.js b/backend/src/routes/products.js index 018fdbe..5540ba2 100644 --- a/backend/src/routes/products.js +++ b/backend/src/routes/products.js @@ -24,6 +24,22 @@ const syncRateLimit = rateLimit( 'Sync was run too recently. Please wait a minute and try again.' ); +function matchProducts(hubProducts, shopifyProducts) { + const results = hubProducts.map((hubProduct) => { + const shopifyProduct = shopifyProducts.find((shopifyProduct) => + (hubProduct.shopify_inventory_item_id && + shopifyProduct.inventory_item_id === hubProduct.shopify_inventory_item_id) || + (hubProduct.sku && + shopifyProduct.sku === hubProduct.sku) + ); + + return [hubProduct.id, + shopifyProduct ? 'matched' : 'unmatched'] + }); + + return Object.fromEntries(results); +} + router.get('/', asyncRoute(async (req, res) => { const sql = 'SELECT p.*, m.name AS manufacturer_name, rt.threshold ' + @@ -34,8 +50,27 @@ router.get('/', asyncRoute(async (req, res) => { 'LIMIT 1000'; const result = await query(sql); + const hubProducts = result.rows; + let shopifyProducts; + try { + shopifyProducts = await shopify.getStockLevels(); + } catch (err) { + console.error('Unable to retrieve Shopify products: ' + err.message); + const products = hubProducts.map((product) => ({ + ...product, + shopify_match: null + })); + return res.json({ products: products }); + } + + const matches = matchProducts(hubProducts, shopifyProducts); + + const products = hubProducts.map((product) => ({ + ...product, + shopify_match: matches[product.id] + })); - res.json({ products: result.rows }); + res.json({ products: products }); })); router.get('/:id', asyncRoute(async (req, res) => { @@ -116,11 +151,12 @@ router.post('/sync-shopify', syncRateLimit, asyncRoute(async (req, res) => { try { await query( - 'INSERT INTO products (sku, name, shopify_inventory_item_id) ' + - 'VALUES ($1, $2, $3) ' + + 'INSERT INTO products (sku, name, shopify_inventory_item_id, last_synced_at) ' + + 'VALUES ($1, $2, $3, NOW()) ' + 'ON CONFLICT (sku) DO UPDATE SET ' + ' name = EXCLUDED.name, ' + - ' shopify_inventory_item_id = COALESCE(products.shopify_inventory_item_id, EXCLUDED.shopify_inventory_item_id)', + ' shopify_inventory_item_id = COALESCE(products.shopify_inventory_item_id, EXCLUDED.shopify_inventory_item_id), ' + + ' last_synced_at = EXCLUDED.last_synced_at', [item.sku, item.name, item.inventory_item_id || null] ); synced += 1; diff --git a/backend/src/services/sampleData/shopify.json b/backend/src/services/sampleData/shopify.json index 9b70e3c..c74cd49 100644 --- a/backend/src/services/sampleData/shopify.json +++ b/backend/src/services/sampleData/shopify.json @@ -169,7 +169,8 @@ "unit_cost": 14.00, "stock_on_hand": 34, "reorder_level": 50, - "image": "https://placehold.co/300x300?text=Serum" + "image": "https://placehold.co/300x300?text=Serum", + "inventory_item_id": "INV_01" }, { "sku": "ER-OIL-002", @@ -179,7 +180,8 @@ "unit_cost": 11.00, "stock_on_hand": 120, "reorder_level": 40, - "image": "https://placehold.co/300x300?text=Face+Oil" + "image": "https://placehold.co/300x300?text=Face+Oil", + "inventory_item_id": "INV_02" }, { "sku": "ER-CRM-003", @@ -189,7 +191,8 @@ "unit_cost": 13.00, "stock_on_hand": 58, "reorder_level": 60, - "image": "https://placehold.co/300x300?text=Day+Cream" + "image": "https://placehold.co/300x300?text=Day+Cream", + "inventory_item_id": "INV_03" }, { "sku": "ER-CRM-004", @@ -199,7 +202,8 @@ "unit_cost": 16.00, "stock_on_hand": 210, "reorder_level": 40, - "image": "https://placehold.co/300x300?text=Night+Cream" + "image": "https://placehold.co/300x300?text=Night+Cream", + "inventory_item_id": "INV_04" }, { "sku": "ER-BLM-005", @@ -209,7 +213,8 @@ "unit_cost": 3.00, "stock_on_hand": 95, "reorder_level": 100, - "image": "https://placehold.co/300x300?text=Lip+Balm" + "image": "https://placehold.co/300x300?text=Lip+Balm", + "inventory_item_id": "INV_05" }, { "sku": "ER-MSK-006", @@ -219,7 +224,8 @@ "unit_cost": 12.00, "stock_on_hand": 77, "reorder_level": 30, - "image": "https://placehold.co/300x300?text=Detox+Mask" + "image": "https://placehold.co/300x300?text=Detox+Mask", + "inventory_item_id": "INV_06" }, { "sku": "ER-TNR-007", @@ -229,7 +235,8 @@ "unit_cost": 7.00, "stock_on_hand": 160, "reorder_level": 50, - "image": "https://placehold.co/300x300?text=Toner" + "image": "https://placehold.co/300x300?text=Toner", + "inventory_item_id": "INV_07" }, { "sku": "ER-KIT-008", @@ -239,7 +246,8 @@ "unit_cost": 35.00, "stock_on_hand": 12, "reorder_level": 20, - "image": "https://placehold.co/300x300?text=Ritual+Kit" + "image": "https://placehold.co/300x300?text=Ritual+Kit", + "inventory_item_id": "INV_08" } ], "reviews": [ diff --git a/frontend/src/pages/Products.jsx b/frontend/src/pages/Products.jsx index b5d2954..c089c9c 100644 --- a/frontend/src/pages/Products.jsx +++ b/frontend/src/pages/Products.jsx @@ -5,6 +5,13 @@ import { useTableView, SortHeader, SearchBox, onEnter } from '../ui.jsx'; const EMPTY_FORM = { sku: '', name: '', manufacturer_id: '', threshold: '', unit_cost: '' }; +const MATCH_STATES = [ + { value: 'matched', label: 'Matched', className: 'ok' }, + { value: 'unmatched', label: 'Unmatched', className: 'warn' }, +]; + +const UNKNOWN_MATCH = { label: 'Unable to Verify', className: 'low' } + export default function Products() { const [products, setProducts] = useState(null); const [manufacturers, setManufacturers] = useState([]); @@ -150,7 +157,7 @@ export default function Products() { const r = await api('/products/sync-shopify', { method: 'POST' }); const skippedNote = r.skipped ? `, ${r.skipped} skipped` : ''; setSyncMsg(`Synced ${r.synced} of ${r.total} Shopify items${skippedNote}.`); - load(); + await load(); } catch (e) { setSyncMsg(''); setError(e.message); @@ -191,7 +198,15 @@ export default function Products() { )); } + function formatDate(value) { + return new Date(value).toLocaleString('en-GB'); + } + function renderRow(product) { + const matchState = + MATCH_STATES.find(state => state.value === product.shopify_match) + ?? UNKNOWN_MATCH; + return ( {writable && ( @@ -206,6 +221,12 @@ export default function Products() { )} {product.sku} {product.name} + + + {matchState.label} + + + {product.last_synced_at ? formatDate(product.last_synced_at) : 'Never Recorded'}