Skip to content

Commit f7bcd4e

Browse files
committed
Harden order creation validation and pricing integrity
1 parent 0e08ae8 commit f7bcd4e

3 files changed

Lines changed: 333 additions & 61 deletions

File tree

backend/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Backend API (Starter)
1+
# Backend API
22

3-
A minimal Node.js backend for BroCode Spot.
3+
A minimal Node.js backend for BroCode Spot backed by a persistent SQLite database.
44

55
## Start
66

@@ -10,6 +10,13 @@ npm run backend
1010

1111
Server starts at `http://localhost:4000` by default.
1212

13+
## Database
14+
15+
- Uses `node:sqlite` with a local database file at `backend/data/brocode.sqlite`.
16+
- You can override the location with `BROCODE_DB_PATH=/custom/path.sqlite npm run backend`.
17+
- On first start, seed data is inserted for users, spots, catalog items, and a sample order.
18+
- New orders are validated against DB data (known `spotId`, `userId`, `productId`) and item pricing is always derived from catalog prices in the database.
19+
1320
## Available endpoints
1421

1522
- `GET /api/health`
@@ -29,7 +36,3 @@ Server starts at `http://localhost:4000` by default.
2936
"password": "changeme"
3037
}
3138
```
32-
33-
## Note
34-
35-
Data is currently in-memory and resets whenever the process restarts. See `backend/store.js`.

backend/db.js

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { existsSync, mkdirSync } from 'node:fs';
2+
import { dirname, resolve } from 'node:path';
3+
import { randomUUID } from 'node:crypto';
4+
import { DatabaseSync } from 'node:sqlite';
5+
6+
const defaultDbPath = resolve(process.cwd(), 'backend', 'data', 'brocode.sqlite');
7+
const dbPath = process.env.BROCODE_DB_PATH ? resolve(process.env.BROCODE_DB_PATH) : defaultDbPath;
8+
9+
const dbDirectory = dirname(dbPath);
10+
if (!existsSync(dbDirectory)) {
11+
mkdirSync(dbDirectory, { recursive: true });
12+
}
13+
14+
const db = new DatabaseSync(dbPath);
15+
db.exec('PRAGMA journal_mode = WAL;');
16+
db.exec('PRAGMA foreign_keys = ON;');
17+
18+
db.exec(`
19+
CREATE TABLE IF NOT EXISTS users (
20+
id TEXT PRIMARY KEY,
21+
username TEXT NOT NULL UNIQUE,
22+
password TEXT NOT NULL,
23+
name TEXT NOT NULL,
24+
role TEXT NOT NULL
25+
);
26+
27+
CREATE TABLE IF NOT EXISTS spots (
28+
id TEXT PRIMARY KEY,
29+
location TEXT NOT NULL,
30+
date TEXT NOT NULL,
31+
host_user_id TEXT NOT NULL,
32+
FOREIGN KEY (host_user_id) REFERENCES users(id)
33+
);
34+
35+
CREATE TABLE IF NOT EXISTS catalog_items (
36+
id TEXT PRIMARY KEY,
37+
category TEXT NOT NULL,
38+
name TEXT NOT NULL,
39+
price REAL NOT NULL
40+
);
41+
42+
CREATE TABLE IF NOT EXISTS orders (
43+
id TEXT PRIMARY KEY,
44+
spot_id TEXT NOT NULL,
45+
user_id TEXT NOT NULL,
46+
total_amount REAL NOT NULL,
47+
created_at TEXT NOT NULL,
48+
FOREIGN KEY (spot_id) REFERENCES spots(id),
49+
FOREIGN KEY (user_id) REFERENCES users(id)
50+
);
51+
52+
CREATE TABLE IF NOT EXISTS order_items (
53+
id INTEGER PRIMARY KEY AUTOINCREMENT,
54+
order_id TEXT NOT NULL,
55+
product_id TEXT NOT NULL,
56+
name TEXT NOT NULL,
57+
quantity INTEGER NOT NULL,
58+
unit_price REAL NOT NULL,
59+
total REAL NOT NULL,
60+
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
61+
);
62+
`);
63+
64+
const hasUsers = db.prepare('SELECT COUNT(*) AS count FROM users').get().count > 0;
65+
66+
if (!hasUsers) {
67+
const insertUser = db.prepare('INSERT INTO users (id, username, password, name, role) VALUES (?, ?, ?, ?, ?)');
68+
insertUser.run('u-1', 'brocode', 'changeme', 'Ram', 'admin');
69+
insertUser.run('u-2', 'dhanush', 'changeme', 'Dhanush', 'user');
70+
71+
const insertSpot = db.prepare('INSERT INTO spots (id, location, date, host_user_id) VALUES (?, ?, ?, ?)');
72+
insertSpot.run('spot-2025-07-26', 'Attibele Toll Plaza', '2025-07-26T10:00:00.000Z', 'u-1');
73+
74+
const insertCatalogItem = db.prepare('INSERT INTO catalog_items (id, category, name, price) VALUES (?, ?, ?, ?)');
75+
insertCatalogItem.run('d-1', 'drinks', 'Brocode Beer', 180);
76+
insertCatalogItem.run('d-2', 'drinks', 'Kingfisher Beer', 170);
77+
insertCatalogItem.run('f-1', 'food', 'Beef Biriyani', 220);
78+
insertCatalogItem.run('f-2', 'food', 'Parotta', 30);
79+
insertCatalogItem.run('c-1', 'cigarettes', 'Marlboro', 25);
80+
insertCatalogItem.run('c-2', 'cigarettes', 'Classic', 20);
81+
82+
db.prepare(
83+
'INSERT INTO orders (id, spot_id, user_id, total_amount, created_at) VALUES (?, ?, ?, ?, ?)'
84+
).run('ord-1', 'spot-2025-07-26', 'u-2', 360, '2025-07-26T10:30:00.000Z');
85+
86+
db.prepare(
87+
`INSERT INTO order_items (order_id, product_id, name, quantity, unit_price, total)
88+
VALUES (?, ?, ?, ?, ?, ?)`
89+
).run('ord-1', 'd-1', 'Brocode Beer', 2, 180, 360);
90+
}
91+
92+
const mapOrder = (order, items) => ({
93+
id: order.id,
94+
spotId: order.spot_id,
95+
userId: order.user_id,
96+
totalAmount: order.total_amount,
97+
createdAt: order.created_at,
98+
items: items.map((item) => ({
99+
productId: item.product_id,
100+
name: item.name,
101+
quantity: item.quantity,
102+
unitPrice: item.unit_price,
103+
total: item.total,
104+
})),
105+
});
106+
107+
const fetchOrderItemsByOrderIds = (orderIds) => {
108+
if (orderIds.length === 0) return new Map();
109+
110+
const placeholders = orderIds.map(() => '?').join(',');
111+
const rows = db
112+
.prepare(
113+
`SELECT order_id, product_id, name, quantity, unit_price, total
114+
FROM order_items
115+
WHERE order_id IN (${placeholders})
116+
ORDER BY id ASC`
117+
)
118+
.all(...orderIds);
119+
120+
const itemsByOrderId = new Map();
121+
rows.forEach((row) => {
122+
if (!itemsByOrderId.has(row.order_id)) {
123+
itemsByOrderId.set(row.order_id, []);
124+
}
125+
itemsByOrderId.get(row.order_id).push(row);
126+
});
127+
128+
return itemsByOrderId;
129+
};
130+
131+
const getCatalogItemByIdStatement = db.prepare(
132+
'SELECT id, category, name, price FROM catalog_items WHERE id = ?'
133+
);
134+
135+
const userExistsStatement = db.prepare('SELECT 1 AS found FROM users WHERE id = ? LIMIT 1');
136+
const spotExistsStatement = db.prepare('SELECT 1 AS found FROM spots WHERE id = ? LIMIT 1');
137+
138+
export const database = {
139+
getUserByCredentials(username, password) {
140+
const user = db
141+
.prepare('SELECT id, username, name, role FROM users WHERE username = ? AND password = ?')
142+
.get(username, password);
143+
144+
return user || null;
145+
},
146+
147+
getCatalog() {
148+
const rows = db.prepare('SELECT id, category, name, price FROM catalog_items ORDER BY category, id').all();
149+
return rows.reduce(
150+
(acc, row) => {
151+
if (!acc[row.category]) {
152+
acc[row.category] = [];
153+
}
154+
155+
acc[row.category].push({
156+
id: row.id,
157+
name: row.name,
158+
price: row.price,
159+
});
160+
161+
return acc;
162+
},
163+
{ drinks: [], food: [], cigarettes: [] }
164+
);
165+
},
166+
167+
getCatalogCategory(category) {
168+
const rows = db.prepare('SELECT id, name, price FROM catalog_items WHERE category = ? ORDER BY id').all(category);
169+
return rows;
170+
},
171+
172+
userExists(userId) {
173+
return Boolean(userExistsStatement.get(userId));
174+
},
175+
176+
spotExists(spotId) {
177+
return Boolean(spotExistsStatement.get(spotId));
178+
},
179+
180+
getSpots() {
181+
return db
182+
.prepare('SELECT id, location, date, host_user_id AS hostUserId FROM spots ORDER BY date DESC')
183+
.all();
184+
},
185+
186+
getOrders({ spotId, userId }) {
187+
const conditions = [];
188+
const values = [];
189+
190+
if (spotId) {
191+
conditions.push('spot_id = ?');
192+
values.push(spotId);
193+
}
194+
195+
if (userId) {
196+
conditions.push('user_id = ?');
197+
values.push(userId);
198+
}
199+
200+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
201+
const orders = db
202+
.prepare(
203+
`SELECT id, spot_id, user_id, total_amount, created_at
204+
FROM orders
205+
${whereClause}
206+
ORDER BY created_at DESC`
207+
)
208+
.all(...values);
209+
210+
const itemsByOrderId = fetchOrderItemsByOrderIds(orders.map((order) => order.id));
211+
212+
return orders.map((order) => mapOrder(order, itemsByOrderId.get(order.id) || []));
213+
},
214+
215+
createOrder({ spotId, userId, items }) {
216+
const parsedItems = items.map((item) => {
217+
const quantity = Number(item.quantity || 0);
218+
if (!item.productId || !Number.isInteger(quantity) || quantity <= 0) {
219+
throw new Error('Each order item must include productId and a positive integer quantity');
220+
}
221+
222+
const catalogItem = getCatalogItemByIdStatement.get(item.productId);
223+
if (!catalogItem) {
224+
throw new Error(`Unknown productId: ${item.productId}`);
225+
}
226+
227+
return {
228+
productId: catalogItem.id,
229+
name: catalogItem.name,
230+
quantity,
231+
unitPrice: catalogItem.price,
232+
total: quantity * catalogItem.price,
233+
};
234+
});
235+
236+
const totalAmount = parsedItems.reduce((sum, item) => sum + item.total, 0);
237+
const orderId = randomUUID();
238+
const createdAt = new Date().toISOString();
239+
240+
const insertOrder = db.prepare(
241+
'INSERT INTO orders (id, spot_id, user_id, total_amount, created_at) VALUES (?, ?, ?, ?, ?)'
242+
);
243+
const insertOrderItem = db.prepare(
244+
'INSERT INTO order_items (order_id, product_id, name, quantity, unit_price, total) VALUES (?, ?, ?, ?, ?, ?)'
245+
);
246+
247+
db.exec('BEGIN');
248+
try {
249+
insertOrder.run(orderId, spotId, userId, totalAmount, createdAt);
250+
parsedItems.forEach((item) => {
251+
insertOrderItem.run(orderId, item.productId, item.name, item.quantity, item.unitPrice, item.total);
252+
});
253+
db.exec('COMMIT');
254+
} catch (error) {
255+
db.exec('ROLLBACK');
256+
throw error;
257+
}
258+
259+
return {
260+
id: orderId,
261+
spotId,
262+
userId,
263+
items: parsedItems,
264+
totalAmount,
265+
createdAt,
266+
};
267+
},
268+
269+
getBillBySpotId(spotId) {
270+
const summaryRows = db
271+
.prepare(
272+
`SELECT user_id, SUM(total_amount) AS total
273+
FROM orders
274+
WHERE spot_id = ?
275+
GROUP BY user_id`
276+
)
277+
.all(spotId);
278+
279+
const total = summaryRows.reduce((sum, row) => sum + row.total, 0);
280+
const userTotals = summaryRows.reduce((acc, row) => {
281+
acc[row.user_id] = row.total;
282+
return acc;
283+
}, {});
284+
285+
const orderCount = db.prepare('SELECT COUNT(*) AS count FROM orders WHERE spot_id = ?').get(spotId).count;
286+
287+
return {
288+
spotId,
289+
total,
290+
userTotals,
291+
orderCount,
292+
};
293+
},
294+
};
295+
296+
export { dbPath };

0 commit comments

Comments
 (0)