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

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

@@ -0,0 +1,229 @@
const config = require('../config');
const database = require('../database');
const apiClient = require('../api-client');
const printer = require('../printer');
// Page-level auth (redirects to login)
async function requireAuthPage(req, reply) {
const raw = req.cookies && req.cookies.kitchen_session;
if (!raw) { reply.redirect('/login'); return; }
const { valid, value } = req.unsignCookie(raw || '');
if (!valid) { reply.redirect('/login'); return; }
const token = config.get('authToken');
const expiry = config.get('tokenExpiry');
if (!token || apiClient.isTokenExpired(expiry)) {
reply.redirect('/login');
return;
}
if (value === token) return;
const previousToken = config.get('previousAuthToken');
if (previousToken && value === previousToken) {
const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https');
reply.setCookie('kitchen_session', token, {
signed: true, httpOnly: true, secure: isHttps,
sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/'
});
return;
}
reply.redirect('/login');
}
// API-level auth (returns 401 JSON)
async function requireAuthApi(req, reply) {
const raw = req.cookies && req.cookies.kitchen_session;
if (!raw) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); }
const { valid, value } = req.unsignCookie(raw || '');
if (!valid) { return reply.code(401).send({ error: true, message: 'Not authenticated' }); }
const token = config.get('authToken');
const expiry = config.get('tokenExpiry');
if (!token || apiClient.isTokenExpired(expiry)) {
return reply.code(401).send({ error: true, message: 'Not authenticated' });
}
if (value === token) return;
const previousToken = config.get('previousAuthToken');
if (previousToken && value === previousToken) {
const isHttps = (req.protocol === 'https') || ((req.headers['x-forwarded-proto'] || '').toString().toLowerCase() === 'https');
reply.setCookie('kitchen_session', token, {
signed: true, httpOnly: true, secure: isHttps,
sameSite: 'strict', maxAge: 30 * 24 * 60 * 60, path: '/'
});
return;
}
return reply.code(401).send({ error: true, message: 'Not authenticated' });
}
async function abandonedCallRoutes(fastify, options) {
// Page route
fastify.get('/abandoned-calls', { preHandler: requireAuthPage }, async (req, reply) => {
const appConfig = config.getAll();
const stats = database.getAbandonedCallStats();
return reply.view('abandoned-calls', {
config: appConfig,
stats
});
});
// API: list abandoned calls (proxy to backend or serve from cache)
fastify.get('/api/abandoned-calls', { preHandler: requireAuthApi }, async (req, reply) => {
const appConfig = config.getAll();
const token = appConfig.authToken;
const botId = appConfig.selectedBotId;
if (!token || !botId) {
return { error: true, message: 'Not configured' };
}
const options = {
limit: parseInt(req.query.limit, 10) || 50,
offset: parseInt(req.query.offset, 10) || 0
};
if (req.query.stage) options.stage = req.query.stage;
if (req.query.priority) options.priority = req.query.priority;
const result = await apiClient.getAbandonedCalls(token, botId, options);
if (result.error) {
// Fall back to local cache
const cached = database.getCachedAbandonedCalls({
status: req.query.priority ? undefined : undefined,
priority: req.query.priority || undefined,
limit: options.limit
});
return { error: false, calls: cached, cached: true };
}
// Update local cache
if (result.calls) {
for (const call of result.calls) {
database.cacheAbandonedCall(call.id, call);
}
}
return { error: false, calls: result.calls || [] };
});
// API: callback queue
fastify.get('/api/abandoned-calls/callback-queue', { preHandler: requireAuthApi }, async (req, reply) => {
const appConfig = config.getAll();
const token = appConfig.authToken;
const botId = appConfig.selectedBotId;
if (!token || !botId) {
return { error: true, message: 'Not configured' };
}
const limit = parseInt(req.query.limit, 10) || 20;
const offset = parseInt(req.query.offset, 10) || 0;
const result = await apiClient.getAbandonedCallbackQueue(token, botId, limit, offset);
if (result.error) {
const cached = database.getCachedAbandonedCalls({ status: 'queued', limit });
return { error: false, queue: cached, cached: true };
}
if (result.queue) {
for (const call of result.queue) {
database.cacheAbandonedCall(call.id, call);
}
}
return { error: false, queue: result.queue || [] };
});
// API: update callback action
fastify.post('/api/abandoned-calls/:id/action', { preHandler: requireAuthApi }, async (req, reply) => {
const abandonedCallId = parseInt(req.params.id, 10);
const { action, notes } = req.body || {};
if (!action) {
return { error: true, message: 'Action is required' };
}
const validActions = ['call_back', 'reached', 'no_answer', 'converted', 'dismissed', 'deferred'];
if (!validActions.includes(action)) {
return { error: true, message: 'Invalid action' };
}
const appConfig = config.getAll();
const token = appConfig.authToken;
if (!token) {
return { error: true, message: 'Not configured' };
}
const result = await apiClient.updateAbandonedCallback(token, abandonedCallId, action, notes || '');
if (!result.error) {
const statusMap = {
call_back: 'attempted', reached: 'reached', no_answer: 'no_answer',
converted: 'converted', dismissed: 'dismissed', deferred: 'deferred'
};
database.updateCachedCallbackStatus(abandonedCallId, statusMap[action] || action);
}
return result;
});
// API: metrics
fastify.get('/api/abandoned-calls/metrics', { preHandler: requireAuthApi }, async (req, reply) => {
const appConfig = config.getAll();
const token = appConfig.authToken;
const botId = appConfig.selectedBotId;
if (!token || !botId) {
return { error: true, message: 'Not configured' };
}
const startDate = parseInt(req.query.startDate, 10) || 0;
const endDate = parseInt(req.query.endDate, 10) || 0;
return await apiClient.getAbandonedCallMetrics(token, botId, startDate, endDate);
});
// API: reprint abandoned call receipt
fastify.post('/api/abandoned-calls/:id/reprint', { preHandler: requireAuthApi }, async (req, reply) => {
const abandonedCallId = parseInt(req.params.id, 10);
// Get call data from cache
const cached = database.getCachedAbandonedCalls({ limit: 200 });
const call = cached.find(c => c.id === abandonedCallId);
if (!call) {
// Try fetching from API
const appConfig = config.getAll();
const result = await apiClient.getAbandonedCalls(appConfig.authToken, appConfig.selectedBotId, { limit: 1 });
const fromApi = (result.calls || []).find(c => c.id === abandonedCallId);
if (!fromApi) {
return { error: true, message: 'Abandoned call not found' };
}
return await doPrint(fromApi);
}
return await doPrint(call);
async function doPrint(callData) {
try {
const printerConfigs = database.getAbandonedCallPrinters();
if (!printerConfigs || printerConfigs.length === 0) {
return { error: true, message: 'No printers configured for abandoned call receipts' };
}
const result = await printer.printAbandonedCallReceipt(callData, printerConfigs);
if (result && result.success) {
return { error: false, message: `Printed on ${result.successCount} printer(s)` };
}
return { error: true, message: 'Print failed' };
} catch (err) {
return { error: true, message: err.message };
}
}
});
// API: pending count (lightweight, for dashboard badge)
fastify.get('/api/abandoned-calls/pending-count', { preHandler: requireAuthApi }, async (req, reply) => {
const count = database.getPendingAbandonedCallCount();
return { error: false, count };
});
}
module.exports = abandonedCallRoutes;