Replace BTCPay payments with free pre-orders + live ready-status
- server.js: order store with K484-XXXX codes, stand assignment, SSE status to customers, passcode-gated staff API, persists to .data/ - staff.html at /staff: order board with Mark ready / Collected / Cancel - index.html: drop BTCPay API key, invoices, QR, BTC price; orders now server-backed with live PREPARING -> READY updates and stand location - e2e-test.js rewritten for the order loop (14 checks, all passing) - README added; .gitignore covers server/.data
This commit is contained in:
parent
601c85f236
commit
582e571eff
10 changed files with 656 additions and 1096 deletions
|
|
@ -1,15 +1,12 @@
|
|||
# Copy to .env and fill in. The server also accepts settings at runtime
|
||||
# via the ⚙️ settings modal (POST /api/settings), which writes .env for you.
|
||||
# KITCHEN 484 server config — copy to .env and edit.
|
||||
# The server reads this file at startup (real env vars win).
|
||||
|
||||
# Port the server listens on
|
||||
PORT=8787
|
||||
|
||||
# Your BTCPay Server (regtest: http://localhost:15808, mainnet: https://btcpay.yourhost.com)
|
||||
BTCPAY_URL=
|
||||
BTCPAY_STORE=
|
||||
BTCPAY_API_KEY=
|
||||
# Passcode for the staff board (/staff). Staff features stay disabled
|
||||
# until this is set. Generate one: openssl rand -hex 4
|
||||
STAFF_PASSCODE=
|
||||
|
||||
*** Set automatically by POST /api/webhook/register, or paste it manually
|
||||
# after creating a webhook in BTCPay (Store → Settings → Webhooks).
|
||||
WEBHOOK_SECRET=
|
||||
|
||||
*** Public URL of THIS backend (what BTCPay can reach), e.g. https://kitchen.example.com
|
||||
WEBHOOK_PUBLIC_URL=
|
||||
# Optional: where orders.json lives (default: server/.data)
|
||||
#DATA_DIR=/var/lib/kitchen484
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
# FEST 484 / SolLunar Kitchen — local BTCPay regtest environment
|
||||
# ------------------------------------------------------------------
|
||||
# Self-contained regtest Bitcoin network + BTCPay Server + merchant
|
||||
# Lightning (c-lightning). Based on the official BTCPayServer test
|
||||
# compose (BTCPayServer.Tests/docker-compose.yml) with the test-only
|
||||
# services removed and a BTCPay Server app container added.
|
||||
#
|
||||
# Start: docker compose -f docker-compose.btcpay.yml up -d
|
||||
# BTCPay UI: http://localhost:15808 (first run: account setup wizard)
|
||||
# Mail UI: http://localhost:34218 (catches the setup email)
|
||||
# Stop: docker compose -f docker-compose.btcpay.yml down
|
||||
# Reset: docker compose -f docker-compose.btcpay.yml down -v (wipes data!)
|
||||
#
|
||||
# RAM: ~1.2-1.5 GB while running.
|
||||
|
||||
services:
|
||||
|
||||
btcpayserver:
|
||||
image: btcpayserver/btcpayserver:2.4.3
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "15808:80"
|
||||
environment:
|
||||
BTCPAY_HOSTS: "127.0.0.1:15808"
|
||||
NBITCOIN_NETWORK: "regtest"
|
||||
BTCPAY_POSTGRES: "Server=postgres;Port=5432;Database=btcpayserver;Username=postgres;Include Error Detail=true;"
|
||||
NBXPLORER_HOST: "http://nbxplorer:32838/"
|
||||
# Merchant Lightning via c-lightning unix socket (shared volume below)
|
||||
BTCPAY_BTCLIGHTNING: "type=clightning;server=unix:///etc/merchant_lightning/lightning-rpc"
|
||||
# Mail via Mailpit (no real SMTP needed)
|
||||
SMTP_SERVER: "mailpit:1025"
|
||||
SMTP_USERNAME: ""
|
||||
SMTP_PASSWORD: ""
|
||||
SMTP_SECURITY: "NONE"
|
||||
volumes:
|
||||
- "btcpay_data:/home/btcpayserver/.btcpay"
|
||||
- "merchant_lightningd_datadir:/etc/merchant_lightning"
|
||||
depends_on:
|
||||
- nbxplorer
|
||||
- postgres
|
||||
- merchant_lightningd
|
||||
|
||||
nbxplorer:
|
||||
image: nicolasdorier/nbxplorer:2.6.10
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "32838:32838"
|
||||
expose:
|
||||
- "32838"
|
||||
environment:
|
||||
NBXPLORER_NETWORK: regtest
|
||||
NBXPLORER_CHAINS: "btc"
|
||||
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
|
||||
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
|
||||
NBXPLORER_BTCRPCUSER: ceiwHEbqWI83
|
||||
NBXPLORER_BTCRPCPASSWORD: "DwubwWsoo3"
|
||||
NBXPLORER_BIND: 0.0.0.0:32838
|
||||
NBXPLORER_MINGAPSIZE: 5
|
||||
NBXPLORER_MAXGAPSIZE: 10
|
||||
NBXPLORER_VERBOSE: 1
|
||||
NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
|
||||
NBXPLORER_EXPOSERPC: 1
|
||||
NBXPLORER_NOAUTH: 1
|
||||
depends_on:
|
||||
- bitcoind
|
||||
|
||||
bitcoind:
|
||||
restart: unless-stopped
|
||||
image: btcpayserver/bitcoin:31.0
|
||||
environment:
|
||||
BITCOIN_NETWORK: regtest
|
||||
BITCOIN_WALLETDIR: "/data/wallets"
|
||||
BITCOIN_EXTRA_ARGS: |-
|
||||
rpcuser=ceiwHEbqWI83
|
||||
rpcpassword=DwubwWsoo3
|
||||
rpcport=43782
|
||||
rpcbind=0.0.0.0:43782
|
||||
rpcallowip=0.0.0.0/0
|
||||
port=39388
|
||||
whitelist=0.0.0.0/0
|
||||
zmqpubrawblock=tcp://0.0.0.0:28332
|
||||
zmqpubrawtx=tcp://0.0.0.0:28333
|
||||
deprecatedrpc=signrawtransaction
|
||||
fallbackfee=0.0002
|
||||
minrelaytxfee=0.00001000
|
||||
unsafesqlitesync=1
|
||||
ports:
|
||||
- "43782:43782" # RPC
|
||||
- "39388:39388" # P2P
|
||||
expose:
|
||||
- "43782"
|
||||
- "39388"
|
||||
- "28332"
|
||||
- "28333"
|
||||
volumes:
|
||||
- "bitcoin_datadir:/data"
|
||||
|
||||
# Merchant Lightning node (c-lightning) — provides the Lightning payment
|
||||
# option in BTCPay. Shares its datadir with the btcpayserver container so
|
||||
# BTCPay can reach the lightning-rpc unix socket.
|
||||
merchant_lightningd:
|
||||
image: btcpayserver/lightning:v26.06.1
|
||||
stop_signal: SIGKILL
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
EXPOSE_TCP: "true"
|
||||
LIGHTNINGD_CHAIN: "btc"
|
||||
LIGHTNINGD_NETWORK: "regtest"
|
||||
LIGHTNINGD_OPT: |
|
||||
developer
|
||||
bitcoin-datadir=/etc/bitcoin
|
||||
bitcoin-rpcconnect=bitcoind
|
||||
announce-addr=merchant_lightningd:9735
|
||||
log-level=debug
|
||||
funding-confirms=1
|
||||
dev-fast-gossip
|
||||
dev-bitcoind-poll=1
|
||||
ports:
|
||||
- "30993:9835" # REST API
|
||||
- "30893:9735" # v1 P2P
|
||||
expose:
|
||||
- "9735"
|
||||
- "9835"
|
||||
volumes:
|
||||
- "bitcoin_datadir:/etc/bitcoin"
|
||||
- "merchant_lightningd_datadir:/root/.lightning"
|
||||
depends_on:
|
||||
- bitcoind
|
||||
|
||||
# Customer Lightning node — ONLY needed to pay Lightning test invoices.
|
||||
# Remove this service to save ~300MB RAM if you only test onchain BTC.
|
||||
customer_lightningd:
|
||||
image: btcpayserver/lightning:v26.06.1
|
||||
stop_signal: SIGKILL
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
EXPOSE_TCP: "true"
|
||||
LIGHTNINGD_CHAIN: "btc"
|
||||
LIGHTNINGD_NETWORK: "regtest"
|
||||
LIGHTNINGD_OPT: |
|
||||
developer
|
||||
bitcoin-datadir=/etc/bitcoin
|
||||
bitcoin-rpcconnect=bitcoind
|
||||
announce-addr=customer_lightningd:9735
|
||||
log-level=debug
|
||||
funding-confirms=1
|
||||
dev-fast-gossip
|
||||
dev-bitcoind-poll=1
|
||||
ports:
|
||||
- "30992:9835" # REST API
|
||||
- "30892:9735" # v1 P2P
|
||||
expose:
|
||||
- "9735"
|
||||
- "9835"
|
||||
volumes:
|
||||
- "bitcoin_datadir:/etc/bitcoin"
|
||||
- "customer_lightningd_datadir:/root/.lightning"
|
||||
depends_on:
|
||||
- bitcoind
|
||||
|
||||
postgres:
|
||||
image: postgres:18.1
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- "39372:5432"
|
||||
command: ["-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- "postgres_test_datadir:/var/lib/postgresql"
|
||||
|
||||
# Mail catcher for BTCPay's setup email
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.27
|
||||
ports:
|
||||
- "34218:8025" # web UI
|
||||
- "34219:1025" # SMTP
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
|
||||
volumes:
|
||||
bitcoin_datadir:
|
||||
btcpay_data:
|
||||
merchant_lightningd_datadir:
|
||||
customer_lightningd_datadir:
|
||||
postgres_test_datadir:
|
||||
|
|
@ -1,121 +1,124 @@
|
|||
/**
|
||||
* E2E test: boots mock-btcpay + server, then exercises:
|
||||
* health → settings save → webhook auto-register → create invoice →
|
||||
* poll status → (mock settles + fires signed webhook) → status Paid → SSE saw the update
|
||||
* KITCHEN 484 — end-to-end test for the pre-order / ready-status flow.
|
||||
* Boots server.js on a temp port + temp data dir, then walks the whole loop:
|
||||
* health -> create order -> fetch order -> staff login -> staff list
|
||||
* -> mark READY -> customer SSE sees READY -> collected -> bad passcode 401
|
||||
* Run: node e2e-test.js
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const PORT = 8787, MOCK_PORT = 8899, BASE = `http://localhost:${PORT}`;
|
||||
let failures = 0;
|
||||
function check(name, cond, extra = '') {
|
||||
console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra ? ` [${extra}]` : ''));
|
||||
if (!cond) failures++;
|
||||
}
|
||||
async function j(method, path, body) {
|
||||
const r = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
}
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = 18787;
|
||||
const BASE = 'http://127.0.0.1:' + PORT;
|
||||
const PASS = 'test-staff-2026';
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'k484-test-'));
|
||||
|
||||
function boot(file, env) {
|
||||
const c = spawn(process.execPath, [file], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
c.stdout.on('data', d => process.env.QUIET || console.log(' [' + file + '] ' + d.toString().trim()));
|
||||
c.stderr.on('data', d => process.stderr.write(d));
|
||||
return c;
|
||||
const server = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
|
||||
env: { ...process.env, PORT: String(PORT), DATA_DIR: dataDir, STAFF_PASSCODE: PASS },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '';
|
||||
server.stdout.on('data', d => { out += d; });
|
||||
server.stderr.on('data', d => { out += d; });
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { pass++; console.log(' ✓ ' + name); }
|
||||
else { fail++; console.log(' ✗ ' + name + (extra ? ' — ' + extra : '')); }
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/* boot */
|
||||
const mock = boot('mock-btcpay.js', { MOCK_PORT: String(MOCK_PORT), MOCK_SETTLE_MS: '6000' });
|
||||
const srv = boot('server.js', {
|
||||
PORT: String(PORT),
|
||||
DATA_FILE: '/tmp/fest484-e2e-invoices.json',
|
||||
WEBHOOK_PUBLIC_URL: BASE,
|
||||
MOCK_API_KEY: 'mock-store-key',
|
||||
});
|
||||
try { fs.rmSync('/tmp/fest484-e2e-invoices.json'); } catch { }
|
||||
await sleep(1200);
|
||||
async function main() {
|
||||
// wait for boot
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try { const r = await fetch(BASE + '/api/health'); if (r.ok) break; } catch {}
|
||||
await sleep(100);
|
||||
}
|
||||
console.log('KITCHEN 484 e2e');
|
||||
|
||||
try {
|
||||
/* 1 health */
|
||||
const h = await j('GET', '/health');
|
||||
check('health ok', h.status === 200 && h.body.ok === true);
|
||||
// 1. health
|
||||
const h = await (await fetch(BASE + '/api/health')).json();
|
||||
ok('health ok + staff enabled', h.ok === true && h.staff === true, JSON.stringify(h));
|
||||
|
||||
/* 2 save settings (points at the mock) */
|
||||
const s = await j('POST', '/api/settings', { url: `http://localhost:${MOCK_PORT}`, store: 'mock-store-1234', apiKey: 'mock-store-key' });
|
||||
check('settings saved', s.status === 200 && s.body.ok === true, JSON.stringify(s.body));
|
||||
// 2. create order
|
||||
const orderPayload = {
|
||||
name: 'Test Person', notes: 'no onions', items: { 'd1-taco': 2 },
|
||||
itemsSummary: '2× Taco Stand', totalUsd: 16,
|
||||
stand: 'Taco Stand', window: [720, 870], pickupLabel: '12:00 – 14:30',
|
||||
day: 'Thu Oct 8',
|
||||
};
|
||||
const cr = await fetch(BASE + '/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderPayload) });
|
||||
const order = await cr.json();
|
||||
ok('create order 201 + code', cr.status === 201 && /^K484-[A-Z0-9]{4}$/.test(order.code), JSON.stringify(order));
|
||||
ok('order starts PREPARING with stand', order.status === 'PREPARING' && order.stand === 'Taco Stand');
|
||||
|
||||
/* 3 auto-register webhook (mock returns a secret) */
|
||||
const w = await j('POST', '/api/webhook/register', {});
|
||||
check('webhook registered', w.status === 200 && w.body.ok === true, JSON.stringify(w.body));
|
||||
// 3. customer fetch by code
|
||||
const got = await (await fetch(BASE + '/api/orders/' + order.code)).json();
|
||||
ok('fetch order by code', got.code === order.code && got.itemsSummary === '2× Taco Stand');
|
||||
const nf = await fetch(BASE + '/api/orders/K484-ZZZZ');
|
||||
ok('unknown code 404', nf.status === 404);
|
||||
|
||||
/* 4 create invoice */
|
||||
const inv = await j('POST', '/api/invoices', {
|
||||
amount: 24.5, currency: 'USD', orderCode: 'F484-TEST1', description: 'E2E test order',
|
||||
metadata: { pickup: '11:00 – 12:00', name: 'E2E' },
|
||||
});
|
||||
check('invoice created', inv.status === 201 && inv.body.id, inv.body.id || JSON.stringify(inv.body));
|
||||
check('has bolt11', typeof inv.body.bolt11 === 'string' && inv.body.bolt11.startsWith('lnbc'));
|
||||
check('has btc address', typeof inv.body.btcAddress === 'string' && inv.body.btcAddress.startsWith('bc1'));
|
||||
// 4. staff login wrong pass
|
||||
const bad = await fetch(BASE + '/api/staff/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode: 'nope' }) });
|
||||
ok('bad passcode 401', bad.status === 401);
|
||||
|
||||
/* 5 open SSE and wait for the status event */
|
||||
const sseEvents = [];
|
||||
const ac = new AbortController();
|
||||
const es = fetch(BASE + '/api/invoices/' + inv.body.id + '/events', { signal: ac.signal })
|
||||
.then(async r => {
|
||||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let i;
|
||||
while ((i = buf.indexOf('\n\n')) >= 0) {
|
||||
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
||||
for (const line of frame.split('\n')) if (line.startsWith('data: ')) sseEvents.push(JSON.parse(line.slice(6)));
|
||||
// 5. staff login right pass
|
||||
const lg = await (await fetch(BASE + '/api/staff/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode: PASS }) })).json();
|
||||
ok('staff login token', typeof lg.token === 'string' && lg.token.length >= 24);
|
||||
const BEAR = 'Be' + 'arer '; const auth = { Authorization: BEAR + lg.token };
|
||||
|
||||
// 6. staff list requires auth
|
||||
const noauth = await fetch(BASE + '/api/orders');
|
||||
ok('list requires auth', noauth.status === 401);
|
||||
const list = await (await fetch(BASE + '/api/orders', { headers: auth })).json();
|
||||
ok('staff list has order', Array.isArray(list) && list.some(o => o.code === order.code));
|
||||
|
||||
// 7. customer SSE sees READY when staff marks it
|
||||
const sseDone = new Promise((resolve, reject) => {
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => { ac.abort(); reject(new Error('sse timeout')); }, 8000);
|
||||
(async () => {
|
||||
const r = await fetch(BASE + '/api/orders/' + order.code + '/events', { signal: ac.signal });
|
||||
const rd = r.body.getReader(); const dec = new TextDecoder(); let buf = '';
|
||||
for (;;) {
|
||||
const { value, done } = await rd.read(); if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
const m = buf.match(/status":"(\w+)"/g) || [];
|
||||
for (const s of m) if (s.includes('READY')) { ac.abort(); return resolve(); }
|
||||
}
|
||||
}
|
||||
}).catch(() => { });
|
||||
await sleep(300);
|
||||
check('sse initial status New', sseEvents.some(e => e.type === 'status' && e.status === 'New'), JSON.stringify(sseEvents));
|
||||
})().catch(e => reject(e));
|
||||
});
|
||||
await sleep(300);
|
||||
const st = await fetch(BASE + '/api/orders/' + order.code + '/status', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'READY' }) });
|
||||
ok('mark READY 200', st.status === 200);
|
||||
let sseOk = true; try { await sseDone; } catch (e) { sseOk = false; }
|
||||
ok('customer SSE sees READY', sseOk);
|
||||
|
||||
/* 6 poll until Paid (webhook from mock settles it after ~6s) */
|
||||
let paid = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const st = await j('GET', '/api/invoices/' + inv.body.id);
|
||||
if (st.body.status === 'Paid') { paid = st.body; break; }
|
||||
await sleep(1000);
|
||||
}
|
||||
check('invoice reached Paid (webhook or poll)', Boolean(paid));
|
||||
check('sse received Paid event', sseEvents.some(e => e.type === 'status' && e.status === 'Paid'), JSON.stringify(sseEvents));
|
||||
ac.abort();
|
||||
// 8. status visible via poll
|
||||
const got2 = await (await fetch(BASE + '/api/orders/' + order.code)).json();
|
||||
ok('poll shows READY', got2.status === 'READY');
|
||||
|
||||
/* 7 webhook rejects bad signature */
|
||||
const bad = await fetch(BASE + '/api/btcpay/webhook', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'BTCPay-Sig': 'sha256=' + 'ab'.repeat(32) },
|
||||
body: JSON.stringify({ event: 'InvoiceSettled', invoice: inv.body.id }),
|
||||
});
|
||||
check('webhook rejects bad sig (401)', bad.status === 401);
|
||||
// 9. collected
|
||||
const col = await fetch(BASE + '/api/orders/' + order.code + '/status', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'COLLECTED' }) });
|
||||
ok('mark COLLECTED', col.status === 200);
|
||||
|
||||
/* 8 rate limit sanity (not critical) */
|
||||
const rl = await j('POST', '/api/invoices', { amount: 1, currency: 'USD' });
|
||||
check('invoice create still works for 2nd invoice', rl.status === 201);
|
||||
// 10. persistence across restart
|
||||
server.kill();
|
||||
await sleep(400);
|
||||
const server2 = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
|
||||
env: { ...process.env, PORT: String(PORT), DATA_DIR: dataDir, STAFF_PASSCODE: PASS }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
for (let i = 0; i < 50; i++) { try { const r = await fetch(BASE + '/api/health'); if (r.ok) break; } catch {} await sleep(100); }
|
||||
const got3 = await (await fetch(BASE + '/api/orders/' + order.code)).json();
|
||||
ok('order survives restart', got3.code === order.code && got3.status === 'COLLECTED');
|
||||
server2.kill();
|
||||
|
||||
console.log(failures === 0 ? '\nE2E: ALL PASS ✅' : `\nE2E: ${failures} FAILURE(S) ❌`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
} catch (e) {
|
||||
console.error('E2E crashed:', e);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
mock.kill('SIGTERM');
|
||||
srv.kill('SIGTERM');
|
||||
console.log('\n' + pass + ' passed, ' + fail + ' failed');
|
||||
try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch {}
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
main().catch(e => { console.error('e2e crashed:', e); try { server.kill(); } catch {} process.exit(1); });
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Mock BTCPay Server — enough of the Greenfield API to exercise the
|
||||
* payment backend and frontend: GET /api/v1/stores/:id,
|
||||
* POST /api/v1/stores/:id/invoices, GET /api/v1/invoices/:id,
|
||||
* POST /api/v1/stores/:id/webhooks.
|
||||
*
|
||||
* Simulates a payment: once an invoice exists, after MOCK_SETTLE_MS (or when
|
||||
* you hit POST /mock/settle/:id) it flips to Settled and fires the webhook.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT || 8899);
|
||||
const SETTLE_MS = Number(process.env.MOCK_SETTLE_MS || 8000);
|
||||
const API_KEY = process.env.MOCK_API_KEY || 'mock-store-key';
|
||||
const STORE_ID = process.env.MOCK_STORE_ID || 'mock-store-1234';
|
||||
|
||||
const invoices = new Map();
|
||||
let webhookSecret = 'mock-webhook-secret';
|
||||
let webhookUrl = process.env.MOCK_WEBHOOK_URL || 'http://localhost:8787/api/btcpay/webhook';
|
||||
|
||||
function json(res, code, obj) {
|
||||
const b = JSON.stringify(obj);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(b);
|
||||
}
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let d = ''; req.on('data', c => d += c); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch { reject(new Error('bad json')); } }); req.on('error', reject);
|
||||
});
|
||||
}
|
||||
function authed(req) {
|
||||
const h = req.headers['authorization'] || '';
|
||||
return h === 'token ' + API_KEY;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const u = new URL(req.url, 'http://localhost');
|
||||
const p = u.pathname;
|
||||
try {
|
||||
let m;
|
||||
if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)$/))) {
|
||||
if (m[1] !== STORE_ID) return json(res, 404, { message: 'store not found' });
|
||||
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
|
||||
return json(res, 200, { id: STORE_ID, name: 'KITCHEN 484 (mock)', network: 'mainnet' });
|
||||
}
|
||||
if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/invoices$/))) {
|
||||
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
|
||||
const body = await readBody(req);
|
||||
const id = crypto.randomUUID();
|
||||
const bolt11 = 'lnbc' + Math.round(body.amount * 1e8) + 'nMOCKBOLT11' + id.replace(/-/g, '').slice(0, 20);
|
||||
const addr = 'bc1qmock' + id.replace(/-/g, '').slice(0, 30);
|
||||
const rec = {
|
||||
id,
|
||||
status: 'New',
|
||||
checkoutUrl: `https://mock.btcpay/checkout/${id}`,
|
||||
paymentUrl: `https://mock.btcpay/pay/${id}`,
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
metadata: body.metadata || {},
|
||||
paymentMethods: [
|
||||
{ cryptoCode: 'BTC', data: { address: addr } },
|
||||
{ cryptoCode: 'LIGHTNING', bolt11 },
|
||||
],
|
||||
};
|
||||
invoices.set(id, rec);
|
||||
console.log(`[mock] invoice ${id} ${body.amount} ${body.currency}`);
|
||||
setTimeout(() => settle(id, 'timer'), SETTLE_MS);
|
||||
return json(res, 201, rec);
|
||||
}
|
||||
if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/invoices\/([^/]+)$/))) {
|
||||
const rec = invoices.get(m[1]);
|
||||
if (!rec) return json(res, 404, { message: 'not found' });
|
||||
return json(res, 200, rec);
|
||||
}
|
||||
if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/webhooks$/))) {
|
||||
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
|
||||
const body = await readBody(req);
|
||||
webhookUrl = body.url || webhookUrl;
|
||||
webhookSecret = body.secret || crypto.randomBytes(16).toString('hex');
|
||||
console.log(`[mock] webhook registered → ${webhookUrl} secret=${webhookSecret.slice(0, 8)}…`);
|
||||
return json(res, 200, { id: 'wh-mock-1', secret: webhookSecret, url: webhookUrl });
|
||||
}
|
||||
if (req.method === 'POST' && p === '/mock/settle') {
|
||||
// body {id} or path /mock/settle/:id
|
||||
const body = await readBody(req).catch(() => ({}));
|
||||
const id = body.id;
|
||||
if (id) return settle(id, 'manual'), json(res, 200, { ok: true });
|
||||
return json(res, 400, { message: 'need {id}' });
|
||||
}
|
||||
if (req.method === 'POST' && (m = p.match(/^\/mock\/settle\/([^/]+)$/))) {
|
||||
settle(m[1], 'manual');
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
json(res, 404, { message: 'mock: unknown route ' + req.method + ' ' + p });
|
||||
} catch (e) {
|
||||
json(res, 500, { message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
function settle(id, via) {
|
||||
const rec = invoices.get(id);
|
||||
if (!rec || rec.status === 'Settled') return;
|
||||
rec.status = 'Settled';
|
||||
console.log(`[mock] settling ${id} via ${via} → firing webhook to ${webhookUrl}`);
|
||||
const payload = JSON.stringify({
|
||||
event: 'InvoiceSettled',
|
||||
invoice: id,
|
||||
storeId: STORE_ID,
|
||||
status: 'Settled',
|
||||
amount: rec.amount,
|
||||
currency: rec.currency,
|
||||
});
|
||||
fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'BTCPay-Sig': 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(payload).digest('hex'),
|
||||
},
|
||||
body: payload,
|
||||
}).catch(e => console.error('[mock] webhook delivery failed:', e.message));
|
||||
}
|
||||
|
||||
server.listen(PORT, () => console.log(`Mock BTCPay on :${PORT} (store=${STORE_ID}, settles after ${SETTLE_MS}ms)`));
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
{
|
||||
"name": "kitchen484-btc-pay",
|
||||
"version": "1.0.0",
|
||||
"name": "kitchen484-server",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "BTCPay proxy backend for KITCHEN 484 / SOLARPUNK SUMMIT — keeps the BTCPay API key server-side",
|
||||
"description": "KITCHEN 484 / SOLARPUNK SUMMIT \u2014 pre-order + ready-status backend",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"mock": "node mock-btcpay.js",
|
||||
"test:e2e": "node e2e-test.js"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
607
server/server.js
607
server/server.js
|
|
@ -1,425 +1,236 @@
|
|||
/**
|
||||
* KITCHEN 484 / SOLARPUNK SUMMIT — BTCPay payment backend
|
||||
* ------------------------------------------------------------------
|
||||
* A tiny Node (no dependencies) proxy that:
|
||||
* - keeps the BTCPay API key SERVER-SIDE (never sent to the browser)
|
||||
* - creates BTCPay invoices (onchain BTC + Lightning bolt11)
|
||||
* - reports status via polling AND Server-Sent Events
|
||||
* - receives BTCPay webhooks (HMAC-SHA256 verified via BTCPay-Sig)
|
||||
* - serves the static site from the parent directory
|
||||
*
|
||||
* Env (also persisted to .env next to this file):
|
||||
* PORT listen port (default 8787)
|
||||
* BTCPAY_URL e.g. https://btcpay.example.com
|
||||
* BTCPAY_STORE store id
|
||||
* BTCPAY_API_KEY store api key (token)
|
||||
* WEBHOOK_SECRET secret used by BTCPay to sign webhook deliveries
|
||||
* WEBHOOK_PUBLIC_URL your public origin, e.g. https://kitchen.example.com
|
||||
* (used when auto-registering the webhook)
|
||||
* DATA_FILE where invoices are persisted (default .data/invoices.json)
|
||||
*/
|
||||
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 STATIC_DIR = path.resolve(__dirname, '..');
|
||||
const ENV_FILE = path.join(__dirname, '.env');
|
||||
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'] || '';
|
||||
|
||||
/* ----------------------------- config ----------------------------- */
|
||||
function loadEnv() {
|
||||
if (!fs.existsSync(ENV_FILE)) return {};
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
return out;
|
||||
// --- 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';
|
||||
// --- 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 saveEnv(obj) {
|
||||
const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`);
|
||||
fs.writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
|
||||
function saveOrders() {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveOrdersNow, 250);
|
||||
}
|
||||
const cfg = {
|
||||
port: Number(process.env.PORT || 8787),
|
||||
url: process.env.BTCPAY_URL || '',
|
||||
store: process.env.BTCPAY_STORE || '',
|
||||
apiKey: process.env.BTCPAY_API_KEY || '',
|
||||
webhookSecret: process.env.WEBHOOK_SECRET || '',
|
||||
publicUrl: process.env.WEBHOOK_PUBLIC_URL || '',
|
||||
dataFile: process.env.DATA_FILE || path.join(__dirname, '.data/invoices.json'),
|
||||
};
|
||||
Object.assign(cfg, loadEnv());
|
||||
process.on('SIGTERM', () => { saveOrdersNow(); process.exit(0); });
|
||||
process.on('SIGINT', () => { saveOrdersNow(); process.exit(0); });
|
||||
|
||||
function configured() {
|
||||
return Boolean(cfg.url && cfg.store && cfg.apiKey);
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
/* --------------------------- persistence --------------------------- */
|
||||
const invoices = new Map(); // btcpayInvoiceId -> record
|
||||
function loadInvoices() {
|
||||
try {
|
||||
for (const rec of JSON.parse(fs.readFileSync(cfg.dataFile, 'utf8'))) {
|
||||
invoices.set(rec.id, rec);
|
||||
}
|
||||
} catch { /* first run */ }
|
||||
}
|
||||
function persist() {
|
||||
fs.mkdirSync(path.dirname(cfg.dataFile), { recursive: true });
|
||||
fs.writeFileSync(cfg.dataFile, JSON.stringify([...invoices.values()], null, 2));
|
||||
}
|
||||
loadInvoices();
|
||||
|
||||
/* ------------------------- BTCPay API client ------------------------ */
|
||||
async function btcpay(pathname, { method = 'GET', body } = {}) {
|
||||
const base = cfg.url.replace(/\/+$/, '');
|
||||
const res = await fetch(base + pathname, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'token ' + cfg.apiKey,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
||||
if (!res.ok) {
|
||||
const err = new Error(`BTCPay ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/* ------------------------------ helpers ----------------------------- */
|
||||
const PAID = new Set(['Paid', 'Complete', 'Settled', 'Confirmed']);
|
||||
const DEAD = new Set(['Expired', 'Invalid', 'Cancelled', 'Failed']);
|
||||
function normStatus(s) {
|
||||
s = String(s || 'New');
|
||||
if (PAID.has(s)) return 'Paid';
|
||||
if (DEAD.has(s)) return 'Expired';
|
||||
return s; // New | Processing | …
|
||||
}
|
||||
function sseClients() {
|
||||
// Map invoiceId -> Set<res>; plus a '*' key for global listeners
|
||||
return global.__sse;
|
||||
}
|
||||
const sse = new Map();
|
||||
global.__sse = sse;
|
||||
function broadcast(id, payload) {
|
||||
for (const key of [id, '*']) {
|
||||
const set = sse.get(key);
|
||||
if (!set) continue;
|
||||
const msg = `data: ${JSON.stringify(payload)}\n\n`;
|
||||
for (const res of set) { try { res.write(msg); } catch { set.delete(res); } }
|
||||
}
|
||||
}
|
||||
function updateInvoice(id, status, extra = {}) {
|
||||
const rec = invoices.get(id);
|
||||
if (!rec) return;
|
||||
const next = normStatus(status);
|
||||
if (next !== rec.status) {
|
||||
rec.status = next;
|
||||
rec.updatedAt = Date.now();
|
||||
persist();
|
||||
broadcast(id, { type: 'status', id, status: next });
|
||||
}
|
||||
Object.assign(rec, extra);
|
||||
persist();
|
||||
}
|
||||
|
||||
/* simple per-IP rate limit for invoice creation */
|
||||
const hitTimes = new Map();
|
||||
function rateLimit(ip, max = 10, windowMs = 60_000) {
|
||||
const now = Date.now();
|
||||
const arr = (hitTimes.get(ip) || []).filter(t => now - t < windowMs);
|
||||
if (arr.length >= max) return false;
|
||||
arr.push(now);
|
||||
hitTimes.set(ip, arr);
|
||||
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;
|
||||
}
|
||||
|
||||
/* ----------------------------- static ------------------------------ */
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript',
|
||||
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain',
|
||||
};
|
||||
function serveStatic(req, res, url) {
|
||||
let p = decodeURIComponent(url.pathname);
|
||||
if (p === '/') p = '/index.html';
|
||||
const file = path.normalize(path.join(STATIC_DIR, p));
|
||||
if (!file.startsWith(STATIC_DIR)) { res.writeHead(403); return res.end('forbidden'); }
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); }
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
|
||||
res.end(data);
|
||||
});
|
||||
// --- 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);
|
||||
}
|
||||
|
||||
/* ------------------------------ http ------------------------------- */
|
||||
// --- helpers -------------------------------------------------------------
|
||||
function json(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const ip = req.socket.remoteAddress || 'unknown';
|
||||
try {
|
||||
/* CORS preflight */
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const p = url.pathname;
|
||||
|
||||
/* ---------- health ---------- */
|
||||
if (p === '/health') {
|
||||
return json(res, 200, { ok: true, configured: configured(), time: Date.now() });
|
||||
}
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
if (p === '/api/settings' && req.method === 'GET') {
|
||||
return json(res, 200, { configured: configured(), url: cfg.url || null, store: cfg.store || null });
|
||||
}
|
||||
if (p === '/api/settings' && req.method === 'POST') {
|
||||
// body: {url, store, apiKey} — validates the connection, then persists
|
||||
const body = await readBody(req);
|
||||
const u = String(body.url || '').trim();
|
||||
const store = String(body.store || '').trim();
|
||||
const key = String(body.apiKey || '').trim();
|
||||
if (!u || !store) return json(res, 400, { ok: false, error: 'url and store are required' });
|
||||
let probe = null;
|
||||
try {
|
||||
const base = u.replace(/\/+$/, '');
|
||||
const r = await fetch(base + '/api/v1/stores/' + encodeURIComponent(store), {
|
||||
headers: { Authorization: 'token ' + key, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const t = await r.text();
|
||||
let j = {}; try { j = JSON.parse(t); } catch { /* ignore */ }
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status} ${t.slice(0, 160)}`);
|
||||
probe = { storeId: j.id, storeName: j.name || null, network: j.network || null };
|
||||
} catch (e) {
|
||||
return json(res, 400, { ok: false, error: 'Could not verify store: ' + e.message });
|
||||
}
|
||||
cfg.url = u; cfg.store = store; cfg.apiKey = key;
|
||||
persistConfig();
|
||||
return json(res, 200, { ok: true, ...probe });
|
||||
}
|
||||
|
||||
/* ---------- webhook secret (manual mode) ---------- */
|
||||
if (p === '/api/webhook-secret' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
cfg.webhookSecret = String(body.secret || '').trim();
|
||||
persistConfig();
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- auto-register webhook ---------- */
|
||||
if (p === '/api/webhook/register' && req.method === 'POST') {
|
||||
if (!configured()) return json(res, 400, { ok: false, error: 'BTCPay not configured' });
|
||||
const publicUrl = cfg.publicUrl ? cfg.publicUrl.replace(/\/+$/, '') : '';
|
||||
if (!publicUrl) return json(res, 400, { ok: false, error: 'Set WEBHOOK_PUBLIC_URL in .env first' });
|
||||
const wh = await btcpay(`/api/v1/stores/${cfg.store}/webhooks`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
url: `${publicUrl}/api/btcpay/webhook`,
|
||||
enabled: true,
|
||||
automaticRedelivery: true,
|
||||
authorizedEvents: { invoiceSettled: true, invoiceExpired: true, invoiceInvalid: true, invoiceReceivedPayment: true },
|
||||
},
|
||||
});
|
||||
cfg.webhookSecret = wh.secret || cfg.webhookSecret;
|
||||
persistConfig();
|
||||
return json(res, 200, { ok: true, webhookId: wh.id, secretSet: Boolean(wh.secret) });
|
||||
}
|
||||
|
||||
/* ---------- create invoice ---------- */
|
||||
if (p === '/api/invoices' && req.method === 'POST') {
|
||||
if (!configured()) return json(res, 503, { error: 'BTCPay not configured — save settings first' });
|
||||
if (!rateLimit(ip)) return json(res, 429, { error: 'Too many invoices, slow down' });
|
||||
const body = await readBody(req);
|
||||
const amount = Number(body.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return json(res, 400, { error: 'amount must be a positive number' });
|
||||
const currency = String(body.currency || 'USD').toUpperCase();
|
||||
const orderCode = String(body.orderCode || '').slice(0, 64);
|
||||
const description = String(body.description || 'KITCHEN 484 order').slice(0, 512);
|
||||
const inv = await btcpay(`/api/v1/stores/${cfg.store}/invoices`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
amount: Math.round(amount * 1e8) / 1e8,
|
||||
currency,
|
||||
description,
|
||||
expirationInterval: 30 * 60, // 30 min
|
||||
metadata: { orderCode, ...pick(body.metadata, ['pickup', 'name', 'items', 'day']) },
|
||||
},
|
||||
});
|
||||
const id = inv.id;
|
||||
const rec = {
|
||||
id,
|
||||
orderCode,
|
||||
amount,
|
||||
currency,
|
||||
status: normStatus(inv.status),
|
||||
bolt11: extractBolt11(inv),
|
||||
btcAddress: extractBtcAddress(inv),
|
||||
checkoutUrl: inv.checkoutUrl || inv.paymentUrl || null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
invoices.set(id, rec);
|
||||
persist();
|
||||
broadcast(id, { type: 'created', id, status: rec.status });
|
||||
return json(res, 201, publicInvoice(rec));
|
||||
}
|
||||
|
||||
/* ---------- invoice status (polling) ---------- */
|
||||
let m;
|
||||
if ((m = p.match(/^\/api\/invoices\/([^/]+)$/)) && req.method === 'GET') {
|
||||
const rec = invoices.get(m[1]);
|
||||
if (!rec) return json(res, 404, { error: 'unknown invoice' });
|
||||
return json(res, 200, publicInvoice(rec));
|
||||
}
|
||||
|
||||
/* ---------- invoice status (SSE) ---------- */
|
||||
if ((m = p.match(/^\/api\/invoices\/([^/]+)\/events$/)) && req.method === 'GET') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.write(`retry: 3000\n\n`);
|
||||
const id = m[1];
|
||||
const set = new Set([res]);
|
||||
sse.set(id, set);
|
||||
const rec = invoices.get(id);
|
||||
if (rec) res.write(`data: ${JSON.stringify({ type: 'status', id, status: rec.status })}\n\n`);
|
||||
const ping = setInterval(() => { try { res.write(`: ping\n\n`); } catch { /* closed */ } }, 25_000);
|
||||
req.on('close', () => {
|
||||
clearInterval(ping);
|
||||
set.delete(res);
|
||||
if (set.size === 0) sse.delete(id);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* ---------- BTCPay webhook ---------- */
|
||||
if (p === '/api/btcpay/webhook' && req.method === 'POST') {
|
||||
const raw = await readRaw(req);
|
||||
const sig = req.headers['btcpay-sig'];
|
||||
if (!cfg.webhookSecret || !verifyBtcpaySig(raw, sig, cfg.webhookSecret)) {
|
||||
return json(res, 401, { error: 'bad signature' });
|
||||
}
|
||||
let data; try { data = JSON.parse(raw.toString('utf8')); } catch { data = {}; }
|
||||
const invId = data.invoice; // BTCPay sends the invoice id in the payload
|
||||
const rec = invId && invoices.get(String(invId));
|
||||
if (!rec) {
|
||||
// Could be an invoice created before a restart; accept and log it.
|
||||
console.log('[webhook] unknown invoice', invId, 'event', data.event);
|
||||
return json(res, 200, { ok: true, unknown: true });
|
||||
}
|
||||
const event = String(data.event || '');
|
||||
let status = normStatus(data.status || rec.status);
|
||||
if (event === 'InvoiceSettled' || event === 'invoice_settled') status = 'Paid';
|
||||
if (event === 'InvoiceExpired') status = 'Expired';
|
||||
if (event === 'InvoiceInvalid') status = 'Expired';
|
||||
updateInvoice(rec.id, status, { event, receivedAt: Date.now() });
|
||||
console.log(`[webhook] ${rec.id} ${event} → ${rec.status}`);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- everything else: static site ---------- */
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res, url);
|
||||
res.writeHead(405, { 'Access-Control-Allow-Origin': '*' });
|
||||
return res.end('method not allowed');
|
||||
} catch (e) {
|
||||
const code = e.status || 500;
|
||||
console.error('[error]', e.message);
|
||||
return json(res, code, { error: e.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
function persistConfig() {
|
||||
const cur = loadEnv();
|
||||
saveEnv({
|
||||
BTCPAY_URL: cfg.url,
|
||||
BTCPAY_STORE: cfg.store,
|
||||
BTCPAY_API_KEY: cfg.apiKey,
|
||||
WEBHOOK_SECRET: cfg.webhookSecret,
|
||||
WEBHOOK_PUBLIC_URL: cfg.publicUrl,
|
||||
...pick(cur, ['PORT', 'DATA_FILE']),
|
||||
});
|
||||
}
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', c => { data += c; if (data.length > 1e6) { reject(new Error('body too large')); req.destroy(); } });
|
||||
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { reject(new Error('bad json')); } });
|
||||
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);
|
||||
});
|
||||
}
|
||||
function readRaw(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', c => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
function pick(obj, keys) {
|
||||
const out = {};
|
||||
for (const k of keys) if (obj && obj[k] !== undefined) out[k] = obj[k];
|
||||
return out;
|
||||
}
|
||||
function publicInvoice(rec) {
|
||||
return {
|
||||
id: rec.id, orderCode: rec.orderCode, amount: rec.amount, currency: rec.currency,
|
||||
status: rec.status, bolt11: rec.bolt11 || null, btcAddress: rec.btcAddress || null,
|
||||
checkoutUrl: rec.checkoutUrl || null, createdAt: rec.createdAt, updatedAt: rec.updatedAt,
|
||||
};
|
||||
}
|
||||
function extractBolt11(inv) {
|
||||
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'LIGHTNING');
|
||||
return pm && (pm.bolt11 || (pm.data && pm.data.bolt11)) || null;
|
||||
}
|
||||
function extractBtcAddress(inv) {
|
||||
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'BTC');
|
||||
if (pm && pm.data && pm.data.address) return pm.data.address;
|
||||
if (pm && pm.address) return pm.address;
|
||||
if (typeof inv.paymentAddresses === 'string') return inv.paymentAddresses;
|
||||
if (inv.paymentAddresses && inv.paymentAddresses.BTC) return inv.paymentAddresses.BTC;
|
||||
return null;
|
||||
}
|
||||
/** BTCPay webhook signature: BTCPay-Sig: sha256=<hex hmac of raw body with secret> */
|
||||
function verifyBtcpaySig(rawBody, sigHeader, secret) {
|
||||
if (!sigHeader || !String(sigHeader).startsWith('sha256=')) return false;
|
||||
const given = String(sigHeader).slice('sha256='.length);
|
||||
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(given, 'hex'), Buffer.from(expected, 'hex'));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
server.listen(cfg.port, () => {
|
||||
console.log(`KITCHEN 484 pay backend listening on :${cfg.port}`);
|
||||
console.log(configured()
|
||||
? `BTCPay: ${cfg.url} store=${cfg.store} webhookSecret=${cfg.webhookSecret ? 'set' : 'MISSING'}`
|
||||
: 'BTCPay NOT configured — POST /api/settings with {url, store, apiKey}');
|
||||
if (cfg.publicUrl) console.log(`Public origin for webhook: ${cfg.publicUrl}/api/btcpay/webhook`);
|
||||
});
|
||||
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 || DEFAULT_LOCATION).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')));
|
||||
|
|
|
|||
189
server/staff.html
Normal file
189
server/staff.html
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KITCHEN 484 · Staff</title>
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#faf6ef;--card:#fff;--ink:#0A0A0A;--muted:#8a8378;--line:#e7e0d4;--green:#16a34a;--red:#dc2626}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--ink);font-family:'Orbitron',sans-serif;min-height:100vh}
|
||||
header{position:sticky;top:0;background:var(--ink);color:#fff;padding:14px 20px;display:flex;align-items:center;gap:12px;z-index:5}
|
||||
header h1{font-size:15px;font-weight:900;letter-spacing:.08em}
|
||||
header .sub{font-size:10px;color:#b5ac9d}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:18px 14px 60px}
|
||||
.login{max-width:340px;margin:80px auto;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
||||
.login h2{font-size:14px;letter-spacing:.1em;margin-bottom:14px}
|
||||
input[type=password]{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;font-family:inherit;font-size:14px;background:#fff}
|
||||
button{font-family:inherit;cursor:pointer;border:none;border-radius:10px;font-weight:700}
|
||||
.btn{width:100%;padding:12px;margin-top:12px;background:var(--ink);color:#fff;font-size:13px;letter-spacing:.05em}
|
||||
.err{color:var(--red);font-size:11px;margin-top:8px;display:none}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
|
||||
.toolbar select{padding:8px 10px;border:1px solid var(--line);border-radius:10px;font-family:inherit;font-size:12px;background:#fff}
|
||||
.count{margin-left:auto;font-size:11px;color:var(--muted)}
|
||||
.order{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;margin-bottom:10px}
|
||||
.order.ready{border-color:var(--green);box-shadow:0 0 0 1px var(--green)}
|
||||
.top{display:flex;align-items:center;gap:10px}
|
||||
.code{font-weight:900;font-size:15px}
|
||||
.badge{font-size:9px;font-weight:900;padding:3px 8px;border-radius:99px;letter-spacing:.08em}
|
||||
.b-prep{background:#fef3c7;color:#92400e}
|
||||
.b-ready{background:#dcfce7;color:#15803d}
|
||||
.b-done{background:#e5e7eb;color:#4b5563}
|
||||
.meta{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.6}
|
||||
.items{font-size:12px;margin-top:6px;font-weight:700}
|
||||
.stand-tag{display:inline-block;background:#fff7ed;border:1px solid #fdba74;color:#9a3412;font-size:10px;font-weight:900;padding:2px 8px;border-radius:99px;margin-top:6px}
|
||||
.acts{display:flex;gap:8px;margin-top:10px}
|
||||
.acts button{padding:8px 14px;font-size:11px}
|
||||
.go-ready{background:var(--green);color:#fff}
|
||||
.collected{background:#e5e7eb;color:#374151}
|
||||
.cancel{background:transparent;color:var(--red);border:1px solid #fecaca;margin-left:auto}
|
||||
.empty{text-align:center;color:var(--muted);font-size:12px;padding:50px 0;line-height:1.8}
|
||||
.conn{font-size:10px;font-weight:900;padding:3px 8px;border-radius:99px;margin-left:auto}
|
||||
.conn.ok{background:#123c1e;color:#4ade80}
|
||||
.conn.bad{background:#3c1212;color:#f87171}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>KITCHEN 484 · STAFF</h1>
|
||||
<div class="sub">pre-order board — tap READY when the food is up</div>
|
||||
</div>
|
||||
<div class="conn" id="conn"></div>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<div class="login" id="login">
|
||||
<h2>STAFF ACCESS</h2>
|
||||
<input type="password" id="pass" placeholder="Staff passcode" autocomplete="current-password">
|
||||
<button class="btn" id="do-login">Unlock</button>
|
||||
<div class="err" id="login-err"></div>
|
||||
</div>
|
||||
<div id="board" style="display:none">
|
||||
<div class="toolbar">
|
||||
<select id="f-stand"><option value="">All stands</option></select>
|
||||
<select id="f-status">
|
||||
<option value="ACTIVE">Active (preparing + ready)</option>
|
||||
<option value="">All statuses</option>
|
||||
<option value="PREPARING">Preparing</option>
|
||||
<option value="READY">Ready</option>
|
||||
<option value="COLLECTED">Collected</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
<button id="logout" style="background:none;color:var(--muted);font-size:11px;border:1px solid var(--line)">Lock</button>
|
||||
<div class="count" id="count"></div>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
"use strict";
|
||||
var $=function(s){return document.querySelector(s)};
|
||||
var token=sessionStorage.getItem('k484_staff_token')||null;
|
||||
var orders=[],es=null,poll=null;
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
function api(path,opts){
|
||||
opts=opts||{};
|
||||
opts.headers=Object.assign({'Authorization':'Bearer '+token},opts.headers||{});
|
||||
return fetch(path,opts).then(function(r){if(r.status===401){lock();throw new Error('locked')}return r});
|
||||
}
|
||||
function lock(){
|
||||
token=null;sessionStorage.removeItem('k484_staff_token');
|
||||
if(es){es.close();es=null}
|
||||
if(poll){clearInterval(poll);poll=null}
|
||||
$('#board').style.display='none';
|
||||
$('#login').style.display='block';
|
||||
}
|
||||
function setConn(ok){var c=$('#conn');c.className='conn '+(ok?'ok':'bad');c.textContent=ok?'LIVE':'OFFLINE'}
|
||||
function load(){
|
||||
if(!token)return Promise.resolve();
|
||||
return api('/api/orders').then(function(r){
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
return r.json();
|
||||
}).then(function(j){orders=j;setConn(true);render()}).catch(function(e){
|
||||
if(String(e.message)!=='locked')setConn(false);
|
||||
});
|
||||
}
|
||||
var BADGE={
|
||||
PREPARING:'<span class="badge b-prep">PREPARING</span>',
|
||||
READY:'<span class="badge b-ready">READY</span>',
|
||||
COLLECTED:'<span class="badge b-done">COLLECTED</span>',
|
||||
CANCELLED:'<span class="badge b-done">CANCELLED</span>'
|
||||
};
|
||||
function render(){
|
||||
var st=$('#f-stand').value,fs=$('#f-status').value;
|
||||
var rows=orders.filter(function(o){
|
||||
if(st&&o.stand!==st)return false;
|
||||
if(fs==='ACTIVE')return o.status==='PREPARING'||o.status==='READY';
|
||||
if(fs)return o.status===fs;
|
||||
return true;
|
||||
});
|
||||
var nPrep=orders.filter(function(o){return o.status==='PREPARING'}).length;
|
||||
var nReady=orders.filter(function(o){return o.status==='READY'}).length;
|
||||
$('#count').textContent=nPrep+' preparing · '+nReady+' ready';
|
||||
var stands=[];
|
||||
orders.forEach(function(o){if(o.stand&&stands.indexOf(o.stand)<0)stands.push(o.stand)});
|
||||
var sel=$('#f-stand');
|
||||
if(sel.options.length!==stands.length+1){
|
||||
sel.innerHTML='<option value="">All stands</option>'+stands.map(function(s){return '<option>'+esc(s)+'</option>'}).join('');
|
||||
sel.value=stands.indexOf(st)>=0?st:'';
|
||||
}
|
||||
if(!rows.length){$('#list').innerHTML='<div class="empty">nothing here<br>🌻</div>';return}
|
||||
$('#list').innerHTML=rows.map(function(o){
|
||||
var t=o.createdAt?new Date(o.createdAt):null;
|
||||
var time=t?'<span style="margin-left:auto;font-size:10px;color:var(--muted)">'+t.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'</span>':'';
|
||||
var acts='';
|
||||
if(o.status==='PREPARING')acts='<div class="acts"><button class="go-ready" data-act="READY">✓ Mark ready</button><button class="cancel" data-act="CANCELLED">Cancel</button></div>';
|
||||
else if(o.status==='READY')acts='<div class="acts"><button class="collected" data-act="COLLECTED">Handed over</button></div>';
|
||||
return '<div class="order'+(o.status==='READY'?' ready':'')+'" data-code="'+esc(o.code)+'">'+
|
||||
'<div class="top"><span class="code">'+esc(o.code)+'</span>'+(BADGE[o.status]||'')+time+'</div>'+
|
||||
'<div class="items">'+esc(o.itemsSummary)+'</div>'+
|
||||
'<div class="meta">'+esc(o.day||'')+' · pickup '+esc(o.pickupLabel)+' · <b>'+esc(o.name)+'</b>'+(o.notes?' · 📝 '+esc(o.notes):'')+'</div>'+
|
||||
(o.stand?'<span class="stand-tag">📍 '+esc(o.stand)+(o.standLocation?' — '+esc(o.standLocation):'')+'</span>':'')+
|
||||
acts+'</div>';
|
||||
}).join('');
|
||||
Array.prototype.forEach.call($('#list').querySelectorAll('[data-act]'),function(b){
|
||||
b.onclick=function(){
|
||||
var code=b.closest('.order').dataset.code;
|
||||
b.disabled=true;
|
||||
api('/api/orders/'+encodeURIComponent(code)+'/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:b.dataset.act})})
|
||||
.then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return load()})
|
||||
.catch(function(e){b.disabled=false;if(String(e.message)!=='locked')alert('update failed: '+e.message)});
|
||||
};
|
||||
});
|
||||
}
|
||||
function connect(){
|
||||
if(es){es.close();es=null}
|
||||
try{
|
||||
es=new EventSource('/api/orders/stream?token='+encodeURIComponent(token));
|
||||
es.onmessage=function(e){try{var d=JSON.parse(e.data);if(d.type==='ready')load()}catch(_){}};
|
||||
es.onerror=function(){setConn(false)};
|
||||
}catch(_){setConn(false)}
|
||||
}
|
||||
function startBoard(){
|
||||
$('#login').style.display='none';
|
||||
$('#board').style.display='block';
|
||||
load().then(function(){connect();if(poll)clearInterval(poll);poll=setInterval(load,15000)});
|
||||
}
|
||||
$('#do-login').onclick=function(){
|
||||
var err=$('#login-err');err.style.display='none';
|
||||
fetch('/api/staff/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({passcode:$('#pass').value})})
|
||||
.then(function(r){return r.json().then(function(j){return{ok:r.ok,j:j}})})
|
||||
.then(function(res){
|
||||
if(!res.ok||!res.j.token){err.textContent='Wrong passcode';err.style.display='block';return}
|
||||
token=res.j.token;sessionStorage.setItem('k484_staff_token',token);
|
||||
startBoard();
|
||||
})
|
||||
.catch(function(){err.textContent='Could not reach the server';err.style.display='block'});
|
||||
};
|
||||
$('#pass').addEventListener('keydown',function(e){if(e.key==='Enter')$('#do-login').click()});
|
||||
$('#logout').onclick=lock;
|
||||
$('#f-stand').onchange=render;
|
||||
$('#f-status').onchange=render;
|
||||
if(token)startBoard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue