diff --git a/inventory-server/dashboard/acot-server/routes/events.js b/inventory-server/dashboard/acot-server/routes/events.js index 649806d..17cc170 100644 --- a/inventory-server/dashboard/acot-server/routes/events.js +++ b/inventory-server/dashboard/acot-server/routes/events.js @@ -51,7 +51,8 @@ router.get('/stats', async (req, res) => { console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`); const { connection, release } = await getDbConnection(); console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`); - + try { + const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); // Main order stats query (optionally excludes Cherry Box orders) @@ -374,33 +375,27 @@ router.get('/stats', async (req, res) => { } }; - return { response, release }; - }; - - // Race between the main operation and timeout - let result; - try { - result = await Promise.race([mainOperation(), timeoutPromise]); - } catch (error) { - // If it's a timeout, we don't have a release function to call - if (error.message.includes('timeout')) { - console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`); - throw error; + return response; + } finally { + // Always release the connection regardless of whether the outer Promise.race + // used our result. If the timeout wins, this IIFE keeps running in the + // background until MySQL responds, then this finally releases. Without it, + // every timed-out request permanently leaks one pool slot. + release(); } - // For other errors, re-throw - throw error; - } - - const { response, release } = result; - - // Release connection back to pool - if (release) release(); - + }; + + const response = await Promise.race([mainOperation(), timeoutPromise]); + console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`); res.json(response); - + } catch (error) { - console.error('Error in /stats:', error); + if (error.message.includes('timeout')) { + console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`); + } else { + console.error('Error in /stats:', error); + } console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`); res.status(500).json({ error: error.message }); } diff --git a/inventory-server/dashboard/acot-server/routes/operations-metrics.js b/inventory-server/dashboard/acot-server/routes/operations-metrics.js index b0ab135..c549a93 100644 --- a/inventory-server/dashboard/acot-server/routes/operations-metrics.js +++ b/inventory-server/dashboard/acot-server/routes/operations-metrics.js @@ -22,6 +22,7 @@ router.get('/', async (req, res) => { console.log(`[OPERATIONS-METRICS] Getting DB connection...`); const { connection, release } = await getDbConnection(); console.log(`[OPERATIONS-METRICS] DB connection obtained in ${Date.now() - startTime}ms`); + try { const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); @@ -370,29 +371,26 @@ router.get('/', async (req, res) => { trend, }; - return { response, release }; + return response; + } finally { + // Always release the connection regardless of who wins Promise.race. + // If the timeout wins, this IIFE keeps running until MySQL responds; this + // finally ensures the connection still returns to the pool. + release(); + } }; - let result; - try { - result = await Promise.race([mainOperation(), timeoutPromise]); - } catch (error) { - if (error.message.includes('timeout')) { - console.log(`[OPERATIONS-METRICS] Request timed out in ${Date.now() - startTime}ms`); - throw error; - } - throw error; - } - - const { response, release } = result; - - if (release) release(); + const response = await Promise.race([mainOperation(), timeoutPromise]); console.log(`[OPERATIONS-METRICS] Request completed in ${Date.now() - startTime}ms`); res.json(response); } catch (error) { - console.error('Error in /operations-metrics:', error); + if (error.message.includes('timeout')) { + console.log(`[OPERATIONS-METRICS] Request timed out in ${Date.now() - startTime}ms`); + } else { + console.error('Error in /operations-metrics:', error); + } console.log(`[OPERATIONS-METRICS] Request failed in ${Date.now() - startTime}ms`); res.status(500).json({ error: error.message }); } diff --git a/inventory-server/dashboard/acot-server/routes/payroll-metrics.js b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js index 317e7ac..834b56e 100644 --- a/inventory-server/dashboard/acot-server/routes/payroll-metrics.js +++ b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js @@ -281,6 +281,7 @@ router.get('/', async (req, res) => { console.log(`[PAYROLL-METRICS] Getting DB connection...`); const { connection, release } = await getDbConnection(); console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`); + try { // Build query for the pay period const periodStart = payPeriod.start.toJSDate(); @@ -373,29 +374,26 @@ router.get('/', async (req, res) => { byWeek: hoursData.byWeek, }; - return { response, release }; + return response; + } finally { + // Always release the connection regardless of who wins Promise.race. + // If the timeout wins, this IIFE keeps running until MySQL responds; this + // finally ensures the connection still returns to the pool. + release(); + } }; - let result; - try { - result = await Promise.race([mainOperation(), timeoutPromise]); - } catch (error) { - if (error.message.includes('timeout')) { - console.log(`[PAYROLL-METRICS] Request timed out in ${Date.now() - startTime}ms`); - throw error; - } - throw error; - } - - const { response, release } = result; - - if (release) release(); + const response = await Promise.race([mainOperation(), timeoutPromise]); console.log(`[PAYROLL-METRICS] Request completed in ${Date.now() - startTime}ms`); res.json(response); } catch (error) { - console.error('Error in /payroll-metrics:', error); + if (error.message.includes('timeout')) { + console.log(`[PAYROLL-METRICS] Request timed out in ${Date.now() - startTime}ms`); + } else { + console.error('Error in /payroll-metrics:', error); + } console.log(`[PAYROLL-METRICS] Request failed in ${Date.now() - startTime}ms`); res.status(500).json({ error: error.message }); } diff --git a/inventory-server/dashboard/acot-server/server.js b/inventory-server/dashboard/acot-server/server.js index bb3bc87..313800d 100644 --- a/inventory-server/dashboard/acot-server/server.js +++ b/inventory-server/dashboard/acot-server/server.js @@ -66,6 +66,11 @@ if (!process.env.JWT_SECRET) { const app = express(); const PORT = Number(process.env.ACOT_PORT) || 3012; +// Trust X-Forwarded-* only when the immediate hop is loopback (Caddy on the same +// host). Required for the KIOSK_IPS bypass in shared/auth/middleware.js to see +// real client IPs instead of 127.0.0.1. +app.set('trust proxy', 'loopback'); + // Postgres pool for authenticate() (user/permission lookups against inventory_db). // All MySQL access goes through db/connection.js (separate, ssh-tunneled). const pool = new Pool({ diff --git a/inventory-server/dashboard/server.js b/inventory-server/dashboard/server.js index 4313440..ab1ffd7 100644 --- a/inventory-server/dashboard/server.js +++ b/inventory-server/dashboard/server.js @@ -62,6 +62,11 @@ if (!process.env.JWT_SECRET) { const app = express(); const PORT = Number(process.env.DASHBOARD_PORT) || 3015; +// Trust X-Forwarded-* only when the immediate hop is loopback (Caddy on the same +// host). Required for the KIOSK_IPS bypass in shared/auth/middleware.js to see +// real client IPs instead of 127.0.0.1. +app.set('trust proxy', 'loopback'); + // Single Postgres pool — used by authenticate() to load user permissions. // All four vendors share this pool (auth lookups are the only DB hits at runtime). const pool = createPool('DB'); diff --git a/inventory-server/scripts/backfill-supplier-costs.js b/inventory-server/scripts/backfill-supplier-costs.js new file mode 100644 index 0000000..869facb --- /dev/null +++ b/inventory-server/scripts/backfill-supplier-costs.js @@ -0,0 +1,77 @@ +/** + * One-off backfill: populate products.notions_cost_each and supplier_cost_each + * from MySQL supplier_item_data. Idempotent — safe to re-run. + * + * Usage (on the server, where the SSH tunnel and env are configured): + * cd /var/www/inventory && node scripts/backfill-supplier-costs.js + * + * After this lands, the daily products import (via syncSupplierCosts in + * scripts/import/products.js) keeps the columns up to date. + */ + +const dotenv = require("dotenv"); +const path = require("path"); +dotenv.config({ path: path.join(__dirname, "../.env") }); + +const { setupConnections, closeConnections } = require("./import/utils"); +const { syncSupplierCosts } = require("./import/products"); + +const sshConfig = { + ssh: { + host: process.env.PROD_SSH_HOST, + port: process.env.PROD_SSH_PORT || 22, + username: process.env.PROD_SSH_USER, + privateKey: process.env.PROD_SSH_KEY_PATH + ? require("fs").readFileSync(process.env.PROD_SSH_KEY_PATH) + : undefined, + compress: true, + }, + prodDbConfig: { + host: process.env.PROD_DB_HOST || "localhost", + user: process.env.PROD_DB_USER, + password: process.env.PROD_DB_PASSWORD, + database: process.env.PROD_DB_NAME, + port: process.env.PROD_DB_PORT || 3306, + timezone: "-05:00", + }, + localDbConfig: { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT || 5432, + ssl: process.env.DB_SSL === "true", + connectionTimeoutMillis: 60000, + idleTimeoutMillis: 30000, + max: 4, + }, +}; + +(async () => { + let connections; + const start = Date.now(); + try { + console.log("Setting up connections..."); + connections = await setupConnections(sshConfig); + const { prodConnection, localConnection } = connections; + + console.log("Starting transaction..."); + await localConnection.beginTransaction(); + + const result = await syncSupplierCosts(prodConnection, localConnection); + + await localConnection.commit(); + console.log(`Done. Updated ${result.updated} rows in ${(Date.now() - start) / 1000}s`); + } catch (err) { + console.error("Backfill failed:", err); + if (connections?.localConnection?._transactionActive) { + try { await connections.localConnection.rollback(); } catch (e) {} + } + process.exitCode = 1; + } finally { + if (connections) { + try { await closeConnections(connections); } catch (e) { console.error("Close error:", e); } + } + process.exit(); + } +})(); diff --git a/inventory-server/scripts/import/products.js b/inventory-server/scripts/import/products.js index aa191a8..9907874 100644 --- a/inventory-server/scripts/import/products.js +++ b/inventory-server/scripts/import/products.js @@ -922,6 +922,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate // Cleanup temporary tables await cleanupTemporaryTables(localConnection); + // Sync supplier-quoted cost fields (notions_cost_each / supplier_cost_each). + // These feed the Create-PO page so the displayed cost matches what the + // legacy PHP backend will stamp onto the PO line item. + await syncSupplierCosts(prodConnection, localConnection); + // Commit the transaction await localConnection.commit(); @@ -954,10 +959,80 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate } } +// Bulk-sync supplier_item_data.notions_cost_each / supplier_cost_each into +// products.{notions_cost_each, supplier_cost_each}. These mirror the supplier- +// quoted "cost each" values the legacy PHP backend writes onto a PO when +// _product_add() runs (see po.class.php:189-209). Kept as a separate, idempotent +// pass so the main 49-column import paths don't need to know about it. +async function syncSupplierCosts(prodConnection, localConnection) { + outputProgress({ + status: "running", + operation: "Products import", + message: "Syncing supplier costs from supplier_item_data" + }); + + const [rows] = await prodConnection.query(` + SELECT pid, notions_cost_each, supplier_cost_each + FROM supplier_item_data + `); + + if (!rows || rows.length === 0) { + return { updated: 0 }; + } + + // Stage into a temp table, then UPDATE in a single SQL statement. + await localConnection.query(` + CREATE TEMP TABLE temp_supplier_costs ( + pid BIGINT PRIMARY KEY, + notions_cost_each NUMERIC(10,3), + supplier_cost_each NUMERIC(10,3) + ) ON COMMIT DROP + `); + + const CHUNK = 5000; + for (let i = 0; i < rows.length; i += CHUNK) { + const batch = rows.slice(i, i + CHUNK); + const placeholders = batch + .map((_, idx) => `($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`) + .join(','); + const values = batch.flatMap(r => [ + r.pid, + r.notions_cost_each, + r.supplier_cost_each + ]); + await localConnection.query( + `INSERT INTO temp_supplier_costs (pid, notions_cost_each, supplier_cost_each) + VALUES ${placeholders} + ON CONFLICT (pid) DO NOTHING`, + values + ); + } + + const [result] = await localConnection.query(` + UPDATE products p + SET notions_cost_each = t.notions_cost_each, + supplier_cost_each = t.supplier_cost_each + FROM temp_supplier_costs t + WHERE p.pid = t.pid + AND (p.notions_cost_each IS DISTINCT FROM t.notions_cost_each + OR p.supplier_cost_each IS DISTINCT FROM t.supplier_cost_each) + `); + + const updated = result.rowCount || 0; + outputProgress({ + status: "running", + operation: "Products import", + message: `Supplier costs synced for ${updated} products` + }); + + return { updated }; +} + module.exports = { importProducts, importMissingProducts, setupTemporaryTables, cleanupTemporaryTables, - materializeCalculations + materializeCalculations, + syncSupplierCosts }; \ No newline at end of file diff --git a/inventory-server/shared/auth/middleware.js b/inventory-server/shared/auth/middleware.js index 91d4fc1..c9a95c3 100644 --- a/inventory-server/shared/auth/middleware.js +++ b/inventory-server/shared/auth/middleware.js @@ -2,6 +2,20 @@ import { extractBearerToken, verifyToken, TokenError } from './verify.js'; const USER_CACHE_TTL_MS = 60_000; +// Source IPs that bypass token auth — used so the office kiosk can render +// /small without anyone having to log in daily on the device. Synthetic user +// has no permissions, so only endpoints that don't gate on requirePermission() +// are reachable. Requires server.js `trust proxy` setting so req.ip is the +// real client behind Caddy, not 127.0.0.1. +function parseKioskIps(raw) { + return new Set( + (raw || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + ); +} + function createUserCache() { const entries = new Map(); return { @@ -47,10 +61,23 @@ async function loadUser(pool, userId) { return user; } -export function authenticate({ pool, secret = process.env.JWT_SECRET }) { +export function authenticate({ pool, secret = process.env.JWT_SECRET, kioskIps = process.env.KIOSK_IPS }) { const cache = createUserCache(); + const kioskIpSet = parseKioskIps(kioskIps); return async function authenticateMiddleware(req, res, next) { + if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) { + req.user = { + id: 'kiosk', + username: 'kiosk', + is_admin: false, + is_active: true, + permissions: [], + is_kiosk: true, + }; + return next(); + } + let decoded; try { const token = extractBearerToken(req.headers.authorization); diff --git a/inventory-server/shared/auth/middleware.test.js b/inventory-server/shared/auth/middleware.test.js index 34c64b2..ac61333 100644 --- a/inventory-server/shared/auth/middleware.test.js +++ b/inventory-server/shared/auth/middleware.test.js @@ -126,6 +126,62 @@ describe('authenticate middleware', () => { expect(pool.calls.count).toBe(4); vi.useRealTimers(); }); + + describe('KIOSK_IPS bypass', () => { + it('bypasses token check and mints a synthetic kiosk user when req.ip matches', async () => { + const pool = makeFakePool({}); + const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' }); + const req = { headers: {}, ip: '203.0.113.7' }; + const res = makeRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(req.user).toEqual({ + id: 'kiosk', + username: 'kiosk', + is_admin: false, + is_active: true, + permissions: [], + is_kiosk: true, + }); + expect(pool.calls.count).toBe(0); + }); + + it('falls through to normal Bearer auth when req.ip is not in KIOSK_IPS', async () => { + const pool = makeFakePool({ 1: activeUser }, { 1: [] }); + const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' }); + const req = { headers: { authorization: `Bearer ${validToken}` }, ip: '198.51.100.1' }; + const res = makeRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(req.user.id).toBe(1); + expect(req.user.is_kiosk).toBeUndefined(); + }); + + it('does not bypass when KIOSK_IPS is empty, even if req.ip is undefined', async () => { + const pool = makeFakePool({ 1: activeUser }); + const mw = authenticate({ pool, secret: SECRET, kioskIps: '' }); + const req = { headers: {} }; + const res = makeRes(); + const next = vi.fn(); + await mw(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('supports multiple comma-separated IPs', async () => { + const pool = makeFakePool({}); + const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7, 203.0.113.8 ,203.0.113.9' }); + const next = vi.fn(); + for (const ip of ['203.0.113.7', '203.0.113.8', '203.0.113.9']) { + const req = { headers: {}, ip }; + await mw(req, makeRes(), next); + expect(req.user?.is_kiosk).toBe(true); + } + expect(next).toHaveBeenCalledTimes(3); + }); + }); }); describe('requirePermission middleware', () => { diff --git a/inventory-server/shared/logging/request-log.js b/inventory-server/shared/logging/request-log.js index 3e1aded..2e7ce6d 100644 --- a/inventory-server/shared/logging/request-log.js +++ b/inventory-server/shared/logging/request-log.js @@ -20,7 +20,8 @@ export function requestLog(options = {}) { return { method: req.method, url: req.url, - userId: req.raw?.user?.id, + userId: req.raw?.user?.id ?? req.user?.id, + ip: req.raw?.ip ?? req.ip, }; }, res(res) { diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 5594a3b..7f677d9 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -11,6 +11,17 @@ async function executeQuery(sql, params = []) { return pool.query(sql, params); } +// Identity probe for the small dashboard / kiosk flow. Lives under /api/dashboard/* +// so it sits behind Caddy's office-IP `client_ip` allowlist — the office kiosk can +// reach it without a token, while non-office requests must carry a valid Bearer. +// Reaches this handler only if shared `authenticate` middleware populated req.user. +router.get('/whoami', (req, res) => { + res.json({ + authenticated: !!req.user, + is_kiosk: !!req.user?.is_kiosk, + }); +}); + // GET /dashboard/stock/metrics // Returns brand-level stock metrics router.get('/stock/metrics', async (req, res) => { diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 8e8ec2d..b188d40 100644 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -496,7 +496,15 @@ router.get('/search', async (req, res) => { // Batch lookup of product display data by pid list (used by Create PO page) // Accepts ?pids=1,2,3 — comma-separated; de-duped server-side; capped at 500. +// Optional ?supplierId= — when present, current_cost_price is computed via +// the same fallback chain the legacy PHP backend uses in clsPO::_product_add() +// (po.class.php:189-209): +// supplier_id == 92 (Notions) → products.notions_cost_each +// else / fallback → products.supplier_cost_each +// final fallback → most recent receivings.cost_each (>0) // Returns rows in the same order as the deduped input pids; missing pids are silently dropped. +const NOTIONS_SUPPLIER_ID = 92; + router.get('/batch', async (req, res) => { const pool = req.app.locals.pool; const raw = req.query.pids; @@ -514,6 +522,11 @@ router.get('/batch', async (req, res) => { return res.status(400).json({ error: 'No valid pids provided' }); } + const supplierIdRaw = req.query.supplierId; + const supplierId = supplierIdRaw != null && /^\d+$/.test(String(supplierIdRaw)) + ? parseInt(String(supplierIdRaw), 10) + : null; + try { const { rows } = await pool.query(` SELECT @@ -528,7 +541,17 @@ router.get('/batch', async (req, res) => { p.baskets, pm.on_order_qty, p.total_sold, - pm.current_cost_price, + p.notions_cost_each, + p.supplier_cost_each, + ( + SELECT r.cost_each + FROM receivings r + WHERE r.pid = p.pid + AND r.cost_each > 0 + AND r.status <> 'canceled' + ORDER BY r.received_date DESC + LIMIT 1 + ) AS last_received_cost, pm.date_last_sold, pm.date_first_received, p.moq @@ -537,10 +560,30 @@ router.get('/batch', async (req, res) => { WHERE p.pid = ANY($1::bigint[]) `, [pids]); + const pickCost = (r) => { + // Treat 0 as "unset" the same way the PHP code does (`if (!$cost_each)`). + const notions = Number(r.notions_cost_each) || 0; + const supplier = Number(r.supplier_cost_each) || 0; + const lastReceived = Number(r.last_received_cost) || 0; + if (supplierId === NOTIONS_SUPPLIER_ID && notions > 0) return notions; + if (supplier > 0) return supplier; + if (lastReceived > 0) return lastReceived; + return null; + }; + // products.pid is BIGINT, which the pg driver returns as a STRING by // default (to preserve precision for values > 2^53). Coerce to Number // so the JSON response has numeric pids and Map lookups work. - const normalized = rows.map(r => ({ ...r, pid: Number(r.pid) })); + const normalized = rows.map(r => ({ + ...r, + pid: Number(r.pid), + notions_cost_each: r.notions_cost_each != null ? Number(r.notions_cost_each) : null, + supplier_cost_each: r.supplier_cost_each != null ? Number(r.supplier_cost_each) : null, + last_received_cost: r.last_received_cost != null ? Number(r.last_received_cost) : null, + // current_cost_price = the value that will land on the PO if this product + // is added with the given supplier (or non-Notions default when omitted). + current_cost_price: pickCost(r), + })); const byPid = new Map(normalized.map(r => [r.pid, r])); // Preserve the requested order so the frontend can append rows in input order const ordered = pids.map(pid => byPid.get(pid)).filter(Boolean); diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js index 9d8548a..f30dff8 100644 --- a/inventory-server/src/routes/purchase-orders.js +++ b/inventory-server/src/routes/purchase-orders.js @@ -967,6 +967,34 @@ router.get('/order-vs-received', async (req, res) => { }); // Get purchase order items +// Lightweight lookup of a PO's supplier_id by po_id, used by the Create-PO +// page's "Add to existing PO" mode so we can compute the right cost-each +// fallback branch (Notions supplier 92 vs everyone else) without fetching +// the full PO contents. +router.get('/:id/supplier', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { id } = req.params; + if (!id) { + return res.status(400).json({ error: 'Purchase order ID is required' }); + } + const { rows } = await pool.query( + `SELECT supplier_id, vendor FROM purchase_orders WHERE po_id = $1 LIMIT 1`, + [String(id)] + ); + if (rows.length === 0) { + return res.status(404).json({ error: 'Purchase order not found' }); + } + res.json({ + supplierId: rows[0].supplier_id != null ? Number(rows[0].supplier_id) : null, + vendor: rows[0].vendor || null, + }); + } catch (error) { + console.error('Error fetching PO supplier:', error); + res.status(500).json({ error: 'Failed to fetch PO supplier' }); + } +}); + router.get('/:id/items', async (req, res) => { try { const pool = req.app.locals.pool; diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index a7cf6f9..3ceb7b1 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -78,6 +78,12 @@ requiredDirs.forEach((dir) => { const app = express(); +// Trust X-Forwarded-* only when the immediate hop is loopback (Caddy on the same +// host). Anything stricter would leave req.ip as 127.0.0.1; anything looser would +// let arbitrary clients spoof their source IP via X-Forwarded-For. Required for +// the KIOSK_IPS bypass in shared/auth/middleware.js to match real client IPs. +app.set('trust proxy', 'loopback'); + // Phase 6.5/6.9: structured access log (replaces the previous header-dumping debug // middleware that wrote raw Authorization values to stdout). Pino redaction strips // `authorization` and `cookie` automatically — see shared/logging/logger.js. diff --git a/inventory/src/components/create-po/LineItemsTable.tsx b/inventory/src/components/create-po/LineItemsTable.tsx index b008575..b72d79a 100644 --- a/inventory/src/components/create-po/LineItemsTable.tsx +++ b/inventory/src/components/create-po/LineItemsTable.tsx @@ -28,6 +28,7 @@ import { X as XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatCurrency, formatNumber, formatDateShort } from "@/utils/productUtils"; import type { PoLineItem } from "./types"; +import { computeCostEach } from "./types"; import { NOTIONS_SUPPLIER_ID } from "./constants"; type SortKey = @@ -110,7 +111,7 @@ export function LineItemsTable({ baskets: (i) => i.baskets, on_order_qty: (i) => i.on_order_qty, total_sold: (i) => i.total_sold, - current_cost_price: (i) => i.current_cost_price, + current_cost_price: (i) => computeCostEach(i, supplierId), date_last_sold: (i) => i.date_last_sold, date_first_received: (i) => i.date_first_received, notions_inv_count: (i) => i.notions_inv_count, @@ -119,7 +120,7 @@ export function LineItemsTable({ }; const accessor = accessors[sortKey]; return [...items].sort((a, b) => compareValues(accessor(a), accessor(b), sortDir)); - }, [items, sortKey, sortDir, isNotions]); + }, [items, sortKey, sortDir, isNotions, supplierId]); const handleSort = useCallback( (key: SortKey) => { @@ -316,9 +317,10 @@ export function LineItemsTable({ {item.total_sold != null ? formatNumber(item.total_sold) : "—"} - {item.current_cost_price != null - ? formatCurrency(item.current_cost_price) - : "—"} + {(() => { + const cost = computeCostEach(item, supplierId); + return cost != null ? formatCurrency(cost) : "—"; + })()} {item.date_last_sold ? formatDateShort(item.date_last_sold) : "—"} diff --git a/inventory/src/components/create-po/resolveIdentifiers.ts b/inventory/src/components/create-po/resolveIdentifiers.ts index d2f12c0..c3e1824 100644 --- a/inventory/src/components/create-po/resolveIdentifiers.ts +++ b/inventory/src/components/create-po/resolveIdentifiers.ts @@ -106,12 +106,20 @@ export async function resolveIdentifiers( * to BATCH_LOOKUP_MAX_PIDS to stay under URL length limits even if the * caller passes hundreds of pids. * + * When `supplierId` is provided, the backend computes `current_cost_price` + * using the same fallback chain the legacy PHP `_product_add` writes onto + * the PO line item (notions_cost_each / supplier_cost_each / receivings). + * The three raw cost fields (`notions_cost_each`, `supplier_cost_each`, + * `last_received_cost`) are always returned so the UI can re-derive cost + * for a different supplier without a round-trip. + * * Returns a flat array of PoLineItem with `qty` set to the value passed in * the `qtyByPid` map (default 1). */ export async function fetchBatchProducts( pids: number[], - qtyByPid: Map = new Map() + qtyByPid: Map = new Map(), + supplierId?: number ): Promise { if (pids.length === 0) return []; @@ -120,9 +128,13 @@ export async function fetchBatchProducts( for (let i = 0; i < uniqPids.length; i += BATCH_LOOKUP_MAX_PIDS) { const chunk = uniqPids.slice(i, i + BATCH_LOOKUP_MAX_PIDS); + const params: Record = { pids: chunk.join(",") }; + if (supplierId != null && Number.isFinite(supplierId)) { + params.supplierId = String(supplierId); + } const res = await apiClient.get[]>( "/api/products/batch", - { params: { pids: chunk.join(",") } } + { params } ); for (const row of res.data ?? []) { // Defensive Number() coercion: the backend already returns pid as a diff --git a/inventory/src/components/create-po/types.ts b/inventory/src/components/create-po/types.ts index b053a64..5480d0d 100644 --- a/inventory/src/components/create-po/types.ts +++ b/inventory/src/components/create-po/types.ts @@ -1,3 +1,33 @@ +import { NOTIONS_SUPPLIER_ID } from "./constants"; + +/** + * Computes the "cost each" that the PO line item will carry, mirroring the + * fallback chain in the legacy PHP `clsPO::_product_add()` (po.class.php:189): + * + * if supplierId == 92 (Notions): + * notions_cost_each → supplier_cost_each → last_received_cost + * else: + * supplier_cost_each → last_received_cost + * + * Returns null if none of the candidate fields has a positive value, so + * callers can distinguish "no quote" from "$0". + * + * Computed client-side so changing the supplier mid-flow doesn't require a + * round-trip; the three raw fields come from /api/products/batch once. + */ +export function computeCostEach( + item: Pick, + supplierId: number | undefined +): number | null { + const notions = Number(item.notions_cost_each) || 0; + const supplier = Number(item.supplier_cost_each) || 0; + const lastReceived = Number(item.last_received_cost) || 0; + if (Number(supplierId) === NOTIONS_SUPPLIER_ID && notions > 0) return notions; + if (supplier > 0) return supplier; + if (lastReceived > 0) return lastReceived; + return null; +} + /** * Display shape for a single line item on the Create PO page. This is the * exact response shape returned by GET /api/products/batch (snake_case). @@ -17,7 +47,23 @@ export interface PoLineItem { baskets: number | null; on_order_qty: number | null; total_sold: number | null; + /** + * Backend-computed cost using the same fallback chain the legacy PHP + * `_product_add()` writes onto the PO line item: + * supplier=92 → notions_cost_each → supplier_cost_each → last received cost + * else → supplier_cost_each → last received cost + * Sent by the server when a supplierId is passed to /api/products/batch. + * For supplier-aware display the UI should re-compute from the three raw + * fields below using `computeCostEach()`, since the working supplier can + * change after the row was fetched. + */ current_cost_price: number | null; + /** Raw cost from supplier_item_data.notions_cost_each (0/null when unset). */ + notions_cost_each: number | null; + /** Raw cost from supplier_item_data.supplier_cost_each (0/null when unset). */ + supplier_cost_each: number | null; + /** Most recent receivings.cost_each (>0, non-canceled). */ + last_received_cost: number | null; date_last_sold: string | null; date_first_received: string | null; /** From the products table; may be null/0/inconsistent. The user can override locally. */ diff --git a/inventory/src/pages/CreatePurchaseOrder.tsx b/inventory/src/pages/CreatePurchaseOrder.tsx index fde45a0..43ff2be 100644 --- a/inventory/src/pages/CreatePurchaseOrder.tsx +++ b/inventory/src/pages/CreatePurchaseOrder.tsx @@ -30,7 +30,7 @@ * dedup against the target PO (we don't fetch its current contents). */ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -46,6 +46,8 @@ import { AddProductsDialog } from "@/components/create-po/AddProductsDialog"; import { ConfirmationView } from "@/components/create-po/ConfirmationView"; import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers"; import type { PoLineItem } from "@/components/create-po/types"; +import { computeCostEach } from "@/components/create-po/types"; +import { apiClient } from "@/utils/apiClient"; import { submitNewPurchaseOrder, addProductsToPurchaseOrder, @@ -62,6 +64,10 @@ export default function CreatePurchaseOrder() { const [addOpen, setAddOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [hydrating, setHydrating] = useState(false); + // Supplier of the existing PO in "add" mode (looked up from /api/purchase-orders/:id/supplier). + // Drives the Notions branch of the cost fallback so the displayed cost matches what the legacy + // backend will actually stamp onto the line item. + const [existingPoSupplierId, setExistingPoSupplierId] = useState(undefined); const [confirmation, setConfirmation] = useState<{ poId: number; itemCount: number; @@ -77,6 +83,7 @@ export default function CreatePurchaseOrder() { setMode(next); setSupplierId(undefined); setExistingPoInput(""); + setExistingPoSupplierId(undefined); setLineItems([]); setSelectedPids(new Set()); }, []); @@ -106,7 +113,15 @@ export default function CreatePurchaseOrder() { const qtyByPid = new Map(fresh.map((i) => [i.pid, i.qty])); const hydrated = await fetchBatchProducts( fresh.map((i) => i.pid), - qtyByPid + qtyByPid, + // Tell the backend which fallback branch to use for the server-side + // current_cost_price field. The UI also re-computes locally via + // computeCostEach() if the supplier changes mid-flow. + mode === "create" + ? supplierId + ? Number(supplierId) + : undefined + : existingPoSupplierId ); if (hydrated.length === 0) { toast.error("Could not load product details"); @@ -126,7 +141,7 @@ export default function CreatePurchaseOrder() { setHydrating(false); } }, - [lineItems] + [lineItems, mode, supplierId, existingPoSupplierId] ); // ---- Row mutation handlers (passed to LineItemsTable) -------------------- @@ -202,6 +217,43 @@ export default function CreatePurchaseOrder() { const targetReady = mode === "create" ? !!supplierId : parsedPoId !== undefined; + // Effective supplier driving the cost-each fallback chain. + // - create mode → the supplier the user just picked + // - add mode → the supplier of the existing PO (looked up below) + const effectiveSupplierId = + mode === "create" + ? supplierId + ? Number(supplierId) + : undefined + : existingPoSupplierId; + + // ---- Look up existing PO supplier in "add" mode --------------------------- + // The cost-each fallback branches on whether the PO is for Notions (supplier 92), + // so we need to know the target PO's supplier as soon as the user types a valid id. + // Debounced via parsedPoId so we don't fire a request on every keystroke. + useEffect(() => { + if (mode !== "add" || parsedPoId === undefined) { + setExistingPoSupplierId(undefined); + return; + } + let cancelled = false; + apiClient + .get<{ supplierId: number | null }>( + `/api/purchase-orders/${parsedPoId}/supplier` + ) + .then((res) => { + if (cancelled) return; + const s = res.data?.supplierId; + setExistingPoSupplierId(typeof s === "number" && s > 0 ? s : undefined); + }) + .catch(() => { + if (!cancelled) setExistingPoSupplierId(undefined); + }); + return () => { + cancelled = true; + }; + }, [mode, parsedPoId]); + const handleSubmit = useCallback(async () => { if (mode === "create" && !supplierId) { toast.error("Pick a supplier first"); @@ -267,6 +319,7 @@ export default function CreatePurchaseOrder() { const handleCreateAnother = useCallback(() => { setSupplierId(undefined); setExistingPoInput(""); + setExistingPoSupplierId(undefined); setLineItems([]); setSelectedPids(new Set()); setConfirmation(null); @@ -288,9 +341,11 @@ export default function CreatePurchaseOrder() { // ---- Builder view --------------------------------------------------------- const totalQty = lineItems.reduce((sum, i) => sum + (i.qty > 0 ? i.qty : 0), 0); + // Use the supplier-aware fallback (same chain the legacy PHP backend uses) + // so the displayed total reflects what will land on the PO. const totalCost = lineItems.reduce( (sum, i) => - sum + (i.qty > 0 ? i.qty * (i.current_cost_price ?? 0) : 0), + sum + (i.qty > 0 ? i.qty * (computeCostEach(i, effectiveSupplierId) ?? 0) : 0), 0 ); @@ -379,7 +434,7 @@ export default function CreatePurchaseOrder() { p.pid); const controller = new AbortController(); - axios + apiClient .get("/api/import/product-images-batch", { params: { pids: pids.join(",") }, signal: controller.signal, @@ -223,7 +223,7 @@ export default function ProductEditor() { }, [products]); useEffect(() => { - axios + apiClient .get("/api/import/field-options") .then((res) => setFieldOptions(res.data)) .catch((err) => { @@ -243,7 +243,7 @@ export default function ProductEditor() { setSublineOptions([]); if (!lineCompany) return; setIsLoadingLines(true); - axios + apiClient .get(`/api/import/product-lines/${lineCompany}`) .then((res) => setLineOptions(res.data)) .catch(() => setLineOptions([])) @@ -256,7 +256,7 @@ export default function ProductEditor() { setSublineOptions([]); if (!lineLine) return; setIsLoadingSublines(true); - axios + apiClient .get(`/api/import/sublines/${lineLine}`) .then((res) => setSublineOptions(res.data)) .catch(() => setSublineOptions([])) diff --git a/inventory/src/pages/SmallDashboard.tsx b/inventory/src/pages/SmallDashboard.tsx index 37a6f5e..d1685c4 100644 --- a/inventory/src/pages/SmallDashboard.tsx +++ b/inventory/src/pages/SmallDashboard.tsx @@ -1,4 +1,5 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider"; import LockButton from "@/components/dashboard/LockButton"; import PinProtection from "@/components/dashboard/PinProtection"; @@ -10,6 +11,8 @@ import MiniEventFeed from "@/components/dashboard/MiniEventFeed"; import MiniBusinessMetrics from "@/components/dashboard/MiniBusinessMetrics"; // @ts-expect-error - JSX component without type declarations import MiniInventorySnapshot from "@/components/dashboard/MiniInventorySnapshot"; +import PageLoading from "@/components/ui/page-loading"; +import { apiFetch } from "@/utils/api"; // Pin Protected Layout const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => { @@ -29,6 +32,41 @@ const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => { return <>{children}; }; +// Three-way gate: office IP gets PIN, authenticated users skip PIN, +// everyone else is bounced to login. Identity comes from /api/dashboard/whoami, +// which sits behind Caddy's office-IP allowlist for the kiosk case. +type Identity = "probing" | "kiosk" | "authenticated" | "anonymous"; + +const AccessGate = ({ children }: { children: React.ReactNode }) => { + const [identity, setIdentity] = useState("probing"); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch("/api/dashboard/whoami"); + if (cancelled) return; + if (res.status === 401) { + setIdentity("anonymous"); + return; + } + const body = await res.json(); + setIdentity(body.is_kiosk ? "kiosk" : "authenticated"); + } catch { + if (!cancelled) setIdentity("anonymous"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + if (identity === "probing") return ; + if (identity === "anonymous") return ; + if (identity === "kiosk") return {children}; + return <>{children}; +}; + // Small Layout const SmallLayout = () => { const DATETIME_SCALE = 2; @@ -130,9 +168,9 @@ const SmallLayout = () => { export function SmallDashboard() { return ( - + - + ); } diff --git a/inventory/src/services/apiv2.ts b/inventory/src/services/apiv2.ts index 85337fd..79b48d1 100644 --- a/inventory/src/services/apiv2.ts +++ b/inventory/src/services/apiv2.ts @@ -59,8 +59,12 @@ export interface CreateProductCategoryResponse { category?: unknown; } -// Always use relative URLs - proxied by Vite in dev and Caddy in production -// Frontend calls /apiv2/* -> Caddy transforms to /api/* -> proxies to www.acherryontop.com +// Relative URLs — same-origin to the browser. In production, Caddy on +// tools.acherryontop.com reverse-proxies /apiv2/* to backend.acherryontop.com +// (and /apiv2-test/* to work-test-backend.acherryontop.com via Vite in dev). +// The prod endpoints rely on a session cookie scoped to `.acherryontop.com` +// (sent via credentials: 'include' below); the dev/test endpoint instead +// receives an auth token in the request body (VITE_APIV2_AUTH_TOKEN). const DEV_ENDPOINT = "/apiv2-test/product/setup_new"; const DEV_CREATE_CATEGORY_ENDPOINT = "/apiv2-test/prod_cat/new"; const PROD_ENDPOINT = "/apiv2/product/setup_new";