|
| 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