done
This commit is contained in:
143
abandoned-call-poller.js
Normal file
143
abandoned-call-poller.js
Normal 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;
|
||||
106
api-client.js
106
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();
|
||||
|
||||
15
config.js
15
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);
|
||||
}
|
||||
|
||||
161
database.js
161
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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
497
printer.js
497
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
499
public/js/abandoned-calls.js
Normal file
499
public/js/abandoned-calls.js
Normal 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 · ${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">🖨</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">🖨</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, '&').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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
229
routes/abandoned-calls.js
Normal 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;
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
52
server.js
52
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 (_) {}
|
||||
|
||||
103
views/abandoned-calls.ejs
Normal file
103
views/abandoned-calls.ejs
Normal 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">←</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()">×</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>
|
||||
@@ -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">📞</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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user