Files
kooltab-backoffice/scripts/device-sync.ts
T
brammie15 97572fe459
CI / build-and-test (push) Canceled after 0s
feat: add version check app
2026-10-27 00:05:00 +01:00

330 lines
12 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 = '';
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);
});