diff --git a/README.md b/README.md
index 6929aa7..f520026 100644
--- a/README.md
+++ b/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
diff --git a/client/src/api.js b/client/src/api.js
index fcc199c..dca37fc 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -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;
}
diff --git a/client/src/pages/Inventory.jsx b/client/src/pages/Inventory.jsx
index 3a1e9b7..791eec0 100644
--- a/client/src/pages/Inventory.jsx
+++ b/client/src/pages/Inventory.jsx
@@ -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() {
Price
-
- Qty
-
-
Low threshold
diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx
index 6001256..af5caa8 100644
--- a/client/src/pages/Login.jsx
+++ b/client/src/pages/Login.jsx
@@ -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;
}
diff --git a/client/src/pages/OrderDetail.jsx b/client/src/pages/OrderDetail.jsx
index 83f4c8f..2520274 100644
--- a/client/src/pages/OrderDetail.jsx
+++ b/client/src/pages/OrderDetail.jsx
@@ -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() {
{
- 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 }}
>
diff --git a/client/src/pages/OrderNew.jsx b/client/src/pages/OrderNew.jsx
index ce888df..f48955c 100644
--- a/client/src/pages/OrderNew.jsx
+++ b/client/src/pages/OrderNew.jsx
@@ -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 Loading...
;
diff --git a/server/db.js b/server/db.js
index 4c3fe2a..1c2b3cd 100644
--- a/server/db.js
+++ b/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 };
diff --git a/server/index.js b/server/index.js
index 8749059..7811086 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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}`);
});
diff --git a/server/routes/customers.js b/server/routes/customers.js
index 7d12258..ab03e25 100644
--- a/server/routes/customers.js
+++ b/server/routes/customers.js
@@ -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 });
diff --git a/server/routes/orders.js b/server/routes/orders.js
index 448fa28..1c93a43 100644
--- a/server/routes/orders.js
+++ b/server/routes/orders.js
@@ -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();
diff --git a/server/routes/products.js b/server/routes/products.js
index 0cd3cf5..e48d03b 100644
--- a/server/routes/products.js
+++ b/server/routes/products.js
@@ -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 });
diff --git a/server/routes/reports.js b/server/routes/reports.js
index cc84c44..cb20cf6 100644
--- a/server/routes/reports.js
+++ b/server/routes/reports.js
@@ -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
diff --git a/server/utils.js b/server/utils.js
new file mode 100644
index 0000000..f08ed74
--- /dev/null
+++ b/server/utils.js
@@ -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 };