This commit is contained in:
odzugkoev
2026-03-01 17:10:03 -05:00
parent 7e0887c62d
commit 85cf732a61
19 changed files with 2284 additions and 32 deletions

143
abandoned-call-poller.js Normal file
View File

@@ -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;

View File

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

View File

@@ -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);
}

View File

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

View File

@@ -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": {

View File

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

View File

@@ -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;
}
}

View File

@@ -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 = '<div class="no-orders">No missed calls to display</div>';
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 = `<span class="known-customer-badge">Known Customer &middot; ${prevOrders} orders</span>`;
}
let itemsHtml = '';
const items = call.items || [];
if (items.length > 0) {
itemsHtml = '<div class="ac-items"><div class="ac-items-title">Items mentioned:</div>';
for (const item of items) {
const iname = item.item_name || item.itemName || item.name || 'Unknown';
const qty = item.quantity || item.qty || 1;
itemsHtml += `<div class="ac-item">${qty}x ${iname}</div>`;
}
if (call.partial_order_value || call.estimated_order_value) {
const val = Number(call.partial_order_value || call.estimated_order_value);
if (val > 0) itemsHtml += `<div class="ac-item-value">Est. value: $${val.toFixed(2)}</div>`;
}
itemsHtml += '</div>';
}
let scriptHtml = '';
if (call.llm_callback_script) {
scriptHtml = `
<div class="ac-callback-script">
<div class="ac-script-label">Say this:</div>
<div class="ac-script-text">${escapeHtml(call.llm_callback_script)}</div>
</div>`;
}
let summaryHtml = '';
if (call.llm_summary) {
summaryHtml = `<div class="ac-summary">${escapeHtml(call.llm_summary)}</div>`;
}
const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status);
let actionsHtml = '';
if (!isDone) {
actionsHtml = `
<div class="ac-actions">
<button class="btn btn-primary ac-action-btn" onclick="handleAction(${call.id}, 'call_back')">Call Back</button>
<button class="btn btn-success ac-action-btn" onclick="handleAction(${call.id}, 'reached')">Reached</button>
<button class="btn btn-warning ac-action-btn" onclick="handleAction(${call.id}, 'no_answer')">No Answer</button>
<button class="btn btn-success ac-action-btn ac-action-converted" onclick="handleAction(${call.id}, 'converted')">Converted</button>
<button class="btn btn-secondary ac-action-btn" onclick="handleAction(${call.id}, 'dismissed')">Dismiss</button>
<button class="btn btn-secondary ac-action-btn" onclick="handleAction(${call.id}, 'deferred')">Defer</button>
<button class="btn btn-secondary ac-action-btn ac-action-reprint" onclick="reprintReceipt(${call.id})" title="Reprint">&#x1F5A8;</button>
</div>`;
} else {
actionsHtml = `
<div class="ac-actions">
<span class="ac-status-done">Status: ${status.replace('_', ' ')}</span>
<button class="btn btn-secondary ac-action-btn ac-action-reprint" onclick="reprintReceipt(${call.id})" title="Reprint">&#x1F5A8;</button>
</div>`;
}
return `
<div class="order-card abandoned-call-card priority-${priority} ${isDone ? 'ac-done' : ''}" onclick="showDetails(${call.id}, event)">
<div class="order-header ac-header">
<div class="ac-header-left">
<span class="ac-priority-badge badge-${priority}">${priority.toUpperCase()}</span>
<span class="ac-time-ago">${timeAgo}</span>
</div>
<div class="ac-score">Score: ${score}/100</div>
</div>
<div class="ac-caller-info">
<div class="ac-phone">${escapeHtml(phone)}</div>
${name ? `<div class="ac-name">${escapeHtml(name)}</div>` : ''}
${knownBadge}
</div>
<div class="ac-stage">${stageMsg}</div>
<div class="ac-duration">Duration: ${duration}s</div>
${itemsHtml}
${summaryHtml}
${scriptHtml}
${actionsHtml}
</div>
`;
}
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 = '<h4>Partial Order Items</h4><ul>';
for (const item of items) {
const iname = item.item_name || item.itemName || item.name || 'Unknown';
const qty = item.quantity || item.qty || 1;
const price = item.unit_price || item.price;
itemsHtml += `<li>${qty}x ${escapeHtml(iname)}${price ? `$${Number(price).toFixed(2)}` : ''}</li>`;
}
itemsHtml += '</ul>';
if (call.partial_order_value) {
itemsHtml += `<p><strong>Est. Value:</strong> $${Number(call.partial_order_value).toFixed(2)}</p>`;
}
}
content.innerHTML = `
<div class="order-details">
<div class="detail-section">
<h3>Call Information</h3>
<p><strong>ID:</strong> ${call.id}</p>
<p><strong>Time:</strong> ${timeStr}</p>
<p><strong>Duration:</strong> ${call.duration_seconds || 0}s</p>
<p><strong>Priority:</strong> <span class="ac-priority-badge badge-${priority}">${priority.toUpperCase()}</span></p>
<p><strong>Score:</strong> ${score}/100</p>
<p><strong>Stage:</strong> ${call.abandonment_stage || 'N/A'}</p>
<p><strong>Intent:</strong> ${call.detected_intent || 'N/A'}</p>
<p><strong>Status:</strong> ${status}</p>
</div>
<div class="detail-section">
<h3>Caller</h3>
<p><strong>Phone:</strong> ${escapeHtml(call.caller_phone_normalized || call.caller_phone || 'Unknown')}</p>
${call.caller_name ? `<p><strong>Name:</strong> ${escapeHtml(call.caller_name)}</p>` : ''}
<p><strong>Known Customer:</strong> ${call.is_known_customer ? 'Yes' : 'No'}</p>
${call.is_known_customer ? `<p><strong>Previous Orders:</strong> ${call.previous_order_count || 0}</p>` : ''}
</div>
${itemsHtml ? `<div class="detail-section">${itemsHtml}</div>` : ''}
${call.caller_speech_text ? `
<div class="detail-section">
<h3>Caller Speech</h3>
<p>${escapeHtml(call.caller_speech_text)}</p>
</div>` : ''}
${call.llm_summary ? `
<div class="detail-section">
<h3>AI Analysis</h3>
<p><strong>Summary:</strong> ${escapeHtml(call.llm_summary)}</p>
${call.llm_sentiment ? `<p><strong>Sentiment:</strong> ${call.llm_sentiment}</p>` : ''}
${call.llm_hesitancy_analysis ? `<p><strong>Hesitancy:</strong> ${escapeHtml(call.llm_hesitancy_analysis)}</p>` : ''}
${call.llm_recovery_suggestion ? `<p><strong>Recovery:</strong> ${escapeHtml(call.llm_recovery_suggestion)}</p>` : ''}
</div>` : ''}
${call.llm_callback_script ? `
<div class="detail-section">
<h3>Callback Script</h3>
<div class="ac-callback-script">
<div class="ac-script-text">${escapeHtml(call.llm_callback_script)}</div>
</div>
</div>` : ''}
${call.score_breakdown ? `
<div class="detail-section">
<h3>Score Breakdown</h3>
<pre style="font-size:12px;overflow:auto;">${typeof call.score_breakdown === 'string' ? call.score_breakdown : JSON.stringify(call.score_breakdown, null, 2)}</pre>
</div>` : ''}
</div>
`;
const isDone = ['converted', 'dismissed', 'reached', 'no_answer'].includes(status);
if (!isDone) {
actions.innerHTML = `
<button class="btn btn-primary" onclick="handleActionFromModal('call_back')">Call Back</button>
<button class="btn btn-success" onclick="handleActionFromModal('reached')">Reached</button>
<button class="btn btn-warning" onclick="handleActionFromModal('no_answer')">No Answer</button>
<button class="btn btn-success" onclick="handleActionFromModal('converted')">Converted</button>
<button class="btn btn-secondary" onclick="handleActionFromModal('dismissed')">Dismiss</button>
<button class="btn btn-secondary" onclick="handleActionFromModal('deferred')">Defer</button>
<button class="btn btn-secondary" onclick="reprintReceipt(${call.id})">Reprint</button>
<button class="btn btn-secondary" onclick="closeDetailsModal()">Close</button>
`;
} else {
actions.innerHTML = `
<span class="ac-status-done">Status: ${status.replace('_', ' ')}</span>
<button class="btn btn-secondary" onclick="reprintReceipt(${call.id})">Reprint</button>
<button class="btn btn-secondary" onclick="closeDetailsModal()">Close</button>
`;
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// 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;
}

