Compare commits
3 Commits
1ed2642e20
...
75caed2f29
| Author | SHA1 | Date | |
|---|---|---|---|
| 75caed2f29 | |||
| c00bb90cc0 | |||
| 7068ea354e |
287
CHANGELOG.md
Normal file
287
CHANGELOG.md
Normal file
@ -0,0 +1,287 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Cookie Tracker are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-09
|
||||
|
||||
### Feature: Restock History Tracking
|
||||
|
||||
**Commit:** `1ed2642` — *Add restock history tracking, payment tracking, and reports page*
|
||||
|
||||
Every stock change is now logged to a `stock_adjustments` table, creating a full audit trail for inventory movements.
|
||||
|
||||
**Schema** (`server/db.js`):
|
||||
- Added `stock_adjustments` table with columns: `id`, `product_id` (FK → products), `adjustment` (integer, positive or negative), `reason` (text), `reference_id` (nullable, links to order ID), `created_at`.
|
||||
|
||||
**Server changes**:
|
||||
|
||||
- `server/routes/products.js` — `PATCH /:id/stock` now inserts a row into `stock_adjustments` with `reason='restock'` after updating the product quantity.
|
||||
- `server/routes/products.js` — Added `GET /stock-history` endpoint (placed before `/:id` to avoid route collision). Accepts optional query params: `product_id`, `start`, `end`, `limit`. Returns stock adjustment rows joined with product name, ordered by most recent first.
|
||||
- `server/routes/orders.js` — Stock adjustments are logged in all three transaction paths:
|
||||
- **POST `/`** (create order): logs `reason='order_created'`, `adjustment=-qty`, `reference_id=orderId` for each item.
|
||||
- **PUT `/:id`** (update order): logs `reason='order_updated'` with positive adjustment when restoring old stock, then negative adjustment when deducting new stock.
|
||||
- **DELETE `/:id`** (delete order): logs `reason='order_deleted'` with positive adjustment for each restored item.
|
||||
|
||||
---
|
||||
|
||||
### Feature: Payment Tracking
|
||||
|
||||
**Commit:** `1ed2642` — *Add restock history tracking, payment tracking, and reports page*
|
||||
|
||||
Orders now track how they were paid and how much was paid, enabling balance-due tracking.
|
||||
|
||||
**Schema** (`server/db.js`):
|
||||
- Added `payment_method` column (TEXT, nullable) to `orders` table. Valid values: `'cash'`, `'card'`, `'venmo'`, `'check'`, `'other'`, or `null`.
|
||||
- Added `amount_paid` column (REAL, NOT NULL, default 0) to `orders` table.
|
||||
- Both columns added via `ALTER TABLE` with try-catch for idempotency on existing databases. (Later migrated to versioned schema migrations — see below.)
|
||||
|
||||
**Server changes** (`server/routes/orders.js`):
|
||||
- **POST `/`**: Accepts `payment_method` and `amount_paid` in request body. Validates `payment_method` against allowed values. Includes both in the INSERT statement.
|
||||
- **PUT `/:id`**: Accepts `payment_method` and `amount_paid`. Includes in UPDATE statement, preserving existing values when not provided.
|
||||
- GET endpoints already use `SELECT o.*`, so new columns are returned automatically.
|
||||
|
||||
**Client changes**:
|
||||
|
||||
- `client/src/pages/OrderNew.jsx`:
|
||||
- Added `paymentMethod` state (dropdown: None/Cash/Card/Venmo/Check/Other) and `amountPaid` state (number input).
|
||||
- Payment method and amount paid are on separate rows: Status + Payment method share a `form-row`, Amount paid sits below with `maxWidth: 200` to prevent overflow.
|
||||
- When a payment method is selected and amount is empty, auto-fills with current order total.
|
||||
- Both fields included in submit payload.
|
||||
|
||||
- `client/src/pages/OrderDetail.jsx`:
|
||||
- **Display mode**: Shows payment method (capitalized or "Not set"), amount paid, and a red "Balance due" warning when `amount_paid < order total`.
|
||||
- **Edit mode**: Payment method dropdown and amount paid input, initialized from order data in `load()`. When switching from "0" to a payment method, auto-fills with total. Both included in `handleUpdate` payload.
|
||||
|
||||
---
|
||||
|
||||
### Feature: Reports Page
|
||||
|
||||
**Commit:** `1ed2642` — *Add restock history tracking, payment tracking, and reports page*
|
||||
|
||||
A new Reports page provides five aggregated views of sales, customer, and inventory data, with date range filtering.
|
||||
|
||||
**Server** (`server/routes/reports.js` — new file):
|
||||
- `GET /api/reports` accepts optional `start` and `end` query params (YYYY-MM-DD format).
|
||||
- Returns a single JSON object with five sections:
|
||||
|
||||
| Section | Description | Key columns |
|
||||
|---------|-------------|-------------|
|
||||
| `salesByProduct` | LEFT JOIN products → order_items → orders (via subquery for date filtering). All products shown, zero-filled. | `product_name`, `units_sold`, `revenue` |
|
||||
| `topCustomers` | JOIN customers → orders → order_items. Sorted by total spent descending. | `customer_name`, `order_count`, `total_spent` |
|
||||
| `revenueOverTime` | JOIN orders → order_items, grouped by `date(created_at)`. | `date`, `revenue`, `order_count` |
|
||||
| `orderStatusBreakdown` | GROUP BY `orders.status`. | `status`, `count` |
|
||||
| `inventorySummary` | Products with correlated subqueries on `stock_adjustments` for restock totals, sold totals, and restock count. | `product_name`, `current_stock`, `low_stock_threshold`, `total_restocked`, `total_sold`, `restock_count` |
|
||||
|
||||
- Date filtering uses positional `?` params (not named params, which caused errors with better-sqlite3 when not all params are used in every query). Order-based queries use `orderParams`, inventory summary uses `invParams` (SA params repeated 3x for 3 subqueries).
|
||||
- `salesByProduct` uses a subquery in the LEFT JOIN to properly filter order_items by date range (putting date filter directly on the LEFT JOIN ON clause didn't filter correctly).
|
||||
|
||||
**Server** (`server/index.js`):
|
||||
- Imported and mounted `reportsRouter` at `/api/reports` (after dashboard mount, behind auth middleware).
|
||||
|
||||
**Client** (`client/src/pages/Reports.jsx` — new file):
|
||||
- Date range toolbar with preset dropdown (All time / This week / This month / Custom) and conditional date inputs for custom range.
|
||||
- Fetches `/api/reports` with start/end params, re-fetches when dates change via `useEffect`.
|
||||
- Five sections:
|
||||
1. **Sales by Product** — table with totals row in `<tfoot>`.
|
||||
2. **Top Customers** — table sorted by total spent.
|
||||
3. **Revenue Over Time** — table with inline bar indicators (`div` with width proportional to max revenue).
|
||||
4. **Order Status Breakdown** — horizontal colored bar segments (yellow=pending, green=paid, blue=delivered) plus a table.
|
||||
5. **Inventory Summary** — table with low-stock products highlighted (red background, "(low)" label).
|
||||
|
||||
**Client** (`client/src/App.jsx`):
|
||||
- Imported `Reports` component, added `<Route path="/reports" element={<Reports />} />`.
|
||||
|
||||
**Client** (`client/src/components/Layout.jsx`):
|
||||
- Added `{ path: '/reports', label: 'Reports' }` to `navItems` array.
|
||||
|
||||
---
|
||||
|
||||
### Enhancement: Girl Scouts Trefoil Logo
|
||||
|
||||
**Commit:** `1ed2642` — *Add restock history tracking, payment tracking, and reports page*
|
||||
|
||||
- Extracted the trefoil icon paths from the full Girl Scouts wordmark SVG and created `client/src/assets/gs-trefoil.svg` with a `viewBox="0 0 13 11.7"` containing just the two trefoil `<path>` elements.
|
||||
- `client/src/components/Layout.jsx`: Imported the SVG as `trefoilLogo` and added an `<img>` tag next to "Cookie Tracker" in the header link, styled at `height: 1.5em` with `verticalAlign: 'middle'`.
|
||||
|
||||
---
|
||||
|
||||
### Enhancement: Mobile Network Access
|
||||
|
||||
**Commit:** `1ed2642` — *Add restock history tracking, payment tracking, and reports page*
|
||||
|
||||
- `client/vite.config.js`: Added `host: true` to the Vite dev server config so it binds to all network interfaces, allowing access from phones on the same Wi-Fi network.
|
||||
|
||||
---
|
||||
|
||||
### Bug Fixes and Iteration During Development
|
||||
|
||||
These issues were discovered during testing and fixed before the feature commit:
|
||||
|
||||
1. **Reports named params error**: better-sqlite3 threw "Missing named parameter" when using `$start`/`$saStart` named params across queries that didn't all use every param. Fixed by switching to positional `?` params with separate `orderParams` and `saParams` arrays.
|
||||
|
||||
2. **salesByProduct date filtering**: The initial LEFT JOIN approach with date filter on the orders ON clause didn't properly filter order_items — products still showed all-time sales data. Fixed by restructuring to use a subquery: `LEFT JOIN (SELECT ... FROM order_items JOIN orders WHERE date_filter) oi ON oi.product_id = p.id`.
|
||||
|
||||
3. **OrderNew layout overflow**: Three `flex: 1` form groups (Status, Payment method, Amount paid) in a single `form-row` inside a `maxWidth: 640` card caused the amount paid input to overflow the card boundary, especially on mobile. Fixed by moving Amount paid to its own row below the Status/Payment method row, with `maxWidth: 200`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-09 (Code Review Fixes)
|
||||
|
||||
### Bugfix: OrderDetail Product Change Overwrites product_id
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P0
|
||||
|
||||
**Problem**: In `client/src/pages/OrderDetail.jsx` (edit mode), changing the product in a line item called `updateLine` twice — once for `product_id`, once for `price_at_sale`. Under React's state batching, the second `setItems` call used stale state from before the first call, so it overwrote the `product_id` back to the old value.
|
||||
|
||||
**Fix**: Replaced the two `updateLine` calls with a single `setItems` call that updates both `product_id` and `price_at_sale` in one state transition:
|
||||
```jsx
|
||||
setItems((prev) => prev.map((ln, idx) =>
|
||||
idx === i ? { ...ln, product_id: newId, price_at_sale: p?.price ?? ln.price_at_sale } : ln
|
||||
));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bugfix: Validate All `:id` Route Parameters
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P0
|
||||
|
||||
**Problem**: `req.params.id` was passed directly to SQLite queries without validation. Values like `"abc"`, `"-1"`, or `"0"` could produce 500 errors or unexpected behavior.
|
||||
|
||||
**Fix**:
|
||||
- Created `server/utils.js` with a `parseId(raw)` helper that returns a positive integer or `null`.
|
||||
- Added `parseId` validation at the top of every `:id` route handler in `products.js`, `customers.js`, and `orders.js` (10 routes total). Invalid IDs return `400` with a clear error message.
|
||||
- All subsequent DB calls in those routes use the parsed integer `id` instead of `req.params.id`.
|
||||
|
||||
**Files changed**: `server/utils.js` (new), `server/routes/products.js`, `server/routes/customers.js`, `server/routes/orders.js`.
|
||||
|
||||
---
|
||||
|
||||
### Bugfix: Product/Customer Delete with Existing References
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P0
|
||||
|
||||
**Problem**: Deleting a product used in `order_items` or a customer with existing `orders` hit the SQLite foreign key constraint, producing an unhandled 500 error with a raw database error message.
|
||||
|
||||
**Fix**:
|
||||
- `server/routes/products.js` — DELETE `/:id` now checks `SELECT COUNT(*) FROM order_items WHERE product_id = ?` before deleting. If references exist, returns `409 Conflict` with message "Cannot delete product that has been used in orders".
|
||||
- `server/routes/customers.js` — DELETE `/:id` now checks `SELECT COUNT(*) FROM orders WHERE customer_id = ?` before deleting. If references exist, returns `409 Conflict` with message "Cannot delete customer that has existing orders".
|
||||
- The Inventory page's delete handler (`client/src/pages/Inventory.jsx`) already had `.catch((e) => setError(e.message))`, so the 409 error message is now displayed to the user automatically.
|
||||
|
||||
---
|
||||
|
||||
### Improvement: Disallow quantity_on_hand in Product PUT
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P1
|
||||
|
||||
**Problem**: Product `quantity_on_hand` could be changed via two paths: `PUT /products/:id` (not logged) and `PATCH /products/:id/stock` (logged to `stock_adjustments`). This created an unauditable backdoor for stock changes.
|
||||
|
||||
**Fix**:
|
||||
- `server/routes/products.js` — PUT `/:id` no longer accepts or applies `quantity_on_hand`. Only `name`, `price`, and `low_stock_threshold` are updated. The UPDATE query was changed from 4 columns to 3.
|
||||
- `client/src/pages/Inventory.jsx` — Removed the "Qty" input field from the inline edit form. Removed `quantity_on_hand` from the `handleUpdate` payload. Stock changes now exclusively go through the "Adjust stock" button (which uses PATCH).
|
||||
|
||||
---
|
||||
|
||||
### Improvement: Global Error Handler
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P1
|
||||
|
||||
**Fix** (`server/index.js`):
|
||||
- Added Express error middleware (4-arg `(err, req, res, next)`) after all routes. Logs the error stack and returns `500` with either the error message (development) or a generic "Internal server error" (production).
|
||||
- Checks `res.headersSent` to avoid double-sending responses.
|
||||
- Added `process.on('unhandledRejection')` handler that logs the rejection reason.
|
||||
|
||||
---
|
||||
|
||||
### Improvement: Validate Report Dates and Stock-History Limit
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P2
|
||||
|
||||
**Fix**:
|
||||
- `server/utils.js` — Added `isValidDate(str)` (validates YYYY-MM-DD format and that it parses to a real date) and `parseLimit(raw, max)` (returns a positive integer capped at `max`, default 1000, or `null`).
|
||||
- `server/routes/reports.js` — Validates `start` and `end` query params with `isValidDate()` before using them in queries. Returns 400 with clear message on invalid format.
|
||||
- `server/routes/products.js` — Stock-history endpoint validates `start`, `end` with `isValidDate()`, `product_id` with `parseId()`, and `limit` with `parseLimit()`.
|
||||
|
||||
---
|
||||
|
||||
### Improvement: Client-Side Robustness
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P2
|
||||
|
||||
1. **api.js 204 handling** (`client/src/api.js`): Added `res.jsonSafe()` method to the response object. Returns `null` for 204 responses (no body), otherwise calls `res.json()`. This prevents parse errors if a caller accidentally tries to parse a 204 response body.
|
||||
|
||||
2. **OrderNew setSubmitting** (`client/src/pages/OrderNew.jsx`): Changed error-only `setSubmitting(false)` in the `.catch()` to a `.finally(() => setSubmitting(false))` so the button always resets, even if navigation is slow or fails.
|
||||
|
||||
3. **Login error messages** (`client/src/pages/Login.jsx`): Now checks `res.status === 429` and shows "Too many login attempts. Try again later." instead of the generic message. Other auth failures show "Invalid credentials" (generic, doesn't leak information about whether a user exists).
|
||||
|
||||
---
|
||||
|
||||
### Improvement: Schema Migrations
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P3
|
||||
|
||||
**Problem**: New columns were added with `try { database.exec("ALTER TABLE ..."); } catch (e) {}`, which silently swallows all errors including unexpected ones.
|
||||
|
||||
**Fix** (`server/db.js`):
|
||||
- Added `schema_version` table (single row with `version` integer) in the main `CREATE TABLE IF NOT EXISTS` block.
|
||||
- Added `getSchemaVersion()`, `setSchemaVersion()`, and `runMigrations()` functions.
|
||||
- Migrations are version-gated: `if (version < 1) { ... setSchemaVersion(database, 1); }`. Future migrations increment the version number.
|
||||
- ALTER TABLE errors are now only caught if they contain "duplicate column" (expected on re-run); unexpected errors are re-thrown.
|
||||
|
||||
---
|
||||
|
||||
### Improvement: OrderDetail useEffect Dependency
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
**Priority:** P3
|
||||
|
||||
**Fix** (`client/src/pages/OrderDetail.jsx`): Wrapped `load` function in `useCallback` with `[id]` dependency. Changed `useEffect` dependency from `[id]` to `[load]`. This satisfies React's exhaustive-deps lint rule.
|
||||
|
||||
---
|
||||
|
||||
### Documentation: README Deployment Instructions
|
||||
|
||||
**Commit:** `7068ea3` — *Fix bugs, harden validation, and improve robustness*
|
||||
|
||||
Expanded the "Deployment (self-hosted)" section of `README.md` from 4 bullet points to comprehensive instructions:
|
||||
- Prerequisites (Node.js, PM2, reverse proxy)
|
||||
- Initial setup steps (clone, install, configure .env, PM2 start/save/startup)
|
||||
- Deploying updates (`git pull`, `npm run build`, `pm2 restart`)
|
||||
- Nginx reverse proxy example config with HTTPS
|
||||
- Backup cron job example
|
||||
|
||||
Also updated the Features section to include Reports, Stock Audit Trail, and payment/balance tracking.
|
||||
|
||||
---
|
||||
|
||||
### Documentation: CLAUDE.md Architecture Update
|
||||
|
||||
**Commit:** `c00bb90` — *Update CLAUDE.md with current architecture and documentation policy*
|
||||
|
||||
- Added change documentation requirement
|
||||
- Added `utils.js` to server architecture docs
|
||||
- Updated routes description to include reports endpoint
|
||||
- Updated pages list to include Reports, Restock
|
||||
- Updated key data flow to reference `atomicDeductStock()` (corrected from `deductStockForOrder()`), stock_adjustments logging, and PUT restriction on quantity_on_hand
|
||||
- Added validation patterns documentation
|
||||
- Added schema migration approach documentation
|
||||
- Updated database schema section: 6 tables (was 4), payment fields, stock_adjustments
|
||||
17
CLAUDE.md
17
CLAUDE.md
@ -34,19 +34,28 @@ Full-stack monorepo: Express.js API + React 18 SPA (Vite + React Router v7).
|
||||
- `index.js` — Express entry point. Mounts all route groups under `/api`, applies auth middleware to non-auth routes. In production, serves the built client as static files with SPA fallback.
|
||||
- `db.js` — SQLite via `better-sqlite3` (synchronous). Auto-creates the `data/` directory and tables on first run. Foreign keys are enabled.
|
||||
- `middleware/auth.js` — Optional HMAC-SHA256 session cookie auth. If `APP_PASSWORD` is not set in `.env`, authentication is disabled entirely (middleware passes through).
|
||||
- `routes/` — CRUD for products, customers, orders, plus a dashboard summary endpoint.
|
||||
- `utils.js` — Shared helpers (`parseId`, `isValidDate`, `parseLimit`) used across routes.
|
||||
- `routes/` — CRUD for products, customers, orders, plus dashboard summary and reports endpoints.
|
||||
|
||||
**Client** (`client/`):
|
||||
- `src/App.jsx` — Top-level `AuthGate` checks `/api/auth/me` on mount; renders `<Login>` or the authenticated `<Layout>` with routes.
|
||||
- `src/api.js` — Thin fetch wrapper that prefixes `/api`, includes credentials, and handles JSON serialization.
|
||||
- `src/pages/` — One component per route: Dashboard, Inventory, Customers, Orders, OrderNew, OrderDetail, Login.
|
||||
- `src/pages/` — One component per route: Dashboard, Inventory, Customers, Orders, OrderNew, OrderDetail, Reports, Restock, Login.
|
||||
- Vite dev server proxies `/api` requests to the Express backend (configured in `vite.config.js`).
|
||||
|
||||
**Key data flow**: Orders deduct product stock on creation, restore stock on deletion, and recalculate stock differences on update. This logic lives in `server/routes/orders.js` with helper functions `deductStockForOrder()` and `applyOrderItems()`.
|
||||
**Key data flow**: Orders deduct product stock on creation, restore stock on deletion, and recalculate stock differences on update. All stock changes are logged to `stock_adjustments`. This logic lives in `server/routes/orders.js` with helper functions `atomicDeductStock()` and `applyOrderItems()`. The product PUT endpoint does not allow `quantity_on_hand` changes — all stock modifications must go through PATCH `/products/:id/stock`.
|
||||
|
||||
**Validation**: All `:id` route params are validated via `parseId()` (positive integer). Report dates validated as YYYY-MM-DD. Product/customer deletes check for references and return 409 instead of FK errors.
|
||||
|
||||
**Schema migrations**: `server/db.js` uses a `schema_version` table. New migrations are added to `runMigrations()` gated by version number.
|
||||
|
||||
## Database Schema
|
||||
|
||||
Four tables: `products`, `customers`, `orders`, `order_items`. Orders reference customers (nullable for walk-ins). Order items have a `UNIQUE(order_id, product_id)` constraint and `ON DELETE CASCADE` from orders. Prices are snapshot at time of sale (`price_at_sale`).
|
||||
Six tables: `products`, `customers`, `orders`, `order_items`, `stock_adjustments`, `schema_version`. Orders reference customers (nullable for walk-ins). Order items have a `UNIQUE(order_id, product_id)` constraint and `ON DELETE CASCADE` from orders. Prices are snapshot at time of sale (`price_at_sale`). Orders track `payment_method` and `amount_paid`. Stock adjustments log every inventory change with reason and optional reference_id.
|
||||
|
||||
## Change Documentation
|
||||
|
||||
Every change must be meticulously documented. When making commits, each change should be clearly described in the commit message with enough detail to understand what was changed and why. Update CLAUDE.md and README.md when changes affect architecture, schema, deployment, or available features.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
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