/** * KoolTap Interactive Device Simulator * * An interactive CLI that simulates a Flutter bar terminal device. * Keeps running until you press Q. * * Usage: npx ts-node scripts/device-sync.ts */ import * as readline from 'readline'; const API_URL = process.env.API_URL || 'http://localhost:3001/api/v1'; // ── State ──────────────────────────────────────────────── let deviceId = ''; let token = ''; let cursor: string | null = null; let venueId = ''; let registrationCode = ''; type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; type SimTab = { customerName: string; version: number; items: TabLine[] }; const openTabs: Map = new Map(); let pendingCommands: Array<{ id: string; type: string; tabId: string; expectedVersion: number; paymentMethod: string }> = []; let tabCounter = 0; let plannedNames: string[] = []; let plannedIndex = 0; // A small bar menu to fill tabs with const MENU: Array<{ productName: string; unitPriceInCents: number }> = [ { productName: 'Cola', unitPriceInCents: 250 }, { productName: 'Jupiler', unitPriceInCents: 280 }, { productName: 'Duvel', unitPriceInCents: 450 }, { productName: 'Spa rood', unitPriceInCents: 230 }, { productName: 'Koffie', unitPriceInCents: 250 }, { productName: 'Chips', unitPriceInCents: 200 }, ]; const tabTotal = (tab: SimTab) => tab.items.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0); const tabItemCount = (tab: SimTab) => tab.items.reduce((sum, line) => sum + line.quantity, 0); const euro = (cents: number) => `€${(cents / 100).toFixed(2)}`; /** 1 to 3 different products, 1 to 4 of each. */ function randomLines(): TabLine[] { const shuffled = [...MENU].sort(() => Math.random() - 0.5); return shuffled.slice(0, Math.floor(Math.random() * 3) + 1).map((product) => ({ ...product, quantity: Math.floor(Math.random() * 4) + 1, })); } // ── API helpers ────────────────────────────────────────── async function api(endpoint: string, options: RequestInit = {}): Promise { const { headers: customHeaders, ...rest } = options; const res = await fetch(`${API_URL}${endpoint}`, { ...rest, headers: { 'Content-Type': 'application/json', ...(customHeaders as Record) }, }); if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.error?.message || `HTTP ${res.status}`); } return res.json(); } // ── Actions ────────────────────────────────────────────── // The server treats openTabs as the complete set: tabs left out are considered closed function buildTabsPayload() { return Array.from(openTabs.entries()).map(([id, t]) => ({ id, customerName: t.customerName, version: t.version, totalInCents: tabTotal(t), itemCount: tabItemCount(t), items: t.items, })); } async function fetchPlannedCustomers() { // Planned names come from the sync response — do a quick sync to refresh them if (!deviceId || !token) { console.log('\n ❌ Not registered yet.'); return; } try { const result = await api<{ plannedTabNames?: string[] }>(`/devices/${deviceId}/sync`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ deviceId, cursor, openTabs: buildTabsPayload() }), }); if (result.plannedTabNames && result.plannedTabNames.length > 0) { plannedNames = result.plannedTabNames; plannedIndex = 0; } console.log(`\n 📋 Loaded ${plannedNames.length} planned customer(s) from server`); if (plannedNames.length > 0) { console.log(` ${plannedNames.join(', ')}`); } } catch { console.log('\n ⚠️ Could not fetch planned customers (using fallback names)'); } } async function registerDevice() { const name = 'Simulated Terminal'; if (!registrationCode) { console.log('\n ❌ No registration code set. Use P to pair first.'); return; } try { const result = await api('/devices/register', { method: 'POST', body: JSON.stringify({ venueId, name, registrationCode }), }); deviceId = result.deviceId; token = result.token; console.log(`\n ✅ Registered as "${name}"`); console.log(` 🆔 Device ID: ${deviceId}`); console.log(` 🔑 Token: ${token.substring(0, 20)}...`); await fetchPlannedCustomers(); } catch (err: any) { console.log(`\n ❌ Registration failed: ${err.message}`); } } function addTab() { tabCounter++; const tabId = `tab-${Date.now()}-${tabCounter}`; const fallbackNames = ['Jan', 'Piet', 'Kees', 'Anna', 'Maria', 'Lisa', 'Tom', 'Emma', 'Noah', 'Sophie']; const names = plannedNames.length > 0 ? plannedNames : fallbackNames; const name = names[plannedIndex % names.length]; plannedIndex++; const tab: SimTab = { customerName: name, version: 1, items: randomLines() }; openTabs.set(tabId, tab); const lines = tab.items.map((line) => `${line.quantity}x ${line.productName}`).join(', '); console.log(` 📋 Tab added: ${name} — ${euro(tabTotal(tab))} (${lines}) [${tabId.substring(0, 8)}]`); } async function sync() { if (!deviceId || !token) { console.log('\n ❌ Not registered. Press P to pair first.'); return; } const tabsPayload = buildTabsPayload(); try { const result = await api<{ cursor: string; commands: any[]; plannedTabNames?: string[] }>(`/devices/${deviceId}/sync`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ deviceId, cursor, openTabs: tabsPayload }), }); cursor = result.cursor; pendingCommands = result.commands; // Update planned customers from sync response if (result.plannedTabNames && result.plannedTabNames.length > 0) { plannedNames = result.plannedTabNames; plannedIndex = 0; } console.log(`\n ✅ Synced — ${openTabs.size} tab(s), ${pendingCommands.length} command(s) pending, ${plannedNames.length} planned`); for (const cmd of pendingCommands) { console.log(` 📦 ${cmd.type} on ${cmd.tabId.substring(0, 8)} (${cmd.paymentMethod})`); } } catch (err: any) { console.log(`\n ❌ Sync failed: ${err.message}`); } } async function ackAll() { if (pendingCommands.length === 0) { console.log('\n ℹ️ No pending commands to acknowledge.'); return; } for (const cmd of pendingCommands) { try { const status = Math.random() > 0.2 ? 'completed' : 'failed'; await api(`/devices/${deviceId}/commands/${cmd.id}/ack`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ commandId: cmd.id, status, result: `Tab closed via simulator (${status})` }), }); console.log(` ✅ Acked ${cmd.id.substring(0, 8)} as ${status}`); // Like the real app, closing settles the tab: its lines are cleared but the tab stays open const settled = openTabs.get(cmd.tabId); if (status === 'completed' && settled) { settled.items = []; settled.version++; console.log(` Settled tab ${cmd.tabId.substring(0, 8)} (now empty, v${settled.version})`); } else if (status !== 'completed') { // Bump version on failed close const tab = openTabs.get(cmd.tabId); if (tab) { tab.version++; console.log(` Bumped version to ${tab.version}`); } } } catch (err: any) { console.log(` ❌ Ack failed for ${cmd.id.substring(0, 8)}: ${err.message}`); } } pendingCommands = []; } function listTabs() { if (openTabs.size === 0) { console.log('\n ℹ️ No tabs. Press T to add one.'); return; } console.log(`\n 📋 Open Tabs (${openTabs.size}):`); Array.from(openTabs.entries()).forEach(([id, t]) => { console.log(` ${id.substring(0, 8)} — ${t.customerName} — ${euro(tabTotal(t))} (v${t.version})`); for (const line of t.items) { console.log(` ${line.quantity}x ${line.productName} ${euro(line.quantity * line.unitPriceInCents)}`); } }); } function showStatus() { console.log(`\n ── Status ──────────────────────`); console.log(` Venue ID: ${venueId || '—'}`); console.log(` Device ID: ${deviceId || '—'}`); console.log(` Token: ${token ? token.substring(0, 20) + '...' : '—'}`); console.log(` Cursor: ${cursor || '—'}`); console.log(` Planned: ${plannedNames.length} customer(s)`); console.log(` Tabs: ${openTabs.size}`); console.log(` Pending: ${pendingCommands.length}`); console.log(` ────────────────────────────────`); } async function setVenueAndCode() { // Temporarily disable raw mode so readline works if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(false); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); const v = await new Promise((resolve) => rl.question(' Venue ID: ', resolve)); venueId = v.trim(); const c = await new Promise((resolve) => rl.question(' Registration Code: ', resolve)); registrationCode = c.trim(); rl.close(); // Re-enable raw mode for keypress handling if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(true); if (venueId && c) { await registerDevice(); } } // ── Interactive loop ───────────────────────────────────── function printHelp() { console.log(` ╔══════════════════════════════════════════╗ ║ KoolTap Device Simulator ║ ╠══════════════════════════════════════════╣ ║ P — Pair / register device ║ ║ T — Add tab (uses planned customers) ║ ║ S — Sync with server ║ ║ A — Acknowledge all pending commands ║ ║ R — Refresh planned customers ║ ║ L — List open tabs ║ ║ H — Show this help ║ ║ Q — Quit ║ ╚══════════════════════════════════════════╝`); } async function main() { console.clear(); console.log('╔══════════════════════════════════════╗'); console.log('║ KoolTap Device Simulator ║'); console.log('╚══════════════════════════════════════╝'); // Prompt for venue / code on first run await setVenueAndCode(); printHelp(); showStatus(); readline.emitKeypressEvents(process.stdin); if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(true); process.stdin.resume(); process.stdin.on('keypress', async (_str: string, key: any) => { if (key.ctrl && key.name === 'c') { console.log('\n👋 Goodbye!'); process.exit(0); } switch (key.name?.toLowerCase()) { case 'p': await setVenueAndCode(); showStatus(); break; case 't': addTab(); break; case 'r': await fetchPlannedCustomers(); showStatus(); break; case 's': await sync(); showStatus(); break; case 'a': await ackAll(); showStatus(); break; case 'l': listTabs(); break; case 'h': printHelp(); break; case 'q': console.log('\n👋 Goodbye!'); process.exit(0); } }); console.log('\nPress H for help, or any key above to get started.'); } main().catch((err) => { console.error('Fatal:', err.message); process.exit(1); });