257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
import http from 'node:http';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// minimal .env loader (KEY=VALUE), does not override real env vars
|
|
try {
|
|
const txt = fs.readFileSync(new URL('./.env', import.meta.url), 'utf8');
|
|
for (const line of txt.split('\n')) {
|
|
const mm = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
if (mm && process.env[mm[1]] === undefined) process.env[mm[1]] = mm[2].replace(/^["']|["']$/g, '');
|
|
}
|
|
} catch {}
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const SITE_ROOT = path.resolve(__dirname, '..');
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '.data');
|
|
const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
|
|
const PORT = Number(process.env.PORT || 8787);
|
|
const STAFF_PASSCODE = process.env['ST'+'AFF_PASSCODE'] || '';
|
|
|
|
// --- stands ---------------------------------------------------------------
|
|
// Each menu item is its own stand; mixed orders consolidate at Kitchen 484.
|
|
const DEFAULT_STAND = 'Kitchen 484';
|
|
const DEFAULT_LOCATION = 'Center of camp, by the big solar dish';
|
|
|
|
// PLACEHOLDER stand locations — edit these when the site map is final.
|
|
// Keyed by menu item name; orders for that item show this text wherever
|
|
// the app says where to pick up (success screen, orders view, staff board).
|
|
const STAND_LOCATIONS = {
|
|
'Coffee Station': 'PLACEHOLDER — east path, next to the water station',
|
|
'Taco Stand': 'PLACEHOLDER — main lawn, food row #1',
|
|
'BBQ Pit': 'PLACEHOLDER — downwind corner behind the wood pile',
|
|
'Chicken Grill': 'PLACEHOLDER — main lawn, food row #2',
|
|
'Pasta Bar': 'PLACEHOLDER — west tent, near the stage',
|
|
'Drinks': 'PLACEHOLDER — center of camp, big cooler',
|
|
'Garden Burgers': 'PLACEHOLDER — main lawn, food row #2',
|
|
'Grill 484': 'PLACEHOLDER — center of camp, by the big solar dish',
|
|
'Ramen Tent': 'PLACEHOLDER — south tent, steam visible from path',
|
|
'Flatbread Oven': 'PLACEHOLDER — east tent, smell the bread',
|
|
'Dumpling Bar': 'PLACEHOLDER — west tent, next to Pasta Bar',
|
|
'Farewell Feast · BBQ': 'PLACEHOLDER — main lawn, long tables',
|
|
};
|
|
function locationFor(stand) {
|
|
return STAND_LOCATIONS[stand] || STAND_LOCATIONS[DEFAULT_STAND] || DEFAULT_LOCATION;
|
|
}
|
|
// --- order store ---------------------------------------------------------
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
let orders = [];
|
|
try { orders = JSON.parse(fs.readFileSync(ORDERS_FILE, 'utf8')); } catch { orders = []; }
|
|
let saveTimer = null;
|
|
function saveOrdersNow() {
|
|
clearTimeout(saveTimer); saveTimer = null;
|
|
try { fs.writeFileSync(ORDERS_FILE, JSON.stringify(orders, null, 1)); }
|
|
catch (err) { console.error('order save failed:', err.message); }
|
|
}
|
|
function saveOrders() {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(saveOrdersNow, 250);
|
|
}
|
|
process.on('SIGTERM', () => { saveOrdersNow(); process.exit(0); });
|
|
process.on('SIGINT', () => { saveOrdersNow(); process.exit(0); });
|
|
|
|
// --- staff sessions ------------------------------------------------------
|
|
const SESSION_TTL = 12 * 3600 * 1000;
|
|
const sessions = new Map(); // token -> expiry
|
|
function newSession() {
|
|
const t = crypto.randomBytes(24).toString('hex');
|
|
sessions.set(t, Date.now() + SESSION_TTL);
|
|
return t;
|
|
}
|
|
function authed(req) {
|
|
const h = req.headers.authorization || '';
|
|
const t = h.startsWith('Bearer ') ? h.slice(7) : (new URL(req.url, 'http://x').searchParams.get('token') || '');
|
|
const exp = sessions.get(t);
|
|
if (!exp) return false;
|
|
if (exp < Date.now()) { sessions.delete(t); return false; }
|
|
return true;
|
|
}
|
|
|
|
// --- SSE -----------------------------------------------------------------
|
|
const sseClients = new Set(); // staff stream
|
|
const orderStreams = new Map(); // code -> Set(res)
|
|
function sseSend(res, obj) {
|
|
try { res.write('data: ' + JSON.stringify(obj) + '\n\n'); } catch {}
|
|
}
|
|
function broadcastStatus(order) {
|
|
const payload = { code: order.code, status: order.status, stand: order.stand, standLocation: order.standLocation };
|
|
for (const res of sseClients) sseSend(res, { type: 'ready', ...payload });
|
|
const subs = orderStreams.get(order.code);
|
|
if (subs) for (const res of subs) sseSend(res, payload);
|
|
}
|
|
|
|
// --- helpers -------------------------------------------------------------
|
|
function json(res, code, obj) {
|
|
const body = JSON.stringify(obj);
|
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(body);
|
|
}
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '', n = 0;
|
|
req.on('data', c => { n += c.length; if (n > 64 * 1024) { reject(new Error('too big')); req.destroy(); return; } data += c; });
|
|
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(new Error('bad json')); } });
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
const CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
function makeCode() {
|
|
for (let tries = 0; tries < 50; tries++) {
|
|
let c = 'K484-';
|
|
for (let i = 0; i < 4; i++) c += CODE_CHARS[crypto.randomInt(CODE_CHARS.length)];
|
|
if (!orders.some(o => o.code === c)) return c;
|
|
}
|
|
return 'K484-' + Date.now().toString(36).slice(-4).toUpperCase();
|
|
}
|
|
const STATUSES = ['PREPARING', 'READY', 'COLLECTED', 'CANCELLED'];
|
|
|
|
function publicOrder(o) {
|
|
return {
|
|
code: o.code, status: o.status, name: o.name, notes: o.notes,
|
|
itemsSummary: o.itemsSummary, totalUsd: o.totalUsd,
|
|
day: o.day, pickupLabel: o.pickupLabel, window: o.window,
|
|
stand: o.stand, standLocation: o.standLocation, createdAt: o.createdAt,
|
|
};
|
|
}
|
|
|
|
// --- request handler -----------------------------------------------------
|
|
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
|
|
|
|
async function handle(req, res) {
|
|
const u = new URL(req.url, 'http://localhost');
|
|
const p = decodeURIComponent(u.pathname);
|
|
|
|
// ---- staff login ----
|
|
if (p === '/api/staff/login' && req.method === 'POST') {
|
|
let body; try { body = await readBody(req); } catch { return json(res, 400, { error: 'bad body' }); }
|
|
if (!STAFF_PASSCODE) return json(res, 500, { error: 'server has no staff passcode configured' });
|
|
const given = String(body.passcode || '');
|
|
const a = Buffer.from(given), b = Buffer.from(STAFF_PASSCODE);
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return json(res, 401, { error: 'wrong passcode' });
|
|
return json(res, 200, { token: newSession() });
|
|
}
|
|
|
|
// ---- create order (customer, no auth) ----
|
|
if (p === '/api/orders' && req.method === 'POST') {
|
|
let b; try { b = await readBody(req); } catch { return json(res, 400, { error: 'bad json' }); }
|
|
const name = String(b.name || '').trim().slice(0, 40);
|
|
if (!name) return json(res, 400, { error: 'name required' });
|
|
const items = (b.items && typeof b.items === 'object') ? b.items : {};
|
|
const ids = Object.keys(items);
|
|
if (!ids.length) return json(res, 400, { error: 'cart is empty' });
|
|
const stand = String(b.stand || DEFAULT_STAND).trim().slice(0, 40) || DEFAULT_STAND;
|
|
const standLocation = String(b.standLocation || locationFor(stand)).trim().slice(0, 80);
|
|
const order = {
|
|
code: makeCode(),
|
|
status: 'PREPARING',
|
|
name,
|
|
notes: String(b.notes || '').trim().slice(0, 200),
|
|
items,
|
|
itemsSummary: String(b.itemsSummary || ids.join(', ')).slice(0, 300),
|
|
totalUsd: Number(b.totalUsd) || 0,
|
|
day: String(b.day || '').slice(0, 40),
|
|
pickupLabel: String(b.pickupLabel || '').slice(0, 40),
|
|
window: Array.isArray(b.window) ? b.window.slice(0, 2).map(Number) : null,
|
|
stand,
|
|
standLocation,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
orders.unshift(order);
|
|
saveOrders();
|
|
for (const c of sseClients) sseSend(c, { type: 'new', code: order.code, stand: order.stand });
|
|
console.log('new order', order.code, order.name, '|', order.itemsSummary);
|
|
return json(res, 201, publicOrder(order));
|
|
}
|
|
|
|
// ---- list orders (staff) ----
|
|
if (p === '/api/orders' && req.method === 'GET') {
|
|
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
|
|
const q = u.searchParams.get('stand');
|
|
const list = orders.filter(o => !q || o.stand === q).map(publicOrder);
|
|
return json(res, 200, list);
|
|
}
|
|
|
|
// ---- staff live stream (SSE) ----
|
|
if (p === '/api/orders/stream' && req.method === 'GET') {
|
|
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
sseClients.add(res);
|
|
const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 25000);
|
|
req.on('close', () => { clearInterval(hb); sseClients.delete(res); });
|
|
return;
|
|
}
|
|
|
|
// ---- per-order customer endpoints ----
|
|
let m = p.match(/^\/api\/orders\/([A-Za-z0-9-]+)(\/events|\/status)?$/);
|
|
if (m) {
|
|
const code = m[1].toUpperCase();
|
|
const sub = m[2] || '';
|
|
const order = orders.find(o => o.code === code);
|
|
if (!order) return json(res, 404, { error: 'not found' });
|
|
if (!sub && req.method === 'GET') return json(res, 200, publicOrder(order));
|
|
if (sub === '/events' && req.method === 'GET') {
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
sseSend(res, { code: order.code, status: order.status, stand: order.stand, standLocation: order.standLocation });
|
|
let set = orderStreams.get(code);
|
|
if (!set) { set = new Set(); orderStreams.set(code, set); }
|
|
set.add(res);
|
|
const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 25000);
|
|
req.on('close', () => { clearInterval(hb); set.delete(res); if (!set.size) orderStreams.delete(code); });
|
|
return;
|
|
}
|
|
if (sub === '/status' && req.method === 'POST') {
|
|
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
|
|
let b; try { b = await readBody(req); } catch { return json(res, 400, { error: 'bad json' }); }
|
|
if (!STATUSES.includes(b.status)) return json(res, 400, { error: 'bad status' });
|
|
order.status = b.status;
|
|
if (b.stand) { order.stand = String(b.stand).slice(0, 40); if (b.standLocation) order.standLocation = String(b.standLocation).slice(0, 80); }
|
|
saveOrders();
|
|
broadcastStatus(order);
|
|
return json(res, 200, publicOrder(order));
|
|
}
|
|
}
|
|
|
|
// ---- health ----
|
|
if (p === '/api/health') return json(res, 200, { ok: true, orders: orders.length, staff: !!STAFF_PASSCODE });
|
|
|
|
// ---- staff board page ----
|
|
if ((p === '/staff' || p === '/staff.html') && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
return fs.readFile(path.join(__dirname, 'staff.html'), (err, buf) => {
|
|
if (err) return json(res, 404, { error: 'staff page missing' });
|
|
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
|
|
res.end(buf);
|
|
});
|
|
}
|
|
|
|
// ---- static files ----
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
|
let file = p === '/' ? '/index.html' : p;
|
|
const abs = path.normalize(path.join(SITE_ROOT, file));
|
|
if (!abs.startsWith(SITE_ROOT)) return json(res, 403, { error: 'forbidden' });
|
|
const serverDir = path.join(SITE_ROOT, 'server');
|
|
const isStaffPage = abs === path.join(serverDir, 'staff.html');
|
|
if (abs.startsWith(serverDir + path.sep) && !isStaffPage) return json(res, 403, { error: 'forbidden' });
|
|
if (abs.includes('/.data/') || path.basename(abs) === '.env') return json(res, 403, { error: 'forbidden' });
|
|
fs.readFile(abs, (err, buf) => {
|
|
if (err) return json(res, 404, { error: 'not found' });
|
|
res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] || 'application/octet-stream' });
|
|
res.end(buf);
|
|
});
|
|
}
|
|
|
|
http.createServer((req, res) => {
|
|
handle(req, res).catch(e => { if (!res.headersSent) json(res, 500, { error: e.message }); });
|
|
}).listen(PORT, () => console.log('kitchen server on :' + PORT + ' | staff=' + (STAFF_PASSCODE ? 'on' : 'DISABLED')));
|