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 {
|
class APIClient {
|
||||||
constructor(baseUrl = process.env.API_URL || 'https://api.thinklink.ai') {
|
constructor(baseUrl = process.env.API_URL || 'https://api.thinklink.ai') {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this._refreshInFlight = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async request(endpoint, options = {}) {
|
async request(endpoint, options = {}) {
|
||||||
@@ -82,12 +83,117 @@ class APIClient {
|
|||||||
return this.request('/food-order/modify', { body });
|
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) {
|
isTokenExpired(expirationDate) {
|
||||||
if (!expirationDate) return true;
|
if (!expirationDate) return true;
|
||||||
const expiry = new Date(expirationDate);
|
const expiry = new Date(expirationDate);
|
||||||
|
if (Number.isNaN(expiry.getTime())) return true;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return now >= expiry;
|
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();
|
module.exports = new APIClient();
|
||||||
|
|||||||
15
config.js
15
config.js
@@ -74,8 +74,7 @@ class ConfigManager {
|
|||||||
try {
|
try {
|
||||||
const value = database.getConfig(key);
|
const value = database.getConfig(key);
|
||||||
|
|
||||||
// Decrypt token if it's the auth token
|
if ((key === 'authToken' || key === 'previousAuthToken') && value) {
|
||||||
if (key === 'authToken' && value) {
|
|
||||||
return this.decrypt(value);
|
return this.decrypt(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +87,7 @@ class ConfigManager {
|
|||||||
|
|
||||||
set(key, value) {
|
set(key, value) {
|
||||||
try {
|
try {
|
||||||
// Encrypt token if it's the auth token
|
if ((key === 'authToken' || key === 'previousAuthToken') && value) {
|
||||||
if (key === 'authToken' && value) {
|
|
||||||
value = this.encrypt(value);
|
value = this.encrypt(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +101,12 @@ class ConfigManager {
|
|||||||
try {
|
try {
|
||||||
const config = database.getConfig();
|
const config = database.getConfig();
|
||||||
|
|
||||||
// Decrypt auth token if present
|
|
||||||
if (config.authToken) {
|
if (config.authToken) {
|
||||||
config.authToken = this.decrypt(config.authToken);
|
config.authToken = this.decrypt(config.authToken);
|
||||||
}
|
}
|
||||||
|
if (config.previousAuthToken) {
|
||||||
|
config.previousAuthToken = this.decrypt(config.previousAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -117,10 +117,12 @@ class ConfigManager {
|
|||||||
|
|
||||||
setMultiple(configObj) {
|
setMultiple(configObj) {
|
||||||
try {
|
try {
|
||||||
// Encrypt auth token if present
|
|
||||||
if (configObj.authToken) {
|
if (configObj.authToken) {
|
||||||
configObj.authToken = this.encrypt(configObj.authToken);
|
configObj.authToken = this.encrypt(configObj.authToken);
|
||||||
}
|
}
|
||||||
|
if (configObj.previousAuthToken) {
|
||||||
|
configObj.previousAuthToken = this.encrypt(configObj.previousAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
database.setConfigMultiple(configObj);
|
database.setConfigMultiple(configObj);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -149,6 +151,7 @@ class ConfigManager {
|
|||||||
try {
|
try {
|
||||||
this.set('authToken', '');
|
this.set('authToken', '');
|
||||||
this.set('tokenExpiry', '');
|
this.set('tokenExpiry', '');
|
||||||
|
this.set('previousAuthToken', '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear auth:', error.message);
|
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
|
// Create indexes
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
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_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_enabled ON printers(is_enabled);
|
||||||
CREATE INDEX IF NOT EXISTS idx_printers_default ON printers(is_default);
|
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
|
// Initialize default config values if not exists
|
||||||
@@ -115,6 +142,9 @@ class DatabaseManager {
|
|||||||
|
|
||||||
// Migrate old printer config to new table if needed
|
// Migrate old printer config to new table if needed
|
||||||
this.migrateOldPrinterConfig();
|
this.migrateOldPrinterConfig();
|
||||||
|
|
||||||
|
// Add print_abandoned_calls column to printers if missing
|
||||||
|
this.migratePrintersAddAbandonedCalls();
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfigDefaults() {
|
setConfigDefaults() {
|
||||||
@@ -474,9 +504,9 @@ class DatabaseManager {
|
|||||||
header_text, footer_text,
|
header_text, footer_text,
|
||||||
business_name, business_address, business_phone, business_website, business_email,
|
business_name, business_address, business_phone, business_website, business_email,
|
||||||
business_contact_size, show_customer_info, show_order_items, show_prices, show_timestamps,
|
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 (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -509,6 +539,7 @@ class DatabaseManager {
|
|||||||
config.show_timestamps !== false ? 1 : 0,
|
config.show_timestamps !== false ? 1 : 0,
|
||||||
config.logo_path || null,
|
config.logo_path || null,
|
||||||
config.logo_max_width_dots || null,
|
config.logo_max_width_dots || null,
|
||||||
|
config.print_abandoned_calls !== false ? 1 : 0,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@@ -531,7 +562,7 @@ class DatabaseManager {
|
|||||||
header_text = ?, footer_text = ?,
|
header_text = ?, footer_text = ?,
|
||||||
business_name = ?, business_address = ?, business_phone = ?, business_website = ?, business_email = ?,
|
business_name = ?, business_address = ?, business_phone = ?, business_website = ?, business_email = ?,
|
||||||
business_contact_size = ?, show_customer_info = ?, show_order_items = ?, show_prices = ?, show_timestamps = ?,
|
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 = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -564,6 +595,7 @@ class DatabaseManager {
|
|||||||
config.show_timestamps !== false ? 1 : 0,
|
config.show_timestamps !== false ? 1 : 0,
|
||||||
config.logo_path || null,
|
config.logo_path || null,
|
||||||
config.logo_max_width_dots || null,
|
config.logo_max_width_dots || null,
|
||||||
|
config.print_abandoned_calls !== false ? 1 : 0,
|
||||||
now,
|
now,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
@@ -647,6 +679,7 @@ class DatabaseManager {
|
|||||||
show_timestamps: row.show_timestamps === 1,
|
show_timestamps: row.show_timestamps === 1,
|
||||||
logo_path: row.logo_path,
|
logo_path: row.logo_path,
|
||||||
logo_max_width_dots: row.logo_max_width_dots,
|
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,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_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() {
|
close() {
|
||||||
if (this.db) {
|
if (this.db) {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kitchen-agent",
|
"name": "kitchen-agent",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"description": "Kitchen Agent for ThinkLink Food Order Management",
|
"description": "Kitchen Agent for ThinkLink Food Order Management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
497
printer.js
497
printer.js
@@ -1380,16 +1380,25 @@ class PrinterManager {
|
|||||||
doc.moveDown(0.4);
|
doc.moveDown(0.4);
|
||||||
doc.fontSize(12).text('ITEMS:');
|
doc.fontSize(12).text('ITEMS:');
|
||||||
doc.fontSize(11);
|
doc.fontSize(11);
|
||||||
|
const includePrices = (cfg.showPrices === 'true' || !cfg.showPrices);
|
||||||
order.order.items.forEach(item => {
|
order.order.items.forEach(item => {
|
||||||
const itemName = item.itemName || item.name || 'Unknown Item';
|
const itemName = item.itemName || item.name || 'Unknown Item';
|
||||||
const qty = item.qty || 1;
|
const qty = item.qty || 1;
|
||||||
const price = item.price || 0;
|
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} - $${(price * qty).toFixed(2)}`
|
||||||
: `${qty}x ${itemName}`;
|
: `${qty}x ${itemName}`;
|
||||||
doc.text(line, { width: maxWidth });
|
doc.text(line, { width: maxWidth });
|
||||||
if (item.addons && Array.isArray(item.addons)) {
|
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)) {
|
if (item.exclude && Array.isArray(item.exclude)) {
|
||||||
item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`));
|
item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`));
|
||||||
@@ -1540,7 +1549,15 @@ class PrinterManager {
|
|||||||
: `${qty}x ${itemName}`;
|
: `${qty}x ${itemName}`;
|
||||||
doc.text(line, { width: maxWidth });
|
doc.text(line, { width: maxWidth });
|
||||||
if (item.addons && Array.isArray(item.addons)) {
|
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)) {
|
if (item.exclude && Array.isArray(item.exclude)) {
|
||||||
item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`));
|
item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`));
|
||||||
@@ -1751,6 +1768,480 @@ class PrinterManager {
|
|||||||
return [];
|
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();
|
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
|
// Initialize dashboard
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
setupFilterButtons();
|
setupFilterButtons();
|
||||||
audioNotification.init();
|
audioNotification.init();
|
||||||
refreshOrders();
|
refreshOrders();
|
||||||
|
refreshAbandonedCallCount();
|
||||||
|
|
||||||
// Set up auto-refresh
|
// Set up auto-refresh
|
||||||
setInterval(refreshOrders, config.dashboardRefreshInterval || 10000);
|
setInterval(refreshOrders, config.dashboardRefreshInterval || 10000);
|
||||||
|
setInterval(refreshAbandonedCallCount, 30000);
|
||||||
|
|
||||||
// Initialize connection monitoring
|
// Initialize connection monitoring
|
||||||
checkConnectionStatus();
|
checkConnectionStatus();
|
||||||
// Check connection status every 15 seconds
|
|
||||||
setInterval(checkConnectionStatus, 15000);
|
setInterval(checkConnectionStatus, 15000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ function openAddPrinterModal() {
|
|||||||
document.getElementById('qr_code_enabled').checked = true;
|
document.getElementById('qr_code_enabled').checked = true;
|
||||||
document.getElementById('is_enabled').checked = true;
|
document.getElementById('is_enabled').checked = true;
|
||||||
document.getElementById('is_default').checked = false;
|
document.getElementById('is_default').checked = false;
|
||||||
|
document.getElementById('print_abandoned_calls').checked = true;
|
||||||
|
|
||||||
showModal('printerConfigModal');
|
showModal('printerConfigModal');
|
||||||
switchPrinterModalTab('connection');
|
switchPrinterModalTab('connection');
|
||||||
@@ -268,6 +269,7 @@ async function editPrinter(id) {
|
|||||||
document.getElementById('qr_code_enabled').checked = printer.qr_code_enabled !== false;
|
document.getElementById('qr_code_enabled').checked = printer.qr_code_enabled !== false;
|
||||||
document.getElementById('is_default').checked = printer.is_default || false;
|
document.getElementById('is_default').checked = printer.is_default || false;
|
||||||
document.getElementById('is_enabled').checked = printer.is_enabled !== false;
|
document.getElementById('is_enabled').checked = printer.is_enabled !== false;
|
||||||
|
document.getElementById('print_abandoned_calls').checked = printer.print_abandoned_calls !== false;
|
||||||
|
|
||||||
showModal('printerConfigModal');
|
showModal('printerConfigModal');
|
||||||
switchPrinterModalTab('connection');
|
switchPrinterModalTab('connection');
|
||||||
@@ -293,7 +295,7 @@ async function savePrinterConfig() {
|
|||||||
if (key === 'id' && !value) continue; // Skip empty id
|
if (key === 'id' && !value) continue; // Skip empty id
|
||||||
|
|
||||||
// Handle checkboxes
|
// 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;
|
config[key] = document.getElementById(key).checked;
|
||||||
} else if (key === 'paper_width' || key === 'qr_code_size' || key === 'logo_max_width_dots') {
|
} else if (key === 'paper_width' || key === 'qr_code_size' || key === 'logo_max_width_dots') {
|
||||||
const val = parseInt(value, 10);
|
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)
|
// 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);
|
const el = document.getElementById(key);
|
||||||
if (el) {
|
if (el) {
|
||||||
config[key] = !!el.checked;
|
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) {
|
if (valid) {
|
||||||
const token = config.get('authToken');
|
const token = config.get('authToken');
|
||||||
const expiry = config.get('tokenExpiry');
|
const expiry = config.get('tokenExpiry');
|
||||||
if (token && !apiClient.isTokenExpired(expiry) && value === token) {
|
if (token && !apiClient.isTokenExpired(expiry)) {
|
||||||
return reply.redirect('/dashboard');
|
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) {
|
if (valid) {
|
||||||
const token = config.get('authToken');
|
const token = config.get('authToken');
|
||||||
const expiry = config.get('tokenExpiry');
|
const expiry = config.get('tokenExpiry');
|
||||||
if (token && !apiClient.isTokenExpired(expiry) && value === token) {
|
if (token && !apiClient.isTokenExpired(expiry)) {
|
||||||
return reply.redirect('/dashboard');
|
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 token = config.get('authToken');
|
||||||
const expiry = config.get('tokenExpiry');
|
const expiry = config.get('tokenExpiry');
|
||||||
const apiClient = require('../api-client');
|
const apiClient = require('../api-client');
|
||||||
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
|
if (!token || apiClient.isTokenExpired(expiry)) {
|
||||||
reply.redirect('/login');
|
reply.redirect('/login');
|
||||||
return;
|
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) {
|
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' }); }
|
if (!valid) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); }
|
||||||
const token = config.get('authToken');
|
const token = config.get('authToken');
|
||||||
const expiry = config.get('tokenExpiry');
|
const expiry = config.get('tokenExpiry');
|
||||||
const apiClient = require('../api-client');
|
if (!token || apiClient.isTokenExpired(expiry)) {
|
||||||
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
|
|
||||||
return reply.code(401).send({ error: true, message: 'Not authenticated' });
|
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) {
|
async function ordersRoutes(fastify, options) {
|
||||||
|
|||||||
@@ -14,11 +14,21 @@ async function requireAuth(req, reply) {
|
|||||||
if (!valid) { reply.redirect('/login'); return; }
|
if (!valid) { reply.redirect('/login'); return; }
|
||||||
const token = config.get('authToken');
|
const token = config.get('authToken');
|
||||||
const expiry = config.get('tokenExpiry');
|
const expiry = config.get('tokenExpiry');
|
||||||
const apiClient = require('../api-client');
|
if (!token || apiClient.isTokenExpired(expiry)) {
|
||||||
if (!token || apiClient.isTokenExpired(expiry) || value !== token) {
|
|
||||||
reply.redirect('/login');
|
reply.redirect('/login');
|
||||||
return;
|
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) {
|
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 apiClient = require('./api-client');
|
||||||
const printer = require('./printer');
|
const printer = require('./printer');
|
||||||
const PrintQueueWorker = require('./print-worker');
|
const PrintQueueWorker = require('./print-worker');
|
||||||
|
const AbandonedCallPoller = require('./abandoned-call-poller');
|
||||||
|
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
logger: true
|
logger: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDev = false;
|
const isDev = true;
|
||||||
|
|
||||||
// Order Poller Class
|
// Order Poller Class
|
||||||
class OrderPoller {
|
class OrderPoller {
|
||||||
@@ -68,11 +69,16 @@ class OrderPoller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired
|
// Attempt token refresh if expired or near expiry
|
||||||
if (apiClient.isTokenExpired(appConfig.tokenExpiry)) {
|
if (apiClient.isTokenNearExpiry(appConfig.tokenExpiry, 7)) {
|
||||||
console.log('Token expired, please re-login');
|
const refreshed = await apiClient.ensureValidToken();
|
||||||
this.isPolling = false;
|
if (!refreshed) {
|
||||||
return;
|
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
|
// Get last synced order ID
|
||||||
@@ -365,6 +371,7 @@ async function main() {
|
|||||||
await fastify.register(require('./routes/dashboard'));
|
await fastify.register(require('./routes/dashboard'));
|
||||||
await fastify.register(require('./routes/settings'));
|
await fastify.register(require('./routes/settings'));
|
||||||
await fastify.register(require('./routes/orders'));
|
await fastify.register(require('./routes/orders'));
|
||||||
|
await fastify.register(require('./routes/abandoned-calls'));
|
||||||
|
|
||||||
// Initialize printer with config
|
// Initialize printer with config
|
||||||
const appConfig = config.getAll();
|
const appConfig = config.getAll();
|
||||||
@@ -383,10 +390,12 @@ async function main() {
|
|||||||
// Start order poller
|
// Start order poller
|
||||||
const poller = new OrderPoller(apiClient, database, printer);
|
const poller = new OrderPoller(apiClient, database, printer);
|
||||||
const printWorker = new PrintQueueWorker(database, config, 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('orderPoller', poller);
|
||||||
fastify.decorate('printWorker', printWorker);
|
fastify.decorate('printWorker', printWorker);
|
||||||
|
fastify.decorate('abandonedCallPoller', abandonedCallPoller);
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
const port = parseInt(process.env.PORT, 10) || 3000;
|
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);
|
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
|
// Start polling after server is up
|
||||||
poller.start();
|
poller.start();
|
||||||
// Start print queue worker
|
// Start print queue worker
|
||||||
printWorker.start();
|
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)
|
// Handle shutdown gracefully (PM2 reload-friendly)
|
||||||
const gracefulShutdown = async () => {
|
const gracefulShutdown = async () => {
|
||||||
console.log('\nShutting down gracefully...');
|
console.log('\nShutting down gracefully...');
|
||||||
poller.stop();
|
poller.stop();
|
||||||
|
try { abandonedCallPoller.stop(); } catch (_) {}
|
||||||
|
clearInterval(tokenRefreshTimer);
|
||||||
try { printWorker.stop(); } catch (_) {}
|
try { printWorker.stop(); } catch (_) {}
|
||||||
try { database.close(); } catch (_) {}
|
try { database.close(); } catch (_) {}
|
||||||
try { await fastify.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>
|
</div>
|
||||||
<div class="header-actions">
|
<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="/settings" class="btn btn-secondary">Settings</a>
|
||||||
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
|
<a href="/auth/logout" class="btn btn-secondary">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -407,6 +407,14 @@
|
|||||||
Show Timestamps
|
Show Timestamps
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Business Info Tab -->
|
<!-- Business Info Tab -->
|
||||||
|
|||||||
Reference in New Issue
Block a user