feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
+2
View File
@@ -0,0 +1,2 @@
-- The database the end-to-end tests run against
CREATE DATABASE kooltap_test;
-44
View File
@@ -1,44 +0,0 @@
#!/bin/sh
# KoolTap Production Deploy
# Usage: ./scripts/deploy.sh "My Venue" admin@myvenue.com password
# The venue and admin are only created on the first deploy; later deploys keep all data.
VENUE_NAME="${1:-${SETUP_VENUE_NAME}}"
ADMIN_EMAIL="${2:-${SETUP_ADMIN_EMAIL}}"
ADMIN_PASSWORD="${3:-${SETUP_ADMIN_PASSWORD}}"
if [ -z "$VENUE_NAME" ] || [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then
echo "Usage: $0 \"Venue Name\" admin@example.com password"
echo " or: SETUP_VENUE_NAME=... SETUP_ADMIN_EMAIL=... SETUP_ADMIN_PASSWORD=... $0"
exit 1
fi
echo "=== Deploying KoolTap ==="
echo " Venue: $VENUE_NAME"
echo " Admin: $ADMIN_EMAIL"
echo ""
# Build and start containers
docker compose -f docker-compose.prod.yml up --build -d || { echo "Deploy failed"; exit 1; }
# Wait for API to be ready
echo "Waiting for API..."
until docker compose -f docker-compose.prod.yml exec api sh -c "node -e 'require(\"http\").get(\"http://localhost:3001/api/v1/health\",r=>{let b=\"\";r.on(\"data\",c=>b+=c);r.on(\"end\",()=>process.exit(b.includes(\"\\\"status\\\":\\\"ok\\\"\")?0:1))})'" 2>/dev/null; do
sleep 2
done
echo "✓ API is ready"
# Seed the database
echo ""
echo "Seeding database..."
docker compose -f docker-compose.prod.yml exec api sh -c "
SETUP_VENUE_NAME='$VENUE_NAME' \
SETUP_ADMIN_EMAIL='$ADMIN_EMAIL' \
SETUP_ADMIN_PASSWORD='$ADMIN_PASSWORD' \
node prisma/setup.js
" || { echo "Seed failed"; exit 1; }
echo ""
echo "=== Done ==="
echo " Dashboard: https://kooltap.com"
echo " Login: $ADMIN_EMAIL"
-330
View File
@@ -1,330 +0,0 @@
/**
* 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);
});
-41
View File
@@ -1,41 +0,0 @@
#!/bin/sh
# KoolTap Production Setup
# Usage: ./scripts/setup.sh "My Venue" admin@myvenue.com mypassword [--force]
# Does nothing if the database already has a venue, unless --force is given (wipes ALL data).
#
# Or with env vars:
# SETUP_VENUE_NAME="My Venue" SETUP_ADMIN_EMAIL="admin@myvenue.com" SETUP_ADMIN_PASSWORD="mypassword" ./scripts/setup.sh
VENUE_NAME="${SETUP_VENUE_NAME:-$1}"
ADMIN_EMAIL="${SETUP_ADMIN_EMAIL:-$2}"
ADMIN_PASSWORD="${SETUP_ADMIN_PASSWORD:-$3}"
if [ -z "$VENUE_NAME" ] || [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then
echo "Usage: $0 \"Venue Name\" admin@example.com password"
echo " or: SETUP_VENUE_NAME=... SETUP_ADMIN_EMAIL=... SETUP_ADMIN_PASSWORD=... $0"
exit 1
fi
echo "=== KoolTap Production Setup ==="
echo ""
echo " Venue: $VENUE_NAME"
echo " Admin: $ADMIN_EMAIL"
echo ""
# Step 1: Generate Prisma client
echo "[1/3] Generating Prisma client..."
npx prisma generate 2>&1 || { echo "FAILED"; exit 1; }
echo " ✓ Client generated"
# Step 2: Apply database migrations
echo "[2/3] Applying database migrations..."
node prisma/deploy.js 2>&1 || { echo "FAILED"; exit 1; }
echo " ✓ Migrations applied"
# Step 3: Seed with your data
echo "[3/3] Seeding database..."
SETUP_VENUE_NAME="$VENUE_NAME" SETUP_ADMIN_EMAIL="$ADMIN_EMAIL" SETUP_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
node prisma/setup.js "$4" || { echo "FAILED"; exit 1; }
echo ""
echo "Setup complete. Login at https://kooltap.com"
+122
View File
@@ -0,0 +1,122 @@
/**
* A bar terminal on the command line, speaking the terminal protocol (docs/terminal-protocol.md): open tabs, ring up
* drinks, settle, and watch the dashboard follow. Keeps its own outbox like the app does.
*
* pnpm simulate # as the seeded demo terminal
* pnpm simulate --pair <code> # pairs as a new terminal first
*
* API_URL (default http://localhost:3001/api/v1) and TERMINAL_TOKEN override the defaults.
*/
import { randomUUID } from 'crypto';
import * as readline from 'readline/promises';
import { stdin, stdout } from 'process';
import type { SyncResponse, TerminalEvent } from '@kooltap/shared';
const API_URL = process.env.API_URL ?? 'http://localhost:3001/api/v1';
const HEADERS = { 'Content-Type': 'application/json', 'x-app-version': '2.0.0' };
let token = process.env.TERMINAL_TOKEN ?? 'kt_demo-terminal-token';
let state: SyncResponse | null = null;
let outbox: TerminalEvent[] = [];
async function call<T>(path: string, body: unknown, auth = true): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
method: 'POST',
headers: { ...HEADERS, ...(auth ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`);
return json;
}
/** Sends the outbox and takes over the venue's state, as the app does on every sync. */
async function sync() {
const sent = outbox;
state = await call<SyncResponse>('/terminal/sync', {
events: sent,
settlementsCursor: state?.settlementsCursor,
catalogVersion: state?.catalogVersion,
}).then((next) => ({ ...next, catalog: next.catalog ?? state?.catalog ?? [] }));
const acknowledged = new Set(state.acknowledgedEventIds);
outbox = outbox.filter((event) => !acknowledged.has(event.id));
for (const rejected of state.rejectedEvents) console.log(` rejected ${rejected.id}: ${rejected.reason}`);
for (const settlement of state.settlements) console.log(` paid: ${settlement.customerName} € ${(settlement.amountInCents / 100).toFixed(2)} (${settlement.paymentMethod})`);
show();
}
function show() {
const tabs = state?.tabs ?? [];
console.log(`\n${tabs.length} open tabs${outbox.length ? `, ${outbox.length} events unsent` : ''}`);
tabs.forEach((tab, i) => {
const total = tab.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
const drinks = tab.lines.filter((l) => l.quantity > 0).map((l) => `${l.quantity}× ${l.productName}`).join(', ');
console.log(` ${i + 1}. ${tab.customerName.padEnd(16)} € ${(total / 100).toFixed(2).padStart(7)} ${drinks}`);
});
}
const event = <T extends TerminalEvent['type']>(type: T, fields: object) =>
({ id: randomUUID(), type, occurredAt: new Date().toISOString(), ...fields }) as Extract<TerminalEvent, { type: T }>;
async function main() {
const rl = readline.createInterface({ input: stdin, output: stdout });
const pairAt = process.argv.indexOf('--pair');
if (pairAt >= 0) {
const paired = await call<{ token: string; deviceId: string }>('/terminal/register', { registrationCode: process.argv[pairAt + 1], name: 'Simulator' }, false);
token = paired.token;
console.log(`Paired as ${paired.deviceId}`);
}
await sync();
for (;;) {
// Input that ends (a pipe) quits
const choice = (await rl.question('\n[s]ync [o]pen tab [a]dd drinks [r]emove drink [p]ay [q]uit > ').catch(() => 'q')).trim().toLowerCase();
try {
const tabs = state!.tabs;
const pickTab = async () => tabs[Number(await rl.question('Tab number: ')) - 1];
if (choice === 'q') break;
if (choice === 's') await sync();
if (choice === 'o') {
outbox.push(event('tab_opened', { tabId: randomUUID(), customerName: (await rl.question('Customer name: ')).trim() }));
await sync();
}
if (choice === 'a') {
const tab = await pickTab();
const products = state!.catalog!.filter((p) => !p.archived);
products.forEach((p, i) => console.log(` ${i + 1}. ${p.name} € ${(p.priceInCents / 100).toFixed(2)}`));
const product = products[Number(await rl.question('Product number: ')) - 1];
const quantity = Number(await rl.question('How many: ')) || 1;
const line = tab.lines.find((l) => l.productId === product.id);
outbox.push(event('line_added', {
tabId: tab.id, customerName: tab.customerName, lineId: line?.id ?? randomUUID(), orderId: randomUUID(),
productId: product.id, productName: product.name, unitPriceInCents: product.priceInCents, quantity,
}));
await sync();
}
if (choice === 'r') {
const tab = await pickTab();
const line = tab.lines.find((l) => l.quantity > 0);
const unit = line?.units.filter((u) => !u.removedAt).at(-1);
if (line && unit?.orderId) outbox.push(event('line_removed', { tabId: tab.id, lineId: line.id, orderId: unit.orderId, quantity: 1 }));
await sync();
}
if (choice === 'p') {
const tab = await pickTab();
const method = (await rl.question('cash / payconiq / payment_pending / other: ')).trim() || 'cash';
const items = tab.lines.filter((l) => l.quantity > 0)
.map((l) => ({ lineId: l.id, productId: l.productId, productName: l.productName, unitPriceInCents: l.unitPriceInCents, quantity: l.quantity }));
const amountInCents = items.reduce((sum, item) => sum + item.quantity * item.unitPriceInCents, 0);
outbox.push(event('settled', { tabId: tab.id, customerName: tab.customerName, amountInCents, paymentMethod: method, items }));
await sync();
}
} catch (error) {
console.log(` ${(error as Error).message}`);
}
}
rl.close();
}
main().catch((error) => {
console.error(error.message ?? error);
process.exitCode = 1;
});