feat: reminders, Excel customer import and security hardening

Features
- Payment reminders via Resend (candidates, send, history, test email)
- Bulk import of the customer list from Excel/CSV in the dashboard
- Dashboard i18n (en/nl) with language selector

Security
- Enforce venue isolation on all /venues/:venueId routes
- Require admin for planned-tab changes and pairing codes
- Rate-limit login and device registration; opt-in TRUST_PROXY
- Require JWT_SECRET in production; stop leaking internal errors
- Escape customer names in reminder emails

Sync and commands
- Remove tabs the terminal no longer reports; validate nested sync payload
- Return all pending commands on every sync (fixes missed-command race)
- First command ack wins; atomic registration code claim
- Reject commands for revoked devices, stale versions or duplicates
- Remind only unpaid balances, with a 7-day cooldown

Dashboard
- Share a single token refresh across concurrent 401s (random logouts)
- Keep the session alive for the refresh token lifetime

Deployment
- Replace `db push --accept-data-loss` with migrations (baselines legacy DBs)
- Set NODE_ENV=production; pass Resend and TRUST_PROXY env in prod compose
This commit is contained in:
2026-10-07 23:18:37 +02:00
parent 58eb8ddb8e
commit 8b1e38cb4d
56 changed files with 3168 additions and 376 deletions
+250 -167
View File
@@ -1,206 +1,289 @@
/**
* KoolTab Device Simulator
* KoolTab Interactive Device Simulator
*
* An interactive CLI that simulates a Flutter bar terminal device.
* Keeps running until you press Q.
*
* A CLI tool that simulates a Flutter bar terminal device.
* Usage: npx ts-node scripts/device-sync.ts
*
* Steps:
* 1. Register a device (or paste an existing token)
* 2. Sync open tabs
* 3. Receive pending commands
* 4. Acknowledge commands
*/
import * as readline from 'readline';
const API_URL = process.env.API_URL || 'http://localhost:3001/api/v1';
const args = process.argv.slice(2);
const command = args[0] || 'help';
interface SyncResult {
cursor: string;
commands: Array<{
id: string;
type: string;
tabId: string;
expectedVersion: number;
paymentMethod: string;
requestedBy: string;
createdAt: string;
}>;
}
// ── 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}`, {
headers: { 'Content-Type': 'application/json', ...(options.headers as Record<string, string>) },
...options,
...rest,
headers: { 'Content-Type': 'application/json', ...(customHeaders as Record<string, string>) },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const msg = body?.error?.message || `HTTP ${res.status}`;
throw new Error(msg);
throw new Error(body?.error?.message || `HTTP ${res.status}`);
}
return res.json();
}
async function registerDevice() {
const venueId = args[1];
const name = args[2] || 'Simulated Terminal';
const code = args[3];
// ── Actions ──────────────────────────────────────────────
if (!venueId || !code) {
console.error('Usage: npx ts-node scripts/device-sync.ts register <venueId> <name> <registrationCode>');
process.exit(1);
}
console.log(`Registering device "${name}" in venue ${venueId}...`);
const result = await api('/devices/register', {
method: 'POST',
body: JSON.stringify({ venueId, name, registrationCode: code }),
});
console.log('Device registered!');
console.log(` Device ID: ${result.deviceId}`);
console.log(` Token: ${result.token}`);
console.log(` Expires: ${result.tokenExpiresAt}`);
console.log('\nSave this token for future sync operations.');
return result;
// 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 syncDevice() {
const deviceId = args[1];
const token = args[2];
async function fetchPlannedCustomers() {
// Planned names come from the sync response — do a quick sync to refresh them
if (!deviceId || !token) {
console.error('Usage: npx ts-node scripts/device-sync.ts sync <deviceId> <token> [cursor]');
process.exit(1);
console.log('\n ❌ Not registered yet.');
return;
}
const cursor = args[3];
const tabs = [
{ id: 'tab-' + Date.now(), customerName: 'Simulated Customer', version: 1, totalInCents: 1500, itemCount: 2 },
];
console.log(`Syncing device ${deviceId}...`);
const result = await api<SyncResult>(`/devices/${deviceId}/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ deviceId, cursor: cursor || null, openTabs: tabs }),
});
console.log('Sync complete!');
console.log(` New cursor: ${result.cursor}`);
console.log(` Commands received: ${result.commands.length}`);
for (const cmd of result.commands) {
console.log(` Command: ${cmd.id} - ${cmd.type} (tab: ${cmd.tabId}, payment: ${cmd.paymentMethod})`);
}
return result;
}
async function ackCommand() {
const deviceId = args[1];
const token = args[2];
const commandId = args[3];
const status = args[4] || 'completed';
const result = args[5] || 'Tab closed successfully';
if (!deviceId || !token || !commandId) {
console.error('Usage: npx ts-node scripts/device-sync.ts ack <deviceId> <token> <commandId> [status] [result]');
process.exit(1);
}
console.log(`Acknowledging command ${commandId} as ${status}...`);
const res = await api(`/devices/${deviceId}/commands/${commandId}/ack`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ commandId, status, result }),
});
console.log('Ack complete!');
console.log(` Result: ${JSON.stringify(res)}`);
}
// Interactive demo sequence
async function demo() {
const venueId = args[1];
const code = args[2];
if (!venueId || !code) {
console.error('Usage: npx ts-node scripts/device-sync.ts demo <venueId> <registrationCode>');
console.error('First, generate a registration code from the dashboard (Devices > Add Device).');
process.exit(1);
}
try {
// 1. Register
const reg = await registerDevice();
const deviceId = reg.deviceId;
const token = reg.token;
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)');
}
}
// 2. Sync with tabs
console.log('\n--- Sync 1: Upload tabs ---');
const sync1 = await syncDevice();
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}`);
}
}
if (sync1.commands.length > 0) {
// 3. Ack commands
for (const cmd of sync1.commands) {
console.log(`\n--- Ack: ${cmd.id} ---`);
await ackCommand(deviceId, token, cmd.id, 'completed', `Tab ${cmd.tabId} closed successfully`);
}
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)}]`);
}
// 4. Sync again to see no pending commands
console.log('\n--- Sync 2: Verify no pending commands ---');
await syncDevice();
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✅ Demo complete! Your device is fully functional.');
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.error('❌ Demo failed:', err.message);
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(`
╔══════════════════════════════════════════╗
║ KoolTab 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() {
try {
switch (command) {
case 'register':
await registerDevice();
break;
case 'sync':
await syncDevice();
break;
case 'ack':
await ackCommand();
break;
case 'demo':
await demo();
break;
default:
console.log(`
KoolTab Device Simulator
========================
console.clear();
console.log('╔══════════════════════════════════════╗');
console.log('║ KoolTab Device Simulator ║');
console.log('╚══════════════════════════════════════╝');
Commands:
register <venueId> <name> <registrationCode> Register a new device
sync <deviceId> <token> [cursor] Sync device state
ack <deviceId> <token> <commandId> [status] Acknowledge a command
demo <venueId> <registrationCode> Run full demo sequence
// Prompt for venue / code on first run
await setVenueAndCode();
Examples:
npx ts-node scripts/device-sync.ts register <venueId> "Bar Terminal 1" ABC123
npx ts-node scripts/device-sync.ts sync <deviceId> <token>
npx ts-node scripts/device-sync.ts ack <deviceId> <token> <commandId> completed
npx ts-node scripts/device-sync.ts demo <venueId> ABC123
printHelp();
showStatus();
Status values: completed, failed, conflict, expired
`);
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);
}
} catch (err: any) {
console.error('Error:', err.message);
process.exit(1);
}
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();
main().catch((err) => {
console.error('Fatal:', err.message);
process.exit(1);
});