From 85cf732a6111f29da705964ae18a7a29dc925edc Mon Sep 17 00:00:00 2001 From: odzugkoev Date: Sun, 1 Mar 2026 17:10:03 -0500 Subject: [PATCH] done --- abandoned-call-poller.js | 143 ++++++++++ api-client.js | 106 ++++++++ config.js | 15 +- database.js | 161 ++++++++++- package.json | 2 +- printer.js | 497 +++++++++++++++++++++++++++++++++- public/css/style.css | 385 +++++++++++++++++++++++++++ public/js/abandoned-calls.js | 499 +++++++++++++++++++++++++++++++++++ public/js/dashboard.js | 34 ++- public/js/settings.js | 6 +- routes/abandoned-calls.js | 229 ++++++++++++++++ routes/auth.js | 30 ++- routes/dashboard.js | 13 +- routes/orders.js | 14 +- routes/settings.js | 14 +- server.js | 52 +++- views/abandoned-calls.ejs | 103 ++++++++ views/dashboard.ejs | 5 + views/settings.ejs | 8 + 19 files changed, 2284 insertions(+), 32 deletions(-) create mode 100644 abandoned-call-poller.js create mode 100644 public/js/abandoned-calls.js create mode 100644 routes/abandoned-calls.js create mode 100644 views/abandoned-calls.ejs diff --git a/abandoned-call-poller.js b/abandoned-call-poller.js new file mode 100644 index 0000000..65852d6 --- /dev/null +++ b/abandoned-call-poller.js @@ -0,0 +1,143 @@ +const config = require('./config'); +const apiClient = require('./api-client'); + +class AbandonedCallPoller { + constructor(database, printer) { + this.db = database; + this.printer = printer; + this.intervalId = null; + this.isPolling = false; + } + + async start() { + console.log('Starting abandoned call poller...'); + this.poll(); + this.scheduleNextPoll(); + } + + scheduleNextPoll() { + const appConfig = config.getAll(); + const interval = parseInt(appConfig.abandonedCallPollingInterval, 10) || 30000; + + if (this.intervalId) { + clearTimeout(this.intervalId); + } + + this.intervalId = setTimeout(() => { + this.poll(); + this.scheduleNextPoll(); + }, interval); + } + + async poll() { + if (this.isPolling) return; + this.isPolling = true; + + try { + const appConfig = config.getAll(); + + if (!appConfig.authToken || !appConfig.selectedBotId) { + this.isPolling = false; + return; + } + + if (apiClient.isTokenNearExpiry(appConfig.tokenExpiry, 7)) { + const refreshed = await apiClient.ensureValidToken(); + if (!refreshed) { + this.isPolling = false; + return; + } + Object.assign(appConfig, config.getAll()); + } + + const result = await apiClient.getAbandonedCalls( + appConfig.authToken, + appConfig.selectedBotId, + { limit: 50 } + ); + + if (result.error) { + console.error('Abandoned calls poll error:', result.message); + this.isPolling = false; + return; + } + + const calls = result.calls || []; + + for (const call of calls) { + this.db.cacheAbandonedCall(call.id, call); + } + + // Also fetch callback queue for the management page cache + const queueResult = await apiClient.getAbandonedCallbackQueue( + appConfig.authToken, + appConfig.selectedBotId, + 50 + ); + + if (!queueResult.error && queueResult.queue) { + for (const call of queueResult.queue) { + this.db.cacheAbandonedCall(call.id, call); + } + } + + // Print new abandoned calls that haven't been printed yet + await this.printNewAbandonedCalls(calls, appConfig); + + // Clean old cache periodically + this.db.cleanOldAbandonedCallCache(7); + + } catch (error) { + console.error('Abandoned call poll error:', error.message); + } + + this.isPolling = false; + } + + async printNewAbandonedCalls(calls, appConfig) { + const printerConfigs = this.db.getAbandonedCallPrinters(); + if (!printerConfigs || printerConfigs.length === 0) return; + + const cooldownSeconds = parseInt(appConfig.abandonedCallPrintCooldown, 10) || 300; + const lastPrintTime = this.db.getLastAbandonedCallPrintTime(); + const now = Math.floor(Date.now() / 1000); + + const printablePriorities = new Set(['critical', 'high', 'medium']); + + for (const call of calls) { + if (this.db.hasAbandonedCallPrint(call.id)) continue; + + const priority = call.callback_priority || 'none'; + if (!printablePriorities.has(priority)) continue; + + if (lastPrintTime && (now - lastPrintTime) < cooldownSeconds) { + console.log(`Abandoned call #${call.id}: skipping print (cooldown active)`); + continue; + } + + try { + const result = await this.printer.printAbandonedCallReceipt(call, printerConfigs); + const printedCount = result ? (result.successCount || printerConfigs.length) : 0; + this.db.addAbandonedCallPrint(call.id, printedCount); + console.log(`Abandoned call #${call.id}: printed on ${printedCount} printer(s)`); + } catch (err) { + console.error(`Abandoned call #${call.id}: print failed:`, err.message); + } + } + } + + stop() { + if (this.intervalId) { + clearTimeout(this.intervalId); + this.intervalId = null; + } + console.log('Abandoned call poller stopped'); + } + + restart() { + this.stop(); + this.start(); + } +} + +module.exports = AbandonedCallPoller; diff --git a/api-client.js b/api-client.js index 8cd499c..896e34c 100644 --- a/api-client.js +++ b/api-client.js @@ -3,6 +3,7 @@ const fetch = require('node-fetch'); class APIClient { constructor(baseUrl = process.env.API_URL || 'https://api.thinklink.ai') { this.baseUrl = baseUrl; + this._refreshInFlight = null; } async request(endpoint, options = {}) { @@ -82,12 +83,117 @@ class APIClient { return this.request('/food-order/modify', { body }); } + async refreshToken(token) { + return this.request('/user/refresh-token', { + body: { token } + }); + } + + async getAbandonedCalls(token, botId, options = {}) { + const body = { + token, + botId: parseInt(botId, 10) || 0, + limit: options.limit || 50, + offset: options.offset || 0 + }; + if (options.stage) body.stage = options.stage; + if (options.priority) body.priority = options.priority; + if (options.startDate) body.startDate = options.startDate; + if (options.endDate) body.endDate = options.endDate; + return this.request('/abandoned-calls/list', { body }); + } + + async getAbandonedCallbackQueue(token, botId, limit = 20, offset = 0) { + return this.request('/abandoned-calls/callback-queue', { + body: { token, botId: parseInt(botId, 10) || 0, limit, offset } + }); + } + + async updateAbandonedCallback(token, abandonedCallId, action, notes = '') { + return this.request('/abandoned-calls/update-callback', { + body: { token, abandonedCallId: parseInt(abandonedCallId, 10), action, notes } + }); + } + + async getAbandonedCallMetrics(token, botId, startDate = 0, endDate = 0) { + return this.request('/abandoned-calls/metrics', { + body: { token, botId: parseInt(botId, 10) || 0, startDate, endDate } + }); + } + isTokenExpired(expirationDate) { if (!expirationDate) return true; const expiry = new Date(expirationDate); + if (Number.isNaN(expiry.getTime())) return true; const now = new Date(); return now >= expiry; } + + isTokenNearExpiry(expirationDate, thresholdDays = 7) { + if (!expirationDate) return true; + const expiry = new Date(expirationDate); + if (Number.isNaN(expiry.getTime())) return true; + const now = new Date(); + const msRemaining = expiry.getTime() - now.getTime(); + const thresholdMs = thresholdDays * 24 * 60 * 60 * 1000; + return msRemaining < thresholdMs; + } + + async ensureValidToken() { + const config = require('./config'); + const token = config.get('authToken'); + const expiry = config.get('tokenExpiry'); + + if (!token || !expiry) return null; + + if (!this.isTokenNearExpiry(expiry, 7)) return token; + + if (this._refreshInFlight) { + return this._refreshInFlight; + } + + this._refreshInFlight = (async () => { + // Re-read in case another caller refreshed between checks. + const currentToken = config.get('authToken'); + const currentExpiry = config.get('tokenExpiry'); + if (!currentToken || !currentExpiry) return null; + if (!this.isTokenNearExpiry(currentExpiry, 7)) return currentToken; + + console.log('Token near expiry or expired, attempting refresh...'); + + try { + const result = await this.refreshToken(currentToken); + + if (result.error) { + // If another concurrent refresh already updated config, don't treat this as fatal. + const latestToken = config.get('authToken'); + const latestExpiry = config.get('tokenExpiry'); + if (latestToken && latestToken !== currentToken && !this.isTokenExpired(latestExpiry)) { + return latestToken; + } + + console.error('Token refresh failed:', result.message); + return null; + } + + config.set('previousAuthToken', currentToken); + config.set('authToken', result.token); + config.set('tokenExpiry', result.expirationDate); + + console.log('Token refreshed successfully, new expiry:', result.expirationDate); + return result.token; + } catch (error) { + console.error('Token refresh error:', error.message); + return null; + } + })(); + + try { + return await this._refreshInFlight; + } finally { + this._refreshInFlight = null; + } + } } module.exports = new APIClient(); diff --git a/config.js b/config.js index 0a5eeaa..1e20f9b 100644 --- a/config.js +++ b/config.js @@ -74,8 +74,7 @@ class ConfigManager { try { const value = database.getConfig(key); - // Decrypt token if it's the auth token - if (key === 'authToken' && value) { + if ((key === 'authToken' || key === 'previousAuthToken') && value) { return this.decrypt(value); } @@ -88,8 +87,7 @@ class ConfigManager { set(key, value) { try { - // Encrypt token if it's the auth token - if (key === 'authToken' && value) { + if ((key === 'authToken' || key === 'previousAuthToken') && value) { value = this.encrypt(value); } @@ -103,10 +101,12 @@ class ConfigManager { try { const config = database.getConfig(); - // Decrypt auth token if present if (config.authToken) { config.authToken = this.decrypt(config.authToken); } + if (config.previousAuthToken) { + config.previousAuthToken = this.decrypt(config.previousAuthToken); + } return config; } catch (error) { @@ -117,10 +117,12 @@ class ConfigManager { setMultiple(configObj) { try { - // Encrypt auth token if present if (configObj.authToken) { configObj.authToken = this.encrypt(configObj.authToken); } + if (configObj.previousAuthToken) { + configObj.previousAuthToken = this.encrypt(configObj.previousAuthToken); + } database.setConfigMultiple(configObj); } catch (error) { @@ -149,6 +151,7 @@ class ConfigManager { try { this.set('authToken', ''); this.set('tokenExpiry', ''); + this.set('previousAuthToken', ''); } catch (error) { console.error('Failed to clear auth:', error.message); } diff --git a/database.js b/database.js index 63af416..76b5672 100644 --- a/database.js +++ b/database.js @@ -100,6 +100,30 @@ class DatabaseManager { ) `); + // Abandoned call print tracking + this.db.exec(` + CREATE TABLE IF NOT EXISTS abandoned_call_prints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + abandoned_call_id INTEGER UNIQUE NOT NULL, + printed_at INTEGER NOT NULL, + printer_count INTEGER NOT NULL DEFAULT 0 + ) + `); + + // Abandoned calls local cache + this.db.exec(` + CREATE TABLE IF NOT EXISTS abandoned_calls_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + remote_id INTEGER UNIQUE NOT NULL, + data TEXT NOT NULL, + callback_status TEXT, + callback_priority TEXT, + callback_score INTEGER DEFAULT 0, + 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); @@ -108,6 +132,9 @@ class DatabaseManager { 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); + CREATE INDEX IF NOT EXISTS idx_abandoned_prints_call_id ON abandoned_call_prints(abandoned_call_id); + CREATE INDEX IF NOT EXISTS idx_abandoned_cache_remote ON abandoned_calls_cache(remote_id); + CREATE INDEX IF NOT EXISTS idx_abandoned_cache_status ON abandoned_calls_cache(callback_status); `); // Initialize default config values if not exists @@ -115,6 +142,9 @@ class DatabaseManager { // Migrate old printer config to new table if needed this.migrateOldPrinterConfig(); + + // Add print_abandoned_calls column to printers if missing + this.migratePrintersAddAbandonedCalls(); } setConfigDefaults() { @@ -474,9 +504,9 @@ class DatabaseManager { 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 + logo_path, logo_max_width_dots, print_abandoned_calls, created_at, updated_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `); @@ -509,6 +539,7 @@ class DatabaseManager { config.show_timestamps !== false ? 1 : 0, config.logo_path || null, config.logo_max_width_dots || null, + config.print_abandoned_calls !== false ? 1 : 0, now, now ); @@ -531,7 +562,7 @@ class DatabaseManager { 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 = ? + logo_path = ?, logo_max_width_dots = ?, print_abandoned_calls = ?, updated_at = ? WHERE id = ? `); @@ -564,6 +595,7 @@ class DatabaseManager { config.show_timestamps !== false ? 1 : 0, config.logo_path || null, config.logo_max_width_dots || null, + config.print_abandoned_calls !== false ? 1 : 0, now, id ); @@ -647,6 +679,7 @@ class DatabaseManager { show_timestamps: row.show_timestamps === 1, logo_path: row.logo_path, logo_max_width_dots: row.logo_max_width_dots, + print_abandoned_calls: row.print_abandoned_calls == null ? true : row.print_abandoned_calls === 1, created_at: row.created_at, updated_at: row.updated_at }; @@ -710,6 +743,128 @@ class DatabaseManager { } } + migratePrintersAddAbandonedCalls() { + try { + const cols = this.db.pragma('table_info(printers)'); + const hasCol = cols.some(c => c.name === 'print_abandoned_calls'); + if (!hasCol) { + this.db.exec('ALTER TABLE printers ADD COLUMN print_abandoned_calls INTEGER NOT NULL DEFAULT 1'); + console.log('Migration: added print_abandoned_calls column to printers'); + } + } catch (err) { + console.error('Migration error (print_abandoned_calls):', err.message); + } + } + + getAbandonedCallPrinters() { + const rows = this.db.prepare( + 'SELECT * FROM printers WHERE is_enabled = 1 AND print_abandoned_calls = 1 ORDER BY is_default DESC, name ASC' + ).all(); + return rows.map(row => this.mapPrinterRow(row)); + } + + // Abandoned call print tracking + hasAbandonedCallPrint(abandonedCallId) { + const row = this.db.prepare( + 'SELECT 1 FROM abandoned_call_prints WHERE abandoned_call_id = ? LIMIT 1' + ).get(abandonedCallId); + return !!row; + } + + addAbandonedCallPrint(abandonedCallId, printerCount = 0) { + const now = Math.floor(Date.now() / 1000); + try { + this.db.prepare( + 'INSERT OR IGNORE INTO abandoned_call_prints (abandoned_call_id, printed_at, printer_count) VALUES (?, ?, ?)' + ).run(abandonedCallId, now, printerCount); + } catch (_) {} + } + + getLastAbandonedCallPrintTime() { + const row = this.db.prepare( + 'SELECT printed_at FROM abandoned_call_prints ORDER BY printed_at DESC LIMIT 1' + ).get(); + return row ? row.printed_at : 0; + } + + // Abandoned calls cache + cacheAbandonedCall(remoteId, data) { + const now = Math.floor(Date.now() / 1000); + const json = typeof data === 'string' ? data : JSON.stringify(data); + const callbackStatus = (typeof data === 'object' ? data.callback_status : null) || null; + const callbackPriority = (typeof data === 'object' ? data.callback_priority : null) || null; + const callbackScore = (typeof data === 'object' ? Number(data.callback_score) : 0) || 0; + this.db.prepare(` + INSERT INTO abandoned_calls_cache (remote_id, data, callback_status, callback_priority, callback_score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(remote_id) DO UPDATE SET + data = excluded.data, + callback_status = excluded.callback_status, + callback_priority = excluded.callback_priority, + callback_score = excluded.callback_score, + updated_at = excluded.updated_at + `).run(remoteId, json, callbackStatus, callbackPriority, callbackScore, now, now); + } + + getCachedAbandonedCalls(options = {}) { + let query = 'SELECT * FROM abandoned_calls_cache WHERE 1=1'; + const params = []; + if (options.status) { + query += ' AND callback_status = ?'; + params.push(options.status); + } + if (options.priority) { + query += ' AND callback_priority = ?'; + params.push(options.priority); + } + query += ' ORDER BY callback_score DESC, updated_at DESC'; + if (options.limit) { + query += ' LIMIT ?'; + params.push(options.limit); + } + const rows = this.db.prepare(query).all(...params); + return rows.map(row => { + try { return JSON.parse(row.data); } catch (_) { return null; } + }).filter(Boolean); + } + + updateCachedCallbackStatus(remoteId, status) { + const now = Math.floor(Date.now() / 1000); + this.db.prepare( + 'UPDATE abandoned_calls_cache SET callback_status = ?, updated_at = ? WHERE remote_id = ?' + ).run(status, now, remoteId); + } + + getPendingAbandonedCallCount() { + const row = this.db.prepare( + "SELECT COUNT(*) as count FROM abandoned_calls_cache WHERE callback_status IN ('queued', 'deferred')" + ).get(); + return row ? row.count : 0; + } + + getAbandonedCallStats() { + const row = this.db.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN callback_status IN ('queued','deferred') THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN callback_status = 'converted' THEN 1 ELSE 0 END) as converted, + SUM(CASE WHEN callback_status = 'dismissed' THEN 1 ELSE 0 END) as dismissed + FROM abandoned_calls_cache + `).get(); + return { + total: (row && row.total) || 0, + pending: (row && row.pending) || 0, + converted: (row && row.converted) || 0, + dismissed: (row && row.dismissed) || 0 + }; + } + + cleanOldAbandonedCallCache(maxAgeDays = 7) { + const cutoff = Math.floor(Date.now() / 1000) - (maxAgeDays * 86400); + this.db.prepare('DELETE FROM abandoned_calls_cache WHERE updated_at < ?').run(cutoff); + this.db.prepare('DELETE FROM abandoned_call_prints WHERE printed_at < ?').run(cutoff); + } + close() { if (this.db) { this.db.close(); diff --git a/package.json b/package.json index 5c437f3..600c768 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kitchen-agent", - "version": "1.0.5", + "version": "1.0.6", "description": "Kitchen Agent for ThinkLink Food Order Management", "main": "server.js", "scripts": { diff --git a/printer.js b/printer.js index eb90dbe..17c2b24 100644 --- a/printer.js +++ b/printer.js @@ -1380,16 +1380,25 @@ class PrinterManager { doc.moveDown(0.4); doc.fontSize(12).text('ITEMS:'); doc.fontSize(11); + const includePrices = (cfg.showPrices === 'true' || !cfg.showPrices); 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) + const line = includePrices ? `${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}`)); + item.addons.forEach(addon => { + const addonName = addon.name || addon; + const addonPrice = Number(addon && addon.price); + if (includePrices && isFinite(addonPrice) && addonPrice > 0) { + doc.text(` + ${addonName} - $${addonPrice.toFixed(2)}`); + } else { + doc.text(` + ${addonName}`); + } + }); } if (item.exclude && Array.isArray(item.exclude)) { item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`)); @@ -1540,7 +1549,15 @@ class PrinterManager { : `${qty}x ${itemName}`; doc.text(line, { width: maxWidth }); if (item.addons && Array.isArray(item.addons)) { - item.addons.forEach(addon => doc.text(` + ${addon.name || addon}`)); + item.addons.forEach(addon => { + const addonName = addon.name || addon; + const addonPrice = Number(addon && addon.price); + if (mergedShowPrices && isFinite(addonPrice) && addonPrice > 0) { + doc.text(` + ${addonName} - $${addonPrice.toFixed(2)}`); + } else { + doc.text(` + ${addonName}`); + } + }); } if (item.exclude && Array.isArray(item.exclude)) { item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`)); @@ -1751,6 +1768,480 @@ class PrinterManager { return []; } } + + // ===== Abandoned Call Receipt ===== + + async renderAbandonedCallReceipt(instance, call, cfg) { + const width = parseInt(cfg.paper_width, 10) || 48; + const priority = call.callback_priority || 'low'; + const score = Number(call.callback_score) || 0; + const isNarrow = width < 40; + + this.safeCallOn(instance, 'clear'); + + // Logo + const logoPath = cfg.logo_path || cfg.logoPath || null; + if (logoPath && fs.existsSync(logoPath)) { + try { + const maxDots = this.getPrinterMaxDotsWidthFromConfig(cfg); + await this.printLogoWithFitCustom(instance, logoPath, maxDots); + this.safeCallOn(instance, 'newLine'); + } catch (_) {} + } + + // Top border + this.safeCallOn(instance, 'drawLine'); + + // Title: "ABANDONED CALL" in large centered text + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'bold', true); + instance.println('ABANDONED CALL'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + + // Priority badge using inverted text for visual prominence + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + if (priority === 'critical') { + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'invert', true); + const pad = isNarrow ? ' ' : ' '; + instance.println(`${pad}CRITICAL PRIORITY${pad}`); + this.safeCallOn(instance, 'invert', false); + this.safeCallOn(instance, 'setTextNormal'); + } else if (priority === 'high') { + this.safeCallOn(instance, 'invert', true); + const pad = isNarrow ? ' ' : ' '; + instance.println(`${pad}HIGH PRIORITY${pad}`); + this.safeCallOn(instance, 'invert', false); + } else if (priority === 'medium') { + instance.println('MEDIUM PRIORITY'); + } else { + instance.println(`Priority: ${priority.toUpperCase()}`); + } + this.safeCallOn(instance, 'bold', false); + + this.safeCallOn(instance, 'drawLine'); + + // Timestamp + const callTime = call.call_started_at + ? new Date(Number(call.call_started_at) * 1000) + : new Date(); + const timeStr = callTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + const dateStr = callTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + + this.safeCallOn(instance, 'alignCenter'); + instance.println(`${dateStr} ${timeStr}`); + this.safeCallOn(instance, 'newLine'); + + // Caller section + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('CALLER'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const phone = call.caller_phone_normalized || call.caller_phone || 'Unknown'; + const name = call.caller_name || ''; + + this.safeCallOn(instance, 'setTextSize', 1, 0); + this.safeCallOn(instance, 'bold', true); + instance.println(`Phone: ${phone}`); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'setTextNormal'); + if (name) { + instance.println(`Name: ${name}`); + } + + if (call.is_known_customer) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'invert', true); + this.safeCallOn(instance, 'bold', true); + const orderCount = Number(call.previous_order_count || 0); + instance.println(` RETURNING CUSTOMER ${orderCount} previous order${orderCount !== 1 ? 's' : ''} `); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'invert', false); + } + + instance.println(`Callback Score: ${score}/100`); + + this.safeCallOn(instance, 'newLine'); + + // What happened section + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('WHAT HAPPENED'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const stageMessages = { + ring_only: 'Rang but hung up before AI could answer.', + greeting_hangup: 'Heard the greeting then disconnected.', + silent_post_greeting: `Stayed on for ${Math.round(Number(call.time_after_greeting_sec || 0))}s without speaking.`, + minimal_speech: 'Spoke briefly then disconnected.', + pre_intent: 'Began speaking but intent was unclear.', + intent_identified: 'Expressed interest in ordering but disconnected.', + partial_order: 'Started placing an order then disconnected.', + partial_appointment: 'Was booking an appointment then disconnected.', + pre_confirmation: 'Order was nearly complete before disconnecting.', + system_failure: 'A system error caused the disconnection.' + }; + + const stageMsg = stageMessages[call.abandonment_stage] || 'Call ended before completion.'; + instance.println(stageMsg); + + const dur = Number(call.duration_seconds || 0); + const durLabel = dur >= 60 + ? `${Math.floor(dur / 60)}m ${dur % 60}s` + : `${dur}s`; + instance.println(`Call duration: ${durLabel}`); + + this.safeCallOn(instance, 'newLine'); + + // Partial order items + const items = call.items || []; + let parsedSnapshot = null; + if (items.length === 0 && call.partial_order_snapshot) { + try { + parsedSnapshot = typeof call.partial_order_snapshot === 'string' + ? JSON.parse(call.partial_order_snapshot) + : call.partial_order_snapshot; + } catch (_) {} + } + + const displayItems = items.length > 0 + ? items + : (parsedSnapshot && parsedSnapshot.orderData && parsedSnapshot.orderData.items) || []; + + if (displayItems.length > 0) { + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('ITEMS MENTIONED'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const colWidths = this.getColumnWidths(cfg); + for (const item of displayItems) { + const itemName = item.item_name || item.itemName || item.name || 'Unknown'; + const qty = item.quantity || item.qty || 1; + const price = item.unit_price || item.price || null; + + if (price && colWidths.useTable) { + try { + instance.tableCustom([ + { text: `${qty}x ${itemName}`, align: 'LEFT', width: colWidths.itemWidth }, + { text: `$${Number(price).toFixed(2)}`, align: 'RIGHT', width: colWidths.priceWidth } + ]); + } catch (_) { + instance.println(` ${qty}x ${itemName} $${Number(price).toFixed(2)}`); + } + } else { + instance.println(` ${qty}x ${itemName}`); + } + } + + if (call.partial_order_value || call.estimated_order_value) { + const est = Number(call.partial_order_value || call.estimated_order_value); + if (est > 0) { + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignRight'); + this.safeCallOn(instance, 'bold', true); + instance.println(`Est. Value: $${est.toFixed(2)}`); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'alignLeft'); + } + } + this.safeCallOn(instance, 'newLine'); + } + + // LLM callback script + if (call.llm_callback_script) { + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'bold', true); + instance.println('SUGGESTED CALLBACK SCRIPT'); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignLeft'); + + const script = String(call.llm_callback_script); + const maxLineLen = width || 48; + const words = script.split(' '); + let line = ''; + for (const word of words) { + if ((line + ' ' + word).trim().length > maxLineLen) { + instance.println(line.trim()); + line = word; + } else { + line += (line ? ' ' : '') + word; + } + } + if (line.trim()) instance.println(line.trim()); + this.safeCallOn(instance, 'newLine'); + } + + // Action section + if (score >= 60) { + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignCenter'); + this.safeCallOn(instance, 'setTextSize', 1, 1); + this.safeCallOn(instance, 'invert', true); + this.safeCallOn(instance, 'bold', true); + instance.println(' CALLBACK RECOMMENDED '); + this.safeCallOn(instance, 'bold', false); + this.safeCallOn(instance, 'invert', false); + this.safeCallOn(instance, 'setTextNormal'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'alignLeft'); + instance.println('[ ] Called back'); + instance.println('[ ] No answer'); + instance.println('[ ] Skipped'); + } + + // Footer + this.safeCallOn(instance, 'drawLine'); + this.safeCallOn(instance, 'alignCenter'); + instance.println(`Ref #${call.id || ''}`); + instance.println('Powered by Think Link AI'); + this.safeCallOn(instance, 'drawLine'); + + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'newLine'); + this.safeCallOn(instance, 'partialCut'); + } + + async printAbandonedCallReceipt(call, printerConfigs) { + if (!printerConfigs || printerConfigs.length === 0) { + console.log('[PrinterManager] No printers configured for abandoned call receipts'); + return { success: false, successCount: 0 }; + } + + const callId = call && call.id ? call.id : 'unknown'; + console.log(`[PrinterManager] Printing abandoned call #${callId} to ${printerConfigs.length} printer(s)`); + + const jobs = printerConfigs.map(async (cfg, idx) => { + console.log(`[PrinterManager] Abandoned call print ${idx + 1}/${printerConfigs.length}: ${cfg.name}`); + + if (cfg.type === 'system') { + const filePath = path.join(os.tmpdir(), `kitchen-agent-abandoned-${callId}-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`); + try { + await this.writeAbandonedCallPdfToPath(filePath, call, cfg); + await pdfPrinter.print(filePath, { printer: cfg.interface }); + await new Promise(resolve => setTimeout(resolve, 2000)); + } finally { + try { fs.unlinkSync(filePath); } catch (_) {} + } + } else { + const instance = this.createPrinterInstanceFromConfig(cfg); + await this.renderAbandonedCallReceipt(instance, call, cfg); + + let normalizedInterface = cfg.interface; + if (cfg.type === 'network') { + if (!normalizedInterface.startsWith('tcp://')) { + const hasPort = normalizedInterface.includes(':'); + normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`; + } + } else if (cfg.type === 'com' || cfg.type === 'serial') { + if (/^COM\d+$/i.test(normalizedInterface)) { + normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`; + } + } + + await this.executeForInstance(instance, normalizedInterface); + } + console.log(`[PrinterManager] Abandoned call printed to: ${cfg.name}`); + }); + + const results = await Promise.allSettled(jobs); + const successCount = results.filter(r => r.status === 'fulfilled').length; + const failCount = results.length - successCount; + console.log(`[PrinterManager] Abandoned call #${callId}: ${successCount} succeeded, ${failCount} failed`); + + return { success: successCount > 0, successCount }; + } + + async writeAbandonedCallPdfToPath(filePath, call, cfg) { + const doc = new PDFDocument({ size: [226, 800], margin: 10 }); + const pageWidth = 206; // 226 - 2*10 margin + return new Promise((resolve, reject) => { + const stream = fs.createWriteStream(filePath); + doc.pipe(stream); + + const priority = call.callback_priority || 'low'; + const score = Number(call.callback_score) || 0; + + // Top rule + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.4); + + // Title + doc.fontSize(16).font('Helvetica-Bold').text('ABANDONED CALL', { align: 'center' }); + doc.moveDown(0.2); + + // Priority badge - draw a filled rectangle behind text for critical/high + if (priority === 'critical' || priority === 'high') { + const badgeText = priority === 'critical' ? 'CRITICAL PRIORITY' : 'HIGH PRIORITY'; + const badgeWidth = doc.widthOfString(badgeText, { fontSize: 11 }) + 16; + const badgeX = (226 - badgeWidth) / 2; + const badgeY = doc.y; + doc.save(); + doc.rect(badgeX, badgeY, badgeWidth, 16).fill('#000'); + doc.fontSize(11).font('Helvetica-Bold').fillColor('#fff') + .text(badgeText, 10, badgeY + 3, { align: 'center', width: pageWidth }); + doc.fillColor('#000').restore(); + doc.y = badgeY + 20; + } else { + const label = priority === 'medium' ? 'MEDIUM PRIORITY' : `Priority: ${priority.toUpperCase()}`; + doc.fontSize(10).font('Helvetica-Bold').text(label, { align: 'center' }); + } + doc.moveDown(0.3); + + // Divider + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.3); + + // Timestamp + const callTime = call.call_started_at ? new Date(Number(call.call_started_at) * 1000) : new Date(); + const timeStr = callTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + const dateStr = callTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + doc.fontSize(9).font('Helvetica').text(`${dateStr} ${timeStr}`, { align: 'center' }); + doc.moveDown(0.3); + + // Caller section header + doc.fontSize(10).font('Helvetica-Bold').text('CALLER', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + + // Phone number prominent + doc.fontSize(12).font('Helvetica-Bold').text(`Phone: ${call.caller_phone_normalized || call.caller_phone || 'Unknown'}`, { align: 'left' }); + doc.font('Helvetica'); + if (call.caller_name) { + doc.fontSize(10).text(`Name: ${call.caller_name}`); + } + + if (call.is_known_customer) { + doc.moveDown(0.15); + const custText = `RETURNING CUSTOMER ${call.previous_order_count || 0} previous orders`; + const custWidth = doc.widthOfString(custText, { fontSize: 8 }) + 10; + const custX = 10; + const custY = doc.y; + doc.save(); + doc.rect(custX, custY, Math.min(custWidth, pageWidth), 13).fill('#000'); + doc.fontSize(8).font('Helvetica-Bold').fillColor('#fff') + .text(custText, custX + 4, custY + 3, { width: pageWidth - 8 }); + doc.fillColor('#000').restore(); + doc.y = custY + 16; + } + + doc.fontSize(9).font('Helvetica').text(`Callback Score: ${score}/100`); + doc.moveDown(0.3); + + // What happened section + doc.fontSize(10).font('Helvetica-Bold').text('WHAT HAPPENED', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + + const stageMessages = { + ring_only: 'Rang but hung up before AI could answer.', + greeting_hangup: 'Heard the greeting then disconnected.', + silent_post_greeting: 'Stayed on without speaking.', + minimal_speech: 'Spoke briefly then disconnected.', + pre_intent: 'Began speaking but intent was unclear.', + intent_identified: 'Expressed interest in ordering but disconnected.', + partial_order: 'Started placing an order then disconnected.', + partial_appointment: 'Was booking an appointment then disconnected.', + pre_confirmation: 'Order was nearly complete before disconnecting.', + system_failure: 'A system error caused the disconnection.' + }; + + doc.fontSize(9).font('Helvetica').text(stageMessages[call.abandonment_stage] || 'Call ended before completion.'); + const dur = Number(call.duration_seconds || 0); + const durLabel = dur >= 60 ? `${Math.floor(dur / 60)}m ${dur % 60}s` : `${dur}s`; + doc.text(`Call duration: ${durLabel}`); + doc.moveDown(0.3); + + // Items mentioned + const items = call.items || []; + let parsedSnapshot = null; + if (items.length === 0 && call.partial_order_snapshot) { + try { + parsedSnapshot = typeof call.partial_order_snapshot === 'string' + ? JSON.parse(call.partial_order_snapshot) + : call.partial_order_snapshot; + } catch (_) {} + } + const displayItems = items.length > 0 + ? items + : (parsedSnapshot && parsedSnapshot.orderData && parsedSnapshot.orderData.items) || []; + + if (displayItems.length > 0) { + doc.fontSize(10).font('Helvetica-Bold').text('ITEMS MENTIONED', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + doc.fontSize(9).font('Helvetica'); + for (const item of displayItems) { + const itemName = item.item_name || item.itemName || item.name || 'Unknown'; + const qty = item.quantity || item.qty || 1; + const price = item.unit_price || item.price || null; + const line = price ? `${qty}x ${itemName} $${Number(price).toFixed(2)}` : `${qty}x ${itemName}`; + doc.text(line); + } + if (call.partial_order_value || call.estimated_order_value) { + const est = Number(call.partial_order_value || call.estimated_order_value); + if (est > 0) { + doc.moveDown(0.15); + doc.font('Helvetica-Bold').text(`Est. Value: $${est.toFixed(2)}`, { align: 'right' }); + doc.font('Helvetica'); + } + } + doc.moveDown(0.3); + } + + // Callback script + if (call.llm_callback_script) { + doc.fontSize(10).font('Helvetica-Bold').text('SUGGESTED CALLBACK SCRIPT', { align: 'center' }); + doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke(); + doc.moveDown(0.3); + doc.fontSize(9).font('Helvetica').text(call.llm_callback_script, { width: pageWidth }); + doc.moveDown(0.3); + } + + // Action section + if (score >= 60) { + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.3); + const actionText = 'CALLBACK RECOMMENDED'; + const actionWidth = doc.widthOfString(actionText, { fontSize: 12 }) + 20; + const actionX = (226 - actionWidth) / 2; + const actionY = doc.y; + doc.save(); + doc.rect(actionX, actionY, actionWidth, 18).fill('#000'); + doc.fontSize(12).font('Helvetica-Bold').fillColor('#fff') + .text(actionText, 10, actionY + 4, { align: 'center', width: pageWidth }); + doc.fillColor('#000').restore(); + doc.y = actionY + 22; + doc.moveDown(0.2); + doc.fontSize(9).font('Helvetica').fillColor('#000'); + doc.text('[ ] Called back [ ] No answer [ ] Skipped'); + } + + // Footer + doc.moveDown(0.3); + doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke(); + doc.moveDown(0.2); + doc.fontSize(8).font('Helvetica').text(`Ref #${call.id || ''}`, { align: 'center' }); + doc.text('Powered by Think Link AI', { align: 'center' }); + + doc.end(); + stream.on('finish', resolve); + stream.on('error', reject); + }); + } } module.exports = new PrinterManager(); diff --git a/public/css/style.css b/public/css/style.css index a62e43e..6d39229 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1602,3 +1602,388 @@ button, a, input, select, textarea { } } +/* ===== Abandoned Calls ===== */ + +/* Dashboard notification badge */ +.abandoned-calls-link { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.abandoned-calls-icon { + font-size: 16px; +} + +.abandoned-calls-badge { + background: #dc3545; + color: #fff; + font-size: 11px; + font-weight: 700; + min-width: 20px; + height: 20px; + line-height: 20px; + text-align: center; + border-radius: 10px; + padding: 0 6px; +} + +.abandoned-calls-link.pulse { + animation: badgePulse 0.6s ease-in-out 3; +} + +@keyframes badgePulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +/* Back link in header */ +.header-title-group { + display: flex; + align-items: center; + gap: 12px; +} + +.back-link { + font-size: 24px; + color: #fff; + text-decoration: none; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 8px; + background: rgba(255,255,255,0.15); + transition: background 0.2s; +} + +.back-link:hover { + background: rgba(255,255,255,0.25); +} + +/* Abandoned call cards */ +.abandoned-call-card { + border-left-width: 5px; + border-left-style: solid; + cursor: pointer; + transition: box-shadow 0.2s, transform 0.15s; +} + +.abandoned-call-card:active { + transform: scale(0.99); +} + +.abandoned-call-card.priority-critical { border-left-color: #dc3545; } +.abandoned-call-card.priority-high { border-left-color: #fd7e14; } +.abandoned-call-card.priority-medium { border-left-color: #ffc107; } +.abandoned-call-card.priority-low { border-left-color: #adb5bd; } +.abandoned-call-card.priority-none { border-left-color: #dee2e6; } + +.abandoned-call-card.ac-done { + opacity: 0.65; +} + +/* Card header */ +.ac-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.ac-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.ac-priority-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-critical { background: #dc3545; color: #fff; } +.badge-high { background: #fd7e14; color: #fff; } +.badge-medium { background: #ffc107; color: #333; } +.badge-low { background: #e9ecef; color: #666; } +.badge-none { background: #f8f9fa; color: #999; } + +.ac-time-ago { + font-size: 13px; + color: #888; +} + +.ac-score { + font-size: 13px; + font-weight: 600; + color: #555; +} + +/* Caller info */ +.ac-caller-info { + margin: 10px 0 6px; +} + +.ac-phone { + font-size: 18px; + font-weight: 700; + color: #333; + letter-spacing: 0.5px; +} + +.ac-name { + font-size: 14px; + color: #555; + margin-top: 2px; +} + +.known-customer-badge { + display: inline-block; + margin-top: 4px; + padding: 2px 8px; + background: #e8f5e9; + color: #2e7d32; + border-radius: 10px; + font-size: 12px; + font-weight: 600; +} + +/* Stage and duration */ +.ac-stage { + font-size: 14px; + color: #444; + margin: 8px 0 2px; + line-height: 1.4; +} + +.ac-duration { + font-size: 12px; + color: #999; + margin-bottom: 6px; +} + +/* Items */ +.ac-items { + background: #f8f9fa; + border-radius: 6px; + padding: 8px 10px; + margin: 6px 0; +} + +.ac-items-title { + font-size: 12px; + font-weight: 600; + color: #666; + margin-bottom: 4px; +} + +.ac-item { + font-size: 13px; + color: #333; + padding: 1px 0; +} + +.ac-item-value { + font-size: 13px; + font-weight: 600; + color: #28a745; + margin-top: 4px; +} + +/* LLM summary */ +.ac-summary { + font-size: 13px; + color: #555; + font-style: italic; + margin: 6px 0; + line-height: 1.4; +} + +/* Callback script */ +.ac-callback-script { + background: #fff8e1; + border-left: 3px solid #ffc107; + border-radius: 4px; + padding: 8px 10px; + margin: 8px 0; +} + +.ac-script-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + color: #e65100; + margin-bottom: 3px; +} + +.ac-script-text { + font-size: 13px; + color: #333; + line-height: 1.4; +} + +/* Actions */ +.ac-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +.ac-action-btn { + min-height: 44px; + min-width: 44px; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + border-radius: 8px; + flex: 1 1 auto; +} + +.ac-action-converted { + border: 2px solid #28a745; +} + +.ac-action-reprint { + flex: 0 0 44px; + padding: 8px; + font-size: 16px; +} + +.ac-status-done { + font-size: 13px; + font-weight: 600; + color: #666; + text-transform: capitalize; + padding: 8px 0; +} + +/* Stats for abandoned calls page */ +.stat-abandoned-pending { + border-left: 4px solid #dc3545; +} + +.stat-converted { + border-left: 4px solid #28a745; +} + +/* Controls layout */ +.controls-right { + display: flex; + align-items: center; + gap: 12px; +} + +/* Toggle switch */ +.toggle-switch { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + padding: 6px 0; +} + +.toggle-switch input { + display: none; +} + +.toggle-slider { + position: relative; + width: 44px; + height: 24px; + background: #ccc; + border-radius: 12px; + transition: background 0.25s; + flex-shrink: 0; +} + +.toggle-slider::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: #fff; + border-radius: 50%; + transition: transform 0.25s; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.toggle-switch input:checked + .toggle-slider { + background: #667eea; +} + +.toggle-switch input:checked + .toggle-slider::after { + transform: translateX(20px); +} + +.toggle-label { + font-size: 13px; + color: #666; + white-space: nowrap; +} + +/* Filter button colors for abandoned calls page */ +.filter-critical { color: #dc3545; } +.filter-critical.active { background: #dc3545; color: #fff; } +.filter-high { color: #fd7e14; } +.filter-high.active { background: #fd7e14; color: #fff; } +.filter-medium { color: #856404; } +.filter-medium.active { background: #ffc107; color: #333; } + +/* Responsive */ +@media (max-width: 768px) { + .ac-phone { + font-size: 16px; + } + + .ac-action-btn { + font-size: 12px; + padding: 8px 10px; + } + + .back-link { + width: 38px; + height: 38px; + font-size: 20px; + } +} + +@media (max-width: 480px) { + .ac-actions { + gap: 4px; + } + + .ac-action-btn { + min-height: 40px; + padding: 6px 8px; + font-size: 11px; + } + + .abandoned-calls-link span:not(.abandoned-calls-badge):not(.abandoned-calls-icon) { + display: none; + } + + .controls-right { + flex-wrap: wrap; + gap: 8px; + } + + .toggle-label { + font-size: 12px; + } +} + diff --git a/public/js/abandoned-calls.js b/public/js/abandoned-calls.js new file mode 100644 index 0000000..639dc83 --- /dev/null +++ b/public/js/abandoned-calls.js @@ -0,0 +1,499 @@ +let currentFilter = 'all'; +let showProcessed = false; +let previousCallIds = new Set(); +let isFirstLoad = true; +let currentDetailCall = null; + +const PROCESSED_STATUSES = new Set(['converted', 'dismissed', 'reached', 'no_answer', 'attempted']); + +const connectionStatus = { + local: { status: 'checking', lastCheck: null, consecutiveFailures: 0 }, + api: { status: 'checking', lastCheck: null, consecutiveFailures: 0, responseTime: null } +}; + +const audioNotification = { + sound: null, + enabled: true, + init: function() { + fetch('/api/notification-settings') + .then(r => r.json()) + .then(data => { + if (!data.error) { + this.enabled = data.soundNotificationsEnabled !== 'false'; + const soundPath = data.newOrderSoundPath || '/public/sounds/new-order-notification.mp3'; + this.sound = new Audio(soundPath); + if (data.soundVolume) this.sound.volume = parseInt(data.soundVolume, 10) / 100; + } + }) + .catch(() => { this.sound = new Audio('/public/sounds/new-order-notification.mp3'); }); + }, + play: function() { + if (this.enabled && this.sound) { + this.sound.currentTime = 0; + this.sound.play().catch(() => {}); + } + } +}; + +document.addEventListener('DOMContentLoaded', function() { + setupFilterButtons(); + setupShowProcessedToggle(); + audioNotification.init(); + refreshAbandonedCalls(); + setInterval(refreshAbandonedCalls, config.refreshInterval || 15000); + checkConnectionStatus(); + setInterval(checkConnectionStatus, 15000); +}); + +function setupShowProcessedToggle() { + const checkbox = document.getElementById('showProcessedCheckbox'); + if (checkbox) { + checkbox.checked = showProcessed; + checkbox.addEventListener('change', function() { + showProcessed = this.checked; + refreshAbandonedCalls(); + }); + } +} + +function setupFilterButtons() { + document.querySelectorAll('.filter-btn').forEach(button => { + button.addEventListener('click', function() { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + currentFilter = this.dataset.filter; + refreshAbandonedCalls(); + }); + }); +} + +function refreshAbandonedCalls() { + 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 = 'Loading...'; + + let url; + if (currentFilter === 'queued') { + url = '/api/abandoned-calls/callback-queue?limit=50'; + } else if (currentFilter === 'all') { + url = '/api/abandoned-calls?limit=100'; + } else { + url = '/api/abandoned-calls?limit=100&priority=' + currentFilter; + } + + fetch(url) + .then(r => r.json()) + .then(data => { + if (!data.error) { + let calls = data.queue || data.calls || []; + + if (!showProcessed) { + calls = calls.filter(c => !PROCESSED_STATUSES.has(c.callback_status)); + } + + updateCards(calls); + refreshStats(); + } + }) + .catch(err => console.error('Failed to load abandoned calls:', err)) + .finally(() => { + if (syncButton) { syncButton.classList.remove('loading'); syncButton.disabled = false; } + if (syncText) syncText.textContent = 'Refresh'; + }); +} + +function refreshStats() { + fetch('/api/abandoned-calls/pending-count') + .then(r => r.json()) + .then(data => { + if (!data.error) { + const el = document.getElementById('stat-pending'); + if (el) el.textContent = data.count || 0; + } + }) + .catch(() => {}); +} + +function updateCards(calls) { + const container = document.getElementById('abandonedCallsContainer'); + if (!calls || calls.length === 0) { + container.innerHTML = '
No missed calls to display
'; + return; + } + + if (!isFirstLoad) { + const currentIds = new Set(calls.map(c => c.id)); + for (const call of calls) { + if (!previousCallIds.has(call.id)) { + const p = call.callback_priority; + if (p === 'critical' || p === 'high') { + audioNotification.play(); + break; + } + } + } + previousCallIds = currentIds; + } else { + previousCallIds = new Set(calls.map(c => c.id)); + isFirstLoad = false; + } + + container.innerHTML = calls.map(call => createCard(call)).join(''); +} + +function createCard(call) { + const priority = call.callback_priority || 'low'; + const score = Number(call.callback_score) || 0; + const stage = call.abandonment_stage || 'unknown'; + const status = call.callback_status || 'queued'; + const phone = call.caller_phone_normalized || call.caller_phone || 'Unknown'; + const name = call.caller_name || ''; + const isKnown = call.is_known_customer; + const prevOrders = Number(call.previous_order_count) || 0; + const duration = Number(call.duration_seconds) || 0; + + const timeAgo = getTimeAgo(call.call_started_at || call.created_at); + + const stageMessages = { + ring_only: 'Called but hung up before AI answered.', + greeting_hangup: 'Heard AI greeting, hung up immediately.', + silent_post_greeting: 'Listened but never spoke — likely uncomfortable with AI.', + minimal_speech: 'Said very little then hung up.', + pre_intent: 'Spoke briefly but intent unclear.', + intent_identified: 'Wanted to order/book but hung up.', + partial_order: 'Started an order then hung up!', + partial_appointment: 'Was booking an appointment then hung up!', + pre_confirmation: 'Order nearly complete — hung up before confirming.', + system_failure: 'System error caused disconnection.' + }; + const stageMsg = stageMessages[stage] || 'Call ended unexpectedly.'; + + let knownBadge = ''; + if (isKnown) { + knownBadge = `Known Customer · ${prevOrders} orders`; + } + + let itemsHtml = ''; + const items = call.items || []; + if (items.length > 0) { + itemsHtml = '
Items mentioned:
'; + for (const item of items) { + const iname = item.item_name || item.itemName || item.name || 'Unknown'; + const qty = item.quantity || item.qty || 1; + itemsHtml += `
${qty}x ${iname}
`; + } + if (call.partial_order_value || call.estimated_order_value) { + const val = Number(call.partial_order_value || call.estimated_order_value); + if (val > 0) itemsHtml += `
Est. value: $${val.toFixed(2)}
`; + } + itemsHtml += '
'; + } + + let scriptHtml = ''; + if (call.llm_callback_script) { + scriptHtml = ` +
+
Say this:
+
${escapeHtml(call.llm_callback_script)}
+
`; + } + + let summaryHtml = ''; + if (call.llm_summary) { + summaryHtml = `
${escapeHtml(call.llm_summary)}
`; + } + + const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status); + + let actionsHtml = ''; + if (!isDone) { + actionsHtml = ` +
+ + + + + + + +
`; + } else { + actionsHtml = ` +
+ Status: ${status.replace('_', ' ')} + +
`; + } + + return ` +
+
+
+ ${priority.toUpperCase()} + ${timeAgo} +
+
Score: ${score}/100
+
+ +
+
${escapeHtml(phone)}
+ ${name ? `
${escapeHtml(name)}
` : ''} + ${knownBadge} +
+ +
${stageMsg}
+
Duration: ${duration}s
+ + ${itemsHtml} + ${summaryHtml} + ${scriptHtml} + ${actionsHtml} +
+ `; +} + +function handleAction(callId, action) { + event.stopPropagation(); + fetch(`/api/abandoned-calls/${callId}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }) + .then(r => r.json()) + .then(data => { + if (!data.error) { + const labels = { + call_back: 'Marked as called back', + reached: 'Marked as reached', + no_answer: 'Marked as no answer', + converted: 'Marked as converted!', + dismissed: 'Dismissed', + deferred: 'Deferred for later' + }; + showToast(labels[action] || 'Updated', 'success'); + refreshAbandonedCalls(); + } else { + showToast(data.message || 'Action failed', 'error'); + } + }) + .catch(() => showToast('Action failed', 'error')); +} + +function reprintReceipt(callId) { + event.stopPropagation(); + fetch(`/api/abandoned-calls/${callId}/reprint`, { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (!data.error) { + showToast(data.message || 'Sent to printer', 'success'); + } else { + showToast(data.message || 'Print failed', 'error'); + } + }) + .catch(() => showToast('Print failed', 'error')); +} + +function showDetails(callId, event) { + if (event && (event.target.tagName === 'BUTTON' || event.target.closest('button'))) return; + + fetch('/api/abandoned-calls?limit=200') + .then(r => r.json()) + .then(data => { + const calls = data.calls || data.queue || []; + const call = calls.find(c => c.id === callId); + if (call) displayDetails(call); + }) + .catch(() => {}); +} + +function displayDetails(call) { + currentDetailCall = call; + const content = document.getElementById('callDetailsContent'); + const actions = document.getElementById('detailModalActions'); + const priority = call.callback_priority || 'low'; + const score = Number(call.callback_score) || 0; + const status = call.callback_status || 'queued'; + + const callTime = call.call_started_at ? new Date(Number(call.call_started_at) * 1000) : null; + const timeStr = callTime ? callTime.toLocaleString('en-US') : 'N/A'; + + let itemsHtml = ''; + const items = call.items || []; + if (items.length > 0) { + itemsHtml = '

Partial Order Items

'; + if (call.partial_order_value) { + itemsHtml += `

Est. Value: $${Number(call.partial_order_value).toFixed(2)}

`; + } + } + + content.innerHTML = ` +
+
+

Call Information

+

ID: ${call.id}

+

Time: ${timeStr}

+

Duration: ${call.duration_seconds || 0}s

+

Priority: ${priority.toUpperCase()}

+

Score: ${score}/100

+

Stage: ${call.abandonment_stage || 'N/A'}

+

Intent: ${call.detected_intent || 'N/A'}

+

Status: ${status}

+
+ +
+

Caller

+

Phone: ${escapeHtml(call.caller_phone_normalized || call.caller_phone || 'Unknown')}

+ ${call.caller_name ? `

Name: ${escapeHtml(call.caller_name)}

` : ''} +

Known Customer: ${call.is_known_customer ? 'Yes' : 'No'}

+ ${call.is_known_customer ? `

Previous Orders: ${call.previous_order_count || 0}

` : ''} +
+ + ${itemsHtml ? `
${itemsHtml}
` : ''} + + ${call.caller_speech_text ? ` +
+

Caller Speech

+

${escapeHtml(call.caller_speech_text)}

+
` : ''} + + ${call.llm_summary ? ` +
+

AI Analysis

+

Summary: ${escapeHtml(call.llm_summary)}

+ ${call.llm_sentiment ? `

Sentiment: ${call.llm_sentiment}

` : ''} + ${call.llm_hesitancy_analysis ? `

Hesitancy: ${escapeHtml(call.llm_hesitancy_analysis)}

` : ''} + ${call.llm_recovery_suggestion ? `

Recovery: ${escapeHtml(call.llm_recovery_suggestion)}

` : ''} +
` : ''} + + ${call.llm_callback_script ? ` +
+

Callback Script

+
+
${escapeHtml(call.llm_callback_script)}
+
+
` : ''} + + ${call.score_breakdown ? ` +
+

Score Breakdown

+
${typeof call.score_breakdown === 'string' ? call.score_breakdown : JSON.stringify(call.score_breakdown, null, 2)}
+
` : ''} +
+ `; + + const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status); + if (!isDone) { + actions.innerHTML = ` + + + + + + + + + `; + } else { + actions.innerHTML = ` + Status: ${status.replace('_', ' ')} + + + `; + } + + document.getElementById('detailsModal').classList.add('visible'); +} + +function handleActionFromModal(action) { + if (!currentDetailCall) return; + closeDetailsModal(); + handleAction(currentDetailCall.id, action); +} + +function closeDetailsModal() { + document.getElementById('detailsModal').classList.remove('visible'); + currentDetailCall = null; +} + +window.addEventListener('click', function(e) { + if (e.target === document.getElementById('detailsModal')) closeDetailsModal(); +}); +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') closeDetailsModal(); +}); + +function manualRefresh() { + refreshAbandonedCalls(); +} + +// Utility +function getTimeAgo(timestamp) { + if (!timestamp) return ''; + const ts = Number(timestamp); + const now = Math.floor(Date.now() / 1000); + const diff = now - ts; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + ' min ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// Connection monitoring (same pattern as dashboard) +function checkConnectionStatus() { + fetch('/api/health/local') + .then(r => r.ok ? r.json() : Promise.reject()) + .then(() => { + connectionStatus.local.consecutiveFailures = 0; + updateConnectionUI('local', 'online', 'Connected'); + }) + .catch(() => { + connectionStatus.local.consecutiveFailures++; + if (connectionStatus.local.consecutiveFailures >= 2) + updateConnectionUI('local', 'offline', 'Disconnected'); + }); + + fetch('/api/health/external') + .then(r => r.ok ? r.json() : Promise.reject()) + .then(data => { + if (data.status === 'online') { + connectionStatus.api.consecutiveFailures = 0; + updateConnectionUI('api', 'online', data.responseTime ? data.responseTime + 'ms' : 'Connected'); + } else if (data.status === 'unconfigured') { + updateConnectionUI('api', 'unconfigured', 'Not Configured'); + } else { + connectionStatus.api.consecutiveFailures++; + if (connectionStatus.api.consecutiveFailures >= 2) + updateConnectionUI('api', 'offline', data.message || 'Disconnected'); + } + }) + .catch(() => { + connectionStatus.api.consecutiveFailures++; + if (connectionStatus.api.consecutiveFailures >= 2) + updateConnectionUI('api', 'offline', 'Check Failed'); + }); +} + +function updateConnectionUI(type, status, label) { + const elId = type === 'local' ? 'localConnectionStatus' : 'apiConnectionStatus'; + const labelId = type === 'local' ? 'localStatusLabel' : 'apiStatusLabel'; + const item = document.getElementById(elId); + const labelEl = document.getElementById(labelId); + if (!item || !labelEl) return; + const indicator = item.querySelector('.status-indicator'); + indicator.classList.remove('status-online', 'status-offline', 'status-checking', 'status-unconfigured'); + indicator.classList.add('status-' + status); + labelEl.textContent = label; +} diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 41225f6..9e02681 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -84,18 +84,50 @@ const audioNotification = { } }; +// Abandoned call badge tracking +let lastAbandonedCallCount = 0; + +function refreshAbandonedCallCount() { + fetch('/api/abandoned-calls/pending-count') + .then(r => r.json()) + .then(data => { + if (data.error) return; + const count = data.count || 0; + const link = document.getElementById('abandonedCallsLink'); + const badge = document.getElementById('abandonedCallsBadge'); + if (!link || !badge) return; + + if (count > 0) { + badge.style.display = ''; + badge.textContent = count; + if (count > lastAbandonedCallCount && lastAbandonedCallCount >= 0) { + link.classList.add('pulse'); + setTimeout(() => link.classList.remove('pulse'), 2000); + if (!isFirstLoad) { + audioNotification.playNewOrderSound(); + } + } + } else { + badge.style.display = 'none'; + } + lastAbandonedCallCount = count; + }) + .catch(() => {}); +} + // Initialize dashboard document.addEventListener('DOMContentLoaded', function() { setupFilterButtons(); audioNotification.init(); refreshOrders(); + refreshAbandonedCallCount(); // Set up auto-refresh setInterval(refreshOrders, config.dashboardRefreshInterval || 10000); + setInterval(refreshAbandonedCallCount, 30000); // Initialize connection monitoring checkConnectionStatus(); - // Check connection status every 15 seconds setInterval(checkConnectionStatus, 15000); }); diff --git a/public/js/settings.js b/public/js/settings.js index 2918179..097185e 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -206,6 +206,7 @@ function openAddPrinterModal() { document.getElementById('qr_code_enabled').checked = true; document.getElementById('is_enabled').checked = true; document.getElementById('is_default').checked = false; + document.getElementById('print_abandoned_calls').checked = true; showModal('printerConfigModal'); switchPrinterModalTab('connection'); @@ -268,6 +269,7 @@ async function editPrinter(id) { 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; + document.getElementById('print_abandoned_calls').checked = printer.print_abandoned_calls !== false; showModal('printerConfigModal'); switchPrinterModalTab('connection'); @@ -293,7 +295,7 @@ async function savePrinterConfig() { 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)) { + if (['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled', 'print_abandoned_calls'].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); @@ -304,7 +306,7 @@ async function savePrinterConfig() { } // 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) => { + ['show_customer_info', 'show_order_items', 'show_prices', 'show_timestamps', 'qr_code_enabled', 'is_default', 'is_enabled', 'print_abandoned_calls'].forEach((key) => { const el = document.getElementById(key); if (el) { config[key] = !!el.checked; diff --git a/routes/abandoned-calls.js b/routes/abandoned-calls.js new file mode 100644 index 0000000..ee4ca22 --- /dev/null +++ b/routes/abandoned-calls.js @@ -0,0 +1,229 @@ +const config = require('../config'); +const database = require('../database'); +const apiClient = require('../api-client'); +const printer = require('../printer'); + +// Page-level auth (redirects to login) +async function requireAuthPage(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'); + if (!token || apiClient.isTokenExpired(expiry)) { + reply.redirect('/login'); + return; + } + if (value === token) return; + const previousToken = config.get('previousAuthToken'); + if (previousToken && value === previousToken) { + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', token, { + signed: true, httpOnly: true, secure: isHttps, + sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/' + }); + return; + } + reply.redirect('/login'); +} + +// API-level auth (returns 401 JSON) +async function requireAuthApi(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'); + if (!token || apiClient.isTokenExpired(expiry)) { + return reply.code(401).send({ error: true, message: 'Not authenticated' }); + } + if (value === token) return; + const previousToken = config.get('previousAuthToken'); + if (previousToken && value === previousToken) { + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', token, { + signed: true, httpOnly: true, secure: isHttps, + sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/' + }); + return; + } + return reply.code(401).send({ error: true, message: 'Not authenticated' }); +} + +async function abandonedCallRoutes(fastify, options) { + + // Page route + fastify.get('/abandoned-calls', { preHandler: requireAuthPage }, async (req, reply) => { + const appConfig = config.getAll(); + const stats = database.getAbandonedCallStats(); + return reply.view('abandoned-calls', { + config: appConfig, + stats + }); + }); + + // API: list abandoned calls (proxy to backend or serve from cache) + fastify.get('/api/abandoned-calls', { preHandler: requireAuthApi }, async (req, reply) => { + const appConfig = config.getAll(); + const token = appConfig.authToken; + const botId = appConfig.selectedBotId; + + if (!token || !botId) { + return { error: true, message: 'Not configured' }; + } + + const options = { + limit: parseInt(req.query.limit, 10) || 50, + offset: parseInt(req.query.offset, 10) || 0 + }; + if (req.query.stage) options.stage = req.query.stage; + if (req.query.priority) options.priority = req.query.priority; + + const result = await apiClient.getAbandonedCalls(token, botId, options); + + if (result.error) { + // Fall back to local cache + const cached = database.getCachedAbandonedCalls({ + status: req.query.priority ? undefined : undefined, + priority: req.query.priority || undefined, + limit: options.limit + }); + return { error: false, calls: cached, cached: true }; + } + + // Update local cache + if (result.calls) { + for (const call of result.calls) { + database.cacheAbandonedCall(call.id, call); + } + } + + return { error: false, calls: result.calls || [] }; + }); + + // API: callback queue + fastify.get('/api/abandoned-calls/callback-queue', { preHandler: requireAuthApi }, async (req, reply) => { + const appConfig = config.getAll(); + const token = appConfig.authToken; + const botId = appConfig.selectedBotId; + + if (!token || !botId) { + return { error: true, message: 'Not configured' }; + } + + const limit = parseInt(req.query.limit, 10) || 20; + const offset = parseInt(req.query.offset, 10) || 0; + const result = await apiClient.getAbandonedCallbackQueue(token, botId, limit, offset); + + if (result.error) { + const cached = database.getCachedAbandonedCalls({ status: 'queued', limit }); + return { error: false, queue: cached, cached: true }; + } + + if (result.queue) { + for (const call of result.queue) { + database.cacheAbandonedCall(call.id, call); + } + } + + return { error: false, queue: result.queue || [] }; + }); + + // API: update callback action + fastify.post('/api/abandoned-calls/:id/action', { preHandler: requireAuthApi }, async (req, reply) => { + const abandonedCallId = parseInt(req.params.id, 10); + const { action, notes } = req.body || {}; + + if (!action) { + return { error: true, message: 'Action is required' }; + } + + const validActions = ['call_back', 'reached', 'no_answer', 'converted', 'dismissed', 'deferred']; + if (!validActions.includes(action)) { + return { error: true, message: 'Invalid action' }; + } + + const appConfig = config.getAll(); + const token = appConfig.authToken; + + if (!token) { + return { error: true, message: 'Not configured' }; + } + + const result = await apiClient.updateAbandonedCallback(token, abandonedCallId, action, notes || ''); + + if (!result.error) { + const statusMap = { + call_back: 'attempted', reached: 'reached', no_answer: 'no_answer', + converted: 'converted', dismissed: 'dismissed', deferred: 'deferred' + }; + database.updateCachedCallbackStatus(abandonedCallId, statusMap[action] || action); + } + + return result; + }); + + // API: metrics + fastify.get('/api/abandoned-calls/metrics', { preHandler: requireAuthApi }, async (req, reply) => { + const appConfig = config.getAll(); + const token = appConfig.authToken; + const botId = appConfig.selectedBotId; + + if (!token || !botId) { + return { error: true, message: 'Not configured' }; + } + + const startDate = parseInt(req.query.startDate, 10) || 0; + const endDate = parseInt(req.query.endDate, 10) || 0; + + return await apiClient.getAbandonedCallMetrics(token, botId, startDate, endDate); + }); + + // API: reprint abandoned call receipt + fastify.post('/api/abandoned-calls/:id/reprint', { preHandler: requireAuthApi }, async (req, reply) => { + const abandonedCallId = parseInt(req.params.id, 10); + + // Get call data from cache + const cached = database.getCachedAbandonedCalls({ limit: 200 }); + const call = cached.find(c => c.id === abandonedCallId); + + if (!call) { + // Try fetching from API + const appConfig = config.getAll(); + const result = await apiClient.getAbandonedCalls(appConfig.authToken, appConfig.selectedBotId, { limit: 1 }); + const fromApi = (result.calls || []).find(c => c.id === abandonedCallId); + if (!fromApi) { + return { error: true, message: 'Abandoned call not found' }; + } + return await doPrint(fromApi); + } + + return await doPrint(call); + + async function doPrint(callData) { + try { + const printerConfigs = database.getAbandonedCallPrinters(); + if (!printerConfigs || printerConfigs.length === 0) { + return { error: true, message: 'No printers configured for abandoned call receipts' }; + } + const result = await printer.printAbandonedCallReceipt(callData, printerConfigs); + if (result && result.success) { + return { error: false, message: `Printed on ${result.successCount} printer(s)` }; + } + return { error: true, message: 'Print failed' }; + } catch (err) { + return { error: true, message: err.message }; + } + } + }); + + // API: pending count (lightweight, for dashboard badge) + fastify.get('/api/abandoned-calls/pending-count', { preHandler: requireAuthApi }, async (req, reply) => { + const count = database.getPendingAbandonedCallCount(); + return { error: false, count }; + }); +} + +module.exports = abandonedCallRoutes; diff --git a/routes/auth.js b/routes/auth.js index 15b2820..a8b10c5 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -12,8 +12,19 @@ async function authRoutes(fastify, options) { if (valid) { const token = config.get('authToken'); const expiry = config.get('tokenExpiry'); - if (token && !apiClient.isTokenExpired(expiry) && value === token) { - return reply.redirect('/dashboard'); + if (token && !apiClient.isTokenExpired(expiry)) { + if (value === token) { + return reply.redirect('/dashboard'); + } + const previousToken = config.get('previousAuthToken'); + if (previousToken && value === previousToken) { + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', token, { + signed: true, httpOnly: true, secure: isHttps, + sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/' + }); + return reply.redirect('/dashboard'); + } } } } @@ -30,8 +41,19 @@ async function authRoutes(fastify, options) { if (valid) { const token = config.get('authToken'); const expiry = config.get('tokenExpiry'); - if (token && !apiClient.isTokenExpired(expiry) && value === token) { - return reply.redirect('/dashboard'); + if (token && !apiClient.isTokenExpired(expiry)) { + if (value === token) { + return reply.redirect('/dashboard'); + } + const previousToken = config.get('previousAuthToken'); + if (previousToken && value === previousToken) { + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', token, { + signed: true, httpOnly: true, secure: isHttps, + sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/' + }); + return reply.redirect('/dashboard'); + } } } } diff --git a/routes/dashboard.js b/routes/dashboard.js index 60a1cf7..085af2f 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -10,10 +10,21 @@ async function requireAuth(req, reply) { const token = config.get('authToken'); const expiry = config.get('tokenExpiry'); const apiClient = require('../api-client'); - if (!token || apiClient.isTokenExpired(expiry) || value !== token) { + if (!token || apiClient.isTokenExpired(expiry)) { reply.redirect('/login'); return; } + if (value === token) return; + const previousToken = config.get('previousAuthToken'); + if (previousToken && value === previousToken) { + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', token, { + signed: true, httpOnly: true, secure: isHttps, + sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/' + }); + return; + } + reply.redirect('/login'); } async function dashboardRoutes(fastify, options) { diff --git a/routes/orders.js b/routes/orders.js index 44befed..6a6b739 100644 --- a/routes/orders.js +++ b/routes/orders.js @@ -11,10 +11,20 @@ async function requireAuth(req, reply) { 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) { + if (!token || apiClient.isTokenExpired(expiry)) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); } + if (value === token) return; + const previousToken = config.get('previousAuthToken'); + if (previousToken && value === previousToken) { + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', token, { + signed: true, httpOnly: true, secure: isHttps, + sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/' + }); + return; + } + return reply.code(401).send({ error: true, message: 'Not authenticated' }); } async function ordersRoutes(fastify, options) { diff --git a/routes/settings.js b/routes/settings.js index 0eb8282..a239503 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -14,11 +14,21 @@ async function requireAuth(req, reply) { 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) { + if (!token || apiClient.isTokenExpired(expiry)) { reply.redirect('/login'); return; } + if (value === token) return; + const previousToken = config.get('previousAuthToken'); + if (previousToken && value === previousToken) { + const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https'); + reply.setCookie('kitchen_session', token, { + signed: true, httpOnly: true, secure: isHttps, + sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/' + }); + return; + } + reply.redirect('/login'); } async function settingsRoutes(fastify, options) { diff --git a/server.js b/server.js index e7bae9a..9fbf74e 100644 --- a/server.js +++ b/server.js @@ -9,12 +9,13 @@ const config = require('./config'); const apiClient = require('./api-client'); const printer = require('./printer'); const PrintQueueWorker = require('./print-worker'); +const AbandonedCallPoller = require('./abandoned-call-poller'); const fastify = Fastify({ logger: true }); -const isDev = false; +const isDev = true; // Order Poller Class class OrderPoller { @@ -68,11 +69,16 @@ class OrderPoller { return; } - // Check if token is expired - if (apiClient.isTokenExpired(appConfig.tokenExpiry)) { - console.log('Token expired, please re-login'); - this.isPolling = false; - return; + // Attempt token refresh if expired or near expiry + if (apiClient.isTokenNearExpiry(appConfig.tokenExpiry, 7)) { + const refreshed = await apiClient.ensureValidToken(); + if (!refreshed) { + console.log('Token expired and refresh failed, please re-login'); + this.isPolling = false; + return; + } + // Re-read config after refresh so the poll uses the new token + Object.assign(appConfig, config.getAll()); } // Get last synced order ID @@ -365,6 +371,7 @@ async function main() { await fastify.register(require('./routes/dashboard')); await fastify.register(require('./routes/settings')); await fastify.register(require('./routes/orders')); + await fastify.register(require('./routes/abandoned-calls')); // Initialize printer with config const appConfig = config.getAll(); @@ -383,10 +390,12 @@ async function main() { // Start order poller const poller = new OrderPoller(apiClient, database, printer); const printWorker = new PrintQueueWorker(database, config, printer); + const abandonedCallPoller = new AbandonedCallPoller(database, printer); - // Make poller available globally for restart after settings change + // Make pollers available globally for restart after settings change fastify.decorate('orderPoller', poller); fastify.decorate('printWorker', printWorker); + fastify.decorate('abandonedCallPoller', abandonedCallPoller); // Start server const port = parseInt(process.env.PORT, 10) || 3000; @@ -412,15 +421,44 @@ async function main() { setInterval(() => { try { checkAndUpdate(); } catch (e) { fastify.log.error(e); } }, updateIntervalMs); } + // Attempt token refresh on startup in case it expired while offline + try { + const refreshedToken = await apiClient.ensureValidToken(); + if (refreshedToken) { + console.log('Startup token check passed'); + } else if (config.get('authToken')) { + console.log('Startup token refresh failed — user may need to re-login'); + } + } catch (err) { + console.error('Startup token refresh error:', err.message); + } + // Start polling after server is up poller.start(); // Start print queue worker printWorker.start(); + // Start abandoned call poller + abandonedCallPoller.start(); + + // Periodic token refresh (every 6 hours) + const TOKEN_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; + const tokenRefreshTimer = setInterval(async () => { + try { + const result = await apiClient.ensureValidToken(); + if (result) { + console.log('Periodic token refresh: OK'); + } + } catch (err) { + console.error('Periodic token refresh error:', err.message); + } + }, TOKEN_REFRESH_INTERVAL_MS); // Handle shutdown gracefully (PM2 reload-friendly) const gracefulShutdown = async () => { console.log('\nShutting down gracefully...'); poller.stop(); + try { abandonedCallPoller.stop(); } catch (_) {} + clearInterval(tokenRefreshTimer); try { printWorker.stop(); } catch (_) {} try { database.close(); } catch (_) {} try { await fastify.close(); } catch (_) {} diff --git a/views/abandoned-calls.ejs b/views/abandoned-calls.ejs new file mode 100644 index 0000000..90f17b7 --- /dev/null +++ b/views/abandoned-calls.ejs @@ -0,0 +1,103 @@ + + + + + + Missed Calls - Kitchen Agent + + + +
+
+
+ +

Missed Calls

+
+
+
+ + Dashboard + Checking... +
+
+ + API Server + Checking... +
+
+ +
+
+ +
+
+
Pending Callbacks
+
<%= stats.pending || 0 %>
+
+
+
Total Tracked
+
<%= stats.total || 0 %>
+
+
+
Converted
+
<%= stats.converted || 0 %>
+
+
+
Dismissed
+
<%= stats.dismissed || 0 %>
+
+
+ +
+
+ + + + + +
+
+ + +
+
+ +
+
Loading missed calls...
+
+ + + + + +
+ + + + + + diff --git a/views/dashboard.ejs b/views/dashboard.ejs index 5f85ea7..9508a13 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -23,6 +23,11 @@
+ + 📞 + Missed Calls + + Settings Logout
diff --git a/views/settings.ejs b/views/settings.ejs index 7f910e1..28305fa 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -407,6 +407,14 @@ Show Timestamps + +
+ + Enable to print abandoned call alerts on this printer. Disable for kitchen-only printers. +