Files
kooltab-backoffice/scripts/device-sync.ts
T
2026-10-08 00:25:30 +02:00

289 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = '';
const openTabs: Map<string, { customerName: string; version: number; totalInCents: number; itemCount: number }> = new Map();
let pendingCommands: Array<{ id: string; type: string; tabId: string; expectedVersion: number; paymentMethod: string }> = [];
let tabCounter = 0;
let plannedNames: string[] = [];
let plannedIndex = 0;
// ── 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', ...(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: t.totalInCents, itemCount: t.itemCount,
}));
}
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 items = Math.floor(Math.random() * 5) + 1;
const total = items * (Math.floor(Math.random() * 10) + 5) * 100;
openTabs.set(tabId, { customerName: name, version: 1, totalInCents: total, itemCount: items });
console.log(` 📋 Tab added: ${name} — €${(total / 100).toFixed(2)} (${items} items) [${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}`);
// If completed, remove the tab from local state
if (status === 'completed') {
openTabs.delete(cmd.tabId);
console.log(` Removed tab ${cmd.tabId.substring(0, 8)} from local state`);
} else {
// 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} — €${(t.totalInCents / 100).toFixed(2)} (v${t.version})`);
});
}
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);
});