View File

@@ -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);
});

View File

@@ -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;

229
routes/abandoned-calls.js Normal file
View File

@@ -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;

View File

@@ -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');
}
}
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 (_) {}

103
views/abandoned-calls.ejs Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Missed Calls - Kitchen Agent</title>
<link rel="stylesheet" href="/public/css/style.css">
</head>
<body class="dashboard-page abandoned-calls-page">
<header class="main-header">
<div class="header-content">
<div class="header-title-group">
<a href="/dashboard" class="back-link" title="Back to Kitchen Display">&larr;</a>
<h1>Missed Calls</h1>
</div>
<div class="connection-status-bar" id="connectionStatusBar">
<div class="connection-status-item" id="localConnectionStatus" title="Dashboard">
<span class="status-indicator status-checking"></span>
<span class="status-text">Dashboard</span>
<span class="status-label" id="localStatusLabel">Checking...</span>
</div>
<div class="connection-status-item" id="apiConnectionStatus" title="API Server">
<span class="status-indicator status-checking"></span>
<span class="status-text">API Server</span>
<span class="status-label" id="apiStatusLabel">Checking...</span>
</div>
</div>
<div class="header-actions">
<a href="/dashboard" class="btn btn-secondary">Kitchen Display</a>
<a href="/settings" class="btn btn-secondary">Settings</a>
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
</div>
</div>
</header>
<div class="stats-bar" id="statsBar">
<div class="stat-card stat-abandoned-pending">
<div class="stat-label">Pending Callbacks</div>
<div class="stat-value" id="stat-pending"><%= stats.pending || 0 %></div>
</div>
<div class="stat-card">
<div class="stat-label">Total Tracked</div>
<div class="stat-value" id="stat-total"><%= stats.total || 0 %></div>
</div>
<div class="stat-card stat-converted">
<div class="stat-label">Converted</div>
<div class="stat-value" id="stat-converted"><%= stats.converted || 0 %></div>
</div>
<div class="stat-card">
<div class="stat-label">Dismissed</div>
<div class="stat-value" id="stat-dismissed"><%= stats.dismissed || 0 %></div>
</div>
</div>
<div class="dashboard-controls">
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn filter-critical" data-filter="critical">Critical</button>
<button class="filter-btn filter-high" data-filter="high">High</button>
<button class="filter-btn filter-medium" data-filter="medium">Medium</button>
<button class="filter-btn" data-filter="queued">Queued</button>
</div>
<div class="controls-right">
<label class="toggle-switch" id="showProcessedToggle" title="Show already handled calls">
<input type="checkbox" id="showProcessedCheckbox">
<span class="toggle-slider"></span>
<span class="toggle-label">Show Processed</span>
</label>
<button class="btn btn-secondary sync-button" id="syncButton" onclick="manualRefresh()" title="Refresh now">
<span class="sync-spinner"></span>
<span class="sync-text">Refresh</span>
</button>
</div>
</div>
<div class="orders-container abandoned-calls-container" id="abandonedCallsContainer">
<div class="loading">Loading missed calls...</div>
</div>
<!-- Abandoned Call Detail Modal -->
<div class="modal" id="detailsModal">
<div class="modal-content modal-large">
<div class="modal-header">
<h2>Missed Call Details</h2>
<button class="close-btn" onclick="closeDetailsModal()">&times;</button>
</div>
<div id="callDetailsContent"></div>
<div class="modal-actions" id="detailModalActions"></div>
</div>
</div>
<!-- Toast Notification -->
<div class="toast" id="toast"></div>
<script>
const config = {
refreshInterval: <%= config.abandonedCallRefreshInterval || 15000 %>
};
</script>
<script src="/public/js/common.js"></script>
<script src="/public/js/abandoned-calls.js"></script>
</body>
</html>

View File

@@ -23,6 +23,11 @@
</div>
</div>
<div class="header-actions">
<a href="/abandoned-calls" class="btn btn-secondary abandoned-calls-link" id="abandonedCallsLink" title="Missed Calls">
<span class="abandoned-calls-icon">&#128222;</span>
Missed Calls
<span class="abandoned-calls-badge" id="abandonedCallsBadge" style="display:none;">0</span>
</a>
<a href="/settings" class="btn btn-secondary">Settings</a>
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
</div>

View File

@@ -407,6 +407,14 @@
Show Timestamps
</label>
</div>
<div class="form-group" style="margin-top:16px;padding-top:16px;border-top:1px solid #eee;">
<label class="checkbox-label">
<input type="checkbox" id="print_abandoned_calls" name="print_abandoned_calls" checked>
Print Missed Call Notifications
</label>
<small style="display:block;margin-top:4px;color:#666;">Enable to print abandoned call alerts on this printer. Disable for kitchen-only printers.</small>
</div>
</div>
<!-- Business Info Tab -->