require('dotenv').config(); const express = require('express'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); const { v4: uuidv4 } = require('uuid'); const fs = require('fs'); const pool = require('./db'); const { hashSecret, maskSecret } = require('./utils/security'); const transporter = require('./services/mailer'); const { createCorsMiddleware } = require('./config/cors'); const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime'); const { authenticateToken, requireRole } = require('./middleware/auth'); const { createAuthRouter } = require('./routes/authRoutes'); const { createUsersRouter } = require('./routes/usersRoutes'); const { createTeamsRouter } = require('./routes/teamsRoutes'); const { createTenantsRouter } = require('./routes/tenantsRoutes'); const { createActivityRouter } = require('./routes/activityRoutes'); const { canReadAttendance } = require('./policies/accessPolicy'); const { recordActivity } = require('./services/activityService'); const app = express(); app.use(createCorsMiddleware({ allowedOrigins, isProduction })); app.use(express.json()); // Logger de Requisições app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); next(); }); // --- Configuração Multer (Upload Seguro) --- const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, uploadDir); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); cb(null, `${uuidv4()}${ext}`); } }); const upload = multer({ storage: storage, limits: { fileSize: 2 * 1024 * 1024 }, // 2MB fileFilter: (req, file, cb) => { const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Tipo de arquivo inválido. Apenas JPG, PNG e WEBP são permitidos.')); } } }); app.use('/uploads', express.static(uploadDir, { setHeaders: (res) => { res.set('X-Content-Type-Options', 'nosniff'); } })); // --- API Router --- const apiRouter = express.Router(); apiRouter.use(authenticateToken); // --- Auth Routes --- apiRouter.use(createAuthRouter({ pool, transporter, getBaseUrl, jwtSecret: JWT_SECRET })); apiRouter.use(createUsersRouter({ pool, upload, transporter, getBaseUrl })); apiRouter.use(createTeamsRouter({ pool })); apiRouter.use(createTenantsRouter({ pool, transporter, getBaseUrl })); apiRouter.use(createActivityRouter({ pool })); // --- Origin Routes (Groups & Items) --- apiRouter.get('/origins', async (req, res) => { try { const { tenantId } = req.query; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]); const [groups] = await pool.query('SELECT * FROM origin_groups WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]); // Seed default origin group if none exists if (groups.length === 0) { const gid = `origrp_${crypto.randomUUID().split('-')[0]}`; await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']); const defaultOrigins = [ { name: 'WhatsApp', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' }, { name: 'Instagram', color: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' }, { name: 'Website', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' }, { name: 'LinkedIn', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' }, { name: 'Indicação', color: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' } ]; for (const origin of defaultOrigins) { const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; await pool.query( 'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)', [oid, gid, origin.name, origin.color] ); } // Update all teams of this tenant to use this origin group if they have none await pool.query('UPDATE teams SET origin_group_id = ? WHERE tenant_id = ? AND origin_group_id IS NULL', [gid, effectiveTenantId]); groups.push({ id: gid, tenant_id: effectiveTenantId, name: 'Origens Padrão' }); } const [items] = await pool.query('SELECT * FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]); const [teams] = await pool.query('SELECT id, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [effectiveTenantId]); const result = groups.map(g => ({ ...g, items: items.filter(i => i.origin_group_id === g.id), teamIds: teams.filter(t => t.origin_group_id === g.id).map(t => t.id) })); res.json(result); } catch (error) { console.error("GET /origins error:", error); res.status(500).json({ error: error.message }); } }); apiRouter.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, tenantId } = req.body; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; try { const gid = `origrp_${crypto.randomUUID().split('-')[0]}`; await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, name]); res.status(201).json({ id: gid }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, teamIds } = req.body; try { if (name) { await pool.query('UPDATE origin_groups SET name = ? WHERE id = ?', [name, req.params.id]); } if (teamIds && Array.isArray(teamIds)) { await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]); if (teamIds.length > 0) { await pool.query('UPDATE teams SET origin_group_id = ? WHERE id IN (?)', [req.params.id, teamIds]); } } res.json({ message: 'Origin group updated.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => { try { await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]); await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]); await pool.query('DELETE FROM origin_groups WHERE id = ?', [req.params.id]); res.json({ message: 'Origin group deleted.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, color_class } = req.body; try { const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; await pool.query( 'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)', [oid, req.params.id, name, color_class || 'bg-zinc-100 text-zinc-800 border-zinc-200'] ); res.status(201).json({ id: oid }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, color_class } = req.body; try { const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]); if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' }); await pool.query('UPDATE origin_items SET name = ?, color_class = ? WHERE id = ?', [name || existing[0].name, color_class || existing[0].color_class, req.params.id]); res.json({ message: 'Origin item updated.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => { try { await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]); res.json({ message: 'Origin item deleted.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // --- Funnel Routes --- apiRouter.get('/funnels', async (req, res) => { try { const { tenantId } = req.query; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]); const [funnels] = await pool.query('SELECT * FROM funnels WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]); // Seed default funnel if none exists if (funnels.length === 0) { const fid = `funnel_${crypto.randomUUID().split('-')[0]}`; await pool.query('INSERT INTO funnels (id, tenant_id, name) VALUES (?, ?, ?)', [fid, effectiveTenantId, 'Funil Padrão']); const defaultStages = [ { name: 'Sem atendimento', color: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border', order: 0 }, { name: 'Identificação', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800', order: 1 }, { name: 'Negociação', color: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800', order: 2 }, { name: 'Ganhos', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800', order: 3 }, { name: 'Perdidos', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800', order: 4 } ]; for (const s of defaultStages) { const sid = `stage_${crypto.randomUUID().split('-')[0]}`; await pool.query( 'INSERT INTO funnel_stages (id, funnel_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)', [sid, fid, s.name, s.color, s.order] ); } // Update all teams of this tenant to use this funnel if they have none await pool.query('UPDATE teams SET funnel_id = ? WHERE tenant_id = ? AND funnel_id IS NULL', [fid, effectiveTenantId]); funnels.push({ id: fid, tenant_id: effectiveTenantId, name: 'Funil Padrão' }); } const [stages] = await pool.query('SELECT * FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]); const [teams] = await pool.query('SELECT id, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [effectiveTenantId]); const result = funnels.map(f => ({ ...f, stages: stages.filter(s => s.funnel_id === f.id), teamIds: teams.filter(t => t.funnel_id === f.id).map(t => t.id) })); res.json(result); } catch (error) { console.error("GET /funnels error:", error); res.status(500).json({ error: error.message }); } }); apiRouter.post('/funnels', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, tenantId } = req.body; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; try { const fid = `funnel_${crypto.randomUUID().split('-')[0]}`; await pool.query('INSERT INTO funnels (id, tenant_id, name) VALUES (?, ?, ?)', [fid, effectiveTenantId, name]); res.status(201).json({ id: fid }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, teamIds } = req.body; try { if (name) { await pool.query('UPDATE funnels SET name = ? WHERE id = ?', [name, req.params.id]); } if (teamIds && Array.isArray(teamIds)) { await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]); if (teamIds.length > 0) { await pool.query('UPDATE teams SET funnel_id = ? WHERE id IN (?)', [req.params.id, teamIds]); } } res.json({ message: 'Funnel updated.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => { try { await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]); await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]); await pool.query('DELETE FROM funnels WHERE id = ?', [req.params.id]); res.json({ message: 'Funnel deleted.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, color_class, order_index } = req.body; try { const sid = `stage_${crypto.randomUUID().split('-')[0]}`; await pool.query( 'INSERT INTO funnel_stages (id, funnel_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)', [sid, req.params.id, name, color_class, order_index || 0] ); res.status(201).json({ id: sid }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, color_class, order_index } = req.body; try { const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]); if (existing.length === 0) return res.status(404).json({ error: 'Stage not found' }); await pool.query( 'UPDATE funnel_stages SET name = ?, color_class = ?, order_index = ? WHERE id = ?', [name || existing[0].name, color_class || existing[0].color_class, order_index !== undefined ? order_index : existing[0].order_index, req.params.id] ); res.json({ message: 'Stage updated.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => { try { await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]); res.json({ message: 'Stage deleted.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // --- Global Search --- apiRouter.get('/search', async (req, res) => { const { q } = req.query; if (!q || q.length < 2) return res.json({ members: [], teams: [], attendances: [], organizations: [] }); const queryStr = `%${q}%`; const results = { members: [], teams: [], attendances: [], organizations: [] }; try { // 1. Search Members (only for roles above agent) if (req.user.role !== 'agent') { let membersQ = 'SELECT id, name, email, slug, role, team_id, avatar_url FROM users WHERE (name LIKE ? OR email LIKE ?)'; const membersParams = [queryStr, queryStr]; if (req.user.role === 'super_admin') { // No extra filters } else if (req.user.role === 'admin') { membersQ += ' AND tenant_id = ?'; membersParams.push(req.user.tenant_id); } else if (req.user.role === 'manager') { membersQ += ' AND tenant_id = ? AND (team_id = ? OR id = ?)'; membersParams.push(req.user.tenant_id, req.user.team_id, req.user.id); } const [members] = await pool.query(membersQ, membersParams); results.members = members; } // 2. Search Teams (only for roles above agent) if (req.user.role !== 'agent') { let teamsQ = 'SELECT id, name, description FROM teams WHERE name LIKE ?'; const teamsParams = [queryStr]; if (req.user.role === 'super_admin') { // No extra filters } else if (req.user.role === 'admin') { teamsQ += ' AND tenant_id = ?'; teamsParams.push(req.user.tenant_id); } else if (req.user.role === 'manager') { teamsQ += ' AND tenant_id = ? AND id = ?'; teamsParams.push(req.user.tenant_id, req.user.team_id); } const [teams] = await pool.query(teamsQ, teamsParams); results.teams = teams; } // 3. Search Organizations (only for super_admin) if (req.user.role === 'super_admin') { const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ? LIMIT 5', [queryStr, queryStr]); results.organizations = orgs; } // 4. Search Attendances let attendancesQ = 'SELECT a.id, a.title, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.title LIKE ?'; const attendancesParams = [queryStr]; if (req.user.role === 'super_admin') { // No extra filters } else if (req.user.role === 'admin') { attendancesQ += ' AND a.tenant_id = ?'; attendancesParams.push(req.user.tenant_id); } else if (req.user.role === 'manager') { attendancesQ += ' AND a.tenant_id = ? AND u.team_id = ?'; attendancesParams.push(req.user.tenant_id, req.user.team_id); } else { attendancesQ += ' AND a.user_id = ?'; attendancesParams.push(req.user.id); } attendancesQ += ' LIMIT 10'; const [attendances] = await pool.query(attendancesQ, attendancesParams); results.attendances = attendances; res.json(results); } catch (error) { res.status(500).json({ error: error.message }); } }); // --- Attendance Routes --- apiRouter.get('/attendances', async (req, res) => { try { const { tenantId, userId, teamId, startDate, endDate, funnelStage, origin } = req.query; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; let q = 'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.tenant_id = ?'; const params = [effectiveTenantId]; if (startDate && endDate) { q += ' AND a.created_at BETWEEN ? AND ?'; params.push(new Date(startDate), new Date(endDate)); } // Strict RBAC: Agents can ONLY see their own data, regardless of what they request if (req.user.role === 'agent') { q += ' AND a.user_id = ?'; params.push(req.user.id); } else { if (req.user.role === 'manager') { q += ' AND u.team_id = ?'; params.push(req.user.team_id); } else if (teamId && teamId !== 'all') { q += ' AND u.team_id = ?'; params.push(teamId); } if (userId && userId !== 'all') { // check if it's a slug or id if (userId.startsWith('u_') || userId.length === 36) { q += ' AND a.user_id = ?'; params.push(userId); } else { q += ' AND u.slug = ?'; params.push(userId); } } } if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); } if (origin && origin !== 'all') { q += ' AND a.origin = ?'; params.push(origin); } q += ' ORDER BY a.created_at DESC'; const [rows] = await pool.query(q, params); const processed = rows.map(r => ({ ...r, attention_points: typeof r.attention_points === 'string' ? JSON.parse(r.attention_points) : r.attention_points, improvement_points: typeof r.improvement_points === 'string' ? JSON.parse(r.improvement_points) : r.improvement_points, converted: Boolean(r.converted) })); res.json(processed); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.get('/attendances/:id', async (req, res) => { try { const [rows] = await pool.query( 'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.id = ?', [req.params.id] ); if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); if (!canReadAttendance(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' }); const r = rows[0]; res.json({ ...r, attention_points: typeof r.attention_points === 'string' ? JSON.parse(r.attention_points) : r.attention_points, improvement_points: typeof r.improvement_points === 'string' ? JSON.parse(r.improvement_points) : r.improvement_points, converted: Boolean(r.converted) }); } catch (error) { res.status(500).json({ error: error.message }); } }); // --- API Key Management Routes --- apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => { try { const { tenantId } = req.query; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]); const [rows] = await pool.query( 'SELECT id, name, created_at, last_used_at, CASE WHEN secret_key LIKE "masked:%" THEN CONCAT("fasto_sk_", RIGHT(secret_key, 6), "...") ELSE CONCAT(SUBSTRING(secret_key, 1, 14), "...") END as masked_key FROM api_keys WHERE tenant_id = ?', [effectiveTenantId] ); res.json(rows); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => { const { name, tenantId } = req.body; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; try { const id = `apk_${crypto.randomUUID().split('-')[0]}`; // Generate a strong, random 32-byte hex string for the secret key const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`; await pool.query( 'INSERT INTO api_keys (id, tenant_id, name, secret_key, secret_hash) VALUES (?, ?, ?, ?, ?)', [id, effectiveTenantId, name || 'Nova Integração API', maskSecret(id, secretKey), hashSecret(secretKey)] ); // We only return the actual secret key ONCE during creation. res.status(201).json({ id, secret_key: secretKey, message: 'Chave criada. Salve-a agora, ela não será exibida novamente.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => { try { const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]); if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' }); if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) return res.status(403).json({ error: 'Acesso negado' }); await pool.query('DELETE FROM api_keys WHERE id = ?', [req.params.id]); res.json({ message: 'Chave de API revogada com sucesso.' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // --- External Integration API (n8n) --- apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) => { if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); try { const [rows] = await pool.query( 'SELECT u.id, u.name, u.email, t.name as team_name FROM users u LEFT JOIN teams t ON u.team_id = t.id WHERE u.tenant_id = ? AND u.status = "active"', [req.user.tenant_id] ); res.json(rows); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => { if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); try { const [groups] = await pool.query('SELECT id, name FROM origin_groups WHERE tenant_id = ?', [req.user.tenant_id]); if (groups.length === 0) return res.json([]); const [items] = await pool.query('SELECT origin_group_id, name FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]); const [teams] = await pool.query('SELECT id as team_id, name as team_name, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [req.user.tenant_id]); const result = groups.map(g => ({ group_name: g.name, origins: items.filter(i => i.origin_group_id === g.id).map(i => i.name), assigned_teams: teams.filter(t => t.origin_group_id === g.id).map(t => ({ id: t.team_id, name: t.team_name })) })); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.get('/integration/funnels', requireRole(['admin']), async (req, res) => { if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); try { const [funnels] = await pool.query('SELECT id, name FROM funnels WHERE tenant_id = ?', [req.user.tenant_id]); if (funnels.length === 0) return res.json([]); const [stages] = await pool.query('SELECT funnel_id, name, order_index FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]); const [teams] = await pool.query('SELECT id as team_id, name as team_name, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [req.user.tenant_id]); const result = funnels.map(f => ({ funnel_name: f.name, stages: stages.filter(s => s.funnel_id === f.id).map(s => s.name), assigned_teams: teams.filter(t => t.funnel_id === f.id).map(t => ({ id: t.team_id, name: t.team_name })) })); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, res) => { if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); const { user_id, origin, funnel_stage, title, full_summary, score, first_response_time_min, handling_time_min, product_requested, product_sold, converted, attention_points, improvement_points } = req.body; if (!user_id || !origin || !funnel_stage || !title) { return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, title' }); } try { // Validate user belongs to the API Key's tenant const [users] = await pool.query('SELECT id FROM users WHERE id = ? AND tenant_id = ? AND status = "active"', [user_id, req.user.tenant_id]); if (users.length === 0) return res.status(400).json({ error: 'user_id inválido, inativo ou não pertence a esta organização.' }); const attId = `att_${crypto.randomUUID().split('-')[0]}`; await pool.query( `INSERT INTO attendances ( id, tenant_id, user_id, title, full_summary, score, first_response_time_min, handling_time_min, funnel_stage, origin, product_requested, product_sold, converted, attention_points, improvement_points ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ attId, req.user.tenant_id, user_id, title, full_summary || null, score || 0, first_response_time_min || 0, handling_time_min || 0, funnel_stage, origin, product_requested || null, product_sold || null, converted ? 1 : 0, attention_points ? JSON.stringify(attention_points) : null, improvement_points ? JSON.stringify(improvement_points) : null ] ); // Automation Trigger: "Venda Fechada!" (Ganhos) if (converted) { // Find the user's manager/admin const [managers] = await pool.query( "SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager') AND id != ?", [req.user.tenant_id, user_id] ); const [agentInfo] = await pool.query("SELECT name FROM users WHERE id = ?", [user_id]); const agentName = agentInfo[0]?.name || 'Um agente'; for (const m of managers) { await recordActivity(pool, { userId: m.id, type: 'success', title: 'Venda Fechada!', message: `${agentName} converteu um lead em ${funnel_stage}.`, link: `/attendances/${attId}`, }); } } res.status(201).json({ id: attId, message: 'Atendimento registrado com sucesso.' }); } catch (error) { console.error('Integration Error:', error); res.status(500).json({ error: error.message }); } }); // Mount the API Router app.use('/api', apiRouter); // Serve static files if (process.env.NODE_ENV === 'production') { app.use(express.static(path.join(__dirname, 'dist'))); app.get('*', (req, res) => { // Avoid hijacking API requests if (req.url.startsWith('/api')) return res.status(404).json({ error: 'API route not found' }); res.sendFile(path.join(__dirname, 'dist/index.html')); }); } // Auto-provision Super Admin const provisionSuperAdmin = async (retries = 10, delay = 10000) => { const email = 'suporte@blyzer.com.br'; for (let i = 0; i < retries; i++) { try { // Test connection first const connection = await pool.getConnection(); // Auto-create missing tables to prevent issues with outdated Docker configs/volumes await connection.query(` CREATE TABLE IF NOT EXISTS password_resets ( email varchar(255) NOT NULL, token varchar(255) NOT NULL, expires_at timestamp NOT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (token), KEY email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); await connection.query(` CREATE TABLE IF NOT EXISTS pending_registrations ( email varchar(255) NOT NULL, password_hash varchar(255) NOT NULL, full_name varchar(255) NOT NULL, organization_name varchar(255) NOT NULL, verification_code varchar(10) NOT NULL, expires_at timestamp NOT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); await connection.query(` CREATE TABLE IF NOT EXISTS notifications ( id varchar(36) NOT NULL, user_id varchar(36) NOT NULL, type enum('success', 'info', 'warning', 'error') DEFAULT 'info', title varchar(255) NOT NULL, message text NOT NULL, link varchar(255) DEFAULT NULL, is_read boolean DEFAULT false, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY user_id (user_id), KEY created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); // Add slug column if it doesn't exist try { await connection.query('ALTER TABLE users ADD COLUMN slug VARCHAR(255) UNIQUE DEFAULT NULL'); } catch (err) { // Ignore error if column already exists (ER_DUP_FIELDNAME) if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (slug):', err.message); } // Populate empty slugs try { await connection.query(`UPDATE users SET slug = CONCAT(LOWER(REPLACE(name, ' ', '-')), '-', SUBSTRING(MD5(RAND()), 1, 8)) WHERE slug IS NULL`); } catch (err) { console.log('Schema update note (populate slugs):', err.message); } // Update origin to VARCHAR for custom origins try { await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL"); } catch (err) { console.log('Schema update note (origin):', err.message); } // Convert funnel_stage to VARCHAR for custom funnels try { await connection.query("ALTER TABLE attendances MODIFY COLUMN funnel_stage VARCHAR(255) NOT NULL"); } catch (err) { console.log('Schema update note (funnel_stage):', err.message); } // Add full_summary column for detailed AI analysis try { await connection.query("ALTER TABLE attendances ADD COLUMN full_summary TEXT DEFAULT NULL"); } catch (err) { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (full_summary):', err.message); } // Create origin_groups table await connection.query(` CREATE TABLE IF NOT EXISTS origin_groups ( id varchar(36) NOT NULL, tenant_id varchar(36) NOT NULL, name varchar(255) NOT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY tenant_id (tenant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); // Create origin_items table await connection.query(` CREATE TABLE IF NOT EXISTS origin_items ( id varchar(36) NOT NULL, origin_group_id varchar(36) NOT NULL, name varchar(255) NOT NULL, color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200', created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY origin_group_id (origin_group_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); // Attempt to add color_class if table already existed without it try { await connection.query("ALTER TABLE origin_items ADD COLUMN color_class VARCHAR(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200'"); } catch (err) { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (origin_items.color_class):', err.message); } // Add origin_group_id to teams try { await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL"); } catch (err) { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.origin_group_id):', err.message); } // Rename summary to title try { await connection.query("ALTER TABLE attendances RENAME COLUMN summary TO title"); } catch (err) { if (err.code !== 'ER_BAD_FIELD_ERROR' && err.code !== 'ER_DUP_FIELDNAME') { // If RENAME COLUMN fails (older mysql), try CHANGE try { await connection.query("ALTER TABLE attendances CHANGE COLUMN summary title TEXT"); } catch (e) { console.log('Schema update note (summary to title):', e.message); } } } // Create funnels table await connection.query(` CREATE TABLE IF NOT EXISTS funnels ( id varchar(36) NOT NULL, tenant_id varchar(36) NOT NULL, name varchar(255) NOT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY tenant_id (tenant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); // Create funnel_stages table await connection.query(` CREATE TABLE IF NOT EXISTS funnel_stages ( id varchar(36) NOT NULL, funnel_id varchar(36) NOT NULL, name varchar(255) NOT NULL, color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200', order_index int DEFAULT 0, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY funnel_id (funnel_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); // Create api_keys table for external integrations (n8n) await connection.query(` CREATE TABLE IF NOT EXISTS api_keys ( id varchar(36) NOT NULL, tenant_id varchar(36) NOT NULL, name varchar(255) NOT NULL, secret_key varchar(255) NOT NULL, secret_hash varchar(64) DEFAULT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, last_used_at timestamp NULL DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY secret_key (secret_key), UNIQUE KEY secret_hash (secret_hash), KEY tenant_id (tenant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); try { await connection.query("ALTER TABLE api_keys ADD COLUMN secret_hash VARCHAR(64) DEFAULT NULL"); } catch (err) { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (api_keys.secret_hash):', err.message); } try { await connection.query("ALTER TABLE api_keys ADD UNIQUE KEY secret_hash (secret_hash)"); } catch (err) { if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (api_keys.secret_hash index):', err.message); } // Create refresh_tokens table for persistent sessions await connection.query(` CREATE TABLE IF NOT EXISTS refresh_tokens ( id varchar(36) NOT NULL, user_id varchar(36) NOT NULL, token varchar(255) NOT NULL, token_hash varchar(64) DEFAULT NULL, expires_at timestamp NOT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY token (token), UNIQUE KEY token_hash (token_hash), KEY user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); try { await connection.query("ALTER TABLE refresh_tokens ADD COLUMN token_hash VARCHAR(64) DEFAULT NULL"); } catch (err) { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (refresh_tokens.token_hash):', err.message); } try { await connection.query("ALTER TABLE refresh_tokens ADD UNIQUE KEY token_hash (token_hash)"); } catch (err) { if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (refresh_tokens.token_hash index):', err.message); } // Add funnel_id to teams try { await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL"); } catch (err) { if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.funnel_id):', err.message); } connection.release(); // Ensure system tenant exists await pool.query('INSERT IGNORE INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', ['system', 'System Admin', 'system', email, 'active']); const [existing] = await pool.query('SELECT id, password_hash FROM users WHERE email = ?', [email]); if (existing.length === 0 || existing[0].password_hash === 'pending_setup') { console.log('Provisioning default super_admin or resending email...'); if (existing.length === 0) { const uid = `u_${crypto.randomUUID().split('-')[0]}`; const placeholderHash = 'pending_setup'; const superAdminSlug = 'suporte-blyzer'; await pool.query( 'INSERT INTO users (id, tenant_id, name, email, password_hash, slug, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [uid, 'system', 'Blyzer Suporte', email, placeholderHash, superAdminSlug, 'super_admin', 'active'] ); } const token = crypto.randomBytes(32).toString('hex'); // Delete any old unused tokens for this email to prevent buildup await pool.query('DELETE FROM password_resets WHERE email = ?', [email]); await pool.query( 'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [email, token] ); const setupLink = `${getStartupBaseUrl()}/#/setup-account?token=${token}`; console.log(`\n\n=== SUPER ADMIN SETUP LINK ===\n${setupLink}\n==============================\n\n`); await transporter.sendMail({ from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`, to: email, subject: 'Conta Super Admin Criada - Fasto', html: `

Conta Super Admin Gerada

Sua conta de suporte (super_admin) foi criada no Fasto.

Finalizar Cadastro

Este link expira em 15 minutos.

` }).catch(err => console.error("Failed to send super_admin email:", err)); } return; // Success, exit the retry loop } catch (error) { console.error(`Failed to provision super_admin (Attempt ${i + 1}/${retries}):`, error.message); if (i < retries - 1) { await new Promise(res => setTimeout(res, delay)); } } } }; app.listen(PORT, async () => { await provisionSuperAdmin(); console.log(`🚀 Servidor Backend rodando em http://localhost:${PORT}`); });