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; // Hugging Face Spaces: use /data for persistent storage 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'); // Secrets via environment variables (HF Secrets) const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; const SESSION_SECRET = process.env.SESSION_SECRET || 'cafe-secret-' + uuidv4(); // ── Middleware ────────────────────────────────────────────────────────────── 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 } })); // ── Helpers ───────────────────────────────────────────────────────────────── // Seed menu data if not present in persistent storage 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); // Migrations: ensure newer fields exist and persist them 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' }); } // ── Auth Routes ───────────────────────────────────────────────────────────── 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 }); }); // ── Menu API ──────────────────────────────────────────────────────────────── // GET all menu items app.get('/api/menu', (req, res) => { const data = readMenu(); res.json(data); }); // GET single item 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); }); // POST new item (admin) 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); }); // PUT update item (admin) 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]); }); // DELETE item (admin) 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); }); // PUT update categories (admin) app.put('/api/categories', requireAuth, (req, res) => { const data = readMenu(); data.categories = req.body.categories; writeMenu(data); res.json(data.categories); }); // PUT update cafe info (admin) app.put('/api/info', requireAuth, (req, res) => { const data = readMenu(); data.info = { ...data.info, ...req.body }; writeMenu(data); res.json(data.info); }); // ── Tables API ────────────────────────────────────────────────────────────── 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 }); }); // ── Orders API ────────────────────────────────────────────────────────────── // Create order (public) 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); }); // Get all orders (admin) 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); }); // Update order status (admin) 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]); }); // Get live orders for a table (public — customer sees their orders) 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); }); // ── QR Codes (admin) ──────────────────────────────────────────────────────── 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' }); } }); // ── Health ────────────────────────────────────────────────────────────────── app.get('/health', (req, res) => res.json({ status: 'ok' })); // ── Serve HTML ────────────────────────────────────────────────────────────── 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')); }); // SPA fallback app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // ── Midnight cleanup ──────────────────────────────────────────────────────── 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); } // ── Start ─────────────────────────────────────────────────────────────────── app.listen(PORT, '0.0.0.0', () => { console.log(`☕ The Roasted Bean serving on 0.0.0.0:${PORT}`); }); scheduleMidnightCleanup();