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:
parent
1ed2642e20
commit
7068ea354e
95
README.md
95
README.md
@ -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.
|
||||
- **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.
|
||||
|
||||
## Requirements
|
||||
@ -63,10 +65,93 @@ Use the frontend URL in your browser. The database file is created automatically
|
||||
|
||||
## Deployment (self-hosted)
|
||||
|
||||
- Run the app behind a reverse proxy (e.g. nginx) if you want HTTPS or a different port.
|
||||
- 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.
|
||||
- Back up the SQLite file regularly (e.g. cron job copying `data/cookies.db`).
|
||||
### Prerequisites
|
||||
|
||||
- A Linux server with Node.js 18+ and npm installed
|
||||
- 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
|
||||
|
||||
|
||||
@ -18,5 +18,10 @@ export async function api(path, options = {}) {
|
||||
? JSON.stringify(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;
|
||||
}
|
||||
|
||||
@ -43,12 +43,11 @@ export default function Inventory() {
|
||||
function handleUpdate(id, form) {
|
||||
const name = form.name.value?.trim();
|
||||
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;
|
||||
if (!name) return;
|
||||
api(`/products/${id}`, {
|
||||
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(() => {
|
||||
@ -161,10 +160,6 @@ export default function Inventory() {
|
||||
<label>Price</label>
|
||||
<input name="price" type="number" step="0.01" defaultValue={p.price} />
|
||||
</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' }}>
|
||||
<label>Low threshold</label>
|
||||
<input name="low_stock_threshold" type="number" min="0" defaultValue={p.low_stock_threshold} />
|
||||
|
||||
@ -17,7 +17,11 @@ export default function Login({ onLogin }) {
|
||||
try {
|
||||
const res = await api('/auth/login', { method: 'POST', body: { password } });
|
||||
if (!res.ok) {
|
||||
setError('Invalid password');
|
||||
if (res.status === 429) {
|
||||
setError('Too many login attempts. Try again later.');
|
||||
} else {
|
||||
setError('Invalid credentials');
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
|
||||
@ -15,7 +15,7 @@ export default function OrderDetail() {
|
||||
const [amountPaid, setAmountPaid] = useState('');
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
function load() {
|
||||
const load = useCallback(() => {
|
||||
Promise.all([
|
||||
api(`/orders/${id}`).then((r) => (r.ok ? r.json() : Promise.reject(new Error('Not found')))),
|
||||
api('/products').then((r) => r.json()),
|
||||
@ -31,11 +31,11 @@ export default function OrderDetail() {
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id]);
|
||||
}, [load]);
|
||||
|
||||
function handleUpdate(e) {
|
||||
e.preventDefault();
|
||||
@ -193,9 +193,9 @@ export default function OrderDetail() {
|
||||
<select
|
||||
value={line.product_id}
|
||||
onChange={(e) => {
|
||||
const p = products.find((x) => x.id === Number(e.target.value));
|
||||
updateLine(i, 'product_id', Number(e.target.value));
|
||||
if (p) updateLine(i, 'price_at_sale', p.price);
|
||||
const newId = Number(e.target.value);
|
||||
const p = products.find((x) => x.id === newId);
|
||||
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 }}
|
||||
>
|
||||
|
||||
@ -88,10 +88,8 @@ export default function OrderNew() {
|
||||
return r.json();
|
||||
})
|
||||
.then((order) => navigate(`/orders/${order.id}`))
|
||||
.catch((e) => {
|
||||
setError(e.message);
|
||||
setSubmitting(false);
|
||||
});
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setSubmitting(false));
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-muted">Loading...</p>;
|
||||
|
||||
35
server/db.js
35
server/db.js
@ -66,10 +66,41 @@ function initSchema(database) {
|
||||
reference_id INTEGER,
|
||||
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) {}
|
||||
try { database.exec("ALTER TABLE orders ADD COLUMN amount_paid REAL NOT NULL DEFAULT 0"); } catch (e) {}
|
||||
runMigrations(database);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@ -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, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db');
|
||||
const { parseId } = require('../utils');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
@ -14,8 +15,10 @@ router.get('/', (req, res) => {
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid customer ID' });
|
||||
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' });
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
@ -48,8 +51,10 @@ router.post('/', (req, res) => {
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid customer ID' });
|
||||
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' });
|
||||
const { name, phone, email, address, notes } = req.body;
|
||||
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' });
|
||||
db.prepare(
|
||||
'UPDATE customers SET name = ?, phone = ?, email = ?, address = ?, notes = ? WHERE id = ?'
|
||||
).run(n, ph, e, a, no, req.params.id);
|
||||
const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
|
||||
).run(n, ph, e, a, no, id);
|
||||
const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -70,9 +75,14 @@ router.put('/:id', (req, res) => {
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid customer ID' });
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM customers WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||
const existing = db.prepare('SELECT id FROM customers WHERE id = ?').get(id);
|
||||
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();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db');
|
||||
const { parseId } = require('../utils');
|
||||
|
||||
function getOrderWithItems(db, id) {
|
||||
const order = db.prepare(`
|
||||
@ -60,8 +61,10 @@ router.get('/', (req, res) => {
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid order ID' });
|
||||
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' });
|
||||
res.json(order);
|
||||
} catch (err) {
|
||||
@ -123,8 +126,10 @@ router.post('/', (req, res) => {
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid order ID' });
|
||||
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' });
|
||||
const { customer_id, status, notes, items, payment_method, amount_paid } = req.body;
|
||||
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');
|
||||
|
||||
// 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) {
|
||||
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
|
||||
.run(ei.quantity, ei.product_id);
|
||||
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
|
||||
@ -151,10 +156,10 @@ router.put('/:id', (req, res) => {
|
||||
if (qty <= 0) throw new Error('Quantity must be positive');
|
||||
atomicDeductStock(db, it.product_id, qty);
|
||||
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;
|
||||
@ -163,11 +168,11 @@ router.put('/:id', (req, res) => {
|
||||
const pm = payment_method !== undefined ? (payment_method || null) : order.payment_method;
|
||||
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 = ?')
|
||||
.run(cid, st, no, pm, ap, req.params.id);
|
||||
.run(cid, st, no, pm, ap, id);
|
||||
});
|
||||
|
||||
updateOrder();
|
||||
const updated = getOrderWithItems(db, req.params.id);
|
||||
const updated = getOrderWithItems(db, id);
|
||||
res.json(updated);
|
||||
} 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') {
|
||||
@ -179,20 +184,22 @@ router.put('/:id', (req, res) => {
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid order ID' });
|
||||
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' });
|
||||
|
||||
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) {
|
||||
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
|
||||
.run(it.quantity, it.product_id);
|
||||
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 orders WHERE id = ?').run(req.params.id);
|
||||
db.prepare('DELETE FROM order_items WHERE order_id = ?').run(id);
|
||||
db.prepare('DELETE FROM orders WHERE id = ?').run(id);
|
||||
});
|
||||
|
||||
deleteOrder();
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db');
|
||||
const { parseId, isValidDate, parseLimit } = require('../utils');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
@ -16,6 +17,8 @@ router.get('/stock-history', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
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 = `
|
||||
SELECT sa.*, p.name as product_name
|
||||
FROM stock_adjustments sa
|
||||
@ -23,11 +26,12 @@ router.get('/stock-history', (req, res) => {
|
||||
WHERE 1=1
|
||||
`;
|
||||
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 (end) { sql += ' AND sa.created_at <= ?'; params.push(end + ' 23:59:59'); }
|
||||
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);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
@ -37,8 +41,10 @@ router.get('/stock-history', (req, res) => {
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
|
||||
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' });
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
@ -65,19 +71,20 @@ router.post('/', (req, res) => {
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
|
||||
const db = getDb();
|
||||
const { name, price, quantity_on_hand, low_stock_threshold } = req.body;
|
||||
const existing = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
|
||||
const { name, price, low_stock_threshold } = req.body;
|
||||
const existing = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Product not found' });
|
||||
const n = name !== undefined ? name.trim() : existing.name;
|
||||
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;
|
||||
if (!n) return res.status(400).json({ error: 'Name is required' });
|
||||
db.prepare(
|
||||
'UPDATE products SET name = ?, price = ?, quantity_on_hand = ?, low_stock_threshold = ? WHERE id = ?'
|
||||
).run(n, p, q, t, req.params.id);
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
|
||||
'UPDATE products SET name = ?, price = ?, low_stock_threshold = ? WHERE id = ?'
|
||||
).run(n, p, t, id);
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -86,21 +93,23 @@ router.put('/:id', (req, res) => {
|
||||
|
||||
router.patch('/:id/stock', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
|
||||
const db = getDb();
|
||||
const { adjustment } = req.body;
|
||||
if (adjustment === undefined || adjustment === null) {
|
||||
return res.status(400).json({ error: 'adjustment is required' });
|
||||
}
|
||||
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' });
|
||||
const newQty = row.quantity_on_hand + delta;
|
||||
if (newQty < 0) {
|
||||
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('INSERT INTO stock_adjustments (product_id, adjustment, reason) VALUES (?, ?, ?)').run(req.params.id, delta, 'restock');
|
||||
const updated = db.prepare('SELECT * FROM products WHERE id = ?').get(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(id, delta, 'restock');
|
||||
const updated = db.prepare('SELECT * FROM products WHERE id = ?').get(id);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@ -109,9 +118,14 @@ router.patch('/:id/stock', (req, res) => {
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: 'Invalid product ID' });
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM products WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Product not found' });
|
||||
const existing = db.prepare('SELECT id FROM products WHERE id = ?').get(id);
|
||||
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();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db');
|
||||
const { isValidDate } = require('../utils');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
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;
|
||||
|
||||
// Build date filter fragments and positional params for order-based queries
|
||||
|
||||
21
server/utils.js
Normal file
21
server/utils.js
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user