| const express = require('express'); |
| const session = require('express-session'); |
| const bcrypt = require('bcryptjs'); |
| const { v4: uuidv4 } = require('uuid'); |
| const QRCode = require('qrcode'); |
| const fs = require('fs'); |
| const path = require('path'); |
|
|
| const app = express(); |
| const PORT = process.env.PORT || 3001; |
|
|
| |
| const DATA_DIR = fs.existsSync('/data') ? '/data' : path.join(__dirname, 'data'); |
| if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); |
|
|
| const DATA_FILE = path.join(DATA_DIR, 'menu.json'); |
| const CREDENTIALS_FILE = path.join(DATA_DIR, '.credentials.json'); |
|
|
| |
| const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; |
| const SESSION_SECRET = process.env.SESSION_SECRET || 'cafe-secret-' + uuidv4(); |
|
|
| |
| app.use(express.json()); |
| app.use(express.static(path.join(__dirname, 'public'))); |
| app.use(session({ |
| secret: SESSION_SECRET, |
| resave: false, |
| saveUninitialized: false, |
| cookie: { maxAge: 8 * 60 * 60 * 1000 } |
| })); |
|
|
| |
| |
| try { |
| const SEED_FILE = path.join(__dirname, 'data', 'menu.json'); |
| if (!fs.existsSync(DATA_FILE) && fs.existsSync(SEED_FILE)) { |
| fs.copyFileSync(SEED_FILE, DATA_FILE); |
| console.log('📋 Seeded menu data to persistent storage'); |
| } |
| console.log(`📁 Data dir: ${DATA_DIR}`); |
| console.log(`📄 Data file exists: ${fs.existsSync(DATA_FILE)}`); |
| } catch (err) { |
| console.error('Seed error:', err.message); |
| } |
|
|
| function readMenu() { |
| const raw = fs.readFileSync(DATA_FILE, 'utf8'); |
| const data = JSON.parse(raw); |
| |
| let migrated = false; |
| if (data.tables === undefined) { data.tables = 8; migrated = true; } |
| if (!data.orders) { data.orders = []; migrated = true; } |
| if (migrated) writeMenu(data); |
| return data; |
| } |
|
|
| function writeMenu(data) { |
| fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); |
| } |
|
|
| function getCredentials() { |
| if (!fs.existsSync(CREDENTIALS_FILE)) { |
| const hash = bcrypt.hashSync(ADMIN_PASSWORD, 10); |
| fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify({ passwordHash: hash }), 'utf8'); |
| return { passwordHash: hash }; |
| } |
| return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8')); |
| } |
|
|
| function requireAuth(req, res, next) { |
| if (req.session.authenticated) return next(); |
| return res.status(401).json({ error: 'Unauthorized' }); |
| } |
|
|
| |
| app.post('/api/login', (req, res) => { |
| const { password } = req.body; |
| const creds = getCredentials(); |
| if (bcrypt.compareSync(password, creds.passwordHash)) { |
| req.session.authenticated = true; |
| return res.json({ success: true }); |
| } |
| return res.status(401).json({ error: 'Invalid password' }); |
| }); |
|
|
| app.post('/api/logout', (req, res) => { |
| req.session.destroy(); |
| res.json({ success: true }); |
| }); |
|
|
| app.get('/api/auth', (req, res) => { |
| res.json({ authenticated: !!req.session.authenticated }); |
| }); |
|
|
| app.post('/api/change-password', requireAuth, (req, res) => { |
| const { newPassword } = req.body; |
| if (!newPassword || newPassword.length < 6) { |
| return res.status(400).json({ error: 'Password must be at least 6 characters' }); |
| } |
| const hash = bcrypt.hashSync(newPassword, 10); |
| fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify({ passwordHash: hash }), 'utf8'); |
| res.json({ success: true }); |
| }); |
|
|
| |
| |
| app.get('/api/menu', (req, res) => { |
| const data = readMenu(); |
| res.json(data); |
| }); |
|
|
| |
| app.get('/api/menu/:id', (req, res) => { |
| const data = readMenu(); |
| const item = data.items.find(i => i.id === req.params.id); |
| if (!item) return res.status(404).json({ error: 'Item not found' }); |
| res.json(item); |
| }); |
|
|
| |
| app.post('/api/menu', requireAuth, (req, res) => { |
| const data = readMenu(); |
| const { name, description, price, category, image, available, featured } = req.body; |
| if (!name || price == null || !category) { |
| return res.status(400).json({ error: 'name, price, and category are required' }); |
| } |
| const newItem = { |
| id: uuidv4(), |
| name, |
| description: description || '', |
| price: parseFloat(price), |
| category, |
| image: image || '☕', |
| available: available !== false, |
| featured: featured || false |
| }; |
| data.items.push(newItem); |
| writeMenu(data); |
| res.status(201).json(newItem); |
| }); |
|
|
| |
| app.put('/api/menu/:id', requireAuth, (req, res) => { |
| const data = readMenu(); |
| const idx = data.items.findIndex(i => i.id === req.params.id); |
| if (idx === -1) return res.status(404).json({ error: 'Item not found' }); |
|
|
| const existing = data.items[idx]; |
| const { name, description, price, category, image, available, featured } = req.body; |
| data.items[idx] = { |
| ...existing, |
| name: name ?? existing.name, |
| description: description ?? existing.description, |
| price: price != null ? parseFloat(price) : existing.price, |
| category: category ?? existing.category, |
| image: image ?? existing.image, |
| available: available !== undefined ? available : existing.available, |
| featured: featured !== undefined ? featured : existing.featured |
| }; |
| writeMenu(data); |
| res.json(data.items[idx]); |
| }); |
|
|
| |
| app.delete('/api/menu/:id', requireAuth, (req, res) => { |
| const data = readMenu(); |
| const idx = data.items.findIndex(i => i.id === req.params.id); |
| if (idx === -1) return res.status(404).json({ error: 'Item not found' }); |
| const removed = data.items.splice(idx, 1)[0]; |
| writeMenu(data); |
| res.json(removed); |
| }); |
|
|
| |
| app.put('/api/categories', requireAuth, (req, res) => { |
| const data = readMenu(); |
| data.categories = req.body.categories; |
| writeMenu(data); |
| res.json(data.categories); |
| }); |
|
|
| |
| app.put('/api/info', requireAuth, (req, res) => { |
| const data = readMenu(); |
| data.info = { ...data.info, ...req.body }; |
| writeMenu(data); |
| res.json(data.info); |
| }); |
|
|
| |
| app.get('/api/tables', (req, res) => { |
| const data = readMenu(); |
| res.json({ tables: data.tables || 0 }); |
| }); |
|
|
| app.put('/api/tables', requireAuth, (req, res) => { |
| const { count } = req.body; |
| if (typeof count !== 'number' || count < 0 || count > 100) { |
| return res.status(400).json({ error: 'Count must be 0–100' }); |
| } |
| const data = readMenu(); |
| data.tables = count; |
| writeMenu(data); |
| res.json({ tables: count }); |
| }); |
|
|
| |
| |
| app.post('/api/orders', (req, res) => { |
| const { table, items, note } = req.body; |
| if (!table || !items || !Array.isArray(items) || items.length === 0) { |
| return res.status(400).json({ error: 'table and items are required' }); |
| } |
|
|
| const data = readMenu(); |
| const orderItems = []; |
| let total = 0; |
|
|
| for (const it of items) { |
| if (it.mid) { |
| const menuItem = data.items.find(m => m.id === it.mid); |
| if (!menuItem || !menuItem.available) continue; |
| const qty = Math.max(1, it.qty || 1); |
| orderItems.push({ mid: menuItem.id, name: menuItem.name, price: menuItem.price, qty }); |
| total += menuItem.price * qty; |
| } else if (it.text) { |
| orderItems.push({ text: it.text, price: 0 }); |
| } |
| } |
|
|
| if (orderItems.length === 0) { |
| return res.status(400).json({ error: 'No valid items in order' }); |
| } |
|
|
| const order = { |
| id: uuidv4(), |
| table: parseInt(table), |
| items: orderItems, |
| note: note || '', |
| status: 'pending', |
| total: Math.round(total * 100) / 100, |
| createdAt: new Date().toISOString() |
| }; |
|
|
| data.orders = data.orders || []; |
| data.orders.push(order); |
| writeMenu(data); |
| res.status(201).json(order); |
| }); |
|
|
| |
| app.get('/api/orders', requireAuth, (req, res) => { |
| const data = readMenu(); |
| const orders = data.orders || []; |
| orders.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); |
| res.json(orders); |
| }); |
|
|
| |
| app.put('/api/orders/:id', requireAuth, (req, res) => { |
| const data = readMenu(); |
| const idx = (data.orders || []).findIndex(o => o.id === req.params.id); |
| if (idx === -1) return res.status(404).json({ error: 'Order not found' }); |
|
|
| const { status } = req.body; |
| if (!['pending', 'served', 'paid', 'cancelled'].includes(status)) { |
| return res.status(400).json({ error: 'Invalid status' }); |
| } |
| data.orders[idx].status = status; |
| writeMenu(data); |
| res.json(data.orders[idx]); |
| }); |
|
|
| |
| app.get('/api/orders/table/:table', (req, res) => { |
| const data = readMenu(); |
| const table = parseInt(req.params.table); |
| const orders = (data.orders || []) |
| .filter(o => o.table === table && (o.status === 'pending' || o.status === 'served')) |
| .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); |
| res.json(orders); |
| }); |
|
|
| |
| app.get('/api/qr', requireAuth, async (req, res) => { |
| try { |
| const data = readMenu(); |
| const count = data.tables || 0; |
| const host = req.get('host'); |
| const baseUrl = (host && (host.includes('localhost') || host.includes('127.0.0.1'))) |
| ? 'https://qr-code-cafe.hf.space' |
| : `https://${host}`; |
|
|
| const qrs = []; |
| for (let i = 1; i <= count; i++) { |
| const url = `${baseUrl}/order?table=${i}`; |
| const dataUrl = await QRCode.toDataURL(url, { |
| width: 200, margin: 2, |
| color: { dark: '#1B1512', light: '#FFFFFF' } |
| }); |
| qrs.push({ table: i, url, dataUrl }); |
| } |
| res.json(qrs); |
| } catch (err) { |
| console.error('QR generation error:', err); |
| res.status(500).json({ error: 'QR generation failed' }); |
| } |
| }); |
|
|
| |
| app.get('/health', (req, res) => res.json({ status: 'ok' })); |
|
|
| |
| app.get('/admin', (req, res) => { |
| const ua = (req.get('user-agent') || '').toLowerCase(); |
| if (/mobile|android|iphone|ipod|blackberry|webos/i.test(ua)) { |
| return res.status(403).send('Admin is not available on mobile devices.'); |
| } |
| res.sendFile(path.join(__dirname, 'public', 'admin.html')); |
| }); |
|
|
| app.get('/order', (req, res) => { |
| res.sendFile(path.join(__dirname, 'public', 'order.html')); |
| }); |
|
|
| |
| app.get('*', (req, res) => { |
| res.sendFile(path.join(__dirname, 'public', 'index.html')); |
| }); |
|
|
| |
| function scheduleMidnightCleanup() { |
| const now = new Date(); |
| const midnight = new Date(now); |
| midnight.setHours(24, 0, 0, 0); |
| const msUntilMidnight = midnight - now; |
|
|
| setTimeout(() => { |
| try { |
| const data = readMenu(); |
| if (data.orders && data.orders.length > 0) { |
| data.orders = []; |
| writeMenu(data); |
| console.log('🗑️ Midnight: cleared all orders'); |
| } |
| } catch (err) { |
| console.error('Midnight cleanup error:', err); |
| } |
| scheduleMidnightCleanup(); |
| }, msUntilMidnight); |
| } |
|
|
| |
| app.listen(PORT, '0.0.0.0', () => { |
| console.log(`☕ The Roasted Bean serving on 0.0.0.0:${PORT}`); |
| }); |
| scheduleMidnightCleanup(); |
|
|