// 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();