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 transporter = require('./services/mailer'); const { createCorsMiddleware } = require('./config/cors'); const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime'); const { authenticateToken } = 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 { createOriginsRouter } = require('./routes/originsRoutes'); const { createFunnelsRouter } = require('./routes/funnelsRoutes'); const { createSearchRouter } = require('./routes/searchRoutes'); const { createAttendancesRouter } = require('./routes/attendancesRoutes'); const { createApiKeysRouter } = require('./routes/apiKeysRoutes'); const { createIntegrationsRouter } = require('./routes/integrationsRoutes'); 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 })); apiRouter.use(createOriginsRouter({ pool })); apiRouter.use(createFunnelsRouter({ pool })); apiRouter.use(createSearchRouter({ pool })); apiRouter.use(createAttendancesRouter({ pool })); apiRouter.use(createApiKeysRouter({ pool })); apiRouter.use(createIntegrationsRouter({ pool })); // 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: `
Sua conta de suporte (super_admin) foi criada no Fasto.
Este link expira em 15 minutos.