cafe / server.js
Roasted Bean Deploy
Revert "💰 ₹ prices + dynamic cafe name everywhere"
f0e43b0
Raw
History Blame Contribute Delete
13.1 kB
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();