commit 14958ca8518a14d286afd753e06b32d4ff629071 Author: odzugkoev Date: Thu Oct 23 19:02:56 2025 -0400 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ec290e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +npm-debug.log* +.DS_Store +.git +.gitignore +**/*.log + +# Local databases and generated data +/data +/public/uploads + +# Editor/OS +.vscode +.idea +*.swp diff --git a/.env b/.env new file mode 100644 index 0000000..73d6bc6 --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +# Kitchen Agent Configuration + +# Google reCAPTCHA Site Key (v2 Invisible) +RECAPTCHA_SITE_KEY=6LfoaqIqAAAAABi1P-6T1gQpfXNXMv6aQqH0lwGK + +# Server Configuration +PORT=3000 +HOST=0.0.0.0 + +# Cookie Secret (change this in production!) +COOKIE_SECRET=0e4cfa9b-ba26-43b7-a6a1-a21585a6495b + +# ThinkLink API URL +API_URL=https://api.thinklink.ai + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21cc638 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependency directories +node_modules/ +jspm_packages/ # If using JSPM + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Production artifacts +build/ +dist/ +.next/ # For Next.js projects +out/ # For static site generators + +# Editor/IDE specific files +.vscode/ # VS Code settings +.idea/ # IntelliJ IDEA settings +*.sublime-project +*.sublime-workspace + +# OS generated files +.DS_Store +Thumbs.db + +# Test coverage +coverage/ + +# Misc +*.log +*.pid +*.seed +*.gz +pids/ +logs/ +results/ +data/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..936e0b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.6 + +FROM node:20-bookworm-slim AS base +WORKDIR /app +ENV NODE_ENV=production + +# Install dependencies first to leverage Docker layer caching +COPY package.json package-lock.json ./ + +FROM base AS build +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Install production deps (will compile native modules like better-sqlite3/serialport if needed) +RUN npm ci --omit=dev + +# Copy source +COPY . . + +FROM node:20-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# Runtime OS packages for printing via pdf-to-printer (lp/lpr) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + cups-client \ + cups-bsd \ + libcups2 \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# Copy built app with node_modules from the build stage +COPY --from=build /app /app + +# Create volumes for persistent data and user uploads +VOLUME ["/app/data", "/app/public/uploads"] + +# Ensure non-root runtime; change ownership so the node user can write to volumes +RUN chown -R node:node /app +USER node + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --start-period=30s --timeout=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npm", "start"] + + diff --git a/api-client.js b/api-client.js new file mode 100644 index 0000000..8cd499c --- /dev/null +++ b/api-client.js @@ -0,0 +1,94 @@ +const fetch = require('node-fetch'); + +class APIClient { + constructor(baseUrl = process.env.API_URL || 'https://api.thinklink.ai') { + this.baseUrl = baseUrl; + } + + async request(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + + try { + const response = await fetch(url, { + method: options.method || 'POST', + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + }, + body: options.body ? JSON.stringify(options.body) : undefined + }); + + const data = await response.json(); + return data; + } catch (error) { + console.error(`API request failed: ${endpoint}`, error.message); + return { + error: true, + message: `Network error: ${error.message}` + }; + } + } + + async login(email, password, recaptchaToken) { + return this.request('/user/login', { + body: { + login: email, + password: password, + 'g-recaptcha-response': recaptchaToken + } + }); + } + + async getBots(token) { + return this.request('/bot/list', { + body: { + token: token + } + }); + } + + async getOrders(token, botId, afterId = 0, options = {}) { + const body = { + token: token, + botId: parseInt(botId, 10), + afterId: afterId || 0, + limit: options.limit || 50, + includeCanceled: options.includeCanceled || false + }; + + if (options.orderStatus) { + body.orderStatus = options.orderStatus; + } + + if (options.sinceTs) { + body.sinceTs = options.sinceTs; + } + + return this.request('/food-order/orders', { body }); + } + + async modifyOrder(token, botId, orderId, action, cancellationReason = '') { + const body = { + token: token, + botId: parseInt(botId, 10), + orderId: parseInt(orderId, 10), + action: action + }; + + if (action === 'cancel' && cancellationReason) { + body.cancellationReason = cancellationReason; + } + + return this.request('/food-order/modify', { body }); + } + + isTokenExpired(expirationDate) { + if (!expirationDate) return true; + const expiry = new Date(expirationDate); + const now = new Date(); + return now >= expiry; + } +} + +module.exports = new APIClient(); + diff --git a/config.js b/config.js new file mode 100644 index 0000000..0a5eeaa --- /dev/null +++ b/config.js @@ -0,0 +1,159 @@ +const database = require('./database'); +const crypto = require('crypto'); + +class ConfigManager { + constructor() { + this.encryptionKey = null; + } + + getOrCreateEncryptionKey() { + // Lazy initialization - only create key when first needed + if (this.encryptionKey) { + return this.encryptionKey; + } + + // In production, this should be stored securely (environment variable or secure file) + // For now, we'll generate a random key and store it in the database + try { + let key = database.getConfig('_encryption_key'); + if (!key) { + key = crypto.randomBytes(32).toString('hex'); + database.setConfig('_encryption_key', key); + } + this.encryptionKey = key; + return key; + } catch (error) { + // If database not ready, generate temporary key + console.warn('Database not ready, using temporary encryption key'); + this.encryptionKey = crypto.randomBytes(32).toString('hex'); + return this.encryptionKey; + } + } + + encrypt(text) { + if (!text) return null; + try { + const key = this.getOrCreateEncryptionKey(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + Buffer.from(key, 'hex'), + iv + ); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; + } catch (error) { + console.error('Encryption error:', error.message); + return null; + } + } + + decrypt(text) { + if (!text) return null; + try { + const key = this.getOrCreateEncryptionKey(); + const parts = text.split(':'); + const iv = Buffer.from(parts[0], 'hex'); + const encrypted = parts[1]; + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + Buffer.from(key, 'hex'), + iv + ); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (error) { + console.error('Decryption error:', error.message); + return null; + } + } + + get(key) { + try { + const value = database.getConfig(key); + + // Decrypt token if it's the auth token + if (key === 'authToken' && value) { + return this.decrypt(value); + } + + return value; + } catch (error) { + console.warn(`Config.get('${key}') failed:`, error.message); + return null; + } + } + + set(key, value) { + try { + // Encrypt token if it's the auth token + if (key === 'authToken' && value) { + value = this.encrypt(value); + } + + database.setConfig(key, value); + } catch (error) { + console.error(`Config.set('${key}') failed:`, error.message); + } + } + + getAll() { + try { + const config = database.getConfig(); + + // Decrypt auth token if present + if (config.authToken) { + config.authToken = this.decrypt(config.authToken); + } + + return config; + } catch (error) { + console.warn('Config.getAll() failed:', error.message); + return {}; + } + } + + setMultiple(configObj) { + try { + // Encrypt auth token if present + if (configObj.authToken) { + configObj.authToken = this.encrypt(configObj.authToken); + } + + database.setConfigMultiple(configObj); + } catch (error) { + console.error('Config.setMultiple() failed:', error.message); + } + } + + isAuthenticated() { + try { + const token = this.get('authToken'); + const expiry = this.get('tokenExpiry'); + + if (!token) return false; + if (!expiry) return false; + + const expiryDate = new Date(expiry); + const now = new Date(); + + return now < expiryDate; + } catch (error) { + return false; + } + } + + clearAuth() { + try { + this.set('authToken', ''); + this.set('tokenExpiry', ''); + } catch (error) { + console.error('Failed to clear auth:', error.message); + } + } +} + +module.exports = new ConfigManager(); + diff --git a/database.js b/database.js new file mode 100644 index 0000000..63af416 --- /dev/null +++ b/database.js @@ -0,0 +1,721 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +class DatabaseManager { + constructor() { + this.db = null; + } + + init() { + // Ensure data directory exists + const dataDir = path.join(__dirname, 'data'); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + const dbPath = path.join(dataDir, 'kitchen.db'); + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + + this.createTables(); + console.log('Database initialized successfully'); + } + + createTables() { + // Config table + this.db.exec(` + CREATE TABLE IF NOT EXISTS config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + value TEXT + ) + `); + + // Orders table + this.db.exec(` + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER UNIQUE NOT NULL, + bot_id INTEGER NOT NULL, + status TEXT NOT NULL, + local_status TEXT, + order_data TEXT NOT NULL, + customer_data TEXT NOT NULL, + total_amount REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + printed_at INTEGER, + synced_at INTEGER + ) + `); + + // Print queue table + this.db.exec(` + CREATE TABLE IF NOT EXISTS print_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + print_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at INTEGER NOT NULL, + printed_at INTEGER, + FOREIGN KEY (order_id) REFERENCES orders(order_id) + ) + `); + + // Printers table - stores individual printer configurations + this.db.exec(` + CREATE TABLE IF NOT EXISTS printers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + interface TEXT NOT NULL, + printer_type TEXT NOT NULL DEFAULT 'epson', + paper_width INTEGER NOT NULL DEFAULT 48, + paper_format TEXT NOT NULL DEFAULT '80mm', + is_default INTEGER NOT NULL DEFAULT 0, + is_enabled INTEGER NOT NULL DEFAULT 1, + font_size TEXT DEFAULT 'normal', + line_style TEXT DEFAULT 'single', + qr_code_enabled INTEGER DEFAULT 1, + qr_code_size INTEGER DEFAULT 3, + qr_code_correction TEXT DEFAULT 'M', + qr_code_content_template TEXT DEFAULT 'ORDER-{id}', + header_text TEXT DEFAULT 'KITCHEN ORDER', + footer_text TEXT DEFAULT 'Thank you!', + business_name TEXT, + business_address TEXT, + business_phone TEXT, + business_website TEXT, + business_email TEXT, + business_contact_size TEXT DEFAULT 'normal', + show_customer_info INTEGER DEFAULT 1, + show_order_items INTEGER DEFAULT 1, + show_prices INTEGER DEFAULT 1, + show_timestamps INTEGER DEFAULT 1, + logo_path TEXT, + logo_max_width_dots INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + // Create indexes + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); + CREATE INDEX IF NOT EXISTS idx_orders_local_status ON orders(local_status); + CREATE INDEX IF NOT EXISTS idx_orders_created ON orders(created_at); + CREATE INDEX IF NOT EXISTS idx_print_queue_status ON print_queue(status); + CREATE INDEX IF NOT EXISTS idx_printers_enabled ON printers(is_enabled); + CREATE INDEX IF NOT EXISTS idx_printers_default ON printers(is_default); + `); + + // Initialize default config values if not exists + this.setConfigDefaults(); + + // Migrate old printer config to new table if needed + this.migrateOldPrinterConfig(); + } + + setConfigDefaults() { + const defaults = { + apiUrl: 'https://api.thinklink.ai', + pollingInterval: '15000', + dashboardRefreshInterval: '10000', + printerType: 'epson', + printerInterface: 'usb', + printerPath: '/dev/usb/lp0', + printerWidth: '48', + fontSize: 'normal', + qrCodeEnabled: 'true', + headerText: 'KITCHEN ORDER', + footerText: 'Thank you!', + showOrderStats: 'true', + lineStyle: 'single', + printMargins: JSON.stringify({ left: 0, right: 0 }), + showCustomerInfo: 'true', + showOrderItems: 'true', + showPrices: 'true', + showTimestamps: 'true' + }; + + const insert = this.db.prepare('INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'); + for (const [key, value] of Object.entries(defaults)) { + insert.run(key, value); + } + } + + // Config operations + getConfig(key = null) { + if (key) { + const row = this.db.prepare('SELECT value FROM config WHERE key = ?').get(key); + return row ? row.value : null; + } + + const rows = this.db.prepare('SELECT key, value FROM config').all(); + const config = {}; + for (const row of rows) { + config[row.key] = row.value; + } + return config; + } + + setConfig(key, value) { + this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, value); + } + + setConfigMultiple(configObj) { + const insert = this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)'); + const transaction = this.db.transaction(() => { + for (const [key, value] of Object.entries(configObj)) { + insert.run(key, value); + } + }); + transaction(); + } + + // Order operations + insertOrder(order) { + const now = Math.floor(Date.now() / 1000); + return this.db.prepare(` + INSERT INTO orders (order_id, bot_id, status, local_status, order_data, customer_data, total_amount, created_at, updated_at, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + order.id, + order.botId, + order.status, + 'new', + JSON.stringify(order.order), + JSON.stringify(order.customer), + order.totalAmount, + order.createdAt, + order.updatedAt, + now + ); + } + + getOrderById(orderId) { + const row = this.db.prepare(` + SELECT * FROM orders WHERE order_id = ? + `).get(orderId); + + if (!row) return null; + + return { + id: row.order_id, + botId: row.bot_id, + status: row.status, + localStatus: row.local_status, + order: JSON.parse(row.order_data), + customer: JSON.parse(row.customer_data), + totalAmount: row.total_amount, + createdAt: row.created_at, + updatedAt: row.updated_at, + printedAt: row.printed_at, + syncedAt: row.synced_at + }; + } + + getLastOrder() { + const row = this.db.prepare(` + SELECT * FROM orders ORDER BY order_id DESC LIMIT 1 + `).get(); + + if (!row) return null; + + return { + order_id: row.order_id, + botId: row.bot_id, + status: row.status, + localStatus: row.local_status + }; + } + + updateOrder(order) { + const now = Math.floor(Date.now() / 1000); + return this.db.prepare(` + UPDATE orders + SET status = ?, order_data = ?, customer_data = ?, total_amount = ?, updated_at = ?, synced_at = ? + WHERE order_id = ? + `).run( + order.status, + JSON.stringify(order.order), + JSON.stringify(order.customer), + order.totalAmount, + order.updatedAt, + now, + order.id + ); + } + + updateOrderStatus(orderId, localStatus) { + const now = Math.floor(Date.now() / 1000); + return this.db.prepare(` + UPDATE orders SET local_status = ?, updated_at = ? WHERE order_id = ? + `).run(localStatus, now, orderId); + } + + markOrderPrinted(orderId) { + const now = Math.floor(Date.now() / 1000); + return this.db.prepare(` + UPDATE orders SET printed_at = ? WHERE order_id = ? + `).run(now, orderId); + } + + getOrders(filters = {}) { + let query = 'SELECT * FROM orders WHERE 1=1'; + const params = []; + + if (filters.status) { + query += ' AND local_status = ?'; + params.push(filters.status); + } + + if (filters.date) { + // Get orders from start of day to end of day + const startOfDay = Math.floor(new Date(filters.date).setHours(0, 0, 0, 0) / 1000); + const endOfDay = Math.floor(new Date(filters.date).setHours(23, 59, 59, 999) / 1000); + query += ' AND created_at BETWEEN ? AND ?'; + params.push(startOfDay, endOfDay); + } + + query += ' ORDER BY created_at DESC'; + + if (filters.limit) { + query += ' LIMIT ?'; + params.push(filters.limit); + } + + const rows = this.db.prepare(query).all(...params); + + return rows.map(row => ({ + id: row.order_id, + botId: row.bot_id, + status: row.status, + localStatus: row.local_status, + order: JSON.parse(row.order_data), + customer: JSON.parse(row.customer_data), + totalAmount: row.total_amount, + createdAt: row.created_at, + updatedAt: row.updated_at, + printedAt: row.printed_at, + syncedAt: row.synced_at + })); + } + + getOrderStats() { + const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000); + + const stats = { + total: 0, + new: 0, + preparing: 0, + ready: 0 + }; + + const rows = this.db.prepare(` + SELECT local_status, COUNT(*) as count + FROM orders + WHERE created_at >= ? + GROUP BY local_status + `).all(today); + + for (const row of rows) { + if (row.local_status === 'new') stats.new = row.count; + else if (row.local_status === 'preparing') stats.preparing = row.count; + else if (row.local_status === 'ready') stats.ready = row.count; + stats.total += row.count; + } + + return stats; + } + + // Print queue operations + addToPrintQueue(orderId, printType) { + const now = Math.floor(Date.now() / 1000); + const recentWindowSeconds = 300; // 5 minutes window to prevent duplicates + const cutoff = now - recentWindowSeconds; + + // Enhanced idempotency: check for any recent job (pending, processing, or recently completed) + // Apply time window to pending/processing as well to avoid stale blocks + const existing = this.db.prepare(` + SELECT id, status, created_at FROM print_queue + WHERE order_id = ? AND print_type = ? + AND ((status IN ('pending','processing') AND created_at > ?) OR (status = 'completed' AND created_at > ?)) + ORDER BY id DESC LIMIT 1 + `).get(orderId, printType, cutoff, cutoff); + + if (existing && existing.id) { + // If there's a pending or processing job, reuse it + if (existing.status === 'pending' || existing.status === 'processing') { + console.log(`[DB] Reusing existing ${existing.status} job ${existing.id} for order #${orderId} (${printType})`); + return existing.id; + } + // If there's a recently completed job, allow creating a new job for audit + if (existing.status === 'completed') { + console.log(`[DB] Recent completed job ${existing.id} exists for order #${orderId} (${printType}); creating a new queued job.`); + } + } + + console.log(`[DB] Creating new print job for order #${orderId} (${printType})`); + const info = this.db.prepare(` + INSERT INTO print_queue (order_id, print_type, status, created_at) + VALUES (?, ?, 'pending', ?) + `).run(orderId, printType, now); + return info && info.lastInsertRowid ? info.lastInsertRowid : null; + } + + getPendingPrintJobs() { + return this.db.prepare(` + SELECT * FROM print_queue WHERE status = 'pending' ORDER BY created_at ASC + `).all(); + } + + markPrintJobCompleted(id) { + const now = Math.floor(Date.now() / 1000); + return this.db.prepare(` + UPDATE print_queue SET status = 'completed', printed_at = ? WHERE id = ? + `).run(now, id); + } + + markPrintJobFailed(id) { + return this.db.prepare(` + UPDATE print_queue SET status = 'failed' WHERE id = ? + `).run(id); + } + + // Transition a job to processing to prevent the worker from picking it up concurrently + markPrintJobProcessing(id) { + return this.db.prepare(` + UPDATE print_queue SET status = 'processing' WHERE id = ? + `).run(id); + } + + // Revert a job back to pending (e.g., if immediate print failed) so the worker can retry + markPrintJobPending(id) { + return this.db.prepare(` + UPDATE print_queue SET status = 'pending' WHERE id = ? + `).run(id); + } + + hasPrintedCancellation(orderId) { + const row = this.db.prepare(` + SELECT 1 FROM print_queue + WHERE order_id = ? AND print_type = 'canceled' AND status = 'completed' + LIMIT 1 + `).get(orderId); + return !!row; + } + + // Cleanup other jobs for the same order+type when one succeeds + // This prevents duplicate prints from multiple pending jobs + cleanupDuplicateJobs(successfulJobId, orderId, printType) { + const result = this.db.prepare(` + UPDATE print_queue + SET status = 'completed', printed_at = ? + WHERE order_id = ? AND print_type = ? + AND id != ? + AND status IN ('pending', 'processing') + `).run(Math.floor(Date.now() / 1000), orderId, printType, successfulJobId); + + if (result.changes > 0) { + console.log(`[DB] Cleaned up ${result.changes} duplicate job(s) for order #${orderId} (${printType})`); + } + return result.changes; + } + + // Check if there's any active or recently completed job for order+type + hasActiveOrRecentJob(orderId, printType, windowSeconds = 60) { + const cutoff = Math.floor(Date.now() / 1000) - windowSeconds; + const row = this.db.prepare(` + SELECT id, status FROM print_queue + WHERE order_id = ? AND print_type = ? + AND ((status IN ('processing', 'pending') AND created_at > ?) OR (status = 'completed' AND printed_at > ?)) + ORDER BY id DESC LIMIT 1 + `).get(orderId, printType, cutoff, cutoff); + return row ? { hasActive: true, jobId: row.id, status: row.status } : { hasActive: false }; + } + + // Reset stale 'processing' jobs to 'pending' so the worker can pick them up + // Useful when immediate prints failed (e.g., printer was offline) and jobs got stuck + resetStuckProcessingJobs(maxAgeSeconds = 120) { + const cutoff = Math.floor(Date.now() / 1000) - maxAgeSeconds; + const result = this.db.prepare(` + UPDATE print_queue + SET status = 'pending' + WHERE status = 'processing' AND created_at < ? + `).run(cutoff); + if (result && result.changes > 0) { + console.log(`[DB] Recovered ${result.changes} stuck processing job(s) to pending`); + } + return result && result.changes ? result.changes : 0; + } + + // Get count of recently completed jobs for debugging + getRecentlyCompletedJobCount(orderId, printType, windowSeconds = 300) { + const cutoff = Math.floor(Date.now() / 1000) - windowSeconds; + const row = this.db.prepare(` + SELECT COUNT(*) as count FROM print_queue + WHERE order_id = ? AND print_type = ? + AND status = 'completed' + AND printed_at > ? + `).get(orderId, printType, cutoff); + return row ? row.count : 0; + } + + // Printer CRUD operations + addPrinter(config) { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare(` + INSERT INTO printers ( + name, type, interface, printer_type, paper_width, paper_format, + is_default, is_enabled, font_size, line_style, + qr_code_enabled, qr_code_size, qr_code_correction, qr_code_content_template, + header_text, footer_text, + business_name, business_address, business_phone, business_website, business_email, + business_contact_size, show_customer_info, show_order_items, show_prices, show_timestamps, + logo_path, logo_max_width_dots, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + `); + + const info = stmt.run( + config.name || 'Unnamed Printer', + config.type || 'usb', + config.interface || '', + config.printer_type || 'epson', + config.paper_width || 48, + config.paper_format || '80mm', + config.is_default ? 1 : 0, + config.is_enabled !== false ? 1 : 0, + config.font_size || 'normal', + config.line_style || 'single', + config.qr_code_enabled !== false ? 1 : 0, + config.qr_code_size || 3, + config.qr_code_correction || 'M', + config.qr_code_content_template || 'ORDER-{id}', + (typeof config.header_text !== 'undefined' ? config.header_text : 'KITCHEN ORDER'), + (typeof config.footer_text !== 'undefined' ? config.footer_text : 'Thank you!'), + config.business_name || '', + config.business_address || '', + config.business_phone || '', + config.business_website || '', + config.business_email || '', + config.business_contact_size || 'normal', + config.show_customer_info !== false ? 1 : 0, + config.show_order_items !== false ? 1 : 0, + config.show_prices !== false ? 1 : 0, + config.show_timestamps !== false ? 1 : 0, + config.logo_path || null, + config.logo_max_width_dots || null, + now, + now + ); + + // If this is set as default, unset other defaults + if (config.is_default) { + this.db.prepare('UPDATE printers SET is_default = 0 WHERE id != ?').run(info.lastInsertRowid); + } + + return info.lastInsertRowid; + } + + updatePrinter(id, config) { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare(` + UPDATE printers SET + name = ?, type = ?, interface = ?, printer_type = ?, paper_width = ?, paper_format = ?, + is_default = ?, is_enabled = ?, font_size = ?, line_style = ?, + qr_code_enabled = ?, qr_code_size = ?, qr_code_correction = ?, qr_code_content_template = ?, + header_text = ?, footer_text = ?, + business_name = ?, business_address = ?, business_phone = ?, business_website = ?, business_email = ?, + business_contact_size = ?, show_customer_info = ?, show_order_items = ?, show_prices = ?, show_timestamps = ?, + logo_path = ?, logo_max_width_dots = ?, updated_at = ? + WHERE id = ? + `); + + const result = stmt.run( + config.name || 'Unnamed Printer', + config.type || 'usb', + config.interface || '', + config.printer_type || 'epson', + config.paper_width || 48, + config.paper_format || '80mm', + config.is_default ? 1 : 0, + config.is_enabled !== false ? 1 : 0, + config.font_size || 'normal', + config.line_style || 'single', + config.qr_code_enabled !== false ? 1 : 0, + config.qr_code_size || 3, + config.qr_code_correction || 'M', + config.qr_code_content_template || 'ORDER-{id}', + (typeof config.header_text !== 'undefined' ? config.header_text : 'KITCHEN ORDER'), + (typeof config.footer_text !== 'undefined' ? config.footer_text : 'Thank you!'), + config.business_name || '', + config.business_address || '', + config.business_phone || '', + config.business_website || '', + config.business_email || '', + config.business_contact_size || 'normal', + config.show_customer_info !== false ? 1 : 0, + config.show_order_items !== false ? 1 : 0, + config.show_prices !== false ? 1 : 0, + config.show_timestamps !== false ? 1 : 0, + config.logo_path || null, + config.logo_max_width_dots || null, + now, + id + ); + + // If this is set as default, unset other defaults + if (config.is_default) { + this.db.prepare('UPDATE printers SET is_default = 0 WHERE id != ?').run(id); + } + + return result; + } + + deletePrinter(id) { + return this.db.prepare('DELETE FROM printers WHERE id = ?').run(id); + } + + getPrinter(id) { + const row = this.db.prepare('SELECT * FROM printers WHERE id = ?').get(id); + return row ? this.mapPrinterRow(row) : null; + } + + getAllPrinters() { + const rows = this.db.prepare('SELECT * FROM printers ORDER BY is_default DESC, name ASC').all(); + return rows.map(row => this.mapPrinterRow(row)); + } + + getEnabledPrinters() { + const rows = this.db.prepare('SELECT * FROM printers WHERE is_enabled = 1 ORDER BY is_default DESC, name ASC').all(); + return rows.map(row => this.mapPrinterRow(row)); + } + + getDefaultPrinter() { + const row = this.db.prepare('SELECT * FROM printers WHERE is_default = 1 LIMIT 1').get(); + return row ? this.mapPrinterRow(row) : null; + } + + setDefaultPrinter(id) { + const transaction = this.db.transaction(() => { + this.db.prepare('UPDATE printers SET is_default = 0').run(); + this.db.prepare('UPDATE printers SET is_default = 1 WHERE id = ?').run(id); + }); + transaction(); + } + + togglePrinterEnabled(id) { + const printer = this.getPrinter(id); + if (!printer) return null; + const newEnabled = printer.is_enabled ? 0 : 1; + this.db.prepare('UPDATE printers SET is_enabled = ? WHERE id = ?').run(newEnabled, id); + return { is_enabled: newEnabled === 1 }; + } + + mapPrinterRow(row) { + return { + id: row.id, + name: row.name, + type: row.type, + interface: row.interface, + printer_type: row.printer_type, + paper_width: row.paper_width, + paper_format: row.paper_format, + is_default: row.is_default === 1, + is_enabled: row.is_enabled === 1, + font_size: row.font_size, + line_style: row.line_style, + qr_code_enabled: row.qr_code_enabled === 1, + qr_code_size: row.qr_code_size, + qr_code_correction: row.qr_code_correction, + qr_code_content_template: row.qr_code_content_template, + header_text: row.header_text, + footer_text: row.footer_text, + business_name: row.business_name, + business_address: row.business_address, + business_phone: row.business_phone, + business_website: row.business_website, + business_email: row.business_email, + business_contact_size: row.business_contact_size, + show_customer_info: row.show_customer_info === 1, + show_order_items: row.show_order_items === 1, + show_prices: row.show_prices === 1, + show_timestamps: row.show_timestamps === 1, + logo_path: row.logo_path, + logo_max_width_dots: row.logo_max_width_dots, + created_at: row.created_at, + updated_at: row.updated_at + }; + } + + // Migration from old config system to new printers table + migrateOldPrinterConfig() { + try { + // Check if we already have printers + const existingPrinters = this.db.prepare('SELECT COUNT(*) as count FROM printers').get(); + if (existingPrinters && existingPrinters.count > 0) { + return; // Already migrated + } + + // Get old config + const oldConfig = this.getConfig(); + + // Only migrate if old printer config exists + if (!oldConfig.printerInterface && !oldConfig.printerPath) { + return; // No old config to migrate + } + + console.log('Migrating old printer configuration to new printers table...'); + + // Create a printer from old config + const printerConfig = { + name: 'Default Printer (Migrated)', + type: oldConfig.printerInterface === 'serial' ? 'com' : (oldConfig.printerInterface || 'usb'), + interface: oldConfig.printerPath || '/dev/usb/lp0', + printer_type: oldConfig.printerType || 'epson', + paper_width: parseInt(oldConfig.printerWidth, 10) || 48, + paper_format: (parseInt(oldConfig.printerWidth, 10) || 48) >= 48 ? '80mm' : '58mm', + is_default: true, + is_enabled: true, + font_size: oldConfig.fontSize || 'normal', + line_style: oldConfig.lineStyle || 'single', + qr_code_enabled: oldConfig.qrCodeEnabled !== 'false', + qr_code_size: parseInt(oldConfig.qrCodeSize, 10) || 3, + qr_code_correction: oldConfig.qrCodeCorrection || 'M', + qr_code_content_template: oldConfig.qrCodeContentTemplate || 'ORDER-{id}', + header_text: oldConfig.headerText || 'KITCHEN ORDER', + footer_text: oldConfig.footerText || 'Thank you!', + business_name: oldConfig.businessName || '', + business_address: oldConfig.businessAddress || '', + business_phone: oldConfig.businessPhone || '', + business_website: oldConfig.businessWebsite || '', + business_email: oldConfig.businessEmail || '', + business_contact_size: oldConfig.businessContactSize || 'normal', + show_customer_info: oldConfig.showCustomerInfo !== 'false', + show_order_items: oldConfig.showOrderItems !== 'false', + show_prices: oldConfig.showPrices !== 'false', + show_timestamps: oldConfig.showTimestamps !== 'false', + logo_path: oldConfig.logoPath || null, + logo_max_width_dots: oldConfig.logoMaxWidthDots ? parseInt(oldConfig.logoMaxWidthDots, 10) : null + }; + + this.addPrinter(printerConfig); + console.log('Migration complete: Old printer configuration transferred to new system'); + } catch (error) { + console.error('Error migrating old printer config:', error.message); + } + } + + close() { + if (this.db) { + this.db.close(); + } + } +} + +module.exports = new DatabaseManager(); + diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..826f0b0 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,17 @@ +module.exports = { + apps: [{ + name: 'kitchen-agent', + script: './server.js', + instances: 1, + exec_mode: 'cluster', + kill_timeout: 8000, + listen_timeout: 8000, + env: { + REPO_URL: 'https://repo.cloud.thinklink.ai/thinklink/kitchen-agent', + REPO_BRANCH: 'main', + PM2_APP: 'kitchen-agent', + UPDATE_CHECK_INTERVAL_MS: 300000 + // GITEA_TOKEN: '' + } + }] +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5c437f3 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "kitchen-agent", + "version": "1.0.5", + "description": "Kitchen Agent for ThinkLink Food Order Management", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "test": "node run-tests.js" + }, + "keywords": ["kitchen", "food-order", "thermal-printer", "thinklink"], + "author": "", + "license": "MIT", + "dependencies": { + "fastify": "^4.26.0", + "@fastify/view": "^8.2.0", + "@fastify/static": "^6.12.0", + "@fastify/cookie": "^9.3.1", + "@fastify/formbody": "^7.4.0", + "@fastify/multipart": "^8.1.0", + "ejs": "^3.1.9", + "node-thermal-printer": "^4.4.5", + "better-sqlite3": "^9.4.0", + "node-fetch": "^2.7.0", + "dotenv": "^16.3.1", + "pdf-to-printer": "^5.4.0", + "pdfkit": "^0.15.0", + "serialport": "^12.0.0", + "auto-git-update": "^1.1.1" + } +} + diff --git a/print-worker.js b/print-worker.js new file mode 100644 index 0000000..1227799 --- /dev/null +++ b/print-worker.js @@ -0,0 +1,157 @@ +const config = require('./config'); +const database = require('./database'); +const printer = require('./printer'); + +class PrintQueueWorker { + constructor(db = database, cfg = config, printerManager = printer) { + this.db = db; + this.cfg = cfg; + this.printer = printerManager; + this.intervalId = null; + this.isRunning = false; + this.isProcessing = false; + } + + start(intervalMs = 10000) { + if (this.isRunning) return; + this.isRunning = true; + const configured = parseInt(this.cfg.get('printWorkerInterval'), 10); + const tickMs = Number.isFinite(configured) && configured > 0 ? configured : intervalMs; + console.log(`[PrintQueueWorker] Starting with interval ${tickMs} ms`); + this._schedule(tickMs); + } + + stop() { + this.isRunning = false; + if (this.intervalId) { + clearTimeout(this.intervalId); + this.intervalId = null; + } + console.log('[PrintQueueWorker] Stopped'); + } + + _schedule(intervalMs) { + if (!this.isRunning) return; + this.intervalId = setTimeout(async () => { + try { + await this._tick(); + } catch (err) { + console.error('[PrintQueueWorker] Tick error:', err.message); + } finally { + this._schedule(intervalMs); + } + }, intervalMs); + } + + async _tick() { + if (this.isProcessing) { + return; // avoid overlapping runs + } + this.isProcessing = true; + try { + // Skip if no printers likely reachable to avoid hammering + try { + const printerConfigs = this.db.getEnabledPrinters(); + const anyReachable = await this.printer.anyConfiguredPrinterReachable(printerConfigs); + if (!anyReachable) { + console.log('[PrintQueueWorker] No reachable printers detected yet, will retry later'); + return; + } + } catch (_) { + // If reachability check fails, proceed and let print attempt decide + } + + // Recover stuck processing jobs older than 2 minutes + try { this.db.resetStuckProcessingJobs(120); } catch (_) {} + + const pending = this.db.getPendingPrintJobs(); + if (!pending || pending.length === 0) { + return; + } + console.log(`[PrintQueueWorker] Processing ${pending.length} pending print job(s)`); + + for (const job of pending) { + try { + const order = this.db.getOrderById(job.order_id); + if (!order) { + console.warn(`[PrintQueueWorker] Order ${job.order_id} not found, marking job ${job.id} failed`); + this.db.markPrintJobFailed(job.id); + continue; + } + + // Check 1: If the order has been printed after the job was created, mark job completed to avoid duplicates + if (order.printedAt && job.created_at && Number(order.printedAt) >= Number(job.created_at)) { + console.log(`[PrintQueueWorker] Skipping job ${job.id}, order ${order.id} already printed at ${order.printedAt}`); + this.db.markPrintJobCompleted(job.id); + continue; + } + + // Check 2: Verify there's no active job (pending/processing) for this order+type (within last 60 seconds) + // Do NOT block reprints because of a recently COMPLETED job; users may intentionally reprint. + const activeCheck = this.db.hasActiveOrRecentJob(job.order_id, job.print_type, 60); + if (activeCheck.hasActive && activeCheck.jobId !== job.id) { + // Skip if another active pending/processing job exists + if (activeCheck.status === 'pending' || activeCheck.status === 'processing') { + console.log(`[PrintQueueWorker] Skipping job ${job.id}, another active job ${activeCheck.jobId} (${activeCheck.status}) exists for order ${job.order_id} (${job.print_type})`); + this.db.markPrintJobCompleted(job.id); + continue; + } + // If the recent job is COMPLETED, only block for non-reprint types (avoid duplicate cancellations/new) + if (activeCheck.status === 'completed' && job.print_type !== 'reprint') { + console.log(`[PrintQueueWorker] Skipping job ${job.id}, recent completed job ${activeCheck.jobId} exists for order ${job.order_id} (${job.print_type})`); + this.db.markPrintJobCompleted(job.id); + continue; + } + } + + // Check 3: For cancellations, verify we haven't already printed one + if (job.print_type === 'canceled' && this.db.hasPrintedCancellation(job.order_id)) { + console.log(`[PrintQueueWorker] Skipping job ${job.id}, cancellation already printed for order ${job.order_id}`); + this.db.markPrintJobCompleted(job.id); + continue; + } + + let type = job.print_type; + if (type === 'reprint') { + type = order.localStatus === 'canceled' ? 'canceled' : 'new'; + } + + const printerConfigs = this.db.getEnabledPrinters(); + const cancelReason = (order && (order.cancellationReason || (order.order && order.order.cancellationReason))) || 'Order canceled'; + const options = type === 'canceled' ? { reason: cancelReason } : {}; + + let result; + if (printerConfigs && printerConfigs.length > 0) { + result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, type, options); + } else { + const appConfig = this.cfg.getAll(); + if (!this.printer.printer || !this.printer.config) { + try { this.printer.initializePrinter(appConfig); } catch (_) {} + } + result = await this.printer.printOrderReceipt(order, type, options); + } + + if (result && result.success) { + this.db.markOrderPrinted(order.id); + this.db.markPrintJobCompleted(job.id); + // Cleanup any other pending/processing jobs for this order+type to prevent duplicates + this.db.cleanupDuplicateJobs(job.id, job.order_id, job.print_type); + console.log(`[PrintQueueWorker] ✓ Printed order #${order.id} (job ${job.id})`); + } else { + // Keep as pending for retry on next tick + const errMsg = result && result.error ? result.error : 'Unknown print error'; + console.warn(`[PrintQueueWorker] ✗ Print failed for order #${order.id} (job ${job.id}): ${errMsg}`); + } + } catch (errJob) { + console.error(`[PrintQueueWorker] Job ${job.id} error:`, errJob.message); + } + } + } finally { + this.isProcessing = false; + } + } +} + +module.exports = PrintQueueWorker; + + diff --git a/printer.js b/printer.js new file mode 100644 index 0000000..eb90dbe --- /dev/null +++ b/printer.js @@ -0,0 +1,1757 @@ +const { ThermalPrinter, PrinterTypes } = require('node-thermal-printer'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const PDFDocument = require('pdfkit'); +const pdfPrinter = require('pdf-to-printer'); +const { SerialPort } = require('serialport'); +let sharp = null; +try { sharp = require('sharp'); } catch (_) {} +let PNGLib = null; +try { PNGLib = require('pngjs').PNG; } catch (_) {} + +class PrinterManager { + constructor() { + this.printer = null; + this.config = null; + this.currentInterface = null; // normalized interface string used by printer lib + this.inFlightJobs = new Map(); // key -> { promise, timestamp } to dedupe concurrent prints + this.recentlyCompleted = new Map(); // key -> timestamp for recently completed jobs + this.cleanupInterval = null; + + // Start periodic cleanup of old entries + this.startCleanupTimer(); + } + + startCleanupTimer() { + // Clean up old in-flight and recently completed entries every 30 seconds + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + const inFlightTimeout = 120000; // 2 minutes for stuck in-flight jobs + const completedTimeout = 60000; // 1 minute for completed jobs tracking + + // Clean up old in-flight jobs (might be stuck) + for (const [key, data] of this.inFlightJobs.entries()) { + if (now - data.timestamp > inFlightTimeout) { + console.log(`[PrinterManager] Cleaning up stuck in-flight job: ${key}`); + this.inFlightJobs.delete(key); + } + } + + // Clean up old recently completed tracking + for (const [key, timestamp] of this.recentlyCompleted.entries()) { + if (now - timestamp > completedTimeout) { + this.recentlyCompleted.delete(key); + } + } + }, 30000); + } + + stopCleanupTimer() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + initializePrinter(config) { + this.config = config; + + const printerTypeMap = { + 'epson': PrinterTypes.EPSON, + 'star': PrinterTypes.STAR, + 'tanca': PrinterTypes.TANCA, + 'brother': PrinterTypes.BROTHER, + 'custom': PrinterTypes.CUSTOM + }; + + const printerType = printerTypeMap[config.printerType] || PrinterTypes.EPSON; + const printerWidth = parseInt(config.printerWidth, 10) || 48; + + // Normalize interface from config (supports usb/serial/network) + const normalized = this.buildInterfaceFromConfig(config); + this.currentInterface = normalized; + + this.printer = new ThermalPrinter({ + type: printerType, + interface: normalized, + width: printerWidth, + characterSet: 'PC437_USA', + removeSpecialCharacters: false, + lineCharacter: config.lineStyle === 'double' ? '=' : (config.lineStyle === 'dashed' ? '-' : '-') + }); + + console.log('Printer initialized:', { + type: config.printerType, + interface: normalized, + width: printerWidth + }); + } + // Ensure only one print per (orderId,type) runs at a time; others await the same promise + // Also track recently completed jobs to prevent rapid duplicate submissions + async _withInflight(key, executor, cooldownMs = 30000) { + try { + const now = Date.now(); + + // Check if there's an active in-flight job + if (this.inFlightJobs.has(key)) { + const existing = this.inFlightJobs.get(key); + console.log(`[PrinterManager] Reusing in-flight job for ${key}`); + return await existing.promise; + } + + // Check if this job was recently completed (within the configured cooldown) + if (this.recentlyCompleted.has(key)) { + const completedTime = this.recentlyCompleted.get(key); + const timeSince = now - completedTime; + if (timeSince < cooldownMs) { + console.log(`[PrinterManager] Skipping duplicate print for ${key} (completed ${Math.floor(timeSince/1000)}s ago)`); + return { success: true, message: 'Duplicate print prevented - recently completed' }; + } + } + + const p = (async () => { + try { + const result = await executor(); + // Track successful completion + if (result && result.success) { + this.recentlyCompleted.set(key, Date.now()); + } + return result; + } finally { + this.inFlightJobs.delete(key); + } + })(); + + this.inFlightJobs.set(key, { promise: p, timestamp: now }); + return await p; + } catch (err) { + throw err; + } + } + + + buildInterfaceFromConfig(config) { + const iface = (config.printerInterface || '').toLowerCase(); + const rawPath = (config.printerPath || '').trim(); + + // Network: accept "ip" or "ip:port" and prefix tcp:// + if (iface === 'network') { + if (!rawPath) return 'tcp://127.0.0.1:9100'; + if (rawPath.startsWith('tcp://')) return rawPath; + // If port not provided, default 9100 + const hasPort = rawPath.includes(':'); + return hasPort ? `tcp://${rawPath}` : `tcp://${rawPath}:9100`; + } + + // Serial on Windows needs \\ \\ . \\ COMX + if (iface === 'serial') { + // If looks like COMx, normalize, else pass-through (linux e.g. /dev/ttyS0) + if (/^COM\d+$/i.test(rawPath)) { + return `\\\\.\\${rawPath.toUpperCase()}`; + } + return rawPath || 'COM1'; + } + + // Default USB or direct path + return rawPath || '/dev/usb/lp0'; + } + + // Helper to safely call printer APIs that may not exist in some printer profiles + safeCall(methodName, ...args) { + try { + if (!this.printer) return; + const fn = this.printer[methodName]; + if (typeof fn === 'function') { + return fn.apply(this.printer, args); + } + } catch (err) { + console.warn(`Printer method ${methodName} failed:`, err.message); + } + } + + // Helper to safely call a specific printer instance + safeCallOn(instance, methodName, ...args) { + try { + if (!instance) return; + const fn = instance[methodName]; + if (typeof fn === 'function') { + return fn.apply(instance, args); + } + } catch (err) { + console.warn(`Printer method ${methodName} failed:`, err.message); + } + } + + // Direct execute for COM ports to bypass library-side connectivity quirks on Windows + async executeDirectWrite() { + return new Promise((resolve, reject) => { + try { + if (!this.printer) return reject(new Error('Printer not initialized')); + + const buffer = this.printer.getBuffer(); + const comPath = this.currentInterface && this.currentInterface.startsWith('\\\\.\\') + ? this.currentInterface + : null; + + if (!comPath) { + return reject(new Error('Direct write is only for COM ports')); + } + + const port = new SerialPort({ + path: comPath, + baudRate: 115200, + autoOpen: false + }); + + port.on('error', (err) => { + reject(err); + }); + + port.open((err) => { + if (err) { + reject(new Error(`Failed to open serial port: ${err.message}`)); + return; + } + + port.write(buffer, (err2) => { + if (err2) { + try { port.close(); } catch (_) {} + reject(new Error(`Failed to write to printer: ${err2.message}`)); + } else { + setTimeout(() => { + try { + port.close(() => resolve(true)); + } catch (_) { + resolve(true); + } + }, 1000); + } + }); + }); + } catch (error) { + reject(error); + } + }); + } + + // Direct write for a specific printer instance and COM path + async executeDirectWriteFor(instance, comPath) { + return new Promise((resolve, reject) => { + try { + if (!instance) return reject(new Error('Printer not initialized')); + + const buffer = instance.getBuffer(); + if (!comPath) { + return reject(new Error('Direct write is only for COM ports')); + } + + const port = new SerialPort({ + path: comPath, + baudRate: 9600, + autoOpen: false + }); + + port.on('error', (err) => { + reject(err); + }); + + port.open((err) => { + if (err) { + reject(new Error(`Failed to open serial port: ${err.message}`)); + return; + } + + port.write(buffer, (err2) => { + if (err2) { + try { port.close(); } catch (_) {} + reject(new Error(`Failed to write to printer: ${err2.message}`)); + } else { + setTimeout(() => { + try { + port.close(() => resolve(true)); + } catch (_) { + resolve(true); + } + }, 1000); + } + }); + }); + } catch (error) { + reject(error); + } + }); + } + + // Build list of target interfaces from config.selectedPrintersJson + // If empty, falls back to main printer config + getSelectedTargets() { + const targets = []; + try { + const raw = this.config && this.config.selectedPrintersJson; + if (!raw) return targets; + const arr = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!Array.isArray(arr)) return targets; + arr.forEach(t => { + if (!t || !t.type || !t.interface) return; + const norm = this.normalizeTargetInterface(t.type, t.interface); + if (norm && (t.type === 'network' || t.type === 'com' || t.type === 'serial' || t.type === 'usb' || t.type === 'system')) { + targets.push({ type: t.type, interface: norm }); + } + }); + // Dedupe by type+normalized interface to avoid double prints + const seen = new Set(); + const unique = []; + for (const t of targets) { + const key = `${String(t.type).toLowerCase()}|${String(t.interface)}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(t); + } + return unique; + } catch (e) { + console.warn('Failed to parse selectedPrintersJson:', e.message); + } + return targets; + } + + // Build a target object from main printer config + getMainPrinterTarget() { + try { + if (!this.config || !this.config.printerInterface || !this.config.printerPath) { + return null; + } + const iface = (this.config.printerInterface || '').toLowerCase(); + let type = iface; // network, serial, usb + if (iface === 'serial') type = 'com'; + const norm = this.normalizeTargetInterface(type, this.config.printerPath); + if (!norm) return null; + return { type, interface: norm }; + } catch (e) { + return null; + } + } + + // Check if main printer is included in multi-printer selection + isMainPrinterInSelection(targets) { + const mainTarget = this.getMainPrinterTarget(); + if (!mainTarget) return false; + return targets.some(t => + t.type === mainTarget.type && t.interface === mainTarget.interface + ); + } + + normalizeTargetInterface(type, value) { + const val = String(value || '').trim(); + if (!val) return null; + const t = String(type || '').toLowerCase(); + if (t === 'network') { + if (val.startsWith('tcp://')) return val; + const hasPort = val.includes(':'); + return hasPort ? `tcp://${val}` : `tcp://${val}:9100`; + } + if (t === 'com' || t === 'serial') { + if (/^\\\\\.\\COM\d+$/i.test(val)) return val; + if (/^COM\d+$/i.test(val)) return `\\\\.\\${val.toUpperCase()}`; + return val; // linux tty path + } + if (t === 'usb') { + return val; // e.g., /dev/usb/lp0 + } + if (t === 'system') { + return val; // spooler printer name + } + return null; + } + + createPrinterForInterface(normalizedInterface) { + const printerTypeMap = { + 'epson': PrinterTypes.EPSON, + 'star': PrinterTypes.STAR, + 'tanca': PrinterTypes.TANCA, + 'brother': PrinterTypes.BROTHER, + 'custom': PrinterTypes.CUSTOM + }; + const printerType = printerTypeMap[this.config.printerType] || PrinterTypes.EPSON; + const printerWidth = parseInt(this.config.printerWidth, 10) || 48; + return new ThermalPrinter({ + type: printerType, + interface: normalizedInterface, + width: printerWidth, + characterSet: 'PC437_USA', + removeSpecialCharacters: false, + lineCharacter: this.config.lineStyle === 'double' ? '=' : (this.config.lineStyle === 'dashed' ? '-' : '-') + }); + } + + // Create a printer instance from a full printer configuration object + createPrinterInstanceFromConfig(printerConfig) { + const printerTypeMap = { + 'epson': PrinterTypes.EPSON, + 'star': PrinterTypes.STAR, + 'tanca': PrinterTypes.TANCA, + 'brother': PrinterTypes.BROTHER, + 'custom': PrinterTypes.CUSTOM + }; + + const printerType = printerTypeMap[printerConfig.printer_type] || PrinterTypes.EPSON; + const printerWidth = parseInt(printerConfig.paper_width, 10) || 48; + + // Normalize the interface based on type + let normalizedInterface = printerConfig.interface; + if (printerConfig.type === 'network') { + if (!normalizedInterface.startsWith('tcp://')) { + const hasPort = normalizedInterface.includes(':'); + normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`; + } + } else if (printerConfig.type === 'com' || printerConfig.type === 'serial') { + if (/^COM\d+$/i.test(normalizedInterface)) { + normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`; + } + } + + return new ThermalPrinter({ + type: printerType, + interface: normalizedInterface, + width: printerWidth, + characterSet: 'PC437_USA', + removeSpecialCharacters: false, + lineCharacter: printerConfig.line_style === 'double' ? '=' : (printerConfig.line_style === 'dashed' ? '-' : '-') + }); + } + + // Get printer max dots width from config + getPrinterMaxDotsWidthFromConfig(cfg) { + if (cfg.logo_max_width_dots && Number.isFinite(Number(cfg.logo_max_width_dots))) { + const n = Number(cfg.logo_max_width_dots); + if (n > 0) return n; + } + const widthChars = parseInt(cfg.paper_width, 10) || 48; + if (widthChars >= 48) return 576; // typical 80mm + if (widthChars >= 42) return 512; + return 384; // typical 58mm + } + + async executeForInstance(instance, normalizedInterface) { + if (normalizedInterface && /^\\\\\.\\COM\d+/i.test(normalizedInterface)) { + await this.executeDirectWriteFor(instance, normalizedInterface); + } else { + await instance.execute(); + } + } + + getPrinterMaxDotsWidth() { + const cfg = this.config || {}; + if (cfg.logoMaxWidthDots && Number.isFinite(Number(cfg.logoMaxWidthDots))) { + const n = Number(cfg.logoMaxWidthDots); + if (n > 0) return n; + } + const widthChars = parseInt(cfg.printerWidth, 10) || 48; + if (widthChars >= 48) return 576; // typical 80mm + if (widthChars >= 42) return 512; + return 384; // typical 58mm + } + + async printLogoWithFit(instance, logoPath) { + try { + if (!logoPath || !fs.existsSync(logoPath)) return false; + const maxWidth = this.getPrinterMaxDotsWidth(); + this.safeCallOn(instance, 'alignCenter'); + + // Prefer sharp when available (handles PNG/JPEG/GIF and resizes well) + if (sharp) { + try { + const resized = await sharp(logoPath) + .resize({ width: maxWidth, fit: 'inside', withoutEnlargement: true }) + .png() + .toBuffer(); + await instance.printImageBuffer(resized); + instance.newLine(); + return true; + } catch (e) { + console.warn('Sharp resize failed, falling back:', e.message); + } + } + + // Fallback for PNG using pngjs and nearest-neighbor downscale + const lower = logoPath.toLowerCase(); + if (PNGLib && (lower.endsWith('.png'))) { + try { + const input = fs.readFileSync(logoPath); + const png = PNGLib.sync.read(input); + if (png.width <= maxWidth) { + await instance.printImageBuffer(input); + instance.newLine(); + return true; + } + const ratio = maxWidth / png.width; + const targetW = Math.max(1, Math.floor(png.width * ratio)); + const targetH = Math.max(1, Math.floor(png.height * ratio)); + const out = new PNGLib({ width: targetW, height: targetH }); + for (let y2 = 0; y2 < targetH; y2++) { + const y1 = Math.floor(y2 / ratio); + for (let x2 = 0; x2 < targetW; x2++) { + const x1 = Math.floor(x2 / ratio); + const idx1 = (png.width * y1 + x1) << 2; + const idx2 = (targetW * y2 + x2) << 2; + out.data[idx2] = png.data[idx1]; + out.data[idx2 + 1] = png.data[idx1 + 1]; + out.data[idx2 + 2] = png.data[idx1 + 2]; + out.data[idx2 + 3] = png.data[idx1 + 3]; + } + } + const buf = PNGLib.sync.write(out); + await instance.printImageBuffer(buf); + instance.newLine(); + return true; + } catch (e) { + console.warn('PNG fallback resize failed:', e.message); + } + } + + // Last resort: try library native path (may crop if too wide) + await instance.printImage(logoPath); + instance.newLine(); + return true; + } catch (err) { + console.error('Failed to print logo:', err.message); + return false; + } + } + + async printLogoWithFitCustom(instance, logoPath, maxWidth) { + try { + if (!logoPath || !fs.existsSync(logoPath)) return false; + this.safeCallOn(instance, 'alignCenter'); + + // Prefer sharp when available (handles PNG/JPEG/GIF and resizes well) + if (sharp) { + try { + const resized = await sharp(logoPath) + .resize({ width: maxWidth, fit: 'inside', withoutEnlargement: true }) + .png() + .toBuffer(); + await instance.printImageBuffer(resized); + instance.newLine(); + return true; + } catch (e) { + console.warn('Sharp resize failed, falling back:', e.message); + } + } + + // Fallback for PNG using pngjs and nearest-neighbor downscale + const lower = logoPath.toLowerCase(); + if (PNGLib && (lower.endsWith('.png'))) { + try { + const input = fs.readFileSync(logoPath); + const png = PNGLib.sync.read(input); + if (png.width <= maxWidth) { + await instance.printImageBuffer(input); + instance.newLine(); + return true; + } + const ratio = maxWidth / png.width; + const targetW = Math.max(1, Math.floor(png.width * ratio)); + const targetH = Math.max(1, Math.floor(png.height * ratio)); + const out = new PNGLib({ width: targetW, height: targetH }); + for (let y2 = 0; y2 < targetH; y2++) { + const y1 = Math.floor(y2 / ratio); + for (let x2 = 0; x2 < targetW; x2++) { + const x1 = Math.floor(x2 / ratio); + const idx1 = (png.width * y1 + x1) << 2; + const idx2 = (targetW * y2 + x2) << 2; + out.data[idx2] = png.data[idx1]; + out.data[idx2 + 1] = png.data[idx1 + 1]; + out.data[idx2 + 2] = png.data[idx1 + 2]; + out.data[idx2 + 3] = png.data[idx1 + 3]; + } + } + const buf = PNGLib.sync.write(out); + await instance.printImageBuffer(buf); + instance.newLine(); + return true; + } catch (e) { + console.warn('PNG fallback resize failed:', e.message); + } + } + + // Last resort: try library native path (may crop if too wide) + await instance.printImage(logoPath); + instance.newLine(); + return true; + } catch (err) { + console.error('Failed to print logo:', err.message); + return false; + } + } + + buildQrContent(template, order) { + try { + const tpl = (template || 'ORDER-{id}').toString(); + const createdAt = order && order.createdAt ? new Date(order.createdAt * 1000).toISOString() : new Date().toISOString(); + const type = order && order.order && order.order.type ? order.order.type : ''; + const total = (order && (order.totalAmount || (order.order && order.order.amount))) || 0; + return tpl + .replace(/\{id\}/g, String(order && order.id || '')) + .replace(/\{total\}/g, String(total)) + .replace(/\{type\}/g, String(type)) + .replace(/\{createdAt\}/g, String(createdAt)); + } catch (_) { + return `ORDER-${order && order.id ? order.id : ''}`; + } + } + + // Get optimal column widths based on printer width + getColumnWidths(config) { + const width = parseInt(config.paper_width || config.printerWidth, 10) || 48; + + // For narrow receipts (58mm / 32 chars), use tighter layout + if (width <= 32) { + // Total: 0.95 (5% buffer to ensure no edge wrapping) + return { + useTable: true, + itemWidth: 0.55, // 55% for item name + priceWidth: 0.40, // 40% for price (generous space to prevent wrapping) + priceMinChars: 8 // Minimum chars needed for price column + }; + } + // For standard receipts (80mm / 48 chars), use balanced layout + else if (width <= 48) { + // Total: 0.95 (5% buffer to ensure no edge wrapping) + return { + useTable: true, + itemWidth: 0.60, // 60% for item name + priceWidth: 0.35, // 35% for price (good space to prevent wrapping) + priceMinChars: 10 // Minimum chars needed for price column + }; + } + // For wide receipts (letter / 80 chars), use spacious layout + else { + // Total: 0.95 (5% buffer to ensure no edge wrapping) + return { + useTable: true, + itemWidth: 0.65, // 65% for item name + priceWidth: 0.30, // 30% for price (adequate space) + priceMinChars: 12 // Minimum chars needed for price column + }; + } + } + + // Truncate text if needed to fit in column + truncateText(text, maxLength) { + if (!text || text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + '...'; + } + + async renderTest(instance) { + this.safeCallOn(instance, 'clear'); + this.safeCallOn(instance, 'alignCenter'); + instance.println('TEST PRINT'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + instance.println('Printer Type: ' + (this.config.printerType || 'N/A')); + instance.println('Interface: ' + (instance.interface || this.currentInterface || this.config.printerPath || 'N/A')); + instance.println('Width: ' + (this.config.printerWidth || 'N/A') + ' chars'); + instance.println('Time: ' + new Date().toLocaleString()); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignCenter'); + instance.println('Test Successful'); + this.safeCallOn(instance, 'cut'); + } + + async renderReceipt(instance, order, type = 'new', options = {}, cfg = null) { + // Use provided config or fallback to this.config + // Merge in global toggles for backward compatibility when per-printer config omits them + const base = cfg || this.config || {}; + const globalCfg = this.config || {}; + const config = { + ...base, + show_customer_info: typeof base.show_customer_info !== 'undefined' ? base.show_customer_info : undefined, + show_order_items: typeof base.show_order_items !== 'undefined' ? base.show_order_items : undefined, + show_prices: typeof base.show_prices !== 'undefined' ? base.show_prices : undefined, + show_timestamps: typeof base.show_timestamps !== 'undefined' ? base.show_timestamps : undefined, + // Fallback to global string flags if per-printer booleans are not provided + showCustomerInfo: typeof base.show_customer_info === 'undefined' && typeof base.showCustomerInfo === 'undefined' ? globalCfg.showCustomerInfo : base.showCustomerInfo, + showOrderItems: typeof base.show_order_items === 'undefined' && typeof base.showOrderItems === 'undefined' ? globalCfg.showOrderItems : base.showOrderItems, + showPrices: typeof base.show_prices === 'undefined' && typeof base.showPrices === 'undefined' ? globalCfg.showPrices : base.showPrices, + showTimestamps: typeof base.show_timestamps === 'undefined' && typeof base.showTimestamps === 'undefined' ? globalCfg.showTimestamps : base.showTimestamps + }; + // Reset buffer + this.safeCallOn(instance, 'clear'); + + // Logo (resized to fit) - use config-specific logo path and max width + if (config.logo_path && fs.existsSync(config.logo_path)) { + const maxWidth = this.getPrinterMaxDotsWidthFromConfig(config); + await this.printLogoWithFitCustom(instance, config.logo_path, maxWidth); + } else if (config.logoPath && fs.existsSync(config.logoPath)) { + // Backward compatibility with old config key + await this.printLogoWithFit(instance, config.logoPath); + } + + // Business header + this.safeCallOn(instance, 'alignCenter'); + const businessName = config.business_name || config.businessName; + const paperWidth = parseInt(config.paper_width || config.printerWidth, 10) || 48; + + if (businessName) { + // Adjust text size based on business name length and paper width + const nameLength = businessName.length; + const maxCharsAtSize1 = paperWidth / 2; // Approximate chars at 2x size + + // Use smaller text for long names on narrow paper + if (nameLength > maxCharsAtSize1 && paperWidth <= 32) { + this.safeCallOn(instance, 'setTextNormal'); + this.safeCallOn(instance, 'bold', true); + instance.println(businessName); + this.safeCallOn(instance, 'bold', false); + } else { + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'bold', true); + instance.println(businessName); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + } + this.safeCallOn(instance, 'newLine'); + } + + const contactLines = [ + config.business_address || config.businessAddress, + config.business_phone || config.businessPhone, + config.business_website || config.businessWebsite, + config.business_email || config.businessEmail + ].filter(Boolean); + const contactSize = config.business_contact_size || config.businessContactSize || 'normal'; + + if (contactLines.length > 0) { + // For narrow paper, force normal size to avoid overflow + if (paperWidth <= 32 && contactSize === 'large') { + this.safeCallOn(instance, 'setTextNormal'); + } else if (contactSize === 'large') { + this.safeCallOn(instance, 'setTextSize', 1, 1); + } else { + this.safeCallOn(instance, 'setTextNormal'); + } + contactLines.forEach(line => instance.println(line)); + // Reset size after contacts + this.safeCallOn(instance, 'setTextNormal'); + } + + // Optional header text + const headerText = config.header_text || config.headerText; + if (headerText) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'bold', true); + instance.println(headerText); + this.safeCallOn(instance, 'bold', false); + } + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'newLine'); + + // Order type indicator + if (type === 'canceled') { + this.safeCallOn(instance, 'bold', true); + this.safeCallOn(instance, 'setTextSize', 1, 1); + instance.println('*** ORDER CANCELED ***'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + this.safeCallOn(instance, 'newLine'); + if (options && options.reason) { + this.safeCallOn(instance, 'alignLeft'); + instance.println('Cancellation reason:'); + this.safeCallOn(instance, 'bold', true); + instance.println(String(options.reason)); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'newLine'); + } + } else { + this.safeCallOn(instance, 'bold', true); + this.safeCallOn(instance, 'setTextSize', 1, 1); + instance.println('NEW ORDER'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + this.safeCallOn(instance, 'newLine'); + } + + // Order details + this.safeCallOn(instance, 'alignLeft'); + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'bold', true); + instance.println(`Order #${order.id}`); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + this.safeCallOn(instance, 'newLine'); + + const showTimestamps = (config.show_timestamps !== false) && (config.showTimestamps !== 'false'); + if (showTimestamps) { + instance.println(`Time: ${new Date(order.createdAt * 1000).toLocaleString()}`); + } + + instance.println(`Type: ${order.order.type || 'N/A'}`); + + // Customer info + const showCustomerInfo = (config.show_customer_info !== false) && (config.showCustomerInfo !== 'false'); + if (showCustomerInfo) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'bold', true); + instance.println('CUSTOMER INFO:'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'newLine'); + instance.println(`Name: ${order.customer.name || 'N/A'}`); + if (order.customer.phoneNumber) instance.println(`Phone: ${order.customer.phoneNumber}`); + if (order.order.deliveryAddress) { + instance.println(`Address: ${order.order.deliveryAddress}`); + } + } + + // Items + const showOrderItems = (config.show_order_items !== false) && (config.showOrderItems !== 'false'); + if (showOrderItems) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'bold', true); + instance.println('ORDER ITEMS:'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'newLine'); + + if (order.order.items && Array.isArray(order.order.items)) { + const showPrices = (config.show_prices !== false) && (config.showPrices !== 'false'); + + // Get optimal column widths for this printer + const colWidths = this.getColumnWidths(config); + + // Ensure proper alignment for tableCustom + this.safeCallOn(instance, 'alignLeft'); + + order.order.items.forEach(item => { + const itemName = item.itemName || item.name || 'Unknown Item'; + const qty = item.qty || 1; + const price = item.price || 0; + const totalItemPrice = (price * qty).toFixed(2); + + // Use tableCustom for proper alignment of item and price + if (showPrices) { + try { + // Ensure price text doesn't have extra spaces + const priceText = `$${totalItemPrice}`.trim(); + instance.tableCustom([ + { text: `${qty}x ${itemName}`, align: "LEFT", width: colWidths.itemWidth }, + { text: priceText, align: "RIGHT", width: colWidths.priceWidth } + ]); + } catch (e) { + // Fallback if tableCustom is not available + instance.println(`${qty}x ${itemName} - $${totalItemPrice}`); + } + } else { + instance.println(`${qty}x ${itemName}`); + } + + // Print addons with indentation + if (item.addons && Array.isArray(item.addons) && item.addons.length > 0) { + item.addons.forEach(addon => { + const addonName = addon.name || addon; + if (showPrices && addon.price && addon.price > 0) { + const addonPrice = (addon.price || 0).toFixed(2); + // Ensure price text doesn't have extra spaces + const priceText = `$${addonPrice}`.trim(); + try { + instance.tableCustom([ + { text: ` + ${addonName}`, align: "LEFT", width: colWidths.itemWidth }, + { text: priceText, align: "RIGHT", width: colWidths.priceWidth } + ]); + } catch (e) { + instance.println(` + ${addonName} - $${addonPrice}`); + } + } else { + instance.println(` + ${addonName}`); + } + }); + } + + // Print exclusions with indentation + if (item.exclude && Array.isArray(item.exclude) && item.exclude.length > 0) { + item.exclude.forEach(ex => { + const excludeName = ex.name || ex; + instance.println(` - NO ${excludeName}`); + }); + } + }); + } + } + + // Special instructions + if (order.order.specialInstructions) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'bold', true); + instance.println('SPECIAL INSTRUCTIONS:'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'newLine'); + instance.println(order.order.specialInstructions); + } + + // Delivery instructions + if (order.order.deliveryInstructions) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'bold', true); + instance.println('DELIVERY INSTRUCTIONS:'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'newLine'); + instance.println(order.order.deliveryInstructions); + } + + // Food allergy + if (order.order.foodAllergy) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'bold', true); + this.safeCallOn(instance, 'invert', true); + instance.println(' FOOD ALLERGY WARNING '); + this.safeCallOn(instance, 'invert', false); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignLeft'); + if (order.order.foodAllergyNotes) { + instance.println(order.order.foodAllergyNotes.toUpperCase()); + } + } + + // Total + const showPricesForTotal = (config.show_prices !== false) && (config.showPrices !== 'false'); + if (showPricesForTotal) { + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignLeft'); // Ensure proper alignment for tableCustom + //this.safeCallOn(instance, 'setTextSize', 1, 1); + // Price breakdown (normal font, right-aligned): Subtotal, Tax, Delivery Fee + // Note: Keep TOTAL styling unchanged below + try { + // Extract values with fallbacks to support multiple payload shapes + const rawSubtotal = (order && typeof order.amount !== 'undefined') + ? order.amount + : (order && order.order && typeof order.order.amount !== 'undefined' ? order.order.amount : null); + const rawTaxAmount = (order && typeof order.taxAmount !== 'undefined') + ? order.taxAmount + : (order && order.order && typeof order.order.taxAmount !== 'undefined' ? order.order.taxAmount : null); + const rawTaxRate = (order && typeof order.taxRate !== 'undefined') + ? order.taxRate + : (order && order.order && typeof order.order.taxRate !== 'undefined' ? order.order.taxRate : null); + const rawDelivery = (order && typeof order.deliveryFee !== 'undefined') + ? order.deliveryFee + : (order && order.order && typeof order.order.deliveryFee !== 'undefined' ? order.order.deliveryFee : null); + + this.safeCallOn(instance, 'alignRight'); + if (typeof rawSubtotal === 'number' && isFinite(rawSubtotal)) { + instance.println(`Subtotal: $${rawSubtotal.toFixed(2)}`); + } + if (typeof rawTaxAmount === 'number' && isFinite(rawTaxAmount) && rawTaxAmount > 0) { + let taxLabel = 'Tax'; + if (typeof rawTaxRate === 'number' && isFinite(rawTaxRate) && rawTaxRate > 0) { + const rateStr = Number(rawTaxRate).toFixed(2).replace(/\.?0+$/, ''); + taxLabel = `Tax (${rateStr}%)`; + } + instance.println(`${taxLabel}: $${rawTaxAmount.toFixed(2)}`); + } + if (typeof rawDelivery === 'number' && isFinite(rawDelivery) && rawDelivery > 0) { + instance.println(`Delivery Fee: $${rawDelivery.toFixed(2)}`); + } + } catch (_) {} + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'bold', true); + this.safeCallOn(instance, 'alignRight'); + const totalAmount = (order.totalAmount || 0).toFixed(2); + instance.println(`TOTAL: $${totalAmount}`); + + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + } + + // QR code + const qrCodeEnabled = config.qr_code_enabled !== false && config.qrCodeEnabled !== 'false'; + if (qrCodeEnabled) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignCenter'); + try { + const qrSize = config.qr_code_size || config.qrCodeSize || 3; + let sizeRaw = parseInt(qrSize, 10); + + // Auto-adjust QR size based on paper width if not explicitly set + const paperWidth = parseInt(config.paper_width || config.printerWidth, 10) || 48; + if (isNaN(sizeRaw) || sizeRaw <= 0) { + // Default sizes based on paper width + if (paperWidth <= 32) { + sizeRaw = 2; // Small QR for narrow paper + } else if (paperWidth <= 48) { + sizeRaw = 3; // Medium QR for standard paper + } else { + sizeRaw = 4; // Larger QR for wide paper + } + } + + const size = Math.min(8, Math.max(2, sizeRaw)); + const correction = config.qr_code_correction || config.qrCodeCorrection || 'M'; + const template = config.qr_code_content_template || config.qrCodeContentTemplate || 'ORDER-{id}'; + const content = this.buildQrContent(template, order); + instance.printQR(content, { cellSize: size, correction, model: 2 }); + } catch (error) { + console.error('Failed to print QR code:', error.message); + } + } + + // Footer + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignCenter'); + const footerText = (typeof config.footer_text !== 'undefined' ? config.footer_text : config.footerText); + if (footerText) { + this.safeCallOn(instance, 'bold', true); + instance.println(footerText); + this.safeCallOn(instance, 'bold', false); + } + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'cut'); + } + + async testPrint() { + if (!this.printer) { + throw new Error('Printer not initialized'); + } + + const targets = this.getSelectedTargets(); + console.log('[PrinterManager] Test print - Selected targets:', targets.length); + + if (targets.length === 0) { + // Single/fallback printer path using main printer config + console.log('[PrinterManager] Using single/fallback printer:', this.currentInterface); + this.safeCall('clear'); + await this.renderTest(this.printer); + try { + if (this.currentInterface && this.currentInterface.startsWith('\\\\.\\')) { + await this.executeDirectWrite(); + } else { + await this.printer.execute(); + } + console.log('[PrinterManager] Test print successful (single printer)'); + return { success: true }; + } catch (error) { + console.error('[PrinterManager] Test print failed:', error.message); + return { success: false, error: error.message }; + } + } + + // Multi-printer: fan out concurrently + console.log('[PrinterManager] Printing to', targets.length, 'printer(s)'); + const jobs = targets.map(async (t, idx) => { + console.log(`[PrinterManager] Test print job ${idx + 1}/${targets.length}: ${t.type} -> ${t.interface}`); + if (t.type === 'system') { + await this.printSystemTest(t.interface); + } else { + const p = this.createPrinterForInterface(t.interface); + await this.renderTest(p); + await this.executeForInstance(p, t.interface); + } + }); + const results = await Promise.allSettled(jobs); + const succeeded = results.filter(r => r.status === 'fulfilled').length; + const failed = results.length - succeeded; + console.log(`[PrinterManager] Test print results: ${succeeded} succeeded, ${failed} failed`); + + if (succeeded === 0) { + const firstErr = results.find(r => r.status === 'rejected'); + return { success: false, error: firstErr && firstErr.reason ? firstErr.reason.message || String(firstErr.reason) : 'All test prints failed' }; + } + return { success: true, message: `Test sent to ${succeeded} printer(s)` + (failed > 0 ? `, ${failed} failed` : '') }; + } + + // Test print with specific printer configuration + async testPrintWithConfig(printerConfig) { + try { + console.log(`[PrinterManager] Test print for: ${printerConfig.name} (${printerConfig.type}:${printerConfig.interface})`); + + if (printerConfig.type === 'system') { + await this.printSystemTest(printerConfig.interface); + return { success: true, message: 'Test sent to system printer' }; + } + + // Create printer instance from config + const instance = this.createPrinterInstanceFromConfig(printerConfig); + + // Render test receipt + this.safeCallOn(instance, 'clear'); + this.safeCallOn(instance, 'alignCenter'); + instance.println('TEST PRINT'); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + instance.println(`Printer: ${printerConfig.name}`); + instance.println(`Type: ${printerConfig.printer_type}`); + instance.println(`Paper: ${printerConfig.paper_format} (${printerConfig.paper_width} chars)`); + instance.println(`Interface: ${printerConfig.interface}`); + instance.println(`Time: ${new Date().toLocaleString()}`); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignCenter'); + instance.println('Test Successful'); + this.safeCallOn(instance, 'cut'); + + // Execute print + let normalizedInterface = printerConfig.interface; + if (printerConfig.type === 'network') { + if (!normalizedInterface.startsWith('tcp://')) { + const hasPort = normalizedInterface.includes(':'); + normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`; + } + } else if (printerConfig.type === 'com' || printerConfig.type === 'serial') { + if (/^COM\d+$/i.test(normalizedInterface)) { + normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`; + } + } + + await this.executeForInstance(instance, normalizedInterface); + + console.log(`[PrinterManager] Test print successful for: ${printerConfig.name}`); + return { success: true, message: `Test sent to ${printerConfig.name}` }; + } catch (error) { + console.error(`[PrinterManager] Test print failed for ${printerConfig.name}:`, error.message); + return { success: false, error: error.message }; + } + } + + async printOrderReceipt(order, type = 'new', options = {}) { + if (!this.printer) { + throw new Error('Printer not initialized'); + } + + const key = `order:${order && order.id}:${type}`; + const cooldownMs = (options && typeof options.cooldownMs === 'number') ? options.cooldownMs : 30000; + return await this._withInflight(key, async () => { + try { + const targets = this.getSelectedTargets(); + console.log(`[PrinterManager] Print order #${order.id} - Selected targets:`, targets.length); + + if (targets.length === 0) { + // Single/fallback printer path using main printer config + console.log(`[PrinterManager] Printing order #${order.id} to single/fallback printer:`, this.currentInterface); + await this.renderReceipt(this.printer, order, type, options); + // Execute print with one-time reinitialize-on-failure retry + try { + if (this.currentInterface && this.currentInterface.startsWith('\\\\.\\')) { + await this.executeDirectWrite(); + } else { + await this.printer.execute(); + } + } catch (execErr) { + console.warn('[PrinterManager] Execute failed, attempting reinitialize and retry:', execErr.message); + try { + if (this.config) { + this.initializePrinter(this.config); + await this.renderReceipt(this.printer, order, type, options); + if (this.currentInterface && this.currentInterface.startsWith('\\\\.\\')) { + await this.executeDirectWrite(); + } else { + await this.printer.execute(); + } + } else { + throw execErr; + } + } catch (retryErr) { + throw retryErr; + } + } + console.log(`[PrinterManager] Receipt printed for order #${order.id}`); + return { success: true }; + } + + // Multi-printer + console.log(`[PrinterManager] Printing order #${order.id} to ${targets.length} printer(s)`); + const jobs = targets.map(async (t, idx) => { + console.log(`[PrinterManager] Print job ${idx + 1}/${targets.length} for order #${order.id}: ${t.type} -> ${t.interface}`); + if (t.type === 'system') { + await this.printSystemReceipt(order, type, options, t.interface); + } else { + const p = this.createPrinterForInterface(t.interface); + await this.renderReceipt(p, order, type, options); + try { + await this.executeForInstance(p, t.interface); + } catch (multiErr) { + console.warn(`[PrinterManager] Execute failed for ${t.interface}, retrying after reinit:`, multiErr.message); + // Recreate instance and retry once + const p2 = this.createPrinterForInterface(t.interface); + await this.renderReceipt(p2, order, type, options); + await this.executeForInstance(p2, t.interface); + } + } + }); + const results = await Promise.allSettled(jobs); + const succeeded = results.filter(r => r.status === 'fulfilled').length; + const failed = results.length - succeeded; + console.log(`[PrinterManager] Print results for order #${order.id}: ${succeeded} succeeded, ${failed} failed`); + + if (succeeded === 0) { + const firstErr = results.find(r => r.status === 'rejected'); + return { success: false, error: firstErr && firstErr.reason ? firstErr.reason.message || String(firstErr.reason) : 'All prints failed' }; + } + return { success: true }; + } catch (error) { + console.error('[PrinterManager] Print error:', error.message); + return { success: false, error: error.message }; + } + }, cooldownMs); + } + + // Print order with per-printer configurations from database + async printOrderReceiptWithPrinterConfigs(order, printerConfigs, type = 'new', options = {}) { + const key = `order:${order && order.id}:${type}`; + const cooldownMs = (options && typeof options.cooldownMs === 'number') ? options.cooldownMs : 30000; + return await this._withInflight(key, async () => { + try { + if (!printerConfigs || printerConfigs.length === 0) { + console.log('[PrinterManager] No printer configurations provided, falling back to legacy method'); + return await this.printOrderReceipt(order, type, options); + } + + console.log(`[PrinterManager] Printing order #${order.id} to ${printerConfigs.length} configured printer(s)`); + + const jobs = printerConfigs.map(async (config, idx) => { + console.log(`[PrinterManager] Print job ${idx + 1}/${printerConfigs.length} for order #${order.id}: ${config.name}`); + + if (config.type === 'system') { + await this.printSystemReceiptWithConfig(order, type, options, config); + } else { + const instance = this.createPrinterInstanceFromConfig(config); + await this.renderReceipt(instance, order, type, options, config); + + // Normalize interface for execution + let normalizedInterface = config.interface; + if (config.type === 'network') { + if (!normalizedInterface.startsWith('tcp://')) { + const hasPort = normalizedInterface.includes(':'); + normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`; + } + } else if (config.type === 'com' || config.type === 'serial') { + if (/^COM\d+$/i.test(normalizedInterface)) { + normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`; + } + } + + await this.executeForInstance(instance, normalizedInterface); + } + console.log(`[PrinterManager] Successfully printed to: ${config.name}`); + }); + + const results = await Promise.allSettled(jobs); + const succeeded = results.filter(r => r.status === 'fulfilled').length; + const failed = results.length - succeeded; + console.log(`[PrinterManager] Print results for order #${order.id}: ${succeeded} succeeded, ${failed} failed`); + + if (succeeded === 0) { + const firstErr = results.find(r => r.status === 'rejected'); + return { success: false, error: firstErr && firstErr.reason ? firstErr.reason.message || String(firstErr.reason) : 'All prints failed' }; + } + return { success: true }; + } catch (error) { + console.error('[PrinterManager] Print error:', error.message); + return { success: false, error: error.message }; + } + }, cooldownMs); + } + + // ===== System printer (Windows spooler) support via PDF rendering ===== + async printSystemTest(printerName) { + const filePath = path.join(os.tmpdir(), `kitchen-agent-test-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`); + try { + await this.writeTestPdfToPath(filePath); + await pdfPrinter.print(filePath, { printer: printerName }); + // Give the spooler a moment to open the file before deletion + await new Promise(resolve => setTimeout(resolve, 1500)); + } finally { + try { fs.unlinkSync(filePath); } catch (_) {} + } + } + + async printSystemReceipt(order, type, options, printerName) { + const filePath = path.join(os.tmpdir(), `kitchen-agent-receipt-${order && order.id ? order.id : 'x'}-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`); + try { + await this.writeReceiptPdfToPath(filePath, order, type, options); + await pdfPrinter.print(filePath, { printer: printerName }); + // Give the spooler a moment to open the file before deletion + await new Promise(resolve => setTimeout(resolve, 2000)); + } finally { + try { fs.unlinkSync(filePath); } catch (_) {} + } + } + + async printSystemReceiptWithConfig(order, type, options, printerConfig) { + const filePath = path.join(os.tmpdir(), `kitchen-agent-receipt-${order && order.id ? order.id : 'x'}-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`); + try { + await this.writeReceiptPdfToPathWithConfig(filePath, order, type, options, printerConfig); + await pdfPrinter.print(filePath, { printer: printerConfig.interface }); + // Give the spooler a moment to open the file before deletion + await new Promise(resolve => setTimeout(resolve, 2000)); + } finally { + try { fs.unlinkSync(filePath); } catch (_) {} + } + } + + async writeTestPdfToPath(filePath) { + const doc = new PDFDocument({ size: 'A4', margin: 36 }); + return new Promise((resolve, reject) => { + const stream = fs.createWriteStream(filePath); + doc.pipe(stream); + // Header + doc.fontSize(18).text('TEST PRINT', { align: 'center' }); + doc.moveDown(0.5); + doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke(); + doc.moveDown(0.5); + doc.fontSize(12).text(`Printer Type: ${this.config && this.config.printerType || 'N/A'}`); + doc.text(`Interface: ${this.currentInterface || this.config && this.config.printerPath || 'N/A'}`); + doc.text(`Width: ${this.config && this.config.printerWidth || 'N/A'} chars`); + doc.text(`Time: ${new Date().toLocaleString()}`); + doc.moveDown(0.5); + doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke(); + doc.moveDown(0.5); + doc.fontSize(14).text('Test Successful', { align: 'center' }); + doc.end(); + stream.on('finish', () => resolve(true)); + stream.on('error', reject); + }); + } + + async writeReceiptPdfToPath(filePath, order, type = 'new', options = {}) { + const cfg = this.config || {}; + const doc = new PDFDocument({ size: 'A4', margin: 36 }); + const maxWidth = 523; // 559 - 36 + return new Promise(async (resolve, reject) => { + const stream = fs.createWriteStream(filePath); + doc.pipe(stream); + + // Business header + doc.fontSize(18).text(cfg.businessName || 'KITCHEN ORDER', { align: 'center' }); + if (cfg.businessAddress || cfg.businessPhone || cfg.businessWebsite || cfg.businessEmail) { + doc.moveDown(0.2); + doc.fontSize((cfg.businessContactSize || 'normal') === 'large' ? 12 : 10); + [cfg.businessAddress, cfg.businessPhone, cfg.businessWebsite, cfg.businessEmail] + .filter(Boolean) + .forEach(line => doc.text(line, { align: 'center' })); + } + + if (cfg.headerText) { + doc.moveDown(0.4); + doc.fontSize(12).text(cfg.headerText, { align: 'center' }); + } + + doc.moveDown(0.4); + doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke(); + + // Order banner + doc.moveDown(0.4); + doc.fontSize(14).text(type === 'canceled' ? '*** ORDER CANCELED ***' : 'NEW ORDER', { align: 'left' }); + if (type === 'canceled' && options && options.reason) { + doc.moveDown(0.2); + doc.fontSize(12).text(`Cancellation reason: ${String(options.reason)}`); + } + + // Order details + doc.moveDown(0.4); + doc.fontSize(12).text(`Order #${order.id}`); + if (cfg.showTimestamps === 'true' || !cfg.showTimestamps) { + doc.text(`Time: ${new Date(order.createdAt * 1000).toLocaleString()}`); + } + doc.text(`Type: ${order.order.type || 'N/A'}`); + + // Customer + if (cfg.showCustomerInfo === 'true' || !cfg.showCustomerInfo) { + doc.moveDown(0.4); + doc.fontSize(12).text('CUSTOMER:'); + doc.fontSize(11).text(order.customer.name || 'N/A'); + if (order.customer.phoneNumber) doc.text(order.customer.phoneNumber); + if (order.order.deliveryAddress) doc.text(`Address: ${order.order.deliveryAddress}`); + } + + // Items + if (order.order.items && Array.isArray(order.order.items) && (cfg.showOrderItems === 'true' || !cfg.showOrderItems)) { + doc.moveDown(0.4); + doc.fontSize(12).text('ITEMS:'); + doc.fontSize(11); + order.order.items.forEach(item => { + const itemName = item.itemName || item.name || 'Unknown Item'; + const qty = item.qty || 1; + const price = item.price || 0; + const line = (cfg.showPrices === 'true' || !cfg.showPrices) + ? `${qty}x ${itemName} - $${(price * qty).toFixed(2)}` + : `${qty}x ${itemName}`; + doc.text(line, { width: maxWidth }); + if (item.addons && Array.isArray(item.addons)) { + item.addons.forEach(addon => doc.text(` + ${addon.name || addon}`)); + } + if (item.exclude && Array.isArray(item.exclude)) { + item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`)); + } + }); + } + + if (order.order.specialInstructions) { + doc.moveDown(0.4); + doc.fontSize(12).text('SPECIAL INSTRUCTIONS:'); + doc.fontSize(11).text(String(order.order.specialInstructions), { width: maxWidth }); + } + + if (order.order.deliveryInstructions) { + doc.moveDown(0.4); + doc.fontSize(12).text('DELIVERY INSTRUCTIONS:'); + doc.fontSize(11).text(String(order.order.deliveryInstructions), { width: maxWidth }); + } + + if (order.order.foodAllergy) { + doc.moveDown(0.4); + doc.fontSize(12).text('FOOD ALLERGY WARNING', { align: 'center' }); + if (order.order.foodAllergyNotes) doc.fontSize(11).text(order.order.foodAllergyNotes, { align: 'center' }); + } + + if (cfg.showPrices === 'true' || !cfg.showPrices) { + doc.moveDown(0.4); + // Price breakdown (normal font, right-aligned) + try { + const rawSubtotal = (order && typeof order.amount !== 'undefined') + ? order.amount + : (order && order.order && typeof order.order.amount !== 'undefined' ? order.order.amount : null); + const rawTaxAmount = (order && typeof order.taxAmount !== 'undefined') + ? order.taxAmount + : (order && order.order && typeof order.order.taxAmount !== 'undefined' ? order.order.taxAmount : null); + const rawTaxRate = (order && typeof order.taxRate !== 'undefined') + ? order.taxRate + : (order && order.order && typeof order.order.taxRate !== 'undefined' ? order.order.taxRate : null); + const rawDelivery = (order && typeof order.deliveryFee !== 'undefined') + ? order.deliveryFee + : (order && order.order && typeof order.order.deliveryFee !== 'undefined' ? order.order.deliveryFee : null); + + if (typeof rawSubtotal === 'number' && isFinite(rawSubtotal)) { + doc.fontSize(12).text(`Subtotal: $${rawSubtotal.toFixed(2)}`, { align: 'right' }); + } + if (typeof rawTaxAmount === 'number' && isFinite(rawTaxAmount) && rawTaxAmount > 0) { + let taxLabel = 'Tax'; + if (typeof rawTaxRate === 'number' && isFinite(rawTaxRate) && rawTaxRate > 0) { + const rateStr = Number(rawTaxRate).toFixed(2).replace(/\.?0+$/, ''); + taxLabel = `Tax (${rateStr}%)`; + } + doc.fontSize(12).text(`${taxLabel}: $${rawTaxAmount.toFixed(2)}`, { align: 'right' }); + } + if (typeof rawDelivery === 'number' && isFinite(rawDelivery) && rawDelivery > 0) { + doc.fontSize(12).text(`Delivery Fee: $${rawDelivery.toFixed(2)}`, { align: 'right' }); + } + } catch (_) {} + // Keep TOTAL style unchanged + doc.fontSize(12).text(`TOTAL: $${(order.totalAmount || 0).toFixed(2)}`); + } + + if (cfg.footerText) { + doc.moveDown(0.6); + doc.fontSize(12).text(cfg.footerText, { align: 'center' }); + } + + doc.end(); + stream.on('finish', () => resolve(true)); + stream.on('error', reject); + }); + } + + async writeReceiptPdfToPathWithConfig(filePath, order, type = 'new', options = {}, config = {}) { + const doc = new PDFDocument({ size: 'A4', margin: 36 }); + const maxWidth = 523; // 559 - 36 + return new Promise(async (resolve, reject) => { + const stream = fs.createWriteStream(filePath); + doc.pipe(stream); + + // Business header + const businessName = config.business_name || config.businessName || 'KITCHEN ORDER'; + doc.fontSize(18).text(businessName, { align: 'center' }); + + const businessAddress = config.business_address || config.businessAddress; + const businessPhone = config.business_phone || config.businessPhone; + const businessWebsite = config.business_website || config.businessWebsite; + const businessEmail = config.business_email || config.businessEmail; + + if (businessAddress || businessPhone || businessWebsite || businessEmail) { + doc.moveDown(0.2); + const contactSize = config.business_contact_size || config.businessContactSize || 'normal'; + doc.fontSize(contactSize === 'large' ? 12 : 10); + [businessAddress, businessPhone, businessWebsite, businessEmail] + .filter(Boolean) + .forEach(line => doc.text(line, { align: 'center' })); + } + + const headerText = config.header_text || config.headerText; + if (headerText) { + doc.moveDown(0.4); + doc.fontSize(12).text(headerText, { align: 'center' }); + } + + doc.moveDown(0.4); + doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke(); + + // Order banner + doc.moveDown(0.4); + doc.fontSize(14).text(type === 'canceled' ? '*** ORDER CANCELED ***' : 'NEW ORDER', { align: 'left' }); + if (type === 'canceled' && options && options.reason) { + doc.moveDown(0.2); + doc.fontSize(12).text(`Cancellation reason: ${String(options.reason)}`); + } + + // Order details + doc.moveDown(0.4); + doc.fontSize(12).text(`Order #${order.id}`); + // Merge global toggles for PDF path as well + const mergedShowTimestamps = (config.show_timestamps !== false) && (config.showTimestamps !== 'false'); + if (mergedShowTimestamps) { + doc.text(`Time: ${new Date(order.createdAt * 1000).toLocaleString()}`); + } + doc.text(`Type: ${order.order.type || 'N/A'}`); + + // Customer + const mergedShowCustomer = (config.show_customer_info !== false) && (config.showCustomerInfo !== 'false'); + if (mergedShowCustomer) { + doc.moveDown(0.4); + doc.fontSize(12).text('CUSTOMER:'); + doc.fontSize(11).text(order.customer.name || 'N/A'); + if (order.customer.phoneNumber) doc.text(order.customer.phoneNumber); + if (order.order.deliveryAddress) doc.text(`Address: ${order.order.deliveryAddress}`); + } + + // Items + const mergedShowItems = (config.show_order_items !== false) && (config.showOrderItems !== 'false'); + const mergedShowPrices = (config.show_prices !== false) && (config.showPrices !== 'false'); + if (order.order.items && Array.isArray(order.order.items) && mergedShowItems) { + doc.moveDown(0.4); + doc.fontSize(12).text('ITEMS:'); + doc.fontSize(11); + order.order.items.forEach(item => { + const itemName = item.itemName || item.name || 'Unknown Item'; + const qty = item.qty || 1; + const price = item.price || 0; + const line = mergedShowPrices + ? `${qty}x ${itemName} - $${(price * qty).toFixed(2)}` + : `${qty}x ${itemName}`; + doc.text(line, { width: maxWidth }); + if (item.addons && Array.isArray(item.addons)) { + item.addons.forEach(addon => doc.text(` + ${addon.name || addon}`)); + } + if (item.exclude && Array.isArray(item.exclude)) { + item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`)); + } + }); + } + + if (order.order.specialInstructions) { + doc.moveDown(0.4); + doc.fontSize(12).text('SPECIAL INSTRUCTIONS:'); + doc.fontSize(11).text(String(order.order.specialInstructions), { width: maxWidth }); + } + + if (order.order.deliveryInstructions) { + doc.moveDown(0.4); + doc.fontSize(12).text('DELIVERY INSTRUCTIONS:'); + doc.fontSize(11).text(String(order.order.deliveryInstructions), { width: maxWidth }); + } + + if (order.order.foodAllergy) { + doc.moveDown(0.4); + doc.fontSize(12).text('FOOD ALLERGY WARNING', { align: 'center' }); + if (order.order.foodAllergyNotes) doc.fontSize(11).text(order.order.foodAllergyNotes, { align: 'center' }); + } + + if (mergedShowPrices) { + doc.moveDown(0.4); + // Price breakdown (normal font, right-aligned) + try { + const rawSubtotal = (order && typeof order.amount !== 'undefined') + ? order.amount + : (order && order.order && typeof order.order.amount !== 'undefined' ? order.order.amount : null); + const rawTaxAmount = (order && typeof order.taxAmount !== 'undefined') + ? order.taxAmount + : (order && order.order && typeof order.order.taxAmount !== 'undefined' ? order.order.taxAmount : null); + const rawTaxRate = (order && typeof order.taxRate !== 'undefined') + ? order.taxRate + : (order && order.order && typeof order.order.taxRate !== 'undefined' ? order.order.taxRate : null); + const rawDelivery = (order && typeof order.deliveryFee !== 'undefined') + ? order.deliveryFee + : (order && order.order && typeof order.order.deliveryFee !== 'undefined' ? order.order.deliveryFee : null); + + if (typeof rawSubtotal === 'number' && isFinite(rawSubtotal)) { + doc.fontSize(12).text(`Subtotal: $${rawSubtotal.toFixed(2)}`, { align: 'right' }); + } + if (typeof rawTaxAmount === 'number' && isFinite(rawTaxAmount) && rawTaxAmount > 0) { + let taxLabel = 'Tax'; + if (typeof rawTaxRate === 'number' && isFinite(rawTaxRate) && rawTaxRate > 0) { + const rateStr = Number(rawTaxRate).toFixed(2).replace(/\.?0+$/, ''); + taxLabel = `Tax (${rateStr}%)`; + } + doc.fontSize(12).text(`${taxLabel}: $${rawTaxAmount.toFixed(2)}`, { align: 'right' }); + } + if (typeof rawDelivery === 'number' && isFinite(rawDelivery) && rawDelivery > 0) { + doc.fontSize(12).text(`Delivery Fee: $${rawDelivery.toFixed(2)}`, { align: 'right' }); + } + } catch (_) {} + // Keep TOTAL style unchanged + doc.fontSize(12).text(`TOTAL: $${(order.totalAmount || 0).toFixed(2)}`); + } + + const footerText = config.footer_text || config.footerText; + if (footerText) { + doc.moveDown(0.6); + doc.fontSize(12).text(footerText, { align: 'center' }); + } + + doc.end(); + stream.on('finish', () => resolve(true)); + stream.on('error', reject); + }); + } + + isConnected() { + // Check if printer interface is accessible + if (!this.config || !this.currentInterface) { + return false; + } + + // For file-based interfaces (USB, serial), check if file exists + if (this.config.printerInterface === 'usb') { + return fs.existsSync(this.currentInterface); + } + // For serial COM on Windows, SerialPort.list() will detect; assume connected and rely on write errors + if (this.config.printerInterface === 'serial') { + return true; + } + + // For network printers, assume connected (will fail on execute if not) + return true; + } + + // Lightweight reachability check for a specific normalized interface + async isTargetReachable(target) { + try { + if (!target || !target.type || !target.interface) return false; + const t = String(target.type).toLowerCase(); + const iface = String(target.interface); + if (t === 'usb') { + return fs.existsSync(iface); + } + if (t === 'com' || t === 'serial') { + // Assume reachable; OS/driver may hold the port and our open test would be unreliable. + // Actual execute will surface connectivity errors which the worker handles by retrying later. + return true; + } + if (t === 'network') { + // Attempt TCP connection quickly (Node net not used here to avoid extra deps). Rely on library on execute. + return true; + } + if (t === 'system') { + // Assume system spooler availability; printing will surface errors. + return true; + } + return false; + } catch (_) { + return false; + } + } + + // Determine if any configured printers are likely reachable now + async anyConfiguredPrinterReachable(printerConfigs) { + try { + if (Array.isArray(printerConfigs) && printerConfigs.length > 0) { + for (const cfg of printerConfigs) { + let normalizedInterface = cfg.interface; + let type = cfg.type; + if (type === 'network') { + if (!normalizedInterface.startsWith('tcp://')) { + const hasPort = normalizedInterface.includes(':'); + normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`; + } + } else if (type === 'com' || type === 'serial') { + if (/^COM\d+$/i.test(normalizedInterface)) { + normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`; + type = 'com'; + } + } + const reachable = await this.isTargetReachable({ type, interface: normalizedInterface }); + if (reachable) return true; + } + return false; + } + // Also consider selected printers from config (selectedPrintersJson) + const selectedTargets = this.getSelectedTargets(); + if (Array.isArray(selectedTargets) && selectedTargets.length > 0) { + for (const t of selectedTargets) { + const reachable = await this.isTargetReachable(t); + if (reachable) return true; + } + return false; + } + // Fallback to single configured printer + const main = this.getMainPrinterTarget(); + if (!main) return false; + return await this.isTargetReachable(main); + } catch (_) { + return false; + } + } + + async getAvailablePrinters() { + try { + const all = []; + + // Windows system printers via pdf-to-printer + try { + const printers = await pdfPrinter.getPrinters(); + printers.forEach(p => { + const sysName = p && (p.name || p.deviceId) ? (p.name || p.deviceId) : String(p); + all.push({ + name: sysName, + type: 'system', + interface: sysName, + isDefault: !!(p && p.isDefault), + status: 'Available' + }); + }); + } catch (err) { + console.error('List system printers failed:', err.message); + } + + // Serial/COM ports + try { + const ports = await SerialPort.list(); + ports.forEach(port => { + all.push({ + name: `${port.path} ${port.manufacturer ? '(' + port.manufacturer + ')' : ''}`.trim(), + type: 'com', + interface: port.path, + isDefault: false, + status: 'Available', + details: { + manufacturer: port.manufacturer, + serialNumber: port.serialNumber, + vendorId: port.vendorId, + productId: port.productId + } + }); + }); + } catch (err) { + console.error('List COM ports failed:', err.message); + } + + return all; + } catch (error) { + console.error('getAvailablePrinters error:', error.message); + return []; + } + } +} + +module.exports = new PrinterManager(); + diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..a62e43e --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,1604 @@ +/* Reset and Base Styles */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + color: #333; + background-color: #f5f5f5; +} + +/* Touch-friendly tap targets */ +button, a, input, select, textarea { + min-height: 44px; + min-width: 44px; +} + +/* Login Page */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.login-container { + width: 100%; + max-width: 400px; + padding: 20px; +} + +.login-card { + background: white; + border-radius: 8px; + padding: 40px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.login-card h1 { + font-size: 28px; + margin-bottom: 10px; + text-align: center; + color: #667eea; +} + +.login-card h2 { + font-size: 20px; + margin-bottom: 30px; + text-align: center; + color: #666; +} + +.login-footer { + margin-top: 30px; + text-align: center; + font-size: 14px; + color: #999; +} + +/* Forms */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #333; +} + +.form-group input[type="text"], +.form-group input[type="email"], +.form-group input[type="password"], +.form-group input[type="number"], +.form-group select, +.form-group textarea { + width: 100%; + padding: 12px 16px; + border: 2px solid #ddd; + border-radius: 4px; + font-size: 16px; + transition: border-color 0.3s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; +} + +.form-group small { + display: block; + margin-top: 5px; + font-size: 13px; + color: #666; +} + +.checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + font-weight: normal; +} + +.checkbox-label input[type="checkbox"] { + margin-right: 10px; + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 12px 24px; + font-size: 16px; + font-weight: 600; + text-align: center; + text-decoration: none; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.3s; + min-height: 48px; +} + +.btn-primary { + background-color: #667eea; + color: white; +} + +.btn-primary:hover { + background-color: #5568d3; +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +.btn-success { + background-color: #28a745; + color: white; +} + +.btn-success:hover { + background-color: #218838; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.btn-block { + display: block; + width: 100%; +} + +.btn-large { + padding: 16px 32px; + font-size: 18px; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Alerts */ +.alert { + padding: 16px; + border-radius: 4px; + margin-bottom: 20px; +} + +.alert-success { + background-color: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; +} + +.alert-error { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; +} + +.alert-info { + background-color: #d1ecf1; + border: 1px solid #bee5eb; + color: #0c5460; +} + +/* Header */ +.main-header { + background-color: #fff; + border-bottom: 2px solid #e0e0e0; + padding: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 15px; +} + +.header-content h1 { + font-size: 28px; + color: #333; +} + +.header-actions { + display: flex; + gap: 10px; +} + +/* Connection Status Bar */ +.connection-status-bar { + display: flex; + gap: 20px; + align-items: center; + padding: 8px 0; +} + +.connection-status-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background-color: #f8f9fa; + border-radius: 6px; + border: 1px solid #e0e0e0; + transition: all 0.3s ease; + cursor: help; + min-height: auto; +} + +.connection-status-item:hover { + background-color: #e9ecef; +} + +.status-indicator { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + position: relative; + transition: all 0.3s ease; +} + +/* Status indicator animations and colors */ +.status-indicator.status-online { + background-color: #28a745; + box-shadow: 0 0 8px rgba(40, 167, 69, 0.6); +} + +.status-indicator.status-offline { + background-color: #dc3545; + box-shadow: 0 0 8px rgba(220, 53, 69, 0.6); +} + +.status-indicator.status-checking { + background-color: #ffc107; + box-shadow: 0 0 8px rgba(255, 193, 7, 0.6); + animation: pulse 1.5s infinite; +} + +.status-indicator.status-unconfigured { + background-color: #6c757d; + box-shadow: 0 0 8px rgba(108, 117, 125, 0.4); +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.status-text { + font-size: 13px; + font-weight: 600; + color: #333; +} + +.status-label { + font-size: 11px; + color: #666; + text-transform: uppercase; + font-weight: 500; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .connection-status-bar { + width: 100%; + order: 3; + justify-content: center; + } + + .header-content h1 { + order: 1; + } + + .header-actions { + order: 2; + } +} + +/* Stats Bar */ +.stats-bar { + background-color: #fff; + padding: 20px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + max-width: 1400px; + margin: 20px auto; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.stat-card { + padding: 20px; + text-align: center; + border-radius: 8px; + background-color: #f8f9fa; + border: 2px solid #e0e0e0; +} + +.stat-card.stat-new { + background-color: #d4edda; + border-color: #28a745; +} + +.stat-card.stat-preparing { + background-color: #fff3cd; + border-color: #ffc107; +} + +.stat-card.stat-ready { + background-color: #d1ecf1; + border-color: #17a2b8; +} + +.stat-label { + font-size: 14px; + color: #666; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 8px; +} + +.stat-value { + font-size: 36px; + font-weight: bold; + color: #333; +} + +/* Dashboard Controls */ +.dashboard-controls { + max-width: 1400px; + margin: 0 auto 20px; + padding: 0 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 15px; +} + +.filter-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.filter-btn { + padding: 12px 24px; + border: 2px solid #667eea; + background-color: white; + color: #667eea; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 16px; + transition: all 0.3s; +} + +.filter-btn:hover { + background-color: #f0f0f0; +} + +.filter-btn.active { + background-color: #667eea; + color: white; +} + +/* Sync Button with Integrated Loading State */ +.sync-button { + position: relative; + display: inline-flex !important; + align-items: center; + justify-content: center; + gap: 8px; + width: 140px !important; + height: 48px !important; + min-height: 48px !important; + max-height: 48px !important; + padding: 0 20px !important; + transition: opacity 0.3s ease, background-color 0.3s ease !important; + white-space: nowrap; + overflow: hidden; + box-sizing: border-box; +} + +.sync-spinner { + display: none; + width: 16px; + height: 16px; + border: 2.5px solid rgba(255, 255, 255, 0.3); + border-top-color: #ffffff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; + margin: 0; +} + +.sync-button.loading .sync-spinner { + display: block; +} + +.sync-button.loading { + opacity: 0.8; + cursor: not-allowed; + pointer-events: none; +} + +.sync-text { + display: inline-block; + line-height: 1; + transition: opacity 0.3s ease; + flex-shrink: 0; + font-size: 16px; +} + +.sync-button.loading .sync-text { + opacity: 0.9; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Legacy refresh indicator - kept for backward compatibility */ +.refresh-indicator { + display: none; + align-items: center; + gap: 10px; + color: #666; + font-size: 14px; +} + +.refresh-indicator.visible { + display: flex; +} + +.spinner { + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Orders Container */ +.orders-container { + max-width: 1400px; + margin: 0 auto; + padding: 0 20px 40px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; +} + +.loading { + grid-column: 1 / -1; + text-align: center; + padding: 60px; + font-size: 18px; + color: #666; +} + +.no-orders { + grid-column: 1 / -1; + text-align: center; + padding: 60px; + font-size: 18px; + color: #999; +} + +/* Order Card */ +.order-card { + background-color: white; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-left: 5px solid #ccc; + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + flex-direction: column; +} + +.order-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.order-card.status-new { + border-left-color: #28a745; +} + +.order-card.status-preparing { + border-left-color: #ffc107; +} + +.order-card.status-ready { + border-left-color: #17a2b8; +} + +.order-card.status-canceled { + border-left-color: #dc3545; + opacity: 0.7; +} + +.order-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 15px; +} + +.order-number { + font-size: 28px; + font-weight: bold; + color: #333; +} + +.order-badge { + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; +} + +.order-badge.badge-new { + background-color: #28a745; + color: white; +} + +.order-badge.badge-preparing { + background-color: #ffc107; + color: #333; +} + +.order-badge.badge-ready { + background-color: #17a2b8; + color: white; +} + +.order-info { + margin-bottom: 15px; +} + +.order-info-row { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 15px; +} + +.order-info-label { + font-weight: 600; + color: #666; +} + +.order-info-value { + color: #333; +} + +.order-type-icon { + margin-right: 5px; +} + +.order-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 0; +} + +.order-actions .btn { + flex: 1; + min-width: 100px; + padding: 10px; + font-size: 14px; +} + +.order-details-toggle { + margin-top: 10px; + text-align: center; +} + +.order-details-toggle button { + background: none; + border: none; + color: #667eea; + cursor: pointer; + text-decoration: underline; + font-size: 14px; + padding: 5px; +} + +/* Settings Page */ +.settings-container { + max-width: 900px; + margin: 20px auto; + padding: 0 20px 40px; +} + +.tabs { + display: flex; + gap: 10px; + margin-bottom: 30px; + border-bottom: 2px solid #e0e0e0; +} + +.tab-btn { + padding: 15px 30px; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 16px; + font-weight: 600; + color: #666; + transition: all 0.3s; +} + +.tab-btn:hover { + color: #333; +} + +.tab-btn.active { + color: #667eea; + border-bottom-color: #667eea; +} + +.tab-content { + display: none; + background: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.tab-content.active { + display: block; +} + +/* Settings Sections */ +.settings-section { + margin-bottom: 35px; + padding-bottom: 25px; + border-bottom: 1px solid #e0e0e0; +} + +.settings-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.settings-section h3 { + font-size: 18px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.section-description { + font-size: 14px; + color: #666; + margin-bottom: 20px; + line-height: 1.6; +} + +.tab-content h2 { + font-size: 24px; + margin-bottom: 25px; + color: #333; +} + +.tab-content h3 { + font-size: 18px; + margin: 25px 0 15px; + color: #555; +} + +.form-actions { + margin-top: 30px; + padding-top: 30px; + border-top: 2px solid #e0e0e0; + text-align: center; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + z-index: 1000; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease; +} + +.modal.visible { + display: flex; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-content { + background-color: white; + padding: 30px; + border-radius: 8px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + animation: slideUp 0.3s ease; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); +} + +.modal-large { + max-width: 800px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.modal-header h2 { + margin: 0; +} + +.close-btn { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: #999; + padding: 0; + width: 40px; + height: 40px; +} + +.close-btn:hover { + color: #333; +} + +.modal-actions { + display: flex; + gap: 10px; + margin-top: 20px; + justify-content: flex-end; +} + +/* Status Change Confirmation Modal */ +.status-modal-content { + text-align: center; + max-width: 480px; + padding: 35px 40px; +} + +.status-modal-icon { + width: 80px; + height: 80px; + margin: 0 auto 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 40px; + animation: scaleIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes scaleIn { + from { + transform: scale(0); + } + to { + transform: scale(1); + } +} + +.status-modal-icon.preparing { + background: linear-gradient(135deg, #fff3cd, #ffc107); + box-shadow: 0 4px 20px rgba(255, 193, 7, 0.4); +} + +.status-modal-icon.ready { + background: linear-gradient(135deg, #d1ecf1, #17a2b8); + box-shadow: 0 4px 20px rgba(23, 162, 184, 0.4); +} + +.status-modal-icon.completed { + background: linear-gradient(135deg, #d4edda, #28a745); + box-shadow: 0 4px 20px rgba(40, 167, 69, 0.4); +} + +#statusModalTitle { + font-size: 24px; + font-weight: 700; + color: #333; + margin-bottom: 25px; +} + +.status-modal-details { + background: #f8f9fa; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + text-align: left; +} + +.status-modal-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #e0e0e0; +} + +.status-modal-row:last-child { + border-bottom: none; +} + +.status-transition-row { + padding: 15px 0 8px; + justify-content: center; +} + +.status-modal-label { + font-weight: 600; + color: #666; + font-size: 14px; +} + +.status-modal-value { + font-weight: 500; + color: #333; + font-size: 15px; +} + +.status-transition { + display: flex; + align-items: center; + gap: 12px; + font-size: 16px; +} + +.status-badge { + padding: 6px 14px; + border-radius: 20px; + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-badge.badge-new { + background-color: #28a745; + color: white; +} + +.status-badge.badge-preparing { + background-color: #ffc107; + color: #333; +} + +.status-badge.badge-ready { + background-color: #17a2b8; + color: white; +} + +.status-badge.badge-completed { + background-color: #6c757d; + color: white; +} + +.status-arrow { + font-size: 20px; + color: #667eea; + font-weight: bold; + animation: slideRight 1s ease infinite; +} + +@keyframes slideRight { + 0%, 100% { + transform: translateX(0); + } + 50% { + transform: translateX(5px); + } +} + +.status-modal-message { + font-size: 15px; + color: #666; + margin-bottom: 20px; + line-height: 1.5; +} + +.status-confirm-btn { + min-width: 120px; + font-weight: 700; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.status-confirm-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); +} + +.status-confirm-btn:active { + transform: translateY(0); +} + +/* Toast Notification */ +.toast { + position: fixed; + bottom: 30px; + right: 30px; + background-color: #333; + color: white; + padding: 16px 24px; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 2000; + opacity: 0; + transform: translateY(20px); + transition: all 0.3s; + pointer-events: none; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.toast.success { + background-color: #28a745; +} + +.toast.error { + background-color: #dc3545; +} + +/* Responsive */ +@media (max-width: 768px) { + .orders-container { + grid-template-columns: 1fr; + } + + .stats-bar { + grid-template-columns: repeat(2, 1fr); + } + + .header-content { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .dashboard-controls { + flex-direction: column; + align-items: stretch; + } + + .filter-buttons { + justify-content: center; + } + + .tabs { + flex-direction: column; + } + + .tab-btn { + border-bottom: none; + border-left: 3px solid transparent; + } + + .tab-btn.active { + border-left-color: #667eea; + } +} + +@media (max-width: 480px) { + .stats-bar { + grid-template-columns: 1fr; + } + + .order-actions { + flex-direction: column; + } + + .order-actions .btn { + width: 100%; + } + + .status-modal-content { + padding: 25px 20px; + } + + .status-modal-icon { + width: 70px; + height: 70px; + font-size: 35px; + } + + #statusModalTitle { + font-size: 20px; + } + + .status-transition { + flex-direction: column; + gap: 8px; + } + + .status-arrow { + transform: rotate(90deg); + animation: slideDown 1s ease infinite; + } + + @keyframes slideDown { + 0%, 100% { + transform: rotate(90deg) translateX(0); + } + 50% { + transform: rotate(90deg) translateX(5px); + } + } + + .modal-actions { + flex-direction: column-reverse; + } + + .modal-actions .btn { + width: 100%; + } +} + +/* Order Details */ +.detail-section { + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid #e0e0e0; +} + +.detail-section:last-child { + border-bottom: none; +} + +.detail-section h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 10px; + color: #333; +} + +.detail-section p { + margin: 5px 0; + color: #666; +} + +.detail-section table { + font-size: 14px; +} + +.detail-section table th { + font-weight: 600; + color: #333; +} + +.current-logo { + margin-top: 10px; + padding: 10px; + background-color: #f8f9fa; + border-radius: 4px; +} + +.current-logo p { + margin: 0; + font-size: 14px; + color: #666; +} + +/* Printer Management Styles */ +.printer-cards-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.printer-card { + background: white; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + transition: all 0.3s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.printer-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.printer-card.disabled { + opacity: 0.6; + background-color: #f5f5f5; +} + +.printer-card-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 2px solid #f0f0f0; +} + +.printer-card-header h4 { + margin: 0 0 8px 0; + font-size: 18px; + color: #333; + font-weight: 600; +} + +.printer-badges { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.printer-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.printer-badge.badge-default { + background-color: #667eea; + color: white; +} + +.printer-badge.badge-enabled { + background-color: #28a745; + color: white; +} + +.printer-badge.badge-disabled { + background-color: #6c757d; + color: white; +} + +.printer-card-body { + margin-bottom: 15px; +} + +.printer-info-row { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 14px; +} + +.printer-info-label { + font-weight: 600; + color: #666; + min-width: 100px; +} + +.printer-info-value { + color: #333; + text-align: right; + word-break: break-word; +} + +.printer-card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #f0f0f0; +} + +.btn-sm { + padding: 8px 14px; + font-size: 13px; + min-height: 36px; +} + +.no-printers { + grid-column: 1 / -1; + text-align: center; + padding: 60px 20px; + background: white; + border: 2px dashed #ddd; + border-radius: 8px; + color: #999; +} + +.no-printers p { + margin: 10px 0; + font-size: 16px; +} + +/* Printer Modal Styles */ +.printer-modal-body { + max-height: 70vh; + overflow-y: auto; +} + +.printer-modal-tabs { + display: flex; + gap: 5px; + margin-bottom: 25px; + border-bottom: 2px solid #e0e0e0; + flex-wrap: wrap; +} + +.printer-modal-tab-btn { + padding: 12px 20px; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 14px; + font-weight: 600; + color: #666; + transition: all 0.3s; +} + +.printer-modal-tab-btn:hover { + color: #333; + background-color: #f8f9fa; +} + +.printer-modal-tab-btn.active { + color: #667eea; + border-bottom-color: #667eea; +} + +.printer-modal-tab-content { + display: none; +} + +.printer-modal-tab-content.active { + display: block; +} + +.detected-printer-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + margin-bottom: 8px; + background-color: #f8f9fa; + border-radius: 4px; + border: 1px solid #e0e0e0; +} + +.detected-printer-item span { + flex: 1; + font-size: 14px; +} + +/* Responsive adjustments for printer management */ +@media (max-width: 768px) { + .printer-cards-container { + grid-template-columns: 1fr; + } + + .printer-modal-tabs { + flex-direction: column; + gap: 0; + } + + .printer-modal-tab-btn { + border-bottom: none; + border-left: 3px solid transparent; + text-align: left; + } + + .printer-modal-tab-btn.active { + border-left-color: #667eea; + } + + .printer-card-actions { + flex-direction: column; + } + + .printer-card-actions .btn { + width: 100%; + } +} + +/* Kitchen Display - Enhanced Order Cards */ +.allergy-warning { + display: flex; + align-items: start; + gap: 12px; + background-color: #fff3cd; + border: 3px solid #ff6b6b; + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; + animation: pulse-border 2s ease-in-out infinite; +} + +@keyframes pulse-border { + 0%, 100% { + border-color: #ff6b6b; + } + 50% { + border-color: #ff4444; + } +} + +.allergy-icon { + font-size: 28px; + line-height: 1; + flex-shrink: 0; +} + +.allergy-content { + flex: 1; +} + +.allergy-title { + font-size: 16px; + font-weight: 700; + color: #d32f2f; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.allergy-notes { + font-size: 15px; + font-weight: 600; + color: #333; + line-height: 1.4; +} + +.order-items-section { + background-color: #f8f9fa; + border-radius: 6px; + padding: 15px; + margin-bottom: 15px; +} + +.order-items-title { + font-size: 15px; + font-weight: 700; + color: #333; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 2px solid #dee2e6; +} + +.order-item { + background-color: white; + border-radius: 6px; + padding: 12px; + margin-bottom: 10px; + border-left: 4px solid #667eea; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.order-item:last-child { + margin-bottom: 0; +} + +.item-qty-name { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 6px; +} + +.item-qty { + font-size: 18px; + font-weight: 700; + color: #667eea; + min-width: 40px; +} + +.item-name { + font-size: 16px; + font-weight: 600; + color: #333; + line-height: 1.3; +} + +.item-modifier { + font-size: 14px; + padding: 6px 10px; + margin-top: 6px; + border-radius: 4px; + line-height: 1.3; +} + +.item-addon { + background-color: #d4edda; + border-left: 3px solid #28a745; + color: #155724; + font-weight: 600; +} + +.item-exclude { + background-color: #f8d7da; + border-left: 3px solid #dc3545; + color: #721c24; + font-weight: 600; +} + +.special-instructions { + background-color: #e7f3ff; + border-left: 4px solid #2196F3; + border-radius: 6px; + padding: 12px 15px; + margin-bottom: 15px; +} + +.special-instructions-label { + font-size: 14px; + font-weight: 700; + color: #1565c0; + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.special-instructions-text { + font-size: 15px; + color: #333; + line-height: 1.5; + font-weight: 500; +} + +.order-total { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #f8f9fa; + border-radius: 6px; + padding: 12px 15px; + margin-top: auto; + margin-bottom: 15px; + border: 2px solid #e0e0e0; +} + +.order-total-label { + font-size: 16px; + font-weight: 700; + color: #666; + text-transform: uppercase; +} + +.order-total-value { + font-size: 22px; + font-weight: 700; + color: #333; +} + +/* Responsive adjustments for kitchen display */ +@media (max-width: 480px) { + .allergy-warning { + padding: 12px; + } + + .allergy-icon { + font-size: 24px; + } + + .allergy-title { + font-size: 14px; + } + + .allergy-notes { + font-size: 14px; + } + + .order-items-section { + padding: 12px; + } + + .order-item { + padding: 10px; + } + + .item-qty { + font-size: 16px; + min-width: 35px; + } + + .item-name { + font-size: 15px; + } + + .item-modifier { + font-size: 13px; + padding: 5px 8px; + } + + .special-instructions { + padding: 10px 12px; + } + + .special-instructions-text { + font-size: 14px; + } + + .order-total-value { + font-size: 20px; + } +} + diff --git a/public/images/.gitkeep b/public/images/.gitkeep new file mode 100644 index 0000000..d5b0f57 --- /dev/null +++ b/public/images/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the images directory is created + diff --git a/public/js/common.js b/public/js/common.js new file mode 100644 index 0000000..874fc63 --- /dev/null +++ b/public/js/common.js @@ -0,0 +1,59 @@ +// Common utilities and functions + +function showToast(message, type = 'success') { + const toast = document.getElementById('toast'); + if (!toast) return; + + toast.textContent = message; + toast.className = 'toast visible ' + type; + + setTimeout(() => { + toast.classList.remove('visible'); + }, 3000); +} + +function formatTime(timestamp) { + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); +} + +function formatDateTime(timestamp) { + const date = new Date(timestamp * 1000); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +function formatCurrency(amount) { + return '$' + (amount || 0).toFixed(2); +} + +// Tab switching functionality +document.addEventListener('DOMContentLoaded', function() { + const tabButtons = document.querySelectorAll('.tab-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabButtons.forEach(button => { + button.addEventListener('click', function() { + const targetTab = this.dataset.tab; + + // Remove active class from all buttons and contents + tabButtons.forEach(btn => btn.classList.remove('active')); + tabContents.forEach(content => content.classList.remove('active')); + + // Add active class to clicked button and corresponding content + this.classList.add('active'); + const targetContent = document.getElementById(targetTab + '-tab'); + if (targetContent) { + targetContent.classList.add('active'); + } + }); + }); +}); + diff --git a/public/js/dashboard.js b/public/js/dashboard.js new file mode 100644 index 0000000..41225f6 --- /dev/null +++ b/public/js/dashboard.js @@ -0,0 +1,953 @@ +// Dashboard functionality + +let currentFilter = 'all'; +let currentOrderIdForCancel = null; +let pendingStatusChange = { + orderId: null, + newStatus: null, + currentStatus: null, + orderData: null +}; + +// Track orders for notification detection +let previousOrderIds = new Set(); +let previousCanceledOrderIds = new Set(); +let isFirstLoad = true; + +// Connection status tracking +const connectionStatus = { + local: { + status: 'checking', + lastCheck: null, + consecutiveFailures: 0 + }, + api: { + status: 'checking', + lastCheck: null, + consecutiveFailures: 0, + responseTime: null + } +}; + +// Audio notification system +const audioNotification = { + newOrderSound: null, + canceledOrderSound: null, + enabled: true, + + init: function() { + // Load notification settings from server + fetch('/api/notification-settings') + .then(response => response.json()) + .then(data => { + if (!data.error) { + this.enabled = data.soundNotificationsEnabled !== 'false'; + const newOrderPath = data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3'; + const canceledOrderPath = data.canceledOrderSoundPath || '/public/sounds/new-order-notification.mp3'; + + // Preload audio files + this.newOrderSound = new Audio(newOrderPath); + this.canceledOrderSound = new Audio(canceledOrderPath); + + // Set volume if specified + if (data.soundVolume) { + const volume = parseInt(data.soundVolume, 10) / 100; + this.newOrderSound.volume = volume; + this.canceledOrderSound.volume = volume; + } + } + }) + .catch(error => { + console.warn('Failed to load notification settings:', error); + // Use default sound + this.newOrderSound = new Audio('/public/sounds/new-order-notification.mp3'); + this.canceledOrderSound = new Audio('/public/sounds/new-order-notification.mp3'); + }); + }, + + playNewOrderSound: function() { + if (this.enabled && this.newOrderSound) { + this.newOrderSound.currentTime = 0; + this.newOrderSound.play().catch(error => { + console.warn('Failed to play new order sound:', error); + }); + } + }, + + playCanceledOrderSound: function() { + if (this.enabled && this.canceledOrderSound) { + this.canceledOrderSound.currentTime = 0; + this.canceledOrderSound.play().catch(error => { + console.warn('Failed to play canceled order sound:', error); + }); + } + } +}; + +// Initialize dashboard +document.addEventListener('DOMContentLoaded', function() { + setupFilterButtons(); + audioNotification.init(); + refreshOrders(); + + // Set up auto-refresh + setInterval(refreshOrders, config.dashboardRefreshInterval || 10000); + + // Initialize connection monitoring + checkConnectionStatus(); + // Check connection status every 15 seconds + setInterval(checkConnectionStatus, 15000); +}); + +function setupFilterButtons() { + const filterButtons = document.querySelectorAll('.filter-btn'); + + filterButtons.forEach(button => { + button.addEventListener('click', function() { + // Remove active class from all buttons + filterButtons.forEach(btn => btn.classList.remove('active')); + + // Add active class to clicked button + this.classList.add('active'); + + // Update filter and refresh + currentFilter = this.dataset.filter; + refreshOrders(); + }); + }); +} + +function refreshOrders() { + const syncButton = document.getElementById('syncButton'); + const syncText = syncButton ? syncButton.querySelector('.sync-text') : null; + + if (syncButton) { + syncButton.classList.add('loading'); + syncButton.disabled = true; + if (syncText) { + syncText.textContent = 'Syncing...'; + } + } + + const statusParam = currentFilter === 'all' ? '' : currentFilter; + const url = '/api/orders' + (statusParam ? '?status=' + statusParam : ''); + + fetch(url) + .then(response => response.json()) + .then(data => { + if (!data.error) { + updateOrderCards(data.orders); + updateStats(data.stats); + + // Connection successful - reset local connection failure counter + if (connectionStatus.local.consecutiveFailures > 0) { + connectionStatus.local.consecutiveFailures = 0; + connectionStatus.local.status = 'online'; + updateConnectionUI('local', 'online', 'Connected'); + } + } + }) + .catch(error => { + console.error('Failed to refresh orders:', error); + + // Mark local connection as potentially offline + connectionStatus.local.consecutiveFailures++; + if (connectionStatus.local.consecutiveFailures >= 2) { + connectionStatus.local.status = 'offline'; + updateConnectionUI('local', 'offline', 'Disconnected'); + } + }) + .finally(() => { + if (syncButton) { + syncButton.classList.remove('loading'); + syncButton.disabled = false; + if (syncText) { + syncText.textContent = 'Sync Now'; + } + } + }); +} + +function updateStats(stats) { + if (!stats) return; + + const totalEl = document.getElementById('stat-total'); + const newEl = document.getElementById('stat-new'); + const preparingEl = document.getElementById('stat-preparing'); + const readyEl = document.getElementById('stat-ready'); + + if (totalEl) totalEl.textContent = stats.total || 0; + if (newEl) newEl.textContent = stats.new || 0; + if (preparingEl) preparingEl.textContent = stats.preparing || 0; + if (readyEl) readyEl.textContent = stats.ready || 0; +} + +function updateOrderCards(orders) { + const container = document.getElementById('ordersContainer'); + + if (!orders || orders.length === 0) { + container.innerHTML = '
No orders to display
'; + return; + } + + // Detect new orders and canceled orders for sound notifications + if (!isFirstLoad) { + const currentOrderIds = new Set(); + const currentCanceledOrderIds = new Set(); + + orders.forEach(order => { + const orderId = order.id; + const status = order.localStatus || order.status || 'new'; + + currentOrderIds.add(orderId); + + if (status === 'canceled') { + currentCanceledOrderIds.add(orderId); + } + + // Check for new orders (not in previous set) + if (!previousOrderIds.has(orderId) && status === 'new') { + console.log('New order detected:', orderId); + audioNotification.playNewOrderSound(); + } + + // Check for newly canceled orders + if (!previousCanceledOrderIds.has(orderId) && status === 'canceled') { + console.log('Order canceled:', orderId); + audioNotification.playCanceledOrderSound(); + } + }); + + // Update tracking sets + previousOrderIds = currentOrderIds; + previousCanceledOrderIds = currentCanceledOrderIds; + } else { + // On first load, just populate the tracking sets without playing sounds + orders.forEach(order => { + previousOrderIds.add(order.id); + const status = order.localStatus || order.status || 'new'; + if (status === 'canceled') { + previousCanceledOrderIds.add(order.id); + } + }); + isFirstLoad = false; + } + + container.innerHTML = orders.map(order => createOrderCard(order)).join(''); +} + +function createOrderCard(order) { + const status = order.localStatus || order.status || 'new'; + const statusBadge = getStatusBadge(status); + const orderType = order.order.type || 'pickup'; + const typeIcon = orderType === 'delivery' ? '🚚' : '🛍️'; + + // Determine which actions to show based on status + let actions = ''; + if (status === 'new') { + actions = ` + + `; + } else if (status === 'preparing') { + actions = ` + + `; + } else if (status === 'ready') { + actions = ` + + `; + } + + // Add cancel and reprint buttons for non-completed orders + if (status !== 'canceled' && status !== 'completed') { + actions += ` + + `; + } + + actions += ` + + + `; + + // Build detailed items list with addons and excludes + let itemsDetailedHtml = ''; + if (order.order.items && order.order.items.length > 0) { + // Sort items alphabetically by name for better kitchen organization + const sortedItems = [...order.order.items].sort((a, b) => { + const nameA = (a.itemName || a.name || 'Item').toLowerCase(); + const nameB = (b.itemName || b.name || 'Item').toLowerCase(); + return nameA.localeCompare(nameB); + }); + + itemsDetailedHtml = sortedItems.map(item => { + let modifiersHtml = ''; + + // Add addons + if (item.addons && item.addons.length > 0) { + const addonsList = item.addons.map(a => a.name || a).join(', '); + modifiersHtml += `
+ Add: ${addonsList}
`; + } + + // Add excludes + if (item.exclude && item.exclude.length > 0) { + const excludeList = item.exclude.map(e => e.name || e).join(', '); + modifiersHtml += `
− NO: ${excludeList}
`; + } + + return ` +
+
+ ${item.qty}x + ${item.itemName || item.name || 'Item'} +
+ ${modifiersHtml} +
+ `; + }).join(''); + } + + // Food allergy warning (prominent) + let allergyWarning = ''; + if (order.order.foodAllergy) { + const allergyNotes = order.order.foodAllergyNotes || 'Customer has food allergies'; + allergyWarning = ` +
+
⚠️
+
+
FOOD ALLERGY ALERT
+
${allergyNotes}
+
+
+ `; + } + + // Special instructions + let specialInstructionsHtml = ''; + if (order.order.specialInstructions) { + specialInstructionsHtml = ` +
+
📝 Special Instructions:
+
${order.order.specialInstructions}
+
+ `; + } + + return ` +
+
+
#${order.id}
+
${statusBadge}
+
+ + ${allergyWarning} + +
+
+ Time: + ${formatTime(order.createdAt)} +
+
+ Customer: + ${order.customer.name || 'N/A'} +
+
+ Type: + ${typeIcon}${orderType} +
+ ${order.order.deliveryAddress ? `
Address:${order.order.deliveryAddress}
` : ''} +
+ +
+
Order Items:
+ ${itemsDetailedHtml} +
+ + ${specialInstructionsHtml} + +
+ Total: + ${formatCurrency(order.totalAmount)} +
+ +
+ ${actions} +
+
+ `; +} + +function getStatusBadge(status) { + const badges = { + 'new': 'New', + 'preparing': 'Preparing', + 'ready': 'Ready', + 'completed': 'Completed', + 'canceled': 'Canceled' + }; + return badges[status] || status; +} + +function changeStatus(orderId, newStatus) { + // Fetch the order data first to show in the confirmation modal + fetch(`/api/orders/${orderId}`) + .then(response => response.json()) + .then(data => { + if (!data.error && data.order) { + openStatusChangeModal(orderId, newStatus, data.order); + } else { + showToast('Failed to fetch order details', 'error'); + } + }) + .catch(error => { + console.error('Error fetching order:', error); + showToast('Failed to fetch order details', 'error'); + }); +} + +function openStatusChangeModal(orderId, newStatus, orderData) { + const currentStatus = orderData.localStatus || orderData.status || 'new'; + + // Store pending status change + pendingStatusChange = { + orderId: orderId, + newStatus: newStatus, + currentStatus: currentStatus, + orderData: orderData + }; + + // Get status information + const statusInfo = getStatusInfo(newStatus); + + // Update modal icon + const iconEl = document.getElementById('statusModalIcon'); + iconEl.textContent = statusInfo.icon; + iconEl.className = 'status-modal-icon ' + newStatus; + + // Update modal title + document.getElementById('statusModalTitle').textContent = statusInfo.title; + + // Update order details + document.getElementById('statusModalOrderId').textContent = orderId; + document.getElementById('statusModalCustomer').textContent = orderData.customer.name || 'N/A'; + + // Update status badges + const fromBadge = document.getElementById('statusModalFrom'); + const toBadge = document.getElementById('statusModalTo'); + + fromBadge.textContent = getStatusBadge(currentStatus); + fromBadge.className = 'status-badge badge-' + currentStatus; + + toBadge.textContent = getStatusBadge(newStatus); + toBadge.className = 'status-badge badge-' + newStatus; + + // Update message + document.getElementById('statusModalMessage').textContent = statusInfo.message; + + // Update confirm button + const confirmBtn = document.getElementById('statusConfirmBtn'); + confirmBtn.textContent = statusInfo.buttonText; + + // Show modal + document.getElementById('statusChangeModal').classList.add('visible'); +} + +function getStatusInfo(status) { + const statusMap = { + 'preparing': { + icon: '👨‍🍳', + title: 'Start Preparing Order?', + message: 'This will mark the order as being prepared in the kitchen.', + buttonText: 'Start Preparing' + }, + 'ready': { + icon: '✅', + title: 'Mark Order as Ready?', + message: 'This will notify that the order is ready for pickup or delivery.', + buttonText: 'Mark Ready' + }, + 'completed': { + icon: '🎉', + title: 'Complete Order?', + message: 'This will mark the order as completed and it will be archived.', + buttonText: 'Complete Order' + } + }; + + return statusMap[status] || { + icon: '📋', + title: 'Change Order Status?', + message: 'This will update the order status.', + buttonText: 'Confirm' + }; +} + +function closeStatusChangeModal() { + document.getElementById('statusChangeModal').classList.remove('visible'); + pendingStatusChange = { + orderId: null, + newStatus: null, + currentStatus: null, + orderData: null + }; +} + +function confirmStatusChange() { + if (!pendingStatusChange.orderId || !pendingStatusChange.newStatus) { + return; + } + + const orderId = pendingStatusChange.orderId; + const newStatus = pendingStatusChange.newStatus; + + // Close modal immediately for better UX + closeStatusChangeModal(); + + // Make the API call + fetch(`/api/orders/${orderId}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }) + }) + .then(response => response.json()) + .then(data => { + if (!data.error) { + showToast(`Order #${orderId} updated to ${newStatus}`, 'success'); + refreshOrders(); + } else { + showToast(data.message || 'Failed to update order', 'error'); + } + }) + .catch(error => { + console.error('Error updating order:', error); + showToast('Failed to update order', 'error'); + }); +} + +function openCancelModal(orderId) { + currentOrderIdForCancel = orderId; + document.getElementById('cancelReason').value = ''; + document.getElementById('cancelModal').classList.add('visible'); +} + +function closeCancelModal() { + currentOrderIdForCancel = null; + document.getElementById('cancelModal').classList.remove('visible'); +} + +function confirmCancel() { + if (!currentOrderIdForCancel) return; + + const reason = document.getElementById('cancelReason').value; + + fetch(`/api/orders/${currentOrderIdForCancel}/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason || 'Canceled by kitchen' }) + }) + .then(response => response.json()) + .then(data => { + if (!data.error) { + showToast(`Order #${currentOrderIdForCancel} canceled`, 'success'); + closeCancelModal(); + refreshOrders(); + } else { + showToast(data.message || 'Failed to cancel order', 'error'); + } + }) + .catch(error => { + console.error('Error canceling order:', error); + showToast('Failed to cancel order', 'error'); + }); +} + +let reprintInProgressByOrder = {}; +function reprintOrder(orderId) { + if (reprintInProgressByOrder[orderId]) { + return; // guard against rapid double clicks + } + reprintInProgressByOrder[orderId] = true; + fetch(`/api/orders/${orderId}/reprint`, { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + if (!data.error) { + showToast((data && data.message) ? data.message : 'Receipt sent to printer', 'success'); + } else { + showToast(data.message || 'Failed to print receipt', 'error'); + } + }) + .catch(error => { + console.error('Error reprinting order:', error); + showToast('Failed to print receipt', 'error'); + }) + .finally(() => { + reprintInProgressByOrder[orderId] = false; + }); +} + +function showOrderDetails(orderId) { + fetch(`/api/orders/${orderId}`) + .then(response => response.json()) + .then(data => { + if (!data.error && data.order) { + displayOrderDetails(data.order); + } + }) + .catch(error => { + console.error('Error fetching order details:', error); + }); +} + +function displayOrderDetails(order) { + const modal = document.getElementById('detailsModal'); + const content = document.getElementById('orderDetailsContent'); + + let itemsHtml = ''; + if (order.order.items && order.order.items.length > 0) { + itemsHtml = order.order.items.map(item => { + let modifiersHtml = ''; + + // Add addons with prices if available + if (item.addons && item.addons.length > 0) { + item.addons.forEach(addon => { + const addonName = addon.name || addon; + const addonPrice = typeof addon === 'object' && addon.price != null ? addon.price : null; + + if (addonPrice != null && addonPrice > 0) { + modifiersHtml += `
+ ${addonName} (+${formatCurrency(addonPrice)})`; + } else if (addonPrice === 0) { + modifiersHtml += `
+ ${addonName} (Free)`; + } else { + modifiersHtml += `
+ ${addonName}`; + } + }); + } + + // Add excludes (no price) + if (item.exclude && item.exclude.length > 0) { + item.exclude.forEach(exc => { + const excName = exc.name || exc; + modifiersHtml += `
− NO ${excName}`; + }); + } + + return ` + + ${item.qty}x + + ${item.itemName || item.name} + ${modifiersHtml} + + ${formatCurrency(item.price * item.qty)} + + `; + }).join(''); + } + + content.innerHTML = ` +
+
+

Order Information

+

Order ID: ${order.id}

+

Status: ${getStatusBadge(order.localStatus || order.status)}

+

Time: ${formatDateTime(order.createdAt)}

+

Type: ${order.order.type || 'N/A'}

+
+ +
+

Customer Information

+

Name: ${order.customer.name || 'N/A'}

+

Phone: ${order.customer.phoneNumber || 'N/A'}

+ ${order.customer.email ? '

Email: ' + order.customer.email + '

' : ''} + ${order.order.deliveryAddress ? '

Address: ' + order.order.deliveryAddress + '

' : ''} +
+ +
+

Order Items

+ + + + + + + + + + ${itemsHtml} + + + + + + + ${order.order.taxAmount ? ` + + + + ` : ''} + ${order.order.deliveryFee ? ` + + + + ` : ''} + + + + + +
QtyItemPrice
Subtotal:${formatCurrency(order.order.amount)}
Tax (${order.order.taxRate || 0}%):${formatCurrency(order.order.taxAmount)}
Delivery Fee:${formatCurrency(order.order.deliveryFee)}
TOTAL:${formatCurrency(order.totalAmount)}
+
+ + ${order.order.specialInstructions ? ` +
+

Special Instructions

+

${order.order.specialInstructions}

+
` : ''} + + ${order.order.deliveryInstructions ? ` +
+

Delivery Instructions

+

${order.order.deliveryInstructions}

+
` : ''} + + ${order.order.foodAllergy ? ` +
+

⚠ Food Allergy Warning

+

${order.order.foodAllergyNotes || 'Customer has food allergies'}

+
` : ''} +
+ `; + + modal.classList.add('visible'); +} + +function closeDetailsModal() { + document.getElementById('detailsModal').classList.remove('visible'); +} + +// Close modals when clicking outside +window.addEventListener('click', function(event) { + const statusChangeModal = document.getElementById('statusChangeModal'); + const cancelModal = document.getElementById('cancelModal'); + const detailsModal = document.getElementById('detailsModal'); + + if (event.target === statusChangeModal) { + closeStatusChangeModal(); + } + + if (event.target === cancelModal) { + closeCancelModal(); + } + + if (event.target === detailsModal) { + closeDetailsModal(); + } +}); + +// Close modals with Escape key +document.addEventListener('keydown', function(event) { + if (event.key === 'Escape') { + closeStatusChangeModal(); + closeCancelModal(); + closeDetailsModal(); + } +}); + +// Connection status monitoring +function checkConnectionStatus() { + // Check local connection (dashboard server) + checkLocalConnection(); + + // Check external API connection + checkAPIConnection(); +} + +function checkLocalConnection() { + const startTime = Date.now(); + + fetch('/api/health/local') + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error('Server returned error status'); + } + }) + .then(data => { + connectionStatus.local.status = 'online'; + connectionStatus.local.lastCheck = new Date(); + connectionStatus.local.consecutiveFailures = 0; + updateConnectionUI('local', 'online', 'Connected'); + }) + .catch(error => { + console.error('Local connection check failed:', error); + connectionStatus.local.consecutiveFailures++; + + if (connectionStatus.local.consecutiveFailures >= 2) { + connectionStatus.local.status = 'offline'; + updateConnectionUI('local', 'offline', 'Disconnected'); + + // Show warning toast on first detection of offline + if (connectionStatus.local.consecutiveFailures === 2) { + showToast('Dashboard server connection lost', 'error'); + } + } + }); +} + +function checkAPIConnection() { + const startTime = Date.now(); + + fetch('/api/health/external') + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error('Server returned error status'); + } + }) + .then(data => { + connectionStatus.api.lastCheck = new Date(); + + if (data.status === 'online') { + // Was offline, now online - notify user + if (connectionStatus.api.status === 'offline' && connectionStatus.api.consecutiveFailures >= 2) { + showToast('API server connection restored', 'success'); + } + + connectionStatus.api.status = 'online'; + connectionStatus.api.consecutiveFailures = 0; + connectionStatus.api.responseTime = data.responseTime; + + let label = 'Connected'; + if (data.responseTime) { + label = `${data.responseTime}ms`; + } + updateConnectionUI('api', 'online', label); + } else if (data.status === 'unconfigured') { + connectionStatus.api.status = 'unconfigured'; + connectionStatus.api.consecutiveFailures = 0; + updateConnectionUI('api', 'unconfigured', 'Not Configured'); + } else { + connectionStatus.api.consecutiveFailures++; + + if (connectionStatus.api.consecutiveFailures >= 2) { + connectionStatus.api.status = 'offline'; + updateConnectionUI('api', 'offline', data.message || 'Disconnected'); + + // Show warning toast on first detection of offline + if (connectionStatus.api.consecutiveFailures === 2) { + showToast('API server connection lost: ' + (data.message || 'Disconnected'), 'error'); + } + } + } + }) + .catch(error => { + console.error('API connection check failed:', error); + connectionStatus.api.consecutiveFailures++; + + if (connectionStatus.api.consecutiveFailures >= 2) { + connectionStatus.api.status = 'offline'; + updateConnectionUI('api', 'offline', 'Check Failed'); + } + }); +} + +function updateConnectionUI(type, status, label) { + let statusItem, statusIndicator, statusLabel; + + if (type === 'local') { + statusItem = document.getElementById('localConnectionStatus'); + statusLabel = document.getElementById('localStatusLabel'); + } else if (type === 'api') { + statusItem = document.getElementById('apiConnectionStatus'); + statusLabel = document.getElementById('apiStatusLabel'); + } + + if (!statusItem || !statusLabel) return; + + statusIndicator = statusItem.querySelector('.status-indicator'); + + // Remove all status classes + statusIndicator.classList.remove('status-online', 'status-offline', 'status-checking', 'status-unconfigured'); + + // Add new status class + statusIndicator.classList.add('status-' + status); + + // Update label text + statusLabel.textContent = label; + + // Update title attribute for tooltip + let tooltipText = ''; + if (type === 'local') { + tooltipText = 'Dashboard Server: '; + } else { + tooltipText = 'API Server (api.thinklink.ai): '; + } + + tooltipText += label; + if (connectionStatus[type].lastCheck) { + const lastCheckTime = connectionStatus[type].lastCheck.toLocaleTimeString(); + tooltipText += ' (Last checked: ' + lastCheckTime + ')'; + } + + statusItem.title = tooltipText; +} + +// Manual sync trigger +function manualSync() { + const syncButton = document.getElementById('syncButton'); + const syncText = syncButton ? syncButton.querySelector('.sync-text') : null; + + // Prevent multiple simultaneous sync requests + if (syncButton && syncButton.classList.contains('loading')) { + return; + } + + if (syncButton) { + syncButton.classList.add('loading'); + syncButton.disabled = true; + if (syncText) { + syncText.textContent = 'Syncing...'; + } + } + + fetch('/api/sync-now', { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + if (!data.error) { + showToast('Checking for new orders...', 'success'); + // Refresh after a short delay to allow sync to complete + setTimeout(() => { + refreshOrders(); + }, 2000); + } else { + showToast(data.message || 'Sync failed', 'error'); + // Remove loading state on error + if (syncButton) { + syncButton.classList.remove('loading'); + syncButton.disabled = false; + if (syncText) { + syncText.textContent = 'Sync Now'; + } + } + } + }) + .catch(error => { + console.error('Manual sync error:', error); + showToast('Sync failed', 'error'); + // Remove loading state on error + if (syncButton) { + syncButton.classList.remove('loading'); + syncButton.disabled = false; + if (syncText) { + syncText.textContent = 'Sync Now'; + } + } + }); +} + diff --git a/public/js/settings.js b/public/js/settings.js new file mode 100644 index 0000000..2918179 --- /dev/null +++ b/public/js/settings.js @@ -0,0 +1,709 @@ +// Settings page functionality + +// ========== Legacy functions (kept for backward compatibility) ========== +function testPrinter() { + const resultEl = document.getElementById('printerTestResult'); + const selectedHidden = document.getElementById('selectedPrintersJson'); + resultEl.textContent = 'Testing...'; + resultEl.style.color = '#666'; + + const body = JSON.stringify({ + selectedPrintersJson: selectedHidden ? selectedHidden.value : '[]' + }); + + fetch('/settings/test-printer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }) + .then(response => response.json()) + .then(data => { + if (!data.error) { + resultEl.textContent = '✓ ' + data.message; + resultEl.style.color = '#28a745'; + } else { + resultEl.textContent = '✗ ' + (data.message || 'Test failed'); + resultEl.style.color = '#dc3545'; + } + }) + .catch(error => { + console.error('Test print error:', error); + resultEl.textContent = '✗ Network error'; + resultEl.style.color = '#dc3545'; + }); +} + +async function uploadLogo() { + const fileInput = document.getElementById('logoUpload'); + const file = fileInput.files[0]; + + if (!file) { + alert('Please select a file first'); + return; + } + + const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']; + if (!validTypes.includes(file.type)) { + alert('Please select a valid image file (PNG, JPG, or GIF)'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + alert('File size must be less than 5MB'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch('/settings/upload-logo', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (!data.error) { + alert('Logo uploaded successfully!'); + location.reload(); + } else { + alert('Upload failed: ' + (data.message || 'Unknown error')); + } + } catch (error) { + console.error('Logo upload error:', error); + alert('Upload failed: Network error'); + } +} + +// ========== New Printer Management Functions ========== + +let currentPrinterId = null; + +// Load and display printers +async function loadPrinters() { + const container = document.getElementById('printer-cards-container'); + if (!container) return; + + try { + const response = await fetch('/api/printers/list'); + const data = await response.json(); + + if (data.error || !data.printers || data.printers.length === 0) { + container.innerHTML = ` +
+

No printers configured yet.

+

Click "Add Printer" to configure your first printer.

+
+ `; + return; + } + + // Render printer cards + const cardsHTML = data.printers.map(printer => createPrinterCard(printer)).join(''); + container.innerHTML = cardsHTML; + } catch (error) { + console.error('Failed to load printers:', error); + container.innerHTML = `
Failed to load printers: ${error.message}
`; + } +} + +// Create HTML for a printer card +function createPrinterCard(printer) { + const defaultBadge = printer.is_default ? 'DEFAULT' : ''; + const enabledBadge = printer.is_enabled + ? 'ENABLED' + : 'DISABLED'; + + const typeLabel = { + 'network': 'Network', + 'com': 'Serial/COM', + 'usb': 'USB', + 'system': 'System Printer' + }[printer.type] || printer.type; + + return ` +
+
+
+

${escapeHtml(printer.name)}

+
+ ${defaultBadge} + ${enabledBadge} +
+
+
+
+
+ Connection: + ${typeLabel}: ${escapeHtml(printer.interface)} +
+
+ Paper: + ${printer.paper_format} (${printer.paper_width} chars) | ${printer.printer_type} +
+
+
+ + + ${!printer.is_default ? `` : ''} + + ${!printer.is_default ? `` : ''} +
+
+ `; +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Open add printer modal +function openAddPrinterModal() { + currentPrinterId = null; + document.getElementById('printerModalTitle').textContent = 'Add Printer'; + document.getElementById('printerConfigForm').reset(); + document.getElementById('printer_id').value = ''; + + // Set defaults + document.getElementById('paper_width').value = 48; + document.getElementById('paper_format').value = '80mm'; + document.getElementById('printer_type_model').value = 'epson'; + document.getElementById('font_size').value = 'normal'; + document.getElementById('line_style').value = 'single'; + document.getElementById('qr_code_size').value = 3; + document.getElementById('qr_code_correction').value = 'M'; + document.getElementById('qr_code_content_template').value = 'ORDER-{id}'; + document.getElementById('header_text').value = ''; + document.getElementById('footer_text').value = ''; + document.getElementById('business_contact_size').value = 'normal'; + + // Pre-fill Business Information from Receipt Template tab + try { + const tplBusinessName = document.getElementById('businessName'); + const tplBusinessAddress = document.getElementById('businessAddress'); + const tplBusinessPhone = document.getElementById('businessPhone'); + const tplBusinessWebsite = document.getElementById('businessWebsite'); + const tplBusinessEmail = document.getElementById('businessEmail'); + const tplBusinessContactSize = document.getElementById('businessContactSize'); + + if (tplBusinessName) document.getElementById('business_name').value = tplBusinessName.value || ''; + if (tplBusinessAddress) document.getElementById('business_address').value = tplBusinessAddress.value || ''; + if (tplBusinessPhone) document.getElementById('business_phone').value = tplBusinessPhone.value || ''; + if (tplBusinessWebsite) document.getElementById('business_website').value = tplBusinessWebsite.value || ''; + if (tplBusinessEmail) document.getElementById('business_email').value = tplBusinessEmail.value || ''; + if (tplBusinessContactSize) document.getElementById('business_contact_size').value = tplBusinessContactSize.value || 'normal'; + } catch (_) {} + + // Set checkboxes + document.getElementById('show_customer_info').checked = true; + document.getElementById('show_order_items').checked = true; + document.getElementById('show_prices').checked = true; + document.getElementById('show_timestamps').checked = true; + document.getElementById('qr_code_enabled').checked = true; + document.getElementById('is_enabled').checked = true; + document.getElementById('is_default').checked = false; + + showModal('printerConfigModal'); + switchPrinterModalTab('connection'); +} + +// Edit printer +async function editPrinter(id) { + try { + const response = await fetch(`/api/printers/${id}`); + const data = await response.json(); + + if (data.error) { + alert('Failed to load printer: ' + data.message); + return; + } + + const printer = data.printer; + currentPrinterId = id; + + document.getElementById('printerModalTitle').textContent = 'Edit Printer'; + document.getElementById('printer_id').value = id; + + // Fill form with printer data + document.getElementById('printer_name').value = printer.name || ''; + document.getElementById('printer_type_select').value = printer.type || 'network'; + document.getElementById('printer_interface').value = printer.interface || ''; + document.getElementById('paper_format').value = printer.paper_format || '80mm'; + document.getElementById('paper_width').value = printer.paper_width || 48; + document.getElementById('printer_type_model').value = printer.printer_type || 'epson'; + document.getElementById('font_size').value = printer.font_size || 'normal'; + document.getElementById('line_style').value = printer.line_style || 'single'; + + document.getElementById('header_text').value = printer.header_text || ''; + document.getElementById('footer_text').value = printer.footer_text || ''; + + document.getElementById('business_name').value = printer.business_name || ''; + document.getElementById('business_address').value = printer.business_address || ''; + document.getElementById('business_phone').value = printer.business_phone || ''; + document.getElementById('business_website').value = printer.business_website || ''; + document.getElementById('business_email').value = printer.business_email || ''; + document.getElementById('business_contact_size').value = printer.business_contact_size || 'normal'; + + document.getElementById('logo_path').value = printer.logo_path || ''; + document.getElementById('logo_max_width_dots').value = printer.logo_max_width_dots || ''; + + if (printer.logo_path) { + document.getElementById('logo_preview').innerHTML = `Current logo: ${printer.logo_path}`; + } else { + document.getElementById('logo_preview').innerHTML = ''; + } + + document.getElementById('qr_code_size').value = printer.qr_code_size || 3; + document.getElementById('qr_code_correction').value = printer.qr_code_correction || 'M'; + document.getElementById('qr_code_content_template').value = printer.qr_code_content_template || 'ORDER-{id}'; + + document.getElementById('show_customer_info').checked = printer.show_customer_info !== false; + document.getElementById('show_order_items').checked = printer.show_order_items !== false; + document.getElementById('show_prices').checked = printer.show_prices !== false; + document.getElementById('show_timestamps').checked = printer.show_timestamps !== false; + document.getElementById('qr_code_enabled').checked = printer.qr_code_enabled !== false; + document.getElementById('is_default').checked = printer.is_default || false; + document.getElementById('is_enabled').checked = printer.is_enabled !== false; + + showModal('printerConfigModal'); + switchPrinterModalTab('connection'); + } catch (error) { + console.error('Failed to load printer:', error); + alert('Failed to load printer: ' + error.message); + } +} + +// Save printer configuration +async function savePrinterConfig() { + const form = document.getElementById('printerConfigForm'); + + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + const formData = new FormData(form); + const config = {}; + + for (const [key, value] of formData.entries()) { + if (key === 'id' && !value) continue; // Skip empty id + + // Handle checkboxes + if (['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].includes(key)) { + config[key] = document.getElementById(key).checked; + } else if (key === 'paper_width' || key === 'qr_code_size' || key === 'logo_max_width_dots') { + const val = parseInt(value, 10); + if (!isNaN(val) && val > 0) config[key] = val; + } else { + config[key] = value; + } + } + + // Ensure checkbox fields are always present in payload (unchecked boxes are omitted from FormData by default) + ['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled'].forEach((key) => { + const el = document.getElementById(key); + if (el) { + config[key] = !!el.checked; + } + }); + + try { + const printerId = document.getElementById('printer_id').value; + const url = printerId ? `/api/printers/${printerId}` : '/api/printers/create'; + const method = printerId ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + const data = await response.json(); + + if (data.error) { + alert('Failed to save printer: ' + data.message); + return; + } + + alert(data.message || 'Printer saved successfully'); + closePrinterModal(); + loadPrinters(); + } catch (error) { + console.error('Failed to save printer:', error); + alert('Failed to save printer: ' + error.message); + } +} + +// Delete printer +async function deletePrinter(id) { + if (!confirm('Are you sure you want to delete this printer?')) { + return; + } + + try { + const response = await fetch(`/api/printers/${id}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.error) { + alert('Failed to delete printer: ' + data.message); + return; + } + + alert('Printer deleted successfully'); + loadPrinters(); + } catch (error) { + console.error('Failed to delete printer:', error); + alert('Failed to delete printer: ' + error.message); + } +} + +// Test printer +async function testPrinterById(id) { + try { + const response = await fetch(`/api/printers/${id}/test`, { method: 'POST' }); + const data = await response.json(); + + if (data.error) { + alert('Test failed: ' + data.message); + } else { + alert(data.message || 'Test print sent successfully'); + } + } catch (error) { + console.error('Test print error:', error); + alert('Test failed: ' + error.message); + } +} + +// Set default printer +async function setDefaultPrinter(id) { + try { + const response = await fetch(`/api/printers/${id}/set-default`, { method: 'POST' }); + const data = await response.json(); + + if (data.error) { + alert('Failed to set default: ' + data.message); + } else { + loadPrinters(); + } + } catch (error) { + console.error('Failed to set default:', error); + alert('Failed to set default: ' + error.message); + } +} + +// Toggle printer enabled +async function togglePrinterEnabled(id) { + try { + const response = await fetch(`/api/printers/${id}/toggle-enabled`, { method: 'POST' }); + const data = await response.json(); + + if (data.error) { + alert('Failed to toggle printer: ' + data.message); + } else { + loadPrinters(); + } + } catch (error) { + console.error('Failed to toggle printer:', error); + alert('Failed to toggle printer: ' + error.message); + } +} + +// Upload logo for specific printer +async function uploadLogoForPrinter() { + const fileInput = document.getElementById('logo_upload_modal'); + const file = fileInput.files[0]; + + if (!file) { + alert('Please select a file first'); + return; + } + + const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']; + if (!validTypes.includes(file.type)) { + alert('Please select a valid image file (PNG, JPG, or GIF)'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + alert('File size must be less than 5MB'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + // If editing existing printer, include printer_id + const printerId = document.getElementById('printer_id').value; + if (printerId) { + formData.append('printer_id', printerId); + } + + try { + const response = await fetch('/settings/upload-logo', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (!data.error) { + alert('Logo uploaded successfully'); + document.getElementById('logo_path').value = data.filepath; + document.getElementById('logo_preview').innerHTML = `Logo uploaded: ${data.filepath}`; + } else { + alert('Upload failed: ' + (data.message || 'Unknown error')); + } + } catch (error) { + console.error('Logo upload error:', error); + alert('Upload failed: Network error'); + } +} + +// Detect printers for modal +async function detectPrintersForModal() { + const listEl = document.getElementById('detected-printers-list'); + listEl.innerHTML = '

