Fix bugs, harden validation, and improve robustness

P0 fixes:
- Fix OrderDetail product change overwriting product_id due to React
  state batching (single setItems call now updates both fields)
- Validate all :id route params via parseId helper; return 400 for
  invalid IDs instead of passing raw strings to SQLite
- Product/customer delete now checks for references first, returns
  409 Conflict instead of letting FK constraint produce 500

P1 fixes:
- Disallow quantity_on_hand in product PUT so all stock changes go
  through PATCH /stock (preserves audit trail)
- Add global Express error handler and unhandledRejection listener

P2 fixes:
- Validate report date params (YYYY-MM-DD format) and stock-history
  limit (positive integer, capped at 1000)
- Add jsonSafe() helper to api.js for safe 204 handling
- OrderNew setSubmitting now runs in finally block
- Login shows specific message for 429 rate limit, generic message
  for other auth failures

P3 fixes:
- Replace brittle try/catch ALTER TABLE with schema_version migration
  table and versioned migrations
- Fix OrderDetail useEffect missing dependency (useCallback + [load])

Also: expanded README with full production deployment instructions
(PM2, nginx, backups)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adamp 2026-02-09 21:35:53 -06:00
parent 1ed2642e20
commit 7068ea354e
13 changed files with 245 additions and 60 deletions

View File

