Files
bi-agents/server.js
root 844f931076 feat: BI-CCC evolution — 6-phase platform upgrade (45→85 maturity)
Phase 1: Refactor queries.js (1787 lines) into domain modules with facade pattern
  - src/queries/{helpers,payin,payout,corporate,bi,client,provider,compliance}.queries.js
  - New provider performance + compliance data layer queries
  - Health check endpoint (GET /health)

Phase 2: Provider Performance Dashboard (src/admin-providers.js)
  - Hero cards, sortable tables, Chart.js charts, date range filter
  - API routes: /admin/api/providers, /admin/api/providers/failed, /admin/api/providers/trend

Phase 3: Excel Export (exceljs)
  - CambioReal-branded exports for BI, clients, providers, transactions
  - Export buttons added to BI and Client 360 dashboards

Phase 4: Alert System (node-cron + nodemailer)
  - 5 alert rules: volume spike, spread anomaly, large tx, failed tx spike, provider inactivity
  - SQLite alerts table, bell icon UI with acknowledge workflow
  - Email notifications via SMTP

Phase 5: Enhanced Analytics
  - Churn prediction: weighted RFM model (src/services/churn-predictor.js)
  - Volume forecasting: exponential smoothing with confidence bands (src/services/forecast.js)
  - Forecast chart in BI dashboard, churn risk in Client 360

Phase 6: SQLite Analytics Store (ETL)
  - src/db-analytics.js: daily_metrics, client_health_daily, monthly_revenue tables
  - src/etl/daily-sync.js: MySQL RDS → SQLite daily sync at 1 AM + 90-day backfill
  - src/etl/data-quality.js: post-sync validation (row counts, reconciliation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:22:23 -05:00

970 lines
36 KiB
JavaScript

/**
* 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, createAgente, createUser } = require('./src/auth');
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 role
function getRedirectByRole(role) {
if (role === 'admin') return '/corporate'; // Admin vai direto pro Corporate Dashboard
if (role === 'corporate') return '/corporate';
return '/dashboard';
}
// Root -> login page (or redirect if logged in)
app.get('/', (req, res) => {
if (req.session?.user) {
return res.redirect(getRedirectByRole(req.session.user.role));
}
res.redirect('/login');
});
// Login page
app.get('/login', (req, res) => {
if (req.session?.user) {
return res.redirect(getRedirectByRole(req.session.user.role));
}
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
req.session.user = {
id: user.id,
email: user.email,
nome: user.nome,
role: user.role || 'agente',
agente_id: user.agente_id
};
// Redirect based on role
res.redirect(getRedirectByRole(user.role));
} 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 (agente only)
app.get('/dashboard', requireRole('agente'), 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 (admin only)
app.get('/admin', requireRole('admin'), (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', requireRole('admin'), (req, res) => {
res.redirect('/admin');
});
// Legacy route - redirect to /admin
app.get('/admin/agentes', requireRole('admin'), (req, res) => {
res.redirect('/admin');
});
// --- Corporate Routes (Dashboard + Emulation - corporate and admin) ---
// Corporate Dashboard - Full KPIs, Trends and Ranking
app.get('/corporate', requireRole('corporate', 'admin'), 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', requireRole('corporate', 'admin'), (req, res) => {
res.redirect('/corporate');
});
// Corporate emulate agent - view dashboard as specific agent
app.get('/corporate/emular/:agente_id', requireRole('corporate', 'admin'), 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 // Pass the emulator's role
}, 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', requireRole('admin'), (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', requireRole('corporate', 'admin'), 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', requireRole('corporate', 'admin'), 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', requireRole('corporate', 'admin'), 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', requireRole('corporate', 'admin'), 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', requireRole('corporate', 'admin'), 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', requireRole('corporate', 'admin'), 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', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/data?${new URLSearchParams(req.query)}`);
});
app.get('/admin/api/kpis', requireRole('admin'), (req, res) => {
res.redirect('/corporate/api/kpis');
});
app.get('/admin/api/trend', requireRole('admin'), (req, res) => {
res.redirect('/corporate/api/trend');
});
app.get('/admin/api/top-agentes', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/top-agentes?${new URLSearchParams(req.query)}`);
});
app.get('/admin/api/corporate/kpis', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/kpis-period?${new URLSearchParams(req.query)}`);
});
app.get('/admin/api/corporate/trend', requireRole('admin'), (req, res) => {
res.redirect(`/corporate/api/trend-period?${new URLSearchParams(req.query)}`);
});
app.get('/admin/dashboard', requireRole('admin'), (req, res) => {
res.redirect('/corporate/dashboard');
});
// --- Admin BI Dashboard (admin only) ---
app.get('/admin/bi', requireRole('admin'), (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', requireRole('admin'), 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 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', requireRole('admin'), async (req, res) => {
try {
const { start, end, granularity } = req.query;
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
const data = await fetchRevenueAnalytics(start, end, granularity || 'dia');
res.json(data);
} catch (err) {
console.error('Revenue API error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/admin/api/bi/strategic', requireRole('admin'), 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 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 (admin only) ---
app.get('/admin/cliente', requireRole('admin'), (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', requireRole('admin'), 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', requireRole('admin'), 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', requireRole('admin'), 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', requireRole('admin'), 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)
]);
// Add checkout KPIs
data.kpis.checkout = mData.kpis;
// Merge totals
data.kpis.total.qtd += mData.kpis.qtd;
data.kpis.total.vol_usd += mData.kpis.vol_usd;
data.kpis.total.spread_revenue += mData.kpis.revenue;
const totalQtd = data.kpis.total.qtd;
data.kpis.total.ticket_medio = totalQtd > 0 ? Math.round(data.kpis.total.vol_usd / totalQtd) : 0;
// Merge comparison
data.comparison.prev_qtd += mData.comparison.prev_qtd;
data.comparison.prev_vol_usd += mData.comparison.prev_vol_usd;
data.comparison.prev_spread += mData.comparison.prev_revenue;
// 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 } = req.body;
try {
if (!nome || !email || !senha) {
return res.status(400).json({ error: 'Nome, email e senha sao obrigatorios' });
}
const userRole = role || 'agente';
// Admin and Corporate don't need agente_id
const agenteId = (userRole === 'admin' || userRole === 'corporate') ? 0 : (agente_id || 0);
if (userRole === 'agente' && !agente_id) {
return res.status(400).json({ error: 'Agente ID e obrigatorio para agentes' });
}
const result = await createUser(email, senha, nome, userRole, agenteId);
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 } = 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);
}
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', requireRole('admin'), 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', requireRole('admin'), 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', requireRole('admin'), 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', requireRole('admin'), 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', requireRole('admin'), 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', requireRole('admin'), 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', requireRole('admin'), (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', requireRole('admin'), (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', requireRole('admin'), (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 (admin only) ---
app.get('/admin/providers', requireRole('admin'), (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', requireRole('admin'), 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);
res.json(data);
} catch (err) {
console.error('Provider API error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/admin/api/providers/failed', requireRole('admin'), 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 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', requireRole('admin'), 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 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();
});