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;