330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
/**
|
||
* 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<string, SimTab> = 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<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||
const { headers: customHeaders, ...rest } = options;
|
||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||
...rest,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
// Plays an up-to-date terminal app, so the minimum app version never refuses it
|
||
'X-App-Version': process.env.APP_VERSION || '999.0.0',
|
||
...(customHeaders as Record<string, string>),
|
||
},
|
||
});
|
||
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<string>((resolve) => rl.question(' Venue ID: ', resolve));
|
||
venueId = v.trim();
|
||
const c = await new Promise<string>((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);
|
||
}); |