/** * BI - CCC (Central Command Center) — CambioReal * Login Unificado: todos os usuarios acessam via /login * * Uso: node server.js * Abre: http://localhost:3080 */ require('dotenv').config(); const express = require('express'); const session = require('express-session'); const path = require('path'); const { authenticate, requireAuth, requireRole, requirePermission, createAgente, createUser } = require('./src/auth'); const { PANELS, PANEL_ROUTE } = require('./src/panels'); const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData, fetchMerchantProfile, fetchMerchantData, fetchProviderPerformance, fetchFailedTransactions, fetchProviderTrend } = require('./src/queries'); const { buildAdminProvidersHTML } = require('./src/admin-providers'); const pool = require('./src/db-rds'); const { buildHTML } = require('./src/dashboard'); const { buildAdminHTML } = require('./src/admin-panel'); const { buildAdminHomeHTML } = require('./src/admin-home'); const { buildAdminDashboardHTML } = require('./src/admin-dashboard'); const { buildAdminBIHTML } = require('./src/admin-bi'); const { buildAdminClienteHTML } = require('./src/admin-cliente'); const { exportToExcel, createWorkbook, sendWorkbook } = require('./src/export/excel-export'); const { startAlertEngine, getAlerts, acknowledgeAlert, getAlertHistory, getUnackedCount } = require('./src/alerts/alert-engine'); const { predictChurnRisk } = require('./src/services/churn-predictor'); const { forecastFromTrend } = require('./src/services/forecast'); const { startETL } = require('./src/etl/daily-sync'); const bcrypt = require('bcrypt'); const db = require('./src/db-local'); const cache = require('./src/cache'); const app = express(); const PORT = process.env.PORT || 3080; // Middleware app.use(express.urlencoded({ extended: false })); app.use(express.json()); app.use(session({ secret: process.env.SESSION_SECRET || 'bi-agentes-default-secret', resave: false, saveUninitialized: false, cookie: { maxAge: 8 * 60 * 60 * 1000 }, // 8 horas })); // Static files app.use('/public', express.static(path.join(__dirname, 'public'))); // --- Unified Login Routes --- // Helper function to get redirect URL based on user permissions function getRedirectForUser(user) { const perms = Array.isArray(user.permissions) ? user.permissions : JSON.parse(user.permissions || '[]'); const priority = ['corporate', 'bi', 'cliente', 'providers', 'usuarios', 'dashboard']; for (const key of priority) { if (perms.includes(key)) return PANEL_ROUTE[key]; } return '/login'; } // Root -> login page (or redirect if logged in) app.get('/', (req, res) => { if (req.session?.user) { return res.redirect(getRedirectForUser(req.session.user)); } res.redirect('/login'); }); // Login page app.get('/login', (req, res) => { if (req.session?.user) { return res.redirect(getRedirectForUser(req.session.user)); } res.sendFile(path.join(__dirname, 'public', 'login.html')); }); // Unified Login POST - detects role and redirects accordingly app.post('/login', async (req, res) => { const { email, senha } = req.body; const emailParam = encodeURIComponent(email || ''); try { const user = await authenticate(email, senha); if (!user) return res.redirect(`/login?error=1&email=${emailParam}`); // Unified session const permissions = JSON.parse(user.permissions || '[]'); req.session.user = { id: user.id, email: user.email, nome: user.nome, role: user.role || 'agente', agente_id: user.agente_id, permissions }; // Redirect based on permissions res.redirect(getRedirectForUser(req.session.user)); } catch (err) { console.error('Login error:', err); res.redirect(`/login?error=1&email=${emailParam}`); } }); // Unified Logout app.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/login')); }); // Legacy admin login - redirect to unified login app.get('/admin/login', (req, res) => { res.redirect('/login'); }); // Legacy admin logout - redirect to unified logout app.get('/admin/logout', (req, res) => { res.redirect('/logout'); }); // --- Agent Routes --- // Dashboard (requires 'dashboard' permission) app.get('/dashboard', requirePermission('dashboard'), async (req, res) => { try { const user = req.session.user; const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(user.agente_id); const data = serialize(rowsBrlUsd, rowsUsdBrl); const html = buildHTML(data, user); res.send(html); } catch (err) { console.error('Dashboard error:', err); res.status(500).send('Erro ao carregar dashboard: ' + err.message); } }); // --- Admin Routes (User Management - admin only) --- // Admin home - User management panel app.get('/admin', requirePermission('usuarios'), (req, res) => { try { const agentes = db.prepare('SELECT * FROM agentes ORDER BY id DESC').all(); const html = buildAdminHTML(agentes, req.session.user); res.send(html); } catch (err) { console.error('Admin panel error:', err); res.status(500).send('Erro ao carregar painel admin: ' + err.message); } }); // Alias: /admin/usuarios -> /admin app.get('/admin/usuarios', requirePermission('usuarios'), (req, res) => { res.redirect('/admin'); }); // Legacy route - redirect to /admin app.get('/admin/agentes', requirePermission('usuarios'), (req, res) => { res.redirect('/admin'); }); // --- Corporate Routes (Dashboard + Emulation - corporate and admin) --- // Corporate Dashboard - Full KPIs, Trends and Ranking app.get('/corporate', requirePermission('corporate'), async (req, res) => { try { const user = req.session.user; const html = buildAdminDashboardHTML(user); res.send(html); } catch (err) { console.error('Corporate dashboard error:', err); res.status(500).send('Erro ao carregar dashboard corporate: ' + err.message); } }); // Legacy route - redirect to /corporate app.get('/corporate/dashboard', requirePermission('corporate'), (req, res) => { res.redirect('/corporate'); }); // Corporate emulate agent - view dashboard as specific agent app.get('/corporate/emular/:agente_id', requirePermission('corporate'), async (req, res) => { try { const agenteId = parseInt(req.params.agente_id); const agente = db.prepare('SELECT * FROM agentes WHERE agente_id = ?').get(agenteId); if (!agente) { return res.status(404).send('Agente nao encontrado'); } const { rowsBrlUsd, rowsUsdBrl } = await fetchTransacoes(agenteId); const data = serialize(rowsBrlUsd, rowsUsdBrl); const html = buildHTML(data, { nome: agente.nome + ' (Emulando)', agente_id: agenteId, email: agente.email, emulatorRole: req.session.user.role, permissions: req.session.user.permissions || [] }, true, null, false, true); // isEmulating = true res.send(html); } catch (err) { console.error('Corporate emulate error:', err); res.status(500).send('Erro ao emular agente: ' + err.message); } }); // Legacy route - redirect to /corporate/emular app.get('/admin/emular/:agente_id', requirePermission('corporate'), (req, res) => { res.redirect(`/corporate/emular/${req.params.agente_id}`); }); // --- Live Rate Proxy (caches for 3s to avoid rate limiting) --- let _rateCache = { data: null, ts: 0 }; app.get('/api/cotacao', async (req, res) => { try { const now = Date.now(); if (_rateCache.data && now - _rateCache.ts < 3000) { return res.json(_rateCache.data); } const token = process.env.AWESOME_API_TOKEN || ''; const url = 'https://economia.awesomeapi.com.br/json/last/USD-BRL,EUR-BRL' + (token ? '?token=' + token : ''); const resp = await fetch(url); const json = await resp.json(); _rateCache = { data: json, ts: now }; res.json(json); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- Corporate API Routes (dashboard data - corporate and admin) --- // API endpoint for corporate dashboard data app.get('/corporate/api/data', requirePermission('corporate'), async (req, res) => { try { const dias = parseInt(req.query.dias) || 90; const { rowsBrlUsd, rowsUsdBrl } = await fetchAllTransacoes(dias); const data = serialize(rowsBrlUsd, rowsUsdBrl); res.json({ success: true, data, count: data.length }); } catch (err) { console.error('Corporate API error:', err); res.status(500).json({ success: false, error: err.message }); } }); // API: KPIs (hoje vs média 30 dias) - com cache app.get('/corporate/api/kpis', requirePermission('corporate'), async (req, res) => { try { const data = await cache.getOrFetch('kpis', fetchKPIs, 5 * 60 * 1000); res.json({ success: true, data }); } catch (err) { console.error('KPIs API error:', err); res.status(500).json({ success: false, error: err.message }); } }); // API: Tendência 30 dias - com cache app.get('/corporate/api/trend', requirePermission('corporate'), async (req, res) => { try { const data = await cache.getOrFetch('trend30', fetchTrend30Days, 10 * 60 * 1000); res.json({ success: true, data }); } catch (err) { console.error('Trend API error:', err); res.status(500).json({ success: false, error: err.message }); } }); // API: Top 5 agentes - com cache por período app.get('/corporate/api/top-agentes', requirePermission('corporate'), async (req, res) => { try { const dias = parseInt(req.query.dias) || 30; const cacheKey = `top-agentes-${dias}`; // Busca dados do RDS (com cache) const rawData = await cache.getOrFetch(cacheKey, () => fetchTopAgentes(dias), 10 * 60 * 1000); // Adiciona nomes dos agentes do SQLite local const data = rawData.map(r => { const agente = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(r.agente_id); return { ...r, agente: agente?.nome || `Agente #${r.agente_id}` }; }); res.json({ success: true, data }); } catch (err) { console.error('Top Agentes API error:', err); res.status(500).json({ success: false, error: err.message }); } }); // API: Corporate Dashboard - KPIs por período app.get('/corporate/api/kpis-period', requirePermission('corporate'), async (req, res) => { try { const { inicio, fim } = req.query; if (!inicio || !fim) { return res.status(400).json({ success: false, error: 'Parametros inicio e fim sao obrigatorios' }); } const data = await fetchKPIsByPeriod(inicio, fim); res.json({ success: true, data }); } catch (err) { console.error('Corporate KPIs API error:', err); res.status(500).json({ success: false, error: err.message }); } }); // API: Corporate Dashboard - Tendência por período app.get('/corporate/api/trend-period', requirePermission('corporate'), async (req, res) => { try { const { inicio, fim } = req.query; if (!inicio || !fim) { return res.status(400).json({ success: false, error: 'Parametros inicio e fim sao obrigatorios' }); } const data = await fetchTrendByPeriod(inicio, fim); res.json({ success: true, data }); } catch (err) { console.error('Corporate Trend API error:', err); res.status(500).json({ success: false, error: err.message }); } }); // Legacy API routes - redirect to /corporate/api/* app.get('/admin/api/data', requirePermission('corporate'), (req, res) => { res.redirect(`/corporate/api/data?${new URLSearchParams(req.query)}`); }); app.get('/admin/api/kpis', requirePermission('corporate'), (req, res) => { res.redirect('/corporate/api/kpis'); }); app.get('/admin/api/trend', requirePermission('corporate'), (req, res) => { res.redirect('/corporate/api/trend'); }); app.get('/admin/api/top-agentes', requirePermission('corporate'), (req, res) => { res.redirect(`/corporate/api/top-agentes?${new URLSearchParams(req.query)}`); }); app.get('/admin/api/corporate/kpis', requirePermission('corporate'), (req, res) => { res.redirect(`/corporate/api/kpis-period?${new URLSearchParams(req.query)}`); }); app.get('/admin/api/corporate/trend', requirePermission('corporate'), (req, res) => { res.redirect(`/corporate/api/trend-period?${new URLSearchParams(req.query)}`); }); app.get('/admin/dashboard', requirePermission('corporate'), (req, res) => { res.redirect('/corporate/dashboard'); }); // --- Admin BI Dashboard --- app.get('/admin/bi', requirePermission('bi'), (req, res) => { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); res.set('Pragma', 'no-cache'); const html = buildAdminBIHTML(req.session.user); res.send(html); } catch (err) { console.error('Admin BI error:', err); res.status(500).send('Erro ao carregar BI: ' + err.message); } }); app.get('/admin/api/bi', requirePermission('bi'), async (req, res) => { try { const start = req.query.start; const end = req.query.end; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const getAgenteName = (agenteId) => { const row = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(agenteId); return row ? row.nome : null; }; const data = await cache.getOrFetchRange('bi', start, end, () => fetchBIData(start, end, getAgenteName)); res.json(data); } catch (err) { console.error('Admin BI API error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/bi/revenue', requirePermission('bi'), async (req, res) => { try { const { start, end, granularity } = req.query; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const g = granularity || 'dia'; const data = await cache.getOrFetchRange(`bi-rev-${g}`, start, end, () => fetchRevenueAnalytics(start, end, g)); res.json(data); } catch (err) { console.error('Revenue API error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/bi/strategic', requirePermission('bi'), async (req, res) => { try { const { start, end } = req.query; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const data = await cache.getOrFetchRange('bi-strat', start, end, () => fetchBIStrategic(start, end)); res.json(data); } catch (err) { console.error('Strategic BI API error:', err); res.status(500).json({ error: err.message }); } }); // --- Admin Cliente Dashboard --- app.get('/admin/cliente', requirePermission('cliente'), (req, res) => { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); res.set('Pragma', 'no-cache'); const html = buildAdminClienteHTML(req.session.user); res.send(html); } catch (err) { console.error('Admin Cliente error:', err); res.status(500).send('Erro ao carregar pagina de cliente: ' + err.message); } }); app.get('/admin/api/clientes/top', requirePermission('cliente'), async (req, res) => { try { const data = await cache.getOrFetch('top-clients', fetchTopClients, 15 * 60 * 1000); res.json(data); } catch (err) { console.error('Top clients API error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/clientes/search', requirePermission('cliente'), async (req, res) => { try { const q = (req.query.q || '').trim(); if (q.length < 2) return res.json([]); const data = await fetchClientSearch(q); res.json(data); } catch (err) { console.error('Client search API error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/cliente/:id/profile', requirePermission('cliente'), async (req, res) => { try { const clienteId = parseInt(req.params.id); if (!clienteId) return res.status(400).json({ error: 'Invalid client ID' }); const [profile, merchant] = await Promise.all([ fetchClientProfile(clienteId), fetchMerchantProfile(clienteId) ]); if (merchant.is_merchant) { const ck = merchant.checkout; profile.merchant = { empresa_id: merchant.empresa_id, nome_empresa: merchant.nome_empresa }; profile.total_ops += ck.tx_count; profile.total_vol_usd += ck.vol_usd; profile.total_spread_revenue += ck.revenue; profile.ltv = profile.total_spread_revenue; // Extend date ranges const dates = [profile.first_op, ck.first_op].filter(Boolean); const lastDates = [profile.last_op, ck.last_op].filter(Boolean); if (dates.length) profile.first_op = dates.sort()[0]; if (lastDates.length) { profile.last_op = lastDates.sort().pop(); profile.days_inactive = Math.round((Date.now() - new Date(profile.last_op).getTime()) / 86400000); } profile.months_active = Math.max(profile.months_active, ck.months_active); profile.avg_monthly_vol = profile.months_active > 0 ? Math.round(profile.total_vol_usd / profile.months_active) : 0; profile.avg_monthly_ops = profile.months_active > 0 ? Math.round(profile.total_ops / profile.months_active * 10) / 10 : 0; profile.avg_monthly_revenue = profile.months_active > 0 ? Math.round(profile.total_spread_revenue / profile.months_active * 100) / 100 : 0; profile.checkout = ck; } res.json(profile); } catch (err) { console.error('Client profile API error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/cliente/:id/data', requirePermission('cliente'), async (req, res) => { try { const clienteId = parseInt(req.params.id); const { start, end } = req.query; if (!clienteId || !start || !end) return res.status(400).json({ error: 'client ID, start and end required' }); const merchant = await fetchMerchantProfile(clienteId); if (merchant.is_merchant) { const [data, mData] = await Promise.all([ fetchClientData(clienteId, start, end), fetchMerchantData(merchant.empresa_id, start, end) ]); // Pass checkout KPIs separately (no merge into hero totals) data.kpis.checkout = mData.kpis; // Merchant-specific data data.merchant = { monthly: mData.monthly, topPayers: mData.topPayers, comparison: mData.comparison }; // Merge transactions (checkout txs get flow="Checkout") data.transactions = data.transactions.concat(mData.transactions) .sort((a, b) => b.date.localeCompare(a.date)); res.json(data); } else { const data = await fetchClientData(clienteId, start, end); res.json(data); } } catch (err) { console.error('Client data API error:', err); res.status(500).json({ error: err.message }); } }); // Create user (admin only) app.post('/admin/agentes', requireRole('admin'), async (req, res) => { const { nome, email, agente_id, senha, role, permissions } = req.body; try { if (!nome || !email || !senha) { return res.status(400).json({ error: 'Nome, email e senha sao obrigatorios' }); } const userRole = role || 'agente'; const agenteId = agente_id || 0; // dashboard permission requires agente_id if (Array.isArray(permissions) && permissions.includes('dashboard') && !agenteId) { return res.status(400).json({ error: 'Agente ID e obrigatorio para acesso ao Meu Dashboard' }); } const result = await createUser(email, senha, nome, userRole, agenteId, Array.isArray(permissions) ? permissions : null); res.json({ success: true, id: result.lastInsertRowid }); } catch (err) { console.error('Create user error:', err); if (err.message && err.message.includes('UNIQUE')) { return res.status(400).json({ error: 'E-mail ja cadastrado' }); } res.status(500).json({ error: 'Erro ao criar usuario' }); } }); // Update user (admin only) app.put('/admin/agentes/:id', requireRole('admin'), async (req, res) => { const { id } = req.params; const { nome, email, agente_id, ativo, senha, role, permissions } = req.body; try { const agent = db.prepare('SELECT * FROM agentes WHERE id = ?').get(id); if (!agent) { return res.status(404).json({ error: 'Usuario nao encontrado' }); } if (senha) { const hash = await bcrypt.hash(senha, 10); db.prepare('UPDATE agentes SET senha_hash = ? WHERE id = ?').run(hash, id); } if (nome !== undefined) { db.prepare('UPDATE agentes SET nome = ? WHERE id = ?').run(nome, id); } if (email !== undefined) { db.prepare('UPDATE agentes SET email = ? WHERE id = ?').run(email, id); } if (agente_id !== undefined) { db.prepare('UPDATE agentes SET agente_id = ? WHERE id = ?').run(agente_id, id); } if (ativo !== undefined) { db.prepare('UPDATE agentes SET ativo = ? WHERE id = ?').run(ativo, id); } if (role !== undefined) { db.prepare('UPDATE agentes SET role = ? WHERE id = ?').run(role, id); } if (Array.isArray(permissions)) { // Anti-lockout: prevent user from removing 'usuarios' from their own permissions if (parseInt(id) === req.session.user.id && !permissions.includes('usuarios')) { return res.status(400).json({ error: 'Voce nao pode remover a permissao "Usuarios" de si mesmo' }); } db.prepare('UPDATE agentes SET permissions = ? WHERE id = ?').run(JSON.stringify(permissions), id); } res.json({ success: true }); } catch (err) { console.error('Update user error:', err); if (err.message && err.message.includes('UNIQUE')) { return res.status(400).json({ error: 'E-mail ja cadastrado' }); } res.status(500).json({ error: 'Erro ao atualizar usuario' }); } }); // Delete/deactivate user (admin only) app.delete('/admin/agentes/:id', requireRole('admin'), (req, res) => { const { id } = req.params; try { const result = db.prepare('UPDATE agentes SET ativo = 0 WHERE id = ?').run(id); if (result.changes === 0) { return res.status(404).json({ error: 'Usuario nao encontrado' }); } res.json({ success: true }); } catch (err) { console.error('Delete user error:', err); res.status(500).json({ error: 'Erro ao desativar usuario' }); } }); // --- Excel Export Endpoints --- app.get('/admin/api/export/bi-excel', requirePermission('bi'), async (req, res) => { try { const { start, end } = req.query; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const getAgenteName = (agenteId) => { const row = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(agenteId); return row ? row.nome : null; }; const data = await fetchBIData(start, end, getAgenteName); // Build multi-sheet workbook const ExcelJS = require('exceljs'); const workbook = new ExcelJS.Workbook(); workbook.creator = 'CambioReal BI-CCC'; // Sheet 1: KPI Summary const kpiSheet = workbook.addWorksheet('KPI Summary'); kpiSheet.columns = [ { header: 'Metric', key: 'metric', width: 30 }, { header: 'Current', key: 'current', width: 18 }, { header: 'Previous', key: 'previous', width: 18 }, { header: 'Change %', key: 'change', width: 14 } ]; const k = data.kpis.total; const c = data.comparison; const pctChg = (curr, prev) => prev > 0 ? Math.round((curr - prev) / prev * 100) : 0; kpiSheet.addRows([ { metric: 'Total Transactions', current: k.qtd, previous: c.prev_qtd, change: pctChg(k.qtd, c.prev_qtd) }, { metric: 'Total Volume USD', current: k.vol_usd, previous: c.prev_vol_usd, change: pctChg(k.vol_usd, c.prev_vol_usd) }, { metric: 'Spread Revenue', current: k.spread_revenue, previous: c.prev_spread, change: pctChg(k.spread_revenue, c.prev_spread) }, { metric: 'Active Clients', current: k.clientes, previous: '-', change: '-' }, { metric: 'Avg Ticket', current: k.ticket_medio, previous: '-', change: '-' }, { metric: 'Retention Rate %', current: data.retention.rate, previous: '-', change: '-' } ]); // Sheet 2: Top Clients const clientSheet = workbook.addWorksheet('Top Clients'); clientSheet.columns = [ { header: 'Client', key: 'nome', width: 30 }, { header: 'Volume USD', key: 'vol_usd', width: 18 }, { header: 'Transactions', key: 'qtd', width: 14 } ]; data.topClients.forEach(c => clientSheet.addRow(c)); // Sheet 3: Agent Ranking const agentSheet = workbook.addWorksheet('Agent Ranking'); agentSheet.columns = [ { header: 'Rank', key: 'rank', width: 8 }, { header: 'Agent', key: 'nome', width: 25 }, { header: 'Volume USD', key: 'vol_usd', width: 18 }, { header: 'Transactions', key: 'qtd', width: 14 }, { header: 'Spread Revenue', key: 'spread_revenue', width: 18 }, { header: 'Clients', key: 'clientes', width: 12 } ]; data.agentRanking.forEach(a => agentSheet.addRow(a)); // Sheet 4: Clients at Risk const riskSheet = workbook.addWorksheet('Clients at Risk'); riskSheet.columns = [ { header: 'Client', key: 'nome', width: 30 }, { header: 'Volume USD', key: 'vol_usd', width: 18 }, { header: 'Transactions', key: 'qtd', width: 14 }, { header: 'Last Activity', key: 'last_op', width: 18 }, { header: 'Days Inactive', key: 'days_inactive', width: 14 } ]; data.clientsAtRisk.forEach(c => riskSheet.addRow(c)); // Style all sheet headers workbook.eachSheet(sheet => { const hr = sheet.getRow(1); hr.eachCell(cell => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF7600BE' } }; cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; cell.alignment = { vertical: 'middle', horizontal: 'center' }; }); hr.height = 28; sheet.views = [{ state: 'frozen', ySplit: 1 }]; }); await sendWorkbook(res, workbook, `BI_Executive_${start}_${end}`); } catch (err) { console.error('BI Export error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/export/clients-excel', requirePermission('cliente'), async (req, res) => { try { const clients = await cache.getOrFetch('top-clients', fetchTopClients, 15 * 60 * 1000); await exportToExcel(res, clients, [ { header: 'Client', key: 'nome', width: 30 }, { header: 'Volume USD', key: 'vol', width: 18, type: 'currency' }, { header: 'Operations', key: 'ops', width: 14, type: 'number' }, { header: 'Months Active', key: 'months', width: 14, type: 'number' }, { header: 'Last Activity', key: 'lastOp', width: 16, type: 'date' } ], 'Top Clients', `Top_Clients_${new Date().toISOString().slice(0, 10)}`); } catch (err) { console.error('Clients Export error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/export/providers-excel', requirePermission('providers'), async (req, res) => { try { const { start, end } = req.query; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const data = await fetchProviderPerformance(start, end); await exportToExcel(res, data.providers, [ { header: 'Provider', key: 'provider', width: 18 }, { header: 'Flow', key: 'flow', width: 12 }, { header: 'Total Tx', key: 'total_tx', width: 12, type: 'number' }, { header: 'Success Tx', key: 'success_tx', width: 12, type: 'number' }, { header: 'Success Rate %', key: 'success_rate', width: 14, type: 'percentage' }, { header: 'Volume USD', key: 'vol_usd', width: 18, type: 'currency' }, { header: 'Avg Ticket', key: 'avg_ticket', width: 14, type: 'currency' }, { header: 'Spread %', key: 'avg_spread_pct', width: 12, type: 'percentage' }, { header: 'Settlement Hours', key: 'avg_settlement_hours', width: 16, type: 'number' } ], 'Providers', `Providers_${start}_${end}`); } catch (err) { console.error('Providers Export error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/export/transactions-excel', requirePermission('bi'), async (req, res) => { try { const { start, end } = req.query; const dias = start && end ? null : 90; let data; if (start && end) { // Use fetchBIData for date-filtered transactions const biData = await fetchBIData(start, end); // Combine trend data into flat rows const allTrend = []; ['brlUsd', 'usdBrl', 'usdUsd'].forEach(flow => { (biData.trend[flow] || []).forEach(r => { allTrend.push({ date: r.dia, flow, transactions: r.qtd, volume_usd: r.vol_usd, avg_spread: r.avg_spread || 0 }); }); }); data = allTrend; } else { const raw = await fetchAllTransacoes(dias || 90); data = serialize(raw.rowsBrlUsd, raw.rowsUsdBrl); } await exportToExcel(res, data, data.length > 0 && data[0].fluxo ? [ { header: 'Flow', key: 'fluxo', width: 12 }, { header: 'Client', key: 'cliente', width: 28 }, { header: 'Date', key: 'data_operacao', width: 18 }, { header: 'BRL', key: 'valor_reais', width: 14, type: 'currency' }, { header: 'USD', key: 'valor_dolar', width: 14, type: 'currency' }, { header: 'PTAX', key: 'taxa_ptax', width: 12 }, { header: 'Rate', key: 'taxa_cobrada', width: 12 }, { header: 'Spread', key: 'spread_bruto', width: 12 }, { header: 'Spread %', key: 'spread_pct', width: 10, type: 'percentage' }, { header: 'IOF %', key: 'iof_pct', width: 8 }, { header: 'Status', key: 'status', width: 14 } ] : [ { header: 'Date', key: 'date', width: 14 }, { header: 'Flow', key: 'flow', width: 12 }, { header: 'Transactions', key: 'transactions', width: 14, type: 'number' }, { header: 'Volume USD', key: 'volume_usd', width: 16, type: 'currency' }, { header: 'Avg Spread %', key: 'avg_spread', width: 14, type: 'percentage' } ], 'Transactions', `Transactions_${start || 'last90d'}_${end || 'today'}`); } catch (err) { console.error('Transactions Export error:', err); res.status(500).json({ error: err.message }); } }); // --- Forecast API --- app.get('/admin/api/bi/forecast', requirePermission('bi'), async (req, res) => { try { const metric = req.query.metric || 'volume'; const days = parseInt(req.query.days) || 30; // Get last 90 days of trend data for forecasting const now = new Date(); const start = new Date(now.getTime() - 90 * 86400000).toISOString().slice(0, 10); const end = now.toISOString().slice(0, 10); const biData = await fetchBIData(start, end); // Combine all flows into daily totals const dailyMap = {}; ['brlUsd', 'usdBrl', 'usdUsd'].forEach(flow => { (biData.trend[flow] || []).forEach(d => { if (!dailyMap[d.dia]) dailyMap[d.dia] = { dia: d.dia, vol_usd: 0, qtd: 0 }; dailyMap[d.dia].vol_usd += d.vol_usd; dailyMap[d.dia].qtd += d.qtd; }); }); const trendData = Object.values(dailyMap).sort((a, b) => a.dia.localeCompare(b.dia)); const metricKey = metric === 'transactions' ? 'qtd' : 'vol_usd'; const result = forecastFromTrend(trendData, metricKey, days); res.json(result); } catch (err) { console.error('Forecast API error:', err); res.status(500).json({ error: err.message }); } }); // --- Churn Risk API --- app.get('/admin/api/cliente/:id/churn', requirePermission('cliente'), async (req, res) => { try { const clienteId = parseInt(req.params.id); if (!clienteId) return res.status(400).json({ error: 'Invalid client ID' }); // Get profile and recent data const profile = await fetchClientProfile(clienteId); const now = new Date(); const end = now.toISOString().slice(0, 10); const start30 = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10); const start60 = new Date(now.getTime() - 60 * 86400000).toISOString().slice(0, 10); const clientData = await fetchClientData(clienteId, start60, end); // Count operations in current vs previous 30-day windows const currOps = (clientData.trend.brlUsd || []) .filter(d => d.dia >= start30) .reduce((s, d) => s + d.qtd, 0) + (clientData.trend.usdBrl || []) .filter(d => d.dia >= start30) .reduce((s, d) => s + d.qtd, 0); const prevOps = (clientData.trend.brlUsd || []) .filter(d => d.dia < start30 && d.dia >= start60) .reduce((s, d) => s + d.qtd, 0) + (clientData.trend.usdBrl || []) .filter(d => d.dia < start30 && d.dia >= start60) .reduce((s, d) => s + d.qtd, 0); // Determine product count let productCount = 0; if (profile.brlUsd && profile.brlUsd.qtd > 0) productCount++; if (profile.usdBrl && profile.usdBrl.qtd > 0) productCount++; const churn = predictChurnRisk({ days_inactive: profile.days_inactive, avg_monthly_ops: profile.avg_monthly_ops, avg_monthly_vol: profile.avg_monthly_vol, months_active: profile.months_active, curr_ops: currOps, prev_ops: prevOps, product_count: productCount }); res.json(churn); } catch (err) { console.error('Churn API error:', err); res.status(500).json({ error: err.message }); } }); // --- Alert API Endpoints --- app.get('/admin/api/alerts', requirePermission('bi'), (req, res) => { try { const unacked = req.query.unacked === '1'; const alerts = getAlerts(24, unacked); res.json({ alerts, unacked_count: getUnackedCount() }); } catch (err) { console.error('Alerts API error:', err); res.status(500).json({ error: err.message }); } }); app.put('/admin/api/alerts/:id/ack', requirePermission('bi'), (req, res) => { try { const id = parseInt(req.params.id); acknowledgeAlert(id); res.json({ success: true }); } catch (err) { console.error('Alert ack error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/alerts/history', requirePermission('bi'), (req, res) => { try { const days = parseInt(req.query.days) || 7; const alerts = getAlertHistory(days); res.json({ alerts }); } catch (err) { console.error('Alert history error:', err); res.status(500).json({ error: err.message }); } }); // --- Health Check --- app.get('/health', async (req, res) => { const health = { status: 'ok', uptime: Math.round(process.uptime()), memory: { rss: Math.round(process.memoryUsage().rss / 1024 / 1024), heap: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) }, cache: cache.stats(), timestamp: new Date().toISOString() }; try { const conn = await pool.getConnection(); await conn.execute('SELECT 1'); conn.release(); health.mysql = 'connected'; } catch (err) { health.mysql = 'error: ' + err.message; health.status = 'degraded'; } res.json(health); }); // --- Provider Dashboard --- app.get('/admin/providers', requirePermission('providers'), (req, res) => { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); const html = buildAdminProvidersHTML(req.session.user); res.send(html); } catch (err) { console.error('Admin Providers error:', err); res.status(500).send('Erro ao carregar providers: ' + err.message); } }); app.get('/admin/api/providers', requirePermission('providers'), async (req, res) => { try { const { start, end } = req.query; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const data = await cache.getOrFetchRange('providers', start, end, () => fetchProviderPerformance(start, end)); res.json(data); } catch (err) { console.error('Provider API error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/providers/failed', requirePermission('providers'), async (req, res) => { try { const { start, end } = req.query; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const data = await cache.getOrFetchRange('providers-fail', start, end, () => fetchFailedTransactions(start, end)); res.json(data); } catch (err) { console.error('Failed TX API error:', err); res.status(500).json({ error: err.message }); } }); app.get('/admin/api/providers/trend', requirePermission('providers'), async (req, res) => { try { const { start, end } = req.query; if (!start || !end) return res.status(400).json({ error: 'start and end required' }); const data = await cache.getOrFetchRange('providers-trend', start, end, () => fetchProviderTrend(start, end)); res.json(data); } catch (err) { console.error('Provider Trend API error:', err); res.status(500).json({ error: err.message }); } }); // Start app.listen(PORT, () => { console.log(`BI - CCC rodando: http://localhost:${PORT}`); // Inicializa cache com auto-refresh (atualiza a cada 5 minutos) console.log('[Cache] Inicializando cache com auto-refresh...'); cache.registerAutoRefresh('kpis', fetchKPIs, 5 * 60 * 1000); cache.registerAutoRefresh('trend30', fetchTrend30Days, 10 * 60 * 1000); cache.registerAutoRefresh('top-agentes-30', () => fetchTopAgentes(30), 10 * 60 * 1000); cache.registerAutoRefresh('top-agentes-7', () => fetchTopAgentes(7), 10 * 60 * 1000); cache.registerAutoRefresh('top-agentes-90', () => fetchTopAgentes(90), 10 * 60 * 1000); // Start alert engine startAlertEngine(); // Start ETL daily sync (MySQL RDS → SQLite analytics) startETL(); });