Detecting printers...

'; + + try { + const response = await fetch('/api/printers/detect'); + const data = await response.json(); + + if (data.error || !data.printers || data.printers.length === 0) { + listEl.innerHTML = '

No printers detected.

'; + return; + } + + const items = data.printers.map(p => { + const typeLabel = { + 'system': 'System', + 'com': 'COM' + }[p.type] || p.type; + + return ` +
+ ${escapeHtml(p.name)} (${typeLabel}) + +
+ `; + }).join(''); + + listEl.innerHTML = items; + } catch (error) { + console.error('Failed to detect printers:', error); + listEl.innerHTML = '

Failed to detect printers.

'; + } +} + +// Select a detected printer +function selectDetectedPrinter(type, interface) { + document.getElementById('printer_type_select').value = type; + document.getElementById('printer_interface').value = interface; + updateInterfaceHint(); +} + +// Update interface hint based on connection type +function updateInterfaceHint() { + const type = document.getElementById('printer_type_select').value; + const hintEl = document.getElementById('interface_hint'); + + const hints = { + 'network': 'Enter IP:Port for network printers (e.g., 192.168.1.100:9100)', + 'com': 'Enter COM port (e.g., COM1, COM3)', + 'usb': 'Enter USB device path (e.g., /dev/usb/lp0)', + 'system': 'Enter the exact printer name from Windows' + }; + + hintEl.textContent = hints[type] || 'Enter connection address'; +} + +// Auto-update paper width when format changes +function updatePaperWidthFromFormat() { + const format = document.getElementById('paper_format').value; + const widthInput = document.getElementById('paper_width'); + + const widthMap = { + '58mm': 32, + '80mm': 48, + 'letter': 80 + }; + + if (widthMap[format]) { + widthInput.value = widthMap[format]; + } +} + +// Modal tab switching +function switchPrinterModalTab(tabName) { + // Update tab buttons + document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => { + btn.classList.remove('active'); + if (btn.getAttribute('data-tab') === tabName) { + btn.classList.add('active'); + } + }); + + // Update tab content + document.querySelectorAll('.printer-modal-tab-content').forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(tabName + '-tab-content').classList.add('active'); +} + +// Close printer modal +function closePrinterModal() { + hideModal('printerConfigModal'); + currentPrinterId = null; +} + +// Show modal +function showModal(modalId) { + document.getElementById(modalId).classList.add('visible'); +} + +// Hide modal +function hideModal(modalId) { + document.getElementById(modalId).classList.remove('visible'); +} + +// ========== Sound Notification Functions ========== + +// Upload sound file +async function uploadSound(soundType) { + const fileInputId = soundType === 'newOrder' ? 'newOrderSoundUpload' : 'canceledOrderSoundUpload'; + const fileInput = document.getElementById(fileInputId); + const file = fileInput.files[0]; + + if (!file) { + alert('Please select a file first'); + return; + } + + const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg']; + if (!validTypes.includes(file.type)) { + alert('Please select a valid audio file (MP3, WAV, or OGG)'); + return; + } + + if (file.size > 10 * 1024 * 1024) { + alert('File size must be less than 10MB'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('soundType', soundType); + + try { + const response = await fetch('/settings/upload-sound', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (!data.error) { + alert('Sound uploaded successfully! Please save settings to apply changes.'); + location.reload(); + } else { + alert('Upload failed: ' + (data.message || 'Unknown error')); + } + } catch (error) { + console.error('Sound upload error:', error); + alert('Upload failed: Network error'); + } +} + +// Test sound playback +async function testSound(soundType) { + try { + // Get current settings + const response = await fetch('/api/notification-settings'); + const data = await response.json(); + + if (data.error) { + alert('Failed to load sound settings'); + return; + } + + const soundPath = soundType === 'newOrder' + ? (data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3') + : (data.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3'); + + const volumeInput = document.getElementById('soundVolume'); + const volume = volumeInput ? parseInt(volumeInput.value, 10) / 100 : 0.8; + + const audio = new Audio(soundPath); + audio.volume = volume; + + audio.play().catch(error => { + console.error('Failed to play sound:', error); + alert('Failed to play sound. Make sure the file exists and is a valid audio file.'); + }); + } catch (error) { + console.error('Test sound error:', error); + alert('Failed to test sound: ' + error.message); + } +} + +// Update volume display +function updateVolumeDisplay() { + const volumeInput = document.getElementById('soundVolume'); + const volumeValue = document.getElementById('volumeValue'); + + if (volumeInput && volumeValue) { + volumeValue.textContent = volumeInput.value; + } +} + +// ========== Event Listeners ========== +document.addEventListener('DOMContentLoaded', function() { + // Load printers on settings page + if (document.getElementById('printer-cards-container')) { + loadPrinters(); + } + + // Printer modal tab switching + document.querySelectorAll('.printer-modal-tab-btn').forEach(btn => { + btn.addEventListener('click', function() { + switchPrinterModalTab(this.getAttribute('data-tab')); + }); + }); + + // Connection type change handler + const typeSelect = document.getElementById('printer_type_select'); + if (typeSelect) { + typeSelect.addEventListener('change', updateInterfaceHint); + } + + // Paper format change handler + const formatSelect = document.getElementById('paper_format'); + if (formatSelect) { + formatSelect.addEventListener('change', updatePaperWidthFromFormat); + } + + // Volume slider handler + const volumeInput = document.getElementById('soundVolume'); + if (volumeInput) { + volumeInput.addEventListener('input', updateVolumeDisplay); + } + + // Update interface placeholder based on interface type (legacy support) + const interfaceSelect = document.getElementById('printerInterface'); + const pathInput = document.getElementById('printerPath'); + + if (interfaceSelect && pathInput) { + interfaceSelect.addEventListener('change', function() { + const placeholders = { + 'usb': '/dev/usb/lp0 (Linux) or COM1 (Windows)', + 'network': '192.168.1.100:9100', + 'serial': 'COM1 (Windows) or /dev/ttyS0 (Linux)' + }; + + pathInput.placeholder = placeholders[this.value] || 'Enter printer path'; + }); + } +}); diff --git a/public/sounds/canceled-order-notification.mp3 b/public/sounds/canceled-order-notification.mp3 new file mode 100644 index 0000000..fb10e01 Binary files /dev/null and b/public/sounds/canceled-order-notification.mp3 differ diff --git a/public/sounds/new-order-notification.mp3 b/public/sounds/new-order-notification.mp3 new file mode 100644 index 0000000..d958f02 Binary files /dev/null and b/public/sounds/new-order-notification.mp3 differ diff --git a/public/uploads/.gitkeep b/public/uploads/.gitkeep new file mode 100644 index 0000000..1b22c42 --- /dev/null +++ b/public/uploads/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the uploads directory is created for logo uploads + diff --git a/public/uploads/canceled-order-notification.mp3 b/public/uploads/canceled-order-notification.mp3 new file mode 100644 index 0000000..fb10e01 Binary files /dev/null and b/public/uploads/canceled-order-notification.mp3 differ diff --git a/public/uploads/new-order-notification.mp3 b/public/uploads/new-order-notification.mp3 new file mode 100644 index 0000000..d958f02 Binary files /dev/null and b/public/uploads/new-order-notification.mp3 differ diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..15b2820 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,117 @@ +const config = require('../config'); +const apiClient = require('../api-client'); + +async function authRoutes(fastify, options) { + + // Root route - redirect based on signed session cookie validity + fastify.get('/', async (req, reply) => { + try { + const raw = req.cookies && req.cookies.kitchen_session; + if (raw) { + const { valid, value } = req.unsignCookie(raw || ''); + if (valid) { + const token = config.get('authToken'); + const expiry = config.get('tokenExpiry'); + if (token && !apiClient.isTokenExpired(expiry) && value === token) { + return reply.redirect('/dashboard'); + } + } + } + } catch (_) {} + return reply.redirect('/login'); + }); + + // Login page + fastify.get('/login', async (req, reply) => { + try { + const raw = req.cookies && req.cookies.kitchen_session; + if (raw) { + const { valid, value } = req.unsignCookie(raw || ''); + if (valid) { + const token = config.get('authToken'); + const expiry = config.get('tokenExpiry'); + if (token && !apiClient.isTokenExpired(expiry) && value === token) { + return reply.redirect('/dashboard'); + } + } + } + } catch (_) {} + return reply.view('login', { + error: req.query.error || null, + recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || '' + }); + }); + + // Login handler + fastify.post('/auth/login', async (req, reply) => { + const { login, password, 'g-recaptcha-response': recaptchaToken } = req.body; + + // Validate inputs + if (!login || !password) { + return reply.view('login', { + error: 'Please provide email and password', + recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || '' + }); + } + + if (!recaptchaToken) { + return reply.view('login', { + error: 'reCAPTCHA verification required', + recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || '' + }); + } + + try { + // Call API to authenticate + const result = await apiClient.login(login, password, recaptchaToken); + + if (result.error) { + return reply.view('login', { + error: result.message || 'Login failed', + recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || '' + }); + } + + // Store auth token and expiration + config.set('authToken', result.token); + config.set('tokenExpiry', result.expirationDate); + config.set('userEmail', login); + + // Set signed cookie for session; secure only on HTTPS + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', result.token, { + signed: true, + httpOnly: true, + secure: isHttps, + sameSite: 'strict', + maxAge: 30 * 24 * 60 * 60, + path: '/' + }); + + return reply.redirect('/dashboard'); + + } catch (error) { + console.error('Login error:', error.message); + return reply.view('login', { + error: 'An error occurred during login. Please try again.', + recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || '' + }); + } + }); + + // Logout handler + fastify.post('/auth/logout', async (req, reply) => { + // Allow multiple sessions: only clear the browser cookie + reply.clearCookie('kitchen_session'); + return reply.redirect('/login'); + }); + + fastify.get('/auth/logout', async (req, reply) => { + // Allow multiple sessions: only clear the browser cookie + reply.clearCookie('kitchen_session'); + return reply.redirect('/login'); + }); +} + +module.exports = authRoutes; + diff --git a/routes/dashboard.js b/routes/dashboard.js new file mode 100644 index 0000000..60a1cf7 --- /dev/null +++ b/routes/dashboard.js @@ -0,0 +1,35 @@ +const config = require('../config'); +const database = require('../database'); + +// Middleware to check authentication via signed cookie +async function requireAuth(req, reply) { + const raw = req.cookies && req.cookies.kitchen_session; + if (!raw) { reply.redirect('/login'); return; } + const { valid, value } = req.unsignCookie(raw || ''); + if (!valid) { reply.redirect('/login'); return; } + const token = config.get('authToken'); + const expiry = config.get('tokenExpiry'); + const apiClient = require('../api-client'); + if (!token || apiClient.isTokenExpired(expiry) || value !== token) { + reply.redirect('/login'); + return; + } +} + +async function dashboardRoutes(fastify, options) { + + // Dashboard page + fastify.get('/dashboard', { preHandler: requireAuth }, async (req, reply) => { + const appConfig = config.getAll(); + const stats = database.getOrderStats(); + + return reply.view('dashboard', { + config: appConfig, + stats: stats, + showStats: appConfig.showOrderStats !== 'false' + }); + }); +} + +module.exports = dashboardRoutes; + diff --git a/routes/orders.js b/routes/orders.js new file mode 100644 index 0000000..44befed --- /dev/null +++ b/routes/orders.js @@ -0,0 +1,343 @@ +const config = require('../config'); +const database = require('../database'); +const apiClient = require('../api-client'); +const printer = require('../printer'); + +// Middleware to check authentication via signed cookie (JSON response) +async function requireAuth(req, reply) { + const raw = req.cookies && req.cookies.kitchen_session; + if (!raw) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); } + const { valid, value } = req.unsignCookie(raw || ''); + if (!valid) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); } + const token = config.get('authToken'); + const expiry = config.get('tokenExpiry'); + const apiClient = require('../api-client'); + if (!token || apiClient.isTokenExpired(expiry) || value !== token) { + return reply.code(401).send({ error: true, message: 'Not authenticated' }); + } +} + +async function ordersRoutes(fastify, options) { + + // Get orders with filters + fastify.get('/api/orders', { preHandler: requireAuth }, async (req, reply) => { + const filters = { + status: req.query.status, + limit: parseInt(req.query.limit, 10) || 50 + }; + + // Get today's date for stats + const today = new Date(); + filters.date = today; + + const orders = database.getOrders(filters); + const stats = database.getOrderStats(); + + return { error: false, orders, stats }; + }); + + // Update order status + fastify.post('/api/orders/:id/status', { preHandler: requireAuth }, async (req, reply) => { + const orderId = parseInt(req.params.id, 10); + const { status } = req.body; + + if (!status) { + return { error: true, message: 'Status is required' }; + } + + // Valid local statuses + const validStatuses = ['new', 'preparing', 'ready', 'completed']; + if (!validStatuses.includes(status)) { + return { error: true, message: 'Invalid status' }; + } + + try { + // Update local database + database.updateOrderStatus(orderId, status); + + // Sync to backend if status is completed (maps to finished) + if (status === 'completed') { + const appConfig = config.getAll(); + const order = database.getOrderById(orderId); + + if (order && appConfig.authToken && appConfig.selectedBotId) { + // Determine the action based on order type + let action = 'finished'; + if (order.order.type === 'delivery') { + action = 'delivered'; + } else if (order.order.type === 'pickup') { + action = 'picked_up'; + } + + const result = await apiClient.modifyOrder( + appConfig.authToken, + appConfig.selectedBotId, + orderId, + action + ); + + if (result.error) { + console.error('Failed to sync order status to backend:', result.message); + } + } + } + + return { error: false }; + } catch (error) { + console.error('Failed to update order status:', error.message); + return { error: true, message: 'Failed to update order status' }; + } + }); + + // Cancel order + fastify.post('/api/orders/:id/cancel', { preHandler: requireAuth }, async (req, reply) => { + const orderId = parseInt(req.params.id, 10); + const { reason } = req.body; + + try { + // Check if cancellation already printed to prevent duplicates + if (database.hasPrintedCancellation(orderId)) { + console.log(`[API] Cancellation already printed for order #${orderId}, skipping duplicate`); + database.updateOrderStatus(orderId, 'canceled'); + return { error: false, message: 'Order already canceled' }; + } + + // Update local database + database.updateOrderStatus(orderId, 'canceled'); + + // Sync to backend + const appConfig = config.getAll(); + if (appConfig.authToken && appConfig.selectedBotId) { + const result = await apiClient.modifyOrder( + appConfig.authToken, + appConfig.selectedBotId, + orderId, + 'cancel', + reason || 'Canceled by kitchen' + ); + + if (result.error) { + console.error('Failed to sync cancellation to backend:', result.message); + } + } + + // Print cancellation receipt + const order = database.getOrderById(orderId); + if (order) { + try { + // Add to print queue with deduplication check + const jobId = database.addToPrintQueue(orderId, 'canceled'); + if (!jobId) { + console.log(`[API] Cancellation print job not created (duplicate prevention) for order #${orderId}`); + return { error: false, message: 'Cancellation recorded' }; + } + + database.markPrintJobProcessing(jobId); + + // Get enabled printers from database + const printerConfigs = database.getEnabledPrinters(); + let result; + + if (printerConfigs && printerConfigs.length > 0) { + // Use new per-printer config system + result = await printer.printOrderReceiptWithPrinterConfigs( + order, + printerConfigs, + 'canceled', + { reason: reason || 'Canceled by kitchen' } + ); + } else { + // Fallback to legacy system if no printer configs + if (!printer.printer) { + printer.initializePrinter(appConfig); + } + result = await printer.printOrderReceipt(order, 'canceled', { reason: reason || 'Canceled by kitchen' }); + } + + if (result && result.success) { + database.markOrderPrinted(orderId); + database.markPrintJobCompleted(jobId); + // Cleanup any other pending jobs for this order+type + database.cleanupDuplicateJobs(jobId, orderId, 'canceled'); + } else { + // Mark as pending for worker retry + database.markPrintJobPending(jobId); + } + } catch (error) { + console.error('Failed to print cancellation receipt:', error.message); + // Let the worker retry - find the job and mark it pending + try { + const lastJobIdRow = database.db.prepare("SELECT id FROM print_queue WHERE order_id = ? AND print_type = 'canceled' ORDER BY id DESC LIMIT 1").get(orderId); + if (lastJobIdRow && lastJobIdRow.id) { database.markPrintJobPending(lastJobIdRow.id); } + } catch (_) {} + } + } + + return { error: false }; + } catch (error) { + console.error('Failed to cancel order:', error.message); + return { error: true, message: 'Failed to cancel order' }; + } + }); + + // Reprint order + fastify.post('/api/orders/:id/reprint', { preHandler: requireAuth }, async (req, reply) => { + const orderId = parseInt(req.params.id, 10); + + try { + const order = database.getOrderById(orderId); + + if (!order) { + return { error: true, message: 'Order not found' }; + } + + const printType = order.localStatus === 'canceled' ? 'canceled' : 'new'; + + // Check for recent ACTIVE job to prevent double-enqueue while in-flight + const activeCheck = database.hasActiveOrRecentJob(orderId, 'reprint', 10); + if (activeCheck.hasActive && (activeCheck.status === 'pending' || activeCheck.status === 'processing')) { + console.log(`[API] Reprint request for order #${orderId} blocked - active job ${activeCheck.jobId} (${activeCheck.status}) exists`); + return { error: false, message: 'Print already in progress' }; + } + + // Add to print queue with deduplication + const jobId = database.addToPrintQueue(orderId, 'reprint'); + if (!jobId) { + console.log(`[API] Reprint job not created (duplicate prevention) for order #${orderId}`); + return { error: false, message: 'Print recently completed, skipping duplicate' }; + } + + database.markPrintJobProcessing(jobId); + + // Print receipt using per-printer configs + const printerConfigs = database.getEnabledPrinters(); + + let result; + if (printerConfigs && printerConfigs.length > 0) { + // Use new per-printer config system + result = await printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, printType, { cooldownMs: 2000 }); + } else { + // Fallback to legacy system + const appConfig = config.getAll(); + printer.initializePrinter(appConfig); + result = await printer.printOrderReceipt(order, printType, { cooldownMs: 2000 }); + } + + if (result && result.success) { + database.markOrderPrinted(orderId); + database.markPrintJobCompleted(jobId); + // Cleanup any other pending jobs for this order+type + database.cleanupDuplicateJobs(jobId, orderId, 'reprint'); + return { error: false, message: (result && result.message) ? result.message : 'Receipt sent to printer' }; + } else { + // Mark as pending so the worker can retry when printer is online + database.markPrintJobPending(jobId); + return { error: true, message: result.error || 'Print failed' }; + } + } catch (error) { + console.error('Failed to reprint order:', error.message); + try { database.resetStuckProcessingJobs(60); } catch (_) {} + return { error: true, message: 'Failed to reprint order' }; + } + }); + + // Get single order details + fastify.get('/api/orders/:id', { preHandler: requireAuth }, async (req, reply) => { + const orderId = parseInt(req.params.id, 10); + const order = database.getOrderById(orderId); + + if (!order) { + return { error: true, message: 'Order not found' }; + } + + return { error: false, order }; + }); + + // Manual sync trigger (for testing/debugging) + fastify.post('/api/sync-now', { preHandler: requireAuth }, async (req, reply) => { + try { + // Trigger the poller manually + if (fastify.orderPoller) { + fastify.orderPoller.poll(); + return { error: false, message: 'Manual sync triggered' }; + } else { + return { error: true, message: 'Order poller not available' }; + } + } catch (error) { + console.error('Manual sync error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Health check for external API server + fastify.get('/api/health/external', { preHandler: requireAuth }, async (req, reply) => { + try { + const appConfig = config.getAll(); + const token = appConfig.authToken; + const botId = appConfig.selectedBotId; + + if (!token || !botId) { + return { + error: false, + status: 'unconfigured', + message: 'API not configured', + timestamp: new Date().toISOString() + }; + } + + // Check if token is expired + if (apiClient.isTokenExpired(appConfig.tokenExpiry)) { + return { + error: false, + status: 'offline', + message: 'Token expired', + timestamp: new Date().toISOString() + }; + } + + // Try to fetch bots list as a lightweight health check + const startTime = Date.now(); + const result = await apiClient.getBots(token); + const responseTime = Date.now() - startTime; + + if (result.error) { + return { + error: false, + status: 'offline', + message: result.message || 'API server unreachable', + responseTime: responseTime, + timestamp: new Date().toISOString() + }; + } + + return { + error: false, + status: 'online', + message: 'API server connected', + responseTime: responseTime, + timestamp: new Date().toISOString() + }; + } catch (error) { + console.error('External API health check error:', error.message); + return { + error: false, + status: 'offline', + message: error.message || 'Health check failed', + timestamp: new Date().toISOString() + }; + } + }); + + // Health check for local dashboard server + fastify.get('/api/health/local', { preHandler: requireAuth }, async (req, reply) => { + return { + error: false, + status: 'online', + message: 'Local server connected', + timestamp: new Date().toISOString() + }; + }); +} + +module.exports = ordersRoutes; + diff --git a/routes/settings.js b/routes/settings.js new file mode 100644 index 0000000..0eb8282 --- /dev/null +++ b/routes/settings.js @@ -0,0 +1,459 @@ +const config = require('../config'); +const database = require('../database'); +const apiClient = require('../api-client'); +const printer = require('../printer'); +const path = require('path'); +const fs = require('fs'); +const { pipeline } = require('stream/promises'); + +// Middleware to check authentication via signed cookie +async function requireAuth(req, reply) { + const raw = req.cookies && req.cookies.kitchen_session; + if (!raw) { reply.redirect('/login'); return; } + const { valid, value } = req.unsignCookie(raw || ''); + if (!valid) { reply.redirect('/login'); return; } + const token = config.get('authToken'); + const expiry = config.get('tokenExpiry'); + const apiClient = require('../api-client'); + if (!token || apiClient.isTokenExpired(expiry) || value !== token) { + reply.redirect('/login'); + return; + } +} + +async function settingsRoutes(fastify, options) { + + // Settings page + fastify.get('/settings', { preHandler: requireAuth }, async (req, reply) => { + const appConfig = config.getAll(); + + // Fetch available bots + let bots = []; + if (appConfig.authToken) { + const botsResult = await apiClient.getBots(appConfig.authToken); + if (!botsResult.error && botsResult.bots) { + bots = botsResult.bots; + } + } + + return reply.view('settings', { + config: appConfig, + bots: bots, + message: req.query.message || null, + error: req.query.error || null + }); + }); + + // Save settings + fastify.post('/settings/save', { preHandler: requireAuth }, async (req, reply) => { + try { + const { + selectedBotId, + pollingInterval, + dashboardRefreshInterval, + showOrderStats, + soundNotificationsEnabled, + soundVolume, + printerType, + printerInterface, + printerPath, + printerWidth, + fontSize, + lineStyle, + qrCodeEnabled, + qrCodeSize, + qrCodeCorrection, + qrCodeContentTemplate, + headerText, + footerText, + businessName, + businessAddress, + businessPhone, + businessWebsite, + businessEmail, + businessContactSize, + showCustomerInfo, + showOrderItems, + showPrices, + showTimestamps, + selectedPrintersJson + } = req.body; + + // Validate and save configuration + const configToSave = {}; + + if (selectedBotId) configToSave.selectedBotId = selectedBotId; + if (pollingInterval) configToSave.pollingInterval = pollingInterval; + if (dashboardRefreshInterval) configToSave.dashboardRefreshInterval = dashboardRefreshInterval; + + configToSave.showOrderStats = showOrderStats === 'on' ? 'true' : 'false'; + + // Sound notification settings + configToSave.soundNotificationsEnabled = soundNotificationsEnabled === 'on' ? 'true' : 'false'; + if (soundVolume !== undefined) configToSave.soundVolume = soundVolume; + + if (printerType) configToSave.printerType = printerType; + if (printerInterface) configToSave.printerInterface = printerInterface; + if (printerPath) configToSave.printerPath = printerPath; + if (printerWidth) configToSave.printerWidth = printerWidth; + if (fontSize) configToSave.fontSize = fontSize; + if (lineStyle) configToSave.lineStyle = lineStyle; + + configToSave.qrCodeEnabled = qrCodeEnabled === 'on' ? 'true' : 'false'; + if (qrCodeSize) configToSave.qrCodeSize = parseInt(qrCodeSize, 10); + if (qrCodeCorrection) configToSave.qrCodeCorrection = qrCodeCorrection; + if (qrCodeContentTemplate !== undefined) configToSave.qrCodeContentTemplate = qrCodeContentTemplate; + + if (headerText !== undefined) configToSave.headerText = headerText; + if (footerText !== undefined) configToSave.footerText = footerText; + if (businessName !== undefined) configToSave.businessName = businessName; + if (businessAddress !== undefined) configToSave.businessAddress = businessAddress; + if (businessPhone !== undefined) configToSave.businessPhone = businessPhone; + if (businessWebsite !== undefined) configToSave.businessWebsite = businessWebsite; + if (businessEmail !== undefined) configToSave.businessEmail = businessEmail; + if (businessContactSize) configToSave.businessContactSize = businessContactSize; + + configToSave.showCustomerInfo = showCustomerInfo === 'on' ? 'true' : 'false'; + configToSave.showOrderItems = showOrderItems === 'on' ? 'true' : 'false'; + configToSave.showPrices = showPrices === 'on' ? 'true' : 'false'; + configToSave.showTimestamps = showTimestamps === 'on' ? 'true' : 'false'; + + // Multi-printer selection (stored raw JSON string) + // Smart sync: If multi-printer list is empty and main printer is configured, + // auto-populate multi-printer with main printer to avoid confusion + if (selectedPrintersJson !== undefined) { + try { + let parsed = JSON.parse(selectedPrintersJson || '[]'); + if (!Array.isArray(parsed)) parsed = []; + + // If multi-printer list is empty but main printer is configured, + // auto-add the main printer to the multi-printer list + if (parsed.length === 0 && printerInterface && printerPath) { + const mainType = printerInterface === 'serial' ? 'com' : printerInterface; + parsed.push({ + type: mainType, + interface: printerPath + }); + console.log('[Settings] Auto-populated multi-printer list with main printer:', mainType, printerPath); + } + + configToSave.selectedPrintersJson = JSON.stringify(parsed); + } catch (e) { + console.warn('[Settings] Failed to process selectedPrintersJson:', e.message); + // ignore malformed input; keep previous config + } + } + + // Save to database + config.setMultiple(configToSave); + + // Reinitialize printer with new config + const updatedConfig = config.getAll(); + printer.initializePrinter(updatedConfig); + + return reply.redirect('/settings?message=Settings saved successfully'); + } catch (error) { + console.error('Failed to save settings:', error.message); + return reply.redirect('/settings?error=Failed to save settings'); + } + }); + + // Test printer + fastify.post('/settings/test-printer', { preHandler: requireAuth }, async (req, reply) => { + try { + const appConfig = config.getAll(); + + // Allow ad-hoc test using current unsaved selections from UI + if (req.body && typeof req.body.selectedPrintersJson !== 'undefined') { + try { + const parsed = JSON.parse(req.body.selectedPrintersJson || '[]'); + if (Array.isArray(parsed)) { + appConfig.selectedPrintersJson = JSON.stringify(parsed); + } + } catch (_) { + // Ignore bad input for test; fall back to saved config + } + } + + // Always initialize to ensure latest selections are used + printer.initializePrinter(appConfig); + + const result = await printer.testPrint(); + + if (result.success) { + return { error: false, message: 'Test print successful' }; + } else { + return { error: true, message: result.error || 'Test print failed' }; + } + } catch (error) { + console.error('Test print error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Detect available printers and COM ports (hardware detection) + fastify.get('/api/printers/detect', { preHandler: requireAuth }, async (req, reply) => { + try { + const list = await printer.getAvailablePrinters(); + return { error: false, printers: list }; + } catch (error) { + console.error('List printers error:', error.message); + return { error: true, message: error.message, printers: [] }; + } + }); + + // Get all configured printers from database + fastify.get('/api/printers/list', { preHandler: requireAuth }, async (req, reply) => { + try { + const printers = database.getAllPrinters(); + return { error: false, printers }; + } catch (error) { + console.error('Get printers error:', error.message); + return { error: true, message: error.message, printers: [] }; + } + }); + + // Get single printer configuration + fastify.get('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => { + try { + const printerId = parseInt(req.params.id, 10); + const printerConfig = database.getPrinter(printerId); + if (!printerConfig) { + return { error: true, message: 'Printer not found' }; + } + return { error: false, printer: printerConfig }; + } catch (error) { + console.error('Get printer error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Create new printer configuration + fastify.post('/api/printers/create', { preHandler: requireAuth }, async (req, reply) => { + try { + const printerId = database.addPrinter(req.body); + const newPrinter = database.getPrinter(printerId); + return { error: false, message: 'Printer created successfully', printer: newPrinter }; + } catch (error) { + console.error('Create printer error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Update printer configuration + fastify.put('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => { + try { + const printerId = parseInt(req.params.id, 10); + database.updatePrinter(printerId, req.body); + const updatedPrinter = database.getPrinter(printerId); + return { error: false, message: 'Printer updated successfully', printer: updatedPrinter }; + } catch (error) { + console.error('Update printer error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Delete printer configuration + fastify.delete('/api/printers/:id', { preHandler: requireAuth }, async (req, reply) => { + try { + const printerId = parseInt(req.params.id, 10); + database.deletePrinter(printerId); + return { error: false, message: 'Printer deleted successfully' }; + } catch (error) { + console.error('Delete printer error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Set printer as default + fastify.post('/api/printers/:id/set-default', { preHandler: requireAuth }, async (req, reply) => { + try { + const printerId = parseInt(req.params.id, 10); + database.setDefaultPrinter(printerId); + return { error: false, message: 'Default printer updated' }; + } catch (error) { + console.error('Set default printer error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Toggle printer enabled/disabled + fastify.post('/api/printers/:id/toggle-enabled', { preHandler: requireAuth }, async (req, reply) => { + try { + const printerId = parseInt(req.params.id, 10); + const result = database.togglePrinterEnabled(printerId); + return { error: false, message: 'Printer status updated', is_enabled: result.is_enabled }; + } catch (error) { + console.error('Toggle printer error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Test specific printer + fastify.post('/api/printers/:id/test', { preHandler: requireAuth }, async (req, reply) => { + try { + const printerId = parseInt(req.params.id, 10); + const printerConfig = database.getPrinter(printerId); + + if (!printerConfig) { + return { error: true, message: 'Printer not found' }; + } + + const result = await printer.testPrintWithConfig(printerConfig); + + if (result.success) { + return { error: false, message: result.message || 'Test print successful' }; + } else { + return { error: true, message: result.error || 'Test print failed' }; + } + } catch (error) { + console.error('Test printer error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Upload logo - requires @fastify/multipart (now supports per-printer logos) + fastify.post('/settings/upload-logo', { preHandler: requireAuth }, async (req, reply) => { + try { + // Get multipart data + const data = await req.file(); + + if (!data) { + return { error: true, message: 'No file uploaded' }; + } + + // Ensure uploads directory exists + const uploadsDir = path.join(__dirname, '..', 'public', 'uploads'); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + // Save file + const filename = `logo-${Date.now()}${path.extname(data.filename)}`; + const filepath = path.join(uploadsDir, filename); + + // Write file using stream + await pipeline(data.file, fs.createWriteStream(filepath)); + + // If printer_id is provided in fields, update that printer's logo + // Otherwise update global config (backward compatibility) + const fields = data.fields; + const printerId = fields && fields.printer_id ? parseInt(fields.printer_id.value, 10) : null; + + if (printerId) { + // Update specific printer's logo + const printerConfig = database.getPrinter(printerId); + if (printerConfig) { + database.updatePrinter(printerId, { ...printerConfig, logo_path: filepath }); + } + } else { + // Update global config (backward compatibility) + config.set('logoPath', filepath); + try { + const updated = config.getAll(); + printer.initializePrinter(updated); + } catch (e) { + // proceed even if reinit fails + } + } + + return { error: false, message: 'Logo uploaded successfully', path: `/public/uploads/${filename}`, filepath }; + } catch (error) { + console.error('Logo upload error:', error.message); + return { error: true, message: error.message }; + } + }); + + // Get available bots + fastify.get('/api/bots', { preHandler: requireAuth }, async (req, reply) => { + const appConfig = config.getAll(); + + if (!appConfig.authToken) { + return { error: true, message: 'Not authenticated' }; + } + + const result = await apiClient.getBots(appConfig.authToken); + + if (result.error) { + return { error: true, message: result.message }; + } + + return { error: false, bots: result.bots || [] }; + }); + + // Get notification settings (for dashboard) + fastify.get('/api/notification-settings', async (req, reply) => { + try { + const appConfig = config.getAll(); + return { + error: false, + soundNotificationsEnabled: appConfig.soundNotificationsEnabled || 'true', + soundVolume: appConfig.soundVolume || '80', + newOrderSoundPath: appConfig.newOrderSoundPath || '/public/sounds/new-order-notification.mp3', + canceledOrderSoundPath: appConfig.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3' + }; + } catch (error) { + console.error('Failed to get notification settings:', error.message); + return { error: true, message: error.message }; + } + }); + + // Upload sound file for notifications + fastify.post('/settings/upload-sound', { preHandler: requireAuth }, async (req, reply) => { + try { + // Get multipart data + const data = await req.file(); + + if (!data) { + return { error: true, message: 'No file uploaded' }; + } + + // Ensure sounds directory exists + const soundsDir = path.join(__dirname, '..', 'public', 'sounds'); + if (!fs.existsSync(soundsDir)) { + fs.mkdirSync(soundsDir, { recursive: true }); + } + + // Validate file type + const validExtensions = ['.mp3', '.wav', '.ogg']; + const fileExt = path.extname(data.filename).toLowerCase(); + + if (!validExtensions.includes(fileExt)) { + return { error: true, message: 'Invalid file type. Please upload MP3, WAV, or OGG file.' }; + } + + // Save file + const filename = `notification-${Date.now()}${fileExt}`; + const filepath = path.join(soundsDir, filename); + + // Write file using stream + await pipeline(data.file, fs.createWriteStream(filepath)); + + // Get the sound type from fields (newOrder or canceled) + const fields = data.fields; + const soundType = fields && fields.soundType ? fields.soundType.value : null; + + const publicPath = `/public/sounds/${filename}`; + + // Update config based on sound type + if (soundType === 'newOrder') { + config.set('newOrderSoundPath', publicPath); + } else if (soundType === 'canceled') { + config.set('canceledOrderSoundPath', publicPath); + } + + return { + error: false, + message: 'Sound uploaded successfully', + path: publicPath, + soundType: soundType + }; + } catch (error) { + console.error('Sound upload error:', error.message); + return { error: true, message: error.message }; + } + }); +} + +module.exports = settingsRoutes; + diff --git a/server.js b/server.js new file mode 100644 index 0000000..e7bae9a --- /dev/null +++ b/server.js @@ -0,0 +1,442 @@ +// Load environment variables +require('dotenv').config(); +const { checkAndUpdate } = require('./updater'); + +const Fastify = require('fastify'); +const path = require('path'); +const database = require('./database'); +const config = require('./config'); +const apiClient = require('./api-client'); +const printer = require('./printer'); +const PrintQueueWorker = require('./print-worker'); + +const fastify = Fastify({ + logger: true +}); + +const isDev = false; + +// Order Poller Class +class OrderPoller { + constructor(apiClient, database, printer) { + this.apiClient = apiClient; + this.db = database; + this.printer = printer; + this.intervalId = null; + this.isPolling = false; + } + + async start() { + console.log('Starting order poller...'); + + // Initial poll + this.poll(); + + // Schedule recurring polls + this.scheduleNextPoll(); + } + + scheduleNextPoll() { + const appConfig = config.getAll(); + const interval = parseInt(appConfig.pollingInterval, 10) || 15000; + + if (this.intervalId) { + clearTimeout(this.intervalId); + } + + this.intervalId = setTimeout(() => { + this.poll(); + this.scheduleNextPoll(); + }, interval); + } + + async poll() { + if (this.isPolling) { + console.log('Poll already in progress, skipping...'); + return; + } + + this.isPolling = true; + + try { + const appConfig = config.getAll(); + + // Check if configured + if (!appConfig.authToken || !appConfig.selectedBotId) { + console.log('Not configured yet, skipping poll'); + this.isPolling = false; + return; + } + + // Check if token is expired + if (apiClient.isTokenExpired(appConfig.tokenExpiry)) { + console.log('Token expired, please re-login'); + this.isPolling = false; + return; + } + + // Get last synced order ID + const lastOrder = this.db.getLastOrder(); + const afterId = lastOrder ? lastOrder.order_id : 0; + + console.log(`Polling for new orders (afterId: ${afterId})...`); + + // Fetch new orders from API + const result = await this.apiClient.getOrders( + appConfig.authToken, + appConfig.selectedBotId, + afterId, + { includeCanceled: true, limit: 50 } + ); + + if (result.error) { + console.error('Failed to fetch orders:', result.message); + this.isPolling = false; + return; + } + + const orders = result.orders || []; + console.log(`Received ${orders.length} orders from API`); + + // Process each order + for (const order of orders) { + const existingOrder = this.db.getOrderById(order.id); + + if (!existingOrder) { + // New order - save and print + console.log(`New order detected: #${order.id}`); + this.db.insertOrder(order); + + // Initialize printer if needed + if (!this.printer.printer) { + this.printer.initializePrinter(appConfig); + } + + // Add to print queue with deduplication check + const newJobId = this.db.addToPrintQueue(order.id, 'new'); + if (!newJobId) { + console.log(`Print job not created for order #${order.id} (duplicate prevention)`); + continue; + } + + this.db.markPrintJobProcessing(newJobId); + + try { + // Use per-printer config system + const printerConfigs = this.db.getEnabledPrinters(); + let result; + + if (printerConfigs && printerConfigs.length > 0) { + result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'new'); + } else { + // Fallback to legacy system + if (!this.printer.printer || !this.printer.config) { + const currentConfig = require('./config').getAll(); + this.printer.initializePrinter(currentConfig); + } + result = await this.printer.printOrderReceipt(order, 'new'); + } + + if (result && result.success) { + this.db.markOrderPrinted(order.id); + this.db.markPrintJobCompleted(newJobId); + // Cleanup any other pending jobs for this order+type + this.db.cleanupDuplicateJobs(newJobId, order.id, 'new'); + console.log(`✓ Receipt printed for order #${order.id}`); + } else { + console.error(`✗ Print result indicates failure for order #${order.id}:`, result && result.error ? result.error : 'Unknown error'); + this.db.markPrintJobPending(newJobId); + } + } catch (error) { + console.error(`✗ Failed to print order #${order.id}:`, error.message); + console.log(' Order saved to database. You can reprint from dashboard.'); + this.db.markPrintJobPending(newJobId); + } + } else { + // Check for status changes (cancellation status from backend) + const statusChanged = existingOrder.status !== order.status; + + if (statusChanged) { + console.log(`Order #${order.id} status changed: ${existingOrder.status} → ${order.status}`); + + this.db.updateOrder(order); + + // Print cancellation receipt if order was canceled + if (order.status === 'canceled' && existingOrder.status !== 'canceled') { + console.log(`Order #${order.id} was canceled - printing cancellation receipt...`); + + // Update local status to 'canceled' so it shows on the dashboard + this.db.updateOrderStatus(order.id, 'canceled'); + + // Check if cancellation already printed to prevent duplicates + if (this.db.hasPrintedCancellation(order.id)) { + console.log(`Skipping duplicate cancellation print for order #${order.id}`); + } else { + // Add to print queue with deduplication check + const jobId = this.db.addToPrintQueue(order.id, 'canceled'); + if (!jobId) { + console.log(`Cancellation job not created for order #${order.id} (duplicate prevention)`); + } else { + this.db.markPrintJobProcessing(jobId); + + try { + const cancelReason = order.cancellationReason || order.order?.cancellationReason || 'Order canceled'; + + // Use per-printer config system + const printerConfigs = this.db.getEnabledPrinters(); + let result; + + if (printerConfigs && printerConfigs.length > 0) { + result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'canceled', { reason: cancelReason }); + } else { + // Fallback to legacy system + if (!this.printer.printer || !this.printer.config) { + const currentConfig = require('./config').getAll(); + this.printer.initializePrinter(currentConfig); + } + result = await this.printer.printOrderReceipt(order, 'canceled', { reason: cancelReason }); + } + + if (result && result.success) { + this.db.markOrderPrinted(order.id); + this.db.markPrintJobCompleted(jobId); + // Cleanup any other pending jobs for this order+type + this.db.cleanupDuplicateJobs(jobId, order.id, 'canceled'); + console.log(`✓ Cancellation receipt printed for order #${order.id}`); + } else { + console.error(`✗ Cancellation print failed for order #${order.id}:`, result && result.error ? result.error : 'Unknown error'); + this.db.markPrintJobPending(jobId); + } + } catch (error) { + console.error(`✗ Failed to print cancellation for order #${order.id}:`, error.message); + console.log(' Order updated in database. You can reprint from dashboard.'); + this.db.markPrintJobPending(jobId); + } + } + } + } + } + } + } + + // Reconciliation fetch: scan recent orders window to catch updates (e.g., cancellations) + try { + const recentResult = await this.apiClient.getOrders( + appConfig.authToken, + appConfig.selectedBotId, + 0, + { includeCanceled: true, limit: 200 } + ); + + if (!recentResult.error) { + const recentOrders = recentResult.orders || []; + for (const order of recentOrders) { + const existingOrder = this.db.getOrderById(order.id); + if (!existingOrder) { + // Skip new orders here; they are handled by the main afterId fetch + continue; + } + + // Check for status changes (cancellation status from backend) + const statusChanged = existingOrder.status !== order.status; + + if (statusChanged) { + console.log(`(reconcile) Order #${order.id} status changed: ${existingOrder.status} → ${order.status}`); + + this.db.updateOrder(order); + + // Print cancellation receipt if order was canceled + if (order.status === 'canceled' && existingOrder.status !== 'canceled') { + console.log(`(reconcile) Order #${order.id} was canceled - printing cancellation receipt...`); + + // Update local status to 'canceled' so it shows on the dashboard + this.db.updateOrderStatus(order.id, 'canceled'); + + // Check if cancellation already printed to prevent duplicates + if (this.db.hasPrintedCancellation(order.id)) { + console.log(`(reconcile) Skipping duplicate cancellation print for order #${order.id}`); + } else { + // Add to print queue with deduplication check + const jobId = this.db.addToPrintQueue(order.id, 'canceled'); + if (!jobId) { + console.log(`(reconcile) Cancellation job not created for order #${order.id} (duplicate prevention)`); + } else { + this.db.markPrintJobProcessing(jobId); + + try { + const cancelReason = order.cancellationReason || order.order?.cancellationReason || 'Order canceled'; + + // Use per-printer config system + const printerConfigs = this.db.getEnabledPrinters(); + let result; + + if (printerConfigs && printerConfigs.length > 0) { + result = await this.printer.printOrderReceiptWithPrinterConfigs(order, printerConfigs, 'canceled', { reason: cancelReason }); + } else { + // Fallback to legacy system + if (!this.printer.printer || !this.printer.config) { + const currentConfig = require('./config').getAll(); + this.printer.initializePrinter(currentConfig); + } + result = await this.printer.printOrderReceipt(order, 'canceled', { reason: cancelReason }); + } + + if (result && result.success) { + this.db.markOrderPrinted(order.id); + this.db.markPrintJobCompleted(jobId); + // Cleanup any other pending jobs for this order+type + this.db.cleanupDuplicateJobs(jobId, order.id, 'canceled'); + console.log(`✓ Cancellation receipt printed for order #${order.id}`); + } else { + console.error(`✗ Cancellation print failed for order #${order.id}:`, result && result.error ? result.error : 'Unknown error'); + this.db.markPrintJobPending(jobId); + } + } catch (error) { + console.error(`✗ Failed to print cancellation for order #${order.id}:`, error.message); + console.log(' Order updated in database. You can reprint from dashboard.'); + this.db.markPrintJobPending(jobId); + } + } + } + } + } + } + } + } catch (reconcileErr) { + console.error('Reconciliation fetch error:', reconcileErr.message); + } + + } catch (error) { + console.error('Poll error:', error.message); + } + + this.isPolling = false; + } + + stop() { + if (this.intervalId) { + clearTimeout(this.intervalId); + this.intervalId = null; + } + console.log('Order poller stopped'); + } + + restart() { + this.stop(); + this.start(); + } +} + +// Main initialization +async function main() { + try { + // Initialize database + database.init(); + console.log('Database initialized'); + + // Register plugins + await fastify.register(require('@fastify/view'), { + engine: { + ejs: require('ejs') + }, + root: path.join(__dirname, 'views') + }); + + await fastify.register(require('@fastify/static'), { + root: path.join(__dirname, 'public'), + prefix: '/public/' + }); + + await fastify.register(require('@fastify/cookie'), { + secret: process.env.COOKIE_SECRET || 'kitchen-agent-secret-key-change-in-production', + hook: 'onRequest' + }); + + await fastify.register(require('@fastify/formbody')); + + await fastify.register(require('@fastify/multipart'), { + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit for logo uploads + } + }); + + // Register routes + await fastify.register(require('./routes/auth')); + await fastify.register(require('./routes/dashboard')); + await fastify.register(require('./routes/settings')); + await fastify.register(require('./routes/orders')); + + // Initialize printer with config + const appConfig = config.getAll(); + if (appConfig.printerType && appConfig.printerPath) { + try { + printer.initializePrinter(appConfig); + console.log('Printer initialized successfully'); + } catch (error) { + console.error('Failed to initialize printer:', error.message); + console.log('Printer can be configured later in Settings'); + } + } else { + console.log('Printer not configured - configure in Settings'); + } + + // Start order poller + const poller = new OrderPoller(apiClient, database, printer); + const printWorker = new PrintQueueWorker(database, config, printer); + + // Make poller available globally for restart after settings change + fastify.decorate('orderPoller', poller); + fastify.decorate('printWorker', printWorker); + + // Start server + const port = parseInt(process.env.PORT, 10) || 3000; + const host = process.env.HOST || '0.0.0.0'; + + await fastify.listen({ port, host }); + + const addresses = fastify.server.address(); + console.log('\n================================================='); + console.log('Think Link AI Kitchen Agent is running!'); + console.log(`Access at: http://localhost:${port}`); + console.log(`Or use your computer's IP address from other devices`); + console.log('=================================================\n'); + + // Kick off auto-update check (at boot and on interval) + if (isDev) { + console.log('Dev mode detected: skipping auto-update checks'); + } else { + try { checkAndUpdate(); } catch (_) {} + const envIntervalRaw = process.env.UPDATE_CHECK_INTERVAL_MS; + const envIntervalMs = envIntervalRaw ? parseInt(envIntervalRaw, 10) : NaN; + const updateIntervalMs = (!Number.isNaN(envIntervalMs) && envIntervalMs > 0) ? envIntervalMs : (5 * 60 * 1000); + setInterval(() => { try { checkAndUpdate(); } catch (e) { fastify.log.error(e); } }, updateIntervalMs); + } + + // Start polling after server is up + poller.start(); + // Start print queue worker + printWorker.start(); + + // Handle shutdown gracefully (PM2 reload-friendly) + const gracefulShutdown = async () => { + console.log('\nShutting down gracefully...'); + poller.stop(); + try { printWorker.stop(); } catch (_) {} + try { database.close(); } catch (_) {} + try { await fastify.close(); } catch (_) {} + process.exit(0); + }; + + process.on('SIGINT', gracefulShutdown); + process.on('SIGTERM', gracefulShutdown); + process.on('message', (msg) => { if (msg === 'shutdown') gracefulShutdown(); }); + + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Start the application +main(); + diff --git a/test-startup.js b/test-startup.js new file mode 100644 index 0000000..ea5fe5d --- /dev/null +++ b/test-startup.js @@ -0,0 +1,128 @@ +/** + * Kitchen Agent - Startup Test + * Tests the complete server startup and shutdown + */ + +console.log('Testing Kitchen Agent startup...\n'); + +// Load environment +require('dotenv').config(); + +async function testStartup() { + let server = null; + + try { + console.log('Step 1: Initializing database...'); + const database = require('./database'); + database.init(); + console.log(' ✓ Database ready\n'); + + console.log('Step 2: Loading modules...'); + const config = require('./config'); + const apiClient = require('./api-client'); + const printer = require('./printer'); + console.log(' ✓ All modules loaded\n'); + + console.log('Step 3: Starting Fastify server...'); + const Fastify = require('fastify'); + const path = require('path'); + + const fastify = Fastify({ logger: false }); + + // Register plugins + await fastify.register(require('@fastify/view'), { + engine: { ejs: require('ejs') }, + root: path.join(__dirname, 'views') + }); + + await fastify.register(require('@fastify/static'), { + root: path.join(__dirname, 'public'), + prefix: '/public/' + }); + + await fastify.register(require('@fastify/cookie'), { + secret: process.env.COOKIE_SECRET || 'test-secret' + }); + + await fastify.register(require('@fastify/formbody')); + + await fastify.register(require('@fastify/multipart'), { + limits: { fileSize: 5 * 1024 * 1024 } + }); + + console.log(' ✓ Plugins registered\n'); + + console.log('Step 4: Registering routes...'); + await fastify.register(require('./routes/auth')); + await fastify.register(require('./routes/dashboard')); + await fastify.register(require('./routes/settings')); + await fastify.register(require('./routes/orders')); + console.log(' ✓ All routes registered\n'); + + console.log('Step 5: Starting server on port 3000...'); + await fastify.listen({ port: 3000, host: '127.0.0.1' }); + console.log(' ✓ Server started successfully\n'); + + server = fastify; + + console.log('Step 6: Testing routes...'); + + // Test root route + const response = await fastify.inject({ + method: 'GET', + url: '/' + }); + + if (response.statusCode === 302) { + console.log(' ✓ Root route works (redirects)'); + } else { + console.log(` ✗ Root route returned ${response.statusCode}`); + hasErrors = true; + } + + // Test login page + const loginResponse = await fastify.inject({ + method: 'GET', + url: '/login' + }); + + if (loginResponse.statusCode === 200) { + console.log(' ✓ Login page loads'); + } else { + console.log(` ✗ Login page returned ${loginResponse.statusCode}`); + hasErrors = true; + } + + console.log('\nStep 7: Shutting down server...'); + await fastify.close(); + console.log(' ✓ Server stopped gracefully\n'); + + console.log('='.repeat(60)); + console.log('✅ STARTUP TEST PASSED'); + console.log('='.repeat(60)); + console.log('\nThe Kitchen Agent is ready to run!'); + console.log('Start with: npm start\n'); + + database.close(); + process.exit(0); + + } catch (error) { + console.error('\n❌ STARTUP TEST FAILED'); + console.error('Error:', error.message); + console.error('\nStack trace:'); + console.error(error.stack); + + if (server) { + try { + await server.close(); + } catch (e) { + // ignore + } + } + + process.exit(1); + } +} + +testStartup(); + diff --git a/test-utils.js b/test-utils.js new file mode 100644 index 0000000..2c7c110 --- /dev/null +++ b/test-utils.js @@ -0,0 +1,184 @@ +/** + * Test utilities for Kitchen Agent + * Use these functions to manually test the system + */ + +const database = require('./database'); + +// Initialize database first +database.init(); + +/** + * Insert a mock order for testing UI + */ +function insertMockOrder(orderId = null) { + const id = orderId || Math.floor(Math.random() * 10000); + const now = Math.floor(Date.now() / 1000); + + const mockOrder = { + id: id, + botId: 1, + orderStatus: 'new', + order: { + type: Math.random() > 0.5 ? 'delivery' : 'pickup', + items: [ + { + id: 1, + itemName: 'Cheeseburger', + qty: 2, + price: 12.99, + addons: [{ name: 'Extra Cheese', price: 1.00 }], + exclude: [{ name: 'Onions', price: 0 }] + }, + { + id: 2, + itemName: 'French Fries', + qty: 1, + price: 4.99, + addons: [], + exclude: [] + }, + { + id: 3, + itemName: 'Coca-Cola', + qty: 2, + price: 2.50, + addons: [], + exclude: [] + } + ], + amount: 35.97, + taxRate: 8.5, + taxAmount: 3.06, + deliveryFee: 5.00, + totalAmount: 44.03, + deliveryAddress: '123 Main Street, Anytown, USA 12345', + deliveryInstructions: 'Ring doorbell, leave at door', + specialInstructions: 'Extra napkins please, no ketchup', + foodAllergy: Math.random() > 0.7, + foodAllergyNotes: 'Severe peanut allergy - please ensure no cross-contamination' + }, + customer: { + id: 1, + name: 'John Doe', + phoneNumber: '+15551234567', + email: 'john.doe@example.com' + }, + totalAmount: 44.03, + createdAt: now, + updatedAt: now + }; + + try { + database.insertOrder(mockOrder); + console.log(`✓ Mock order #${id} inserted successfully`); + return mockOrder; + } catch (error) { + console.error('Failed to insert mock order:', error.message); + return null; + } +} + +/** + * Insert multiple mock orders + */ +function insertMultipleMockOrders(count = 5) { + console.log(`Inserting ${count} mock orders...`); + + for (let i = 0; i < count; i++) { + const baseId = 10000 + i; + insertMockOrder(baseId); + } + + console.log(`✓ ${count} mock orders inserted`); +} + +/** + * Clear all orders from database (for testing) + */ +function clearAllOrders() { + try { + database.db.exec('DELETE FROM orders'); + database.db.exec('DELETE FROM print_queue'); + console.log('✓ All orders cleared'); + } catch (error) { + console.error('Failed to clear orders:', error.message); + } +} + +/** + * View current configuration + */ +function viewConfig() { + const config = database.getConfig(); + console.log('\nCurrent Configuration:'); + console.log('====================='); + for (const [key, value] of Object.entries(config)) { + if (key === 'authToken' && value) { + console.log(`${key}: [ENCRYPTED]`); + } else { + console.log(`${key}: ${value}`); + } + } + console.log('=====================\n'); +} + +/** + * View order statistics + */ +function viewStats() { + const stats = database.getOrderStats(); + console.log('\nOrder Statistics:'); + console.log('================='); + console.log(`Total Today: ${stats.total}`); + console.log(`New: ${stats.new}`); + console.log(`Preparing: ${stats.preparing}`); + console.log(`Ready: ${stats.ready}`); + console.log('=================\n'); +} + +/** + * List all orders + */ +function listOrders(limit = 10) { + const orders = database.getOrders({ limit }); + console.log(`\nRecent Orders (${orders.length}):`); + console.log('==================='); + + orders.forEach(order => { + console.log(`#${order.id} | ${order.customer.name} | ${order.localStatus} | $${order.totalAmount.toFixed(2)}`); + }); + + console.log('===================\n'); +} + +// Export functions +module.exports = { + insertMockOrder, + insertMultipleMockOrders, + clearAllOrders, + viewConfig, + viewStats, + listOrders +}; + +// If run directly, show help +if (require.main === module) { + console.log('\nKitchen Agent Test Utilities'); + console.log('============================\n'); + console.log('Usage:'); + console.log(' node test-utils.js\n'); + console.log('Available in Node REPL:'); + console.log(' const test = require("./test-utils");\n'); + console.log(' test.insertMockOrder(); // Insert one mock order'); + console.log(' test.insertMultipleMockOrders(5); // Insert 5 mock orders'); + console.log(' test.listOrders(); // List recent orders'); + console.log(' test.viewStats(); // View order statistics'); + console.log(' test.viewConfig(); // View configuration'); + console.log(' test.clearAllOrders(); // Clear all orders (careful!)\n'); + + // Show current stats + viewStats(); + listOrders(5); +} + diff --git a/updater.js b/updater.js new file mode 100644 index 0000000..b50c3af --- /dev/null +++ b/updater.js @@ -0,0 +1,94 @@ +const path = require('path'); +const os = require('os'); +const fetch = require('node-fetch'); + +const { + GITEA_TOKEN, + REPO_URL = 'https://repo.cloud.thinklink.ai/thinklink/kitchen-agent.git', + REPO_BRANCH = 'main', + PM2_APP = 'kitchen-agent' +} = process.env; + +const repositoryNormalized = REPO_URL.endsWith('.git') ? REPO_URL : `${REPO_URL}.git`; + +const updaterOptions = { + repository: repositoryNormalized, + branch: REPO_BRANCH, + token: GITEA_TOKEN, + // place updates in OS temp dir to avoid permission/self-copy issues + tempLocation: path.join(os.tmpdir(), 'kitchen-agent-updates'), + // only include files that actually exist in the upstream repo; module uses unlinkSync + ignoreFiles: ['.env'], + executeOnComplete: process.platform === 'win32' + ? `IF EXIST package-lock.json (npm ci --omit=dev) ELSE (npm install --omit=dev) & pm2 reload ${PM2_APP}` + : `[ -f package-lock.json ] && npm ci --omit=dev || npm install --omit=dev; pm2 reload ${PM2_APP}`, + exitOnComplete: false +}; + +let updaterInstance = null; + +async function getUpdater() { + if (updaterInstance) { return updaterInstance; } + const mod = await import('auto-git-update'); + const AutoGitUpdate = mod && mod.default ? mod.default : mod; + updaterInstance = new AutoGitUpdate(updaterOptions); + return updaterInstance; +} + +async function checkAndUpdate() { + try { + const updater = await getUpdater(); + + const isGithub = /github\.com/i.test(repositoryNormalized); + let needsUpdate = false; + + if (!isGithub) { + // Use Gitea-aware version check to avoid library's GitHub-only path + const localVersion = (() => { + try { return require('./package.json').version || null; } catch (_) { return null; } + })(); + const base = repositoryNormalized.replace(/\.git$/i, ''); + const rawUrl = `${base}/raw/branch/${REPO_BRANCH}/package.json`; + + async function tryFetch(url) { + const headers = GITEA_TOKEN ? { Authorization: `token ${GITEA_TOKEN}` } : undefined; + const r = await fetch(url, { headers }); + if (r.ok) return r.json(); + return null; + } + + let remotePkg = await tryFetch(rawUrl); + if (!remotePkg && GITEA_TOKEN) { + remotePkg = (await tryFetch(`${rawUrl}?token=${GITEA_TOKEN}`)) + || (await tryFetch(`${rawUrl}?access_token=${GITEA_TOKEN}`)); + } + + const remoteVersion = remotePkg && remotePkg.version ? String(remotePkg.version) : null; + if (!remoteVersion) { + console.error('[auto-update] Unable to read remote version from Gitea raw endpoint'); + } else if (!localVersion || remoteVersion !== String(localVersion)) { + console.log(`[auto-update] Update available: ${localVersion || 'unknown'} -> ${remoteVersion}`); + needsUpdate = true; + } + + if (needsUpdate) { + await updater.forceUpdate(); + } else { + console.log('[auto-update] Up to date'); + } + return; + } + + // GitHub-based repos: use library's compareVersions, then forceUpdate to avoid double compare + const res = await updater.compareVersions(); + if (res && res.upToDate === false) { + await updater.forceUpdate(); + } else { + console.log('[auto-update] Up to date'); + } + } catch (err) { + console.error('[auto-update] failed:', err); + } +} + +module.exports = { checkAndUpdate }; diff --git a/views/dashboard.ejs b/views/dashboard.ejs new file mode 100644 index 0000000..5f85ea7 --- /dev/null +++ b/views/dashboard.ejs @@ -0,0 +1,145 @@ + + + + + + Kitchen Display - Kitchen Agent + + + +
+
+

