Skip to content

Commit a9e9d9d

Browse files
committed
Fix major chat issue: duplicate messages and inaccurate unread counts
1 parent 0e08ae8 commit a9e9d9d

4 files changed

Lines changed: 404 additions & 96 deletions

File tree

backend/README.md

Lines changed: 16 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,20 @@ npm run backend
1010

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

13+
## Database
14+
15+
### Issue #26: Move from in-memory store to persistent DB
16+
17+
- Uses `node:sqlite` with a local database file at `backend/data/brocode.sqlite`.
18+
- You can override the location with `BROCODE_DB_PATH=/custom/path.sqlite npm run backend`.
19+
- On first start, seed data is inserted for users, spots, catalog items, and a sample order.
20+
- New orders are validated against DB data (known `spotId`, `userId`, `productId`) and item pricing is always derived from catalog prices in the database.
21+
22+
### Issue #28: Secure credential storage and verification
23+
24+
- Passwords are stored as salted `scrypt` hashes (not plaintext).
25+
- Legacy plaintext user passwords are auto-migrated to hashed values on successful login.
26+
1327
## Available endpoints
1428

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

0 commit comments

Comments
 (0)