Initial commit
This commit is contained in:
442
server.js
Normal file
442
server.js
Normal file
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user