Kitchen Display

+
+
+ + Dashboard + Checking... +
+
+ + API Server + Checking... +
+
+ +
+
+ + <% if (showStats) { %> +
+
+
Total Today
+
<%= stats.total %>
+
+
+
New
+
<%= stats.new %>
+
+
+
Preparing
+
<%= stats.preparing %>
+
+
+
Ready
+
<%= stats.ready %>
+
+
+ <% } %> + +
+
+ + + + +
+ +
+ +
+
Loading orders...
+
+ + + + + + + + + + + +
+ + + + + + + diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..206972e --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,80 @@ + + + + + + Login - Kitchen Agent + + + + +
+ +
+ + + + + diff --git a/views/settings.ejs b/views/settings.ejs new file mode 100644 index 0000000..7f910e1 --- /dev/null +++ b/views/settings.ejs @@ -0,0 +1,523 @@ + + + + + + Settings - Kitchen Agent + + + +
+
+

Settings

+ +
+
+ +
+ <% if (message) { %> +
+ <%= message %> +
+ <% } %> + + <% if (error) { %> +
+ <%= error %> +
+ <% } %> + +
+ + + +
+ +
+ + +
+

General Settings

+ +
+ + + Select which bot's orders to display +
+ +
+ + + How often to check for new orders (default: 15000 = 15 seconds) +
+ +
+ + + How often to refresh the dashboard display (default: 10000 = 10 seconds) +
+ +
+ +
+ +

