Files
kitchen-agent/server.js
2025-10-23 19:02:56 -04:00

443 lines
17 KiB
JavaScript

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