Auth fixes, show correct cost each value on pos
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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=<n> — 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user