Sound Notifications

+ +
+ + Play sounds when new orders arrive or orders are canceled +
+ +
+ + + Volume: <%= config.soundVolume || 80 %>% +
+ +
+

New Order Sound

+
+ +

+ <%= config.newOrderSoundPath || '/public/sounds/new-order-notification.mp3' %> +

+
+ +
+ + + + + Supported formats: MP3, WAV, OGG +
+
+ +
+

Canceled Order Sound

+
+ +

+ <%= config.canceledOrderSoundPath || '/public/sounds/canceled-order-notification.mp3' %> +

+
+ +
+ + + + + Supported formats: MP3, WAV, OGG +
+
+
+ + +
+

Printer Management

+

Configure individual printers with specific paper sizes, templates, and settings.

+ +
+
+

Configured Printers

+ +
+ +
+ +
Loading printers...
+
+
+
+ + +
+

Receipt Template

+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + + Cell size (2–8). Larger = bigger QR. +
+ +
+ + +
+ +
+ + + Use placeholders: {id}, {total}, {type}, {createdAt} +
+ +

Show/Hide Sections

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + Upload a logo image to print at the top of receipts + <% if (config.logoPath) { %> + + <% } %> +
+ +

Business Information

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Controls the size of address/phone/website/email in header. +
+
+ +
+ +
+
+
+ + + + + + + + +