@ -6,7 +6,9 @@ A self-hosted web app for tracking cookie inventory and customers for a single t
- **Inventory**: Add cookie products (name, price, quantity, low-stock threshold). Adjust stock (restock or deduct). Low-stock highlighting. - **Inventory**: Add cookie products (name, price, quantity, low-stock threshold). Adjust stock (restock or deduct). Low-stock highlighting.
- **Customers**: Store name, phone, email, address, notes. Search by name, email, or phone. - **Customers**: Store name, phone, email, address, notes. Search by name, email, or phone.
- **Orders**: Create orders with customer (or walk-in), line items (product + quantity), status (pending, paid, delivered). Inventory is deducted automatically. Edit or delete orders (stock is restored on delete). - **Orders**: Create orders with customer (or walk-in), line items (product + quantity), status (pending, paid, delivered), payment method and amount paid. Inventory is deducted automatically. Edit or delete orders (stock is restored on delete). Balance due tracking.
- **Reports**: Sales by product, top customers, revenue over time, order status breakdown, and inventory summary. Filterable by date range (all time, this week, this month, custom).
- **Stock Audit Trail**: Every stock change (restock, order create/update/delete) is logged with reason and reference.
- **Dashboard**: Summary counts, low-stock list, recent orders. - **Dashboard**: Summary counts, low-stock list, recent orders.
## Requirements ## Requirements
@ -63,10 +65,93 @@ Use the frontend URL in your browser. The database file is created automatically
## Deployment (self-hosted) ## Deployment (self-hosted)
- Run the app behind a reverse proxy (e.g. nginx) if you want HTTPS or a different port. ### Prerequisites
- Set `DATABASE_PATH` to a persistent volume path so the SQLite file survives restarts.
- Set `APP_PASSWORD` and `APP_SECRET` in production so only people with the password can access the app. Session cookies are httpOnly and (in production) Secure when served over HTTPS. - A Linux server with Node.js 18+ and npm installed
- Back up the SQLite file regularly (e.g. cron job copying `data/cookies.db`). - Git access to the repository
- (Recommended) PM2 for process management: `npm install -g pm2`
- (Recommended) A reverse proxy (e.g. nginx) for HTTPS
### Initial setup
1. Clone the repo to your server (e.g. `/opt/cookie-tracker`):
```bash
git clone https://your-git-host/your-repo/cookie-tracker.git /opt/cookie-tracker
cd /opt/cookie-tracker
```
2. Install dependencies:
```bash
npm install
cd client && npm install && cd ..
```
3. Create a `.env` file with production settings:
```bash
cp .env.example .env
```
Edit `.env` and set:
- `APP_PASSWORD` — required in production so only authorized users can access the app
- `APP_SECRET` — a long random string for signing session cookies (generate with `openssl rand -hex 32`)
- `DATABASE_PATH` — path to a persistent location (e.g. `/var/data/cookies.db`) so the database survives restarts
- `PORT` — API port (default 3002)
4. Build the client and start with PM2:
```bash
npm run build
pm2 start npm --name cookie-tracker -- start
pm2 save
pm2 startup # follow the instructions to enable auto-start on boot
```
### Deploying updates
After pushing changes to the repository:
```bash
cd /opt/cookie-tracker
git pull origin master
npm run build
pm2 restart cookie-tracker
```
### Reverse proxy (nginx)
If serving over HTTPS, add a site config like:
```nginx
server {
listen 443 ssl;
server_name cookies.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Session cookies are httpOnly and Secure when served over HTTPS in production.
### Backups
Back up the SQLite file regularly (e.g. via cron):
```bash
# Add to crontab: daily backup at 2am
0 2 * * * cp /var/data/cookies.db /var/backups/cookies-$(date +\%Y\%m\%d).db
```
## Project structure ## Project structure

View File

@ -18,5 +18,10 @@ export async function api(path, options = {}) {
? JSON.stringify(body) ? JSON.stringify(body)
: body; : body;
} }
return fetch(url, fetchOptions); const res = await fetch(url, fetchOptions);
res.jsonSafe = async () => {
if (res.status === 204) return null;
return res.json();
};
return res;
} }

View File

@ -43,12 +43,11 @@ export default function Inventory() {
function handleUpdate(id, form) { function handleUpdate(id, form) {
const name = form.name.value?.trim(); const name = form.name.value?.trim();
const price = Number(form.price.value) || 0; const price = Number(form.price.value) || 0;
const quantity_on_hand = Number(form.quantity_on_hand.value) || 0;
const low_stock_threshold = Number(form.low_stock_threshold.value) || 0; const low_stock_threshold = Number(form.low_stock_threshold.value) || 0;
if (!name) return; if (!name) return;
api(`/products/${id}`, { api(`/products/${id}`, {
method: 'PUT', method: 'PUT',
body: { name, price, quantity_on_hand, low_stock_threshold }, body: { name, price, low_stock_threshold },
}) })
.then((r) => (r.ok ? r.json() : r.json().then((e) => Promise.reject(new Error(e.error || 'Failed'))))) .then((r) => (r.ok ? r.json() : r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')))))
.then(() => { .then(() => {
@ -161,10 +160,6 @@ export default function Inventory() {
<label>Price</label> <label>Price</label>
<input name="price" type="number" step="0.01" defaultValue={p.price} /> <input name="price" type="number" step="0.01" defaultValue={p.price} />
</div> </div>
<div className="form-group" style={{ minWidth: '80px' }}>
<label>Qty</label>
<input name="quantity_on_hand" type="number" min="0" defaultValue={p.quantity_on_hand} />
</div>
<div className="form-group" style={{ minWidth: '80px' }}> <div className="form-group" style={{ minWidth: '80px' }}>
<label>Low threshold</label> <label>Low threshold</label>
<input name="low_stock_threshold" type="number" min="0" defaultValue={p.low_stock_threshold} /> <input name="low_stock_threshold" type="number" min="0" defaultValue={p.low_stock_threshold} />

View File

@ -17,7 +17,11 @@ export default function Login({ onLogin }) {
try { try {
const res = await api('/auth/login', { method: 'POST', body: { password } }); const res = await api('/auth/login', { method: 'POST', body: { password } });
if (!res.ok) { if (!res.ok) {
setError('Invalid password'); if (res.status === 429) {
setError('Too many login attempts. Try again later.');
} else {
setError('Invalid credentials');
}
setLoading(false); setLoading(false);
return; return;
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { api } from '../api'; import { api } from '../api';
@ -15,7 +15,7 @@ export default function OrderDetail() {
const [amountPaid, setAmountPaid] = useState(''); const [amountPaid, setAmountPaid] = useState('');
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
function load() { const load = useCallback(() => {
Promise.all([ Promise.all([
api(`/orders/${id}`).then((r) => (r.ok ? r.json() : Promise.reject(new Error('Not found')))), api(`/orders/${id}`).then((r) => (r.ok ? r.json() : Promise.reject(new Error('Not found')))),
api('/products').then((r) => r.json()), api('/products').then((r) => r.json()),
@ -31,11 +31,11 @@ export default function OrderDetail() {
}) })
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }, [id]);
useEffect(() => { useEffect(() => {
load(); load();
}, [id]); }, [load]);
function handleUpdate(e) { function handleUpdate(e) {
e.preventDefault(); e.preventDefault();
@ -193,9 +193,9 @@ export default function OrderDetail() {
<select <select
value={line.product_id} value={line.product_id}
onChange={(e) => { onChange={(e) => {
const p = products.find((x) => x.id === Number(e.target.value)); const newId = Number(e.target.value);
updateLine(i, 'product_id', Number(e.target.value)); const p = products.find((x) => x.id === newId);
if (p) updateLine(i, 'price_at_sale', p.price); setItems((prev) => prev.map((ln, idx) => idx === i ? { ...ln, product_id: newId, price_at_sale: p?.price ?? ln.price_at_sale } : ln));
}} }}
style={{ flex: 2, minWidth: 0 }} style={{ flex: 2, minWidth: 0 }}
> >

View File

@ -88,10 +88,8 @@ export default function OrderNew() {
return r.json(); return r.json();
}) })
.then((order) => navigate(`/orders/${order.id}`)) .then((order) => navigate(`/orders/${order.id}`))
.catch((e) => { .catch((e) => setError(e.message))
setError(e.message); .finally(() => setSubmitting(false));
setSubmitting(false);
});
} }
if (loading) return <p className="text-muted">Loading...</p>; if (loading) return <p className="text-muted">Loading...</p>;

View File

@ -66,10 +66,41 @@ function initSchema(database) {
reference_id INTEGER, reference_id INTEGER,
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
`); `);
try { database.exec("ALTER TABLE orders ADD COLUMN payment_method TEXT"); } catch (e) {} runMigrations(database);
try { database.exec("ALTER TABLE orders ADD COLUMN amount_paid REAL NOT NULL DEFAULT 0"); } catch (e) {} }
function getSchemaVersion(database) {
const row = database.prepare('SELECT version FROM schema_version').get();
return row ? row.version : 0;
}
function setSchemaVersion(database, version) {
const existing = database.prepare('SELECT version FROM schema_version').get();
if (existing) {
database.prepare('UPDATE schema_version SET version = ?').run(version);
} else {
database.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version);
}
}
function runMigrations(database) {
const version = getSchemaVersion(database);
if (version < 1) {
try { database.exec("ALTER TABLE orders ADD COLUMN payment_method TEXT"); } catch (e) {
if (!e.message.includes('duplicate column')) throw e;
}
try { database.exec("ALTER TABLE orders ADD COLUMN amount_paid REAL NOT NULL DEFAULT 0"); } catch (e) {
if (!e.message.includes('duplicate column')) throw e;
}
setSchemaVersion(database, 1);
}
} }
module.exports = { getDb, initSchema }; module.exports = { getDb, initSchema };

View File

@ -39,6 +39,17 @@ if (process.env.NODE_ENV === 'production') {
}); });
} }
// Global error handler
app.use((err, req, res, next) => {
console.error(err.stack || err.message || err);
if (res.headersSent) return next(err);
res.status(500).json({ error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message });
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
}); });

View File

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db'); const { getDb } = require('../db');
const { parseId } = require('../utils');
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
@ -14,8 +15,10 @@ router.get('/', (req, res) => {
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid customer ID' });
const db = getDb(); const db = getDb();
const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id); const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Customer not found' }); if (!row) return res.status(404).json({ error: 'Customer not found' });
res.json(row); res.json(row);
} catch (err) { } catch (err) {
@ -48,8 +51,10 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid customer ID' });
const db = getDb(); const db = getDb();
const existing = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id); const existing = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Customer not found' }); if (!existing) return res.status(404).json({ error: 'Customer not found' });
const { name, phone, email, address, notes } = req.body; const { name, phone, email, address, notes } = req.body;
const n = name !== undefined ? name.trim() : existing.name; const n = name !== undefined ? name.trim() : existing.name;
@ -60,8 +65,8 @@ router.put('/:id', (req, res) => {
if (!n) return res.status(400).json({ error: 'Name is required' }); if (!n) return res.status(400).json({ error: 'Name is required' });
db.prepare( db.prepare(
'UPDATE customers SET name = ?, phone = ?, email = ?, address = ?, notes = ? WHERE id = ?' 'UPDATE customers SET name = ?, phone = ?, email = ?, address = ?, notes = ? WHERE id = ?'
).run(n, ph, e, a, no, req.params.id); ).run(n, ph, e, a, no, id);
const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id); const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
res.json(row); res.json(row);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@ -70,9 +75,14 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid customer ID' });
const db = getDb(); const db = getDb();
const result = db.prepare('DELETE FROM customers WHERE id = ?').run(req.params.id); const existing = db.prepare('SELECT id FROM customers WHERE id = ?').get(id);
if (result.changes === 0) return res.status(404).json({ error: 'Customer not found' }); if (!existing) return res.status(404).json({ error: 'Customer not found' });
const refs = db.prepare('SELECT COUNT(*) as count FROM orders WHERE customer_id = ?').get(id);
if (refs.count > 0) return res.status(409).json({ error: 'Cannot delete customer that has existing orders' });
db.prepare('DELETE FROM customers WHERE id = ?').run(id);
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });

View File

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db'); const { getDb } = require('../db');
const { parseId } = require('../utils');
function getOrderWithItems(db, id) { function getOrderWithItems(db, id) {
const order = db.prepare(` const order = db.prepare(`
@ -60,8 +61,10 @@ router.get('/', (req, res) => {
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid order ID' });
const db = getDb(); const db = getDb();
const order = getOrderWithItems(db, req.params.id); const order = getOrderWithItems(db, id);
if (!order) return res.status(404).json({ error: 'Order not found' }); if (!order) return res.status(404).json({ error: 'Order not found' });
res.json(order); res.json(order);
} catch (err) { } catch (err) {
@ -123,8 +126,10 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid order ID' });
const db = getDb(); const db = getDb();
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id); const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(id);
if (!order) return res.status(404).json({ error: 'Order not found' }); if (!order) return res.status(404).json({ error: 'Order not found' });
const { customer_id, status, notes, items, payment_method, amount_paid } = req.body; const { customer_id, status, notes, items, payment_method, amount_paid } = req.body;
const validPaymentMethods = ['cash', 'card', 'venmo', 'check', 'other']; const validPaymentMethods = ['cash', 'card', 'venmo', 'check', 'other'];
@ -137,12 +142,12 @@ router.put('/:id', (req, res) => {
if (!Array.isArray(items)) throw new Error('items must be an array'); if (!Array.isArray(items)) throw new Error('items must be an array');
// Restore stock from existing items // Restore stock from existing items
const existingItems = db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(req.params.id); const existingItems = db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(id);
for (const ei of existingItems) { for (const ei of existingItems) {
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?') db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
.run(ei.quantity, ei.product_id); .run(ei.quantity, ei.product_id);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)')
.run(ei.product_id, ei.quantity, 'order_updated', req.params.id); .run(ei.product_id, ei.quantity, 'order_updated', id);
} }
// Deduct stock for new items atomically // Deduct stock for new items atomically
@ -151,10 +156,10 @@ router.put('/:id', (req, res) => {
if (qty <= 0) throw new Error('Quantity must be positive'); if (qty <= 0) throw new Error('Quantity must be positive');
atomicDeductStock(db, it.product_id, qty); atomicDeductStock(db, it.product_id, qty);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)')
.run(it.product_id, -qty, 'order_updated', req.params.id); .run(it.product_id, -qty, 'order_updated', id);
} }
applyOrderItems(db, req.params.id, items); applyOrderItems(db, id, items);
} }
const cid = customer_id !== undefined ? (customer_id || null) : order.customer_id; const cid = customer_id !== undefined ? (customer_id || null) : order.customer_id;
@ -163,11 +168,11 @@ router.put('/:id', (req, res) => {
const pm = payment_method !== undefined ? (payment_method || null) : order.payment_method; const pm = payment_method !== undefined ? (payment_method || null) : order.payment_method;
const ap = amount_paid !== undefined ? Number(amount_paid) : order.amount_paid; const ap = amount_paid !== undefined ? Number(amount_paid) : order.amount_paid;
db.prepare('UPDATE orders SET customer_id = ?, status = ?, notes = ?, payment_method = ?, amount_paid = ?, updated_at = datetime(\'now\') WHERE id = ?') db.prepare('UPDATE orders SET customer_id = ?, status = ?, notes = ?, payment_method = ?, amount_paid = ?, updated_at = datetime(\'now\') WHERE id = ?')
.run(cid, st, no, pm, ap, req.params.id); .run(cid, st, no, pm, ap, id);
}); });
updateOrder(); updateOrder();
const updated = getOrderWithItems(db, req.params.id); const updated = getOrderWithItems(db, id);
res.json(updated); res.json(updated);
} catch (err) { } catch (err) {
if (err.message.startsWith('Insufficient stock') || err.message.includes('not found') || err.message === 'items must be an array' || err.message === 'Quantity must be positive') { if (err.message.startsWith('Insufficient stock') || err.message.includes('not found') || err.message === 'items must be an array' || err.message === 'Quantity must be positive') {
@ -179,20 +184,22 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid order ID' });
const db = getDb(); const db = getDb();
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id); const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(id);
if (!order) return res.status(404).json({ error: 'Order not found' }); if (!order) return res.status(404).json({ error: 'Order not found' });
const deleteOrder = db.transaction(() => { const deleteOrder = db.transaction(() => {
const items = db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(req.params.id); const items = db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(id);
for (const it of items) { for (const it of items) {
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?') db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
.run(it.quantity, it.product_id); .run(it.quantity, it.product_id);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)')
.run(it.product_id, it.quantity, 'order_deleted', req.params.id); .run(it.product_id, it.quantity, 'order_deleted', id);
} }
db.prepare('DELETE FROM order_items WHERE order_id = ?').run(req.params.id); db.prepare('DELETE FROM order_items WHERE order_id = ?').run(id);
db.prepare('DELETE FROM orders WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM orders WHERE id = ?').run(id);
}); });
deleteOrder(); deleteOrder();

View File

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db'); const { getDb } = require('../db');
const { parseId, isValidDate, parseLimit } = require('../utils');
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
@ -16,6 +17,8 @@ router.get('/stock-history', (req, res) => {
try { try {
const db = getDb(); const db = getDb();
const { product_id, start, end, limit } = req.query; const { product_id, start, end, limit } = req.query;
if (start && !isValidDate(start)) return res.status(400).json({ error: 'Invalid start date. Use YYYY-MM-DD format.' });
if (end && !isValidDate(end)) return res.status(400).json({ error: 'Invalid end date. Use YYYY-MM-DD format.' });
let sql = ` let sql = `
SELECT sa.*, p.name as product_name SELECT sa.*, p.name as product_name
FROM stock_adjustments sa FROM stock_adjustments sa
@ -23,11 +26,12 @@ router.get('/stock-history', (req, res) => {
WHERE 1=1 WHERE 1=1
`; `;
const params = []; const params = [];
if (product_id) { sql += ' AND sa.product_id = ?'; params.push(product_id); } if (product_id) { const pid = parseId(product_id); if (!pid) return res.status(400).json({ error: 'Invalid product_id' }); sql += ' AND sa.product_id = ?'; params.push(pid); }
if (start) { sql += ' AND sa.created_at >= ?'; params.push(start); } if (start) { sql += ' AND sa.created_at >= ?'; params.push(start); }
if (end) { sql += ' AND sa.created_at <= ?'; params.push(end + ' 23:59:59'); } if (end) { sql += ' AND sa.created_at <= ?'; params.push(end + ' 23:59:59'); }
sql += ' ORDER BY sa.created_at DESC'; sql += ' ORDER BY sa.created_at DESC';
if (limit) { sql += ' LIMIT ?'; params.push(Number(limit)); } const lim = limit ? parseLimit(limit) : null;
if (lim) { sql += ' LIMIT ?'; params.push(lim); }
const rows = db.prepare(sql).all(...params); const rows = db.prepare(sql).all(...params);
res.json(rows); res.json(rows);
} catch (err) { } catch (err) {
@ -37,8 +41,10 @@ router.get('/stock-history', (req, res) => {
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
const db = getDb(); const db = getDb();
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id); const row = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Product not found' }); if (!row) return res.status(404).json({ error: 'Product not found' });
res.json(row); res.json(row);
} catch (err) { } catch (err) {
@ -65,19 +71,20 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
const db = getDb(); const db = getDb();
const { name, price, quantity_on_hand, low_stock_threshold } = req.body; const { name, price, low_stock_threshold } = req.body;
const existing = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id); const existing = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Product not found' }); if (!existing) return res.status(404).json({ error: 'Product not found' });
const n = name !== undefined ? name.trim() : existing.name; const n = name !== undefined ? name.trim() : existing.name;
const p = price !== undefined ? Number(price) : existing.price; const p = price !== undefined ? Number(price) : existing.price;
const q = quantity_on_hand !== undefined ? Number(quantity_on_hand) : existing.quantity_on_hand;
const t = low_stock_threshold !== undefined ? Number(low_stock_threshold) : existing.low_stock_threshold; const t = low_stock_threshold !== undefined ? Number(low_stock_threshold) : existing.low_stock_threshold;
if (!n) return res.status(400).json({ error: 'Name is required' }); if (!n) return res.status(400).json({ error: 'Name is required' });
db.prepare( db.prepare(
'UPDATE products SET name = ?, price = ?, quantity_on_hand = ?, low_stock_threshold = ? WHERE id = ?' 'UPDATE products SET name = ?, price = ?, low_stock_threshold = ? WHERE id = ?'
).run(n, p, q, t, req.params.id); ).run(n, p, t, id);
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id); const row = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
res.json(row); res.json(row);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@ -86,21 +93,23 @@ router.put('/:id', (req, res) => {
router.patch('/:id/stock', (req, res) => { router.patch('/:id/stock', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
const db = getDb(); const db = getDb();
const { adjustment } = req.body; const { adjustment } = req.body;
if (adjustment === undefined || adjustment === null) { if (adjustment === undefined || adjustment === null) {
return res.status(400).json({ error: 'adjustment is required' }); return res.status(400).json({ error: 'adjustment is required' });
} }
const delta = Number(adjustment); const delta = Number(adjustment);
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id); const row = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Product not found' }); if (!row) return res.status(404).json({ error: 'Product not found' });
const newQty = row.quantity_on_hand + delta; const newQty = row.quantity_on_hand + delta;
if (newQty < 0) { if (newQty < 0) {
return res.status(400).json({ error: 'Quantity would go negative' }); return res.status(400).json({ error: 'Quantity would go negative' });
} }
db.prepare('UPDATE products SET quantity_on_hand = ? WHERE id = ?').run(newQty, req.params.id); db.prepare('UPDATE products SET quantity_on_hand = ? WHERE id = ?').run(newQty, id);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason) VALUES (?, ?, ?)').run(req.params.id, delta, 'restock'); db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason) VALUES (?, ?, ?)').run(id, delta, 'restock');
const updated = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id); const updated = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
res.json(updated); res.json(updated);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@ -109,9 +118,14 @@ router.patch('/:id/stock', (req, res) => {
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
try { try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
const db = getDb(); const db = getDb();
const result = db.prepare('DELETE FROM products WHERE id = ?').run(req.params.id); const existing = db.prepare('SELECT id FROM products WHERE id = ?').get(id);
if (result.changes === 0) return res.status(404).json({ error: 'Product not found' }); if (!existing) return res.status(404).json({ error: 'Product not found' });
const refs = db.prepare('SELECT COUNT(*) as count FROM order_items WHERE product_id = ?').get(id);
if (refs.count > 0) return res.status(409).json({ error: 'Cannot delete product that has been used in orders' });
db.prepare('DELETE FROM products WHERE id = ?').run(id);
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });

View File

@ -1,12 +1,16 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db'); const { getDb } = require('../db');
const { isValidDate } = require('../utils');
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
const db = getDb(); const db = getDb();
const { start, end } = req.query; const { start, end } = req.query;
if (start && !isValidDate(start)) return res.status(400).json({ error: 'Invalid start date. Use YYYY-MM-DD format.' });
if (end && !isValidDate(end)) return res.status(400).json({ error: 'Invalid end date. Use YYYY-MM-DD format.' });
const endWithTime = end ? end + ' 23:59:59' : null; const endWithTime = end ? end + ' 23:59:59' : null;
// Build date filter fragments and positional params for order-based queries // Build date filter fragments and positional params for order-based queries

21
server/utils.js Normal file
View File

@ -0,0 +1,21 @@
function parseId(raw) {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return n;
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function isValidDate(str) {
if (!DATE_RE.test(str)) return false;
const d = new Date(str + 'T00:00:00');
return !isNaN(d.getTime());
}
function parseLimit(raw, max = 1000) {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return Math.min(n, max);
}
module.exports = { parseId, isValidDate, parseLimit };