Files
fasto/backend/index.js
Cauê Faleiros 327ad064a4
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m43s
feat: implement secure 2-token authentication with rolling sessions
- Refactored POST /auth/login to issue a 15-minute Access Token and a 30-day Refresh Token.

- Added POST /auth/refresh endpoint to automatically issue new Access Tokens and extend the Refresh Token's lifespan by 30 days upon use (Sliding Expiration).

- Built an HTTP interceptor wrapper (apiFetch) in dataService.ts that automatically catches 401 Unauthorized errors, calls the refresh endpoint, updates localStorage, and silently retries the original request without logging the user out.
2026-03-19 14:45:53 -03:00

1717 lines
72 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const nodemailer = require('nodemailer');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const pool = require('./db');
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'fasto_super_secret_key';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: false, // false para 587 (STARTTLS)
auth: {
user: (process.env.SMTP_USER || 'nao-responda@blyzer.com.br').replace(/^"|"$/g, ''),
pass: (process.env.SMTP_PASS || 'Compor@2017#').replace(/^"|"$/g, ''),
},
tls: {
ciphers: 'SSLv3',
rejectUnauthorized: false
},
debug: true,
logger: true
});
// Helper para obter a URL base
const getBaseUrl = (req) => {
// Use explicit environment variable if set
if (process.env.APP_URL) return process.env.APP_URL;
// Otherwise, attempt to construct it from the request object dynamically
const host = req ? (req.get('host') || 'localhost:3001') : 'localhost:3001';
const protocol = (req && (req.protocol === 'https' || req.get('x-forwarded-proto') === 'https')) ? 'https' : 'http';
// Se estivermos em produção e o host não for localhost, force HTTPS
if (process.env.NODE_ENV === 'production' && !host.includes('localhost')) {
return `https://${host}`;
}
return `${protocol}://${host}`;
};
// Quando não temos a request (ex: startup), usamos uma versão simplificada
const getStartupBaseUrl = () => {
if (process.env.APP_URL) return process.env.APP_URL;
if (process.env.NODE_ENV === 'production') return 'https://fasto.blyzer.com.br';
return 'http://localhost:3001';
};
app.use(cors());
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();
// Middleware de autenticação
const authenticateToken = async (req, res, next) => {
// Ignorar rotas de auth
if (req.path.startsWith('/auth/')) return next();
const authHeader = req.headers['authorization'];
// API Key Authentication for n8n/External Integrations
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
const apiKey = authHeader.split(' ')[1];
try {
const [keys] = await pool.query('SELECT * FROM api_keys WHERE secret_key = ?', [apiKey]);
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
// Update last used timestamp
await pool.query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [keys[0].id]);
// Attach a "system/bot" user identity to the request based on the tenant
req.user = {
id: 'bot_integration',
tenant_id: keys[0].tenant_id,
role: 'admin', // Give integration admin privileges within its tenant
is_api_key: true
};
return next();
} catch (error) {
console.error('API Key validation error:', error);
return res.status(500).json({ error: 'Erro ao validar chave de API.' });
}
}
// Standard JWT Authentication
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Token inválido ou expirado.' });
req.user = user;
next();
});
};
const requireRole = (roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' });
next();
};
apiRouter.use(authenticateToken);
// --- Auth Routes ---
// Register
apiRouter.post('/auth/register', async (req, res) => {
const { name, email, password, organizationName } = req.body;
try {
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' });
const passwordHash = await bcrypt.hash(password, 10);
const verificationCode = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await pool.query(
'INSERT INTO pending_registrations (email, password_hash, full_name, organization_name, verification_code, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
[email, passwordHash, name, organizationName, verificationCode, expiresAt]
);
const mailOptions = {
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: email,
subject: 'Seu código de verificação Fasto',
text: `Olá ${name}, seu código de verificação é: ${verificationCode}`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
<h2 style="color: #0f172a;">Bem-vindo ao Fasto!</h2>
<p style="color: #475569;">Seu código: <strong>${verificationCode}</strong></p>
</div>`
};
await transporter.sendMail(mailOptions);
res.json({ message: 'Código enviado.' });
} catch (error) {
console.error('Register error:', error);
res.status(500).json({ error: error.message });
}
});
// Verify
apiRouter.post('/auth/verify', async (req, res) => {
const { email, code } = req.body;
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [pending] = await connection.query(
'SELECT * FROM pending_registrations WHERE email = ? AND verification_code = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1',
[email, code]
);
if (pending.length === 0) return res.status(400).json({ error: 'Código inválido.' });
const data = pending[0];
const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`;
const userId = `u_${crypto.randomUUID().split('-')[0]}`;
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
[userId, tenantId, data.full_name, email, data.password_hash, 'owner']);
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.commit();
res.json({ message: 'Sucesso.' });
} catch (error) {
await connection.rollback();
res.status(500).json({ error: error.message });
} finally {
connection.release();
}
});
// Login
apiRouter.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
if (users.length === 0) return res.status(401).json({ error: 'Credenciais inválidas.' });
const user = users[0];
// Verificar se o usuário está ativo
if (user.status !== 'active') {
return res.status(403).json({ error: 'Sua conta está inativa. Contate o administrador.' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
// Generate Access Token (short-lived)
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' });
// Generate Refresh Token (long-lived)
const refreshToken = crypto.randomBytes(40).toString('hex');
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
// Store Refresh Token in database (expires in 30 days)
await pool.query(
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[refreshId, user.id, refreshToken]
);
res.json({
token,
refreshToken,
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Refresh Token
apiRouter.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
try {
// Verifies if the token exists and hasn't expired
const [tokens] = await pool.query(
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()',
[refreshToken]
);
if (tokens.length === 0) {
// If invalid, optionally delete the bad token if it's just expired
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
}
const user = tokens[0];
if (user.status !== 'active') {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
return res.status(403).json({ error: 'Sua conta está inativa.' });
}
// Sliding Expiration: Extend the refresh token's life by another 30 days
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]);
// Issue a new short-lived access token
const newAccessToken = jwt.sign(
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ token: newAccessToken });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Logout (Revoke Refresh Token)
apiRouter.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
if (refreshToken) {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
}
res.json({ message: 'Logout bem-sucedido.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// God Mode (Impersonate Tenant)
apiRouter.post('/impersonate/:tenantId', requireRole(['super_admin']), async (req, res) => {
try {
// Buscar o primeiro admin (ou qualquer usuário) do tenant para assumir a identidade
const [users] = await pool.query("SELECT * FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1", [req.params.tenantId]);
if (users.length === 0) {
return res.status(404).json({ error: 'Nenhum administrador encontrado nesta organização para assumir a identidade.' });
}
const user = users[0];
if (user.status !== 'active') {
return res.status(403).json({ error: 'A conta do admin desta organização está inativa.' });
}
// Gerar um token JWT como se fôssemos o admin do tenant
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '2h' });
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Forgot Password
apiRouter.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
try {
const [users] = await pool.query('SELECT name FROM users WHERE email = ?', [email]);
if (users.length > 0) {
const token = crypto.randomBytes(32).toString('hex');
await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [email, token]);
const link = `${getBaseUrl(req)}/#/reset-password?token=${token}`;
const mailOptions = { from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: email,
subject: 'Recuperação de Senha - Fasto',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
<h2 style="color: #0f172a;">Olá, ${users[0].name}!</h2>
<p style="color: #475569;">Você solicitou a recuperação de senha da sua conta no Fasto.</p>
<p style="color: #475569;">Clique no botão abaixo para criar uma nova senha:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${link}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Redefinir Minha Senha</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos. Se você não solicitou isso, pode ignorar este e-mail.</p>
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
</div>
</div>
`
};
await transporter.sendMail(mailOptions);
}
res.json({ message: 'Se o e-mail existir, enviamos as instruções.' });
} catch (error) {
console.error('Forgot password error:', error);
res.status(500).json({ error: error.message });
}
});
// Reset Password
apiRouter.post('/auth/reset-password', async (req, res) => {
const { token, password, name } = req.body;
try {
const [resets] = await pool.query('SELECT email FROM password_resets WHERE token = ? AND expires_at > NOW()', [token]);
if (resets.length === 0) return res.status(400).json({ error: 'Token inválido ou expirado.' });
const hash = await bcrypt.hash(password, 10);
if (name) {
// If a name is provided (like in the initial admin setup flow), update it along with the password
await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]);
// Notify managers/admins of the same tenant
const [u] = await pool.query('SELECT id, name, tenant_id, role, team_id FROM users WHERE email = ?', [resets[0].email]);
if (u.length > 0) {
const user = u[0];
const [notifiable] = await pool.query(
"SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager', 'super_admin') AND id != ?",
[user.tenant_id, user.id]
);
for (const n of notifiable) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`]
);
}
// If the new user is an admin, notify super_admins too
if (user.role === 'admin') {
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
for (const sa of superAdmins) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`]
);
}
}
}
} else {
// Standard password reset, just update the hash
await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]);
}
await pool.query('DELETE FROM password_resets WHERE email = ?', [resets[0].email]);
res.json({ message: 'Senha e perfil atualizados com sucesso.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- User Routes ---
apiRouter.get('/users', async (req, res) => {
try {
const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
let q = 'SELECT * FROM users';
const params = [];
if (effectiveTenantId && effectiveTenantId !== 'all') {
q += ' WHERE tenant_id = ?';
params.push(effectiveTenantId);
}
// Strict RBAC: Managers can only see users in their own team, or themselves if they don't have a team yet
if (req.user.role === 'manager') {
if (req.user.team_id) {
q += (params.length > 0 ? ' AND' : ' WHERE') + ' (team_id = ? OR id = ?)';
params.push(req.user.team_id, req.user.id);
} else {
q += (params.length > 0 ? ' AND' : ' WHERE') + ' id = ?';
params.push(req.user.id);
}
}
const [rows] = await pool.query(q, params);
res.json(rows);
} catch (error) { res.status(500).json({ error: error.message }); }
});
apiRouter.get('/users/:idOrSlug', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (!req.user) return res.status(401).json({ error: 'Não autenticado' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
}
res.json(rows[0]);
} catch (error) {
console.error('Error in GET /users/:idOrSlug:', error);
res.status(500).json({ error: error.message });
}
});
// Convidar Novo Membro (Admin criando usuário)
apiRouter.post('/users', requireRole(['admin', 'manager', 'super_admin']), async (req, res) => {
const { name, email, role, team_id, tenant_id } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenant_id : req.user.tenant_id;
// Strict RBAC: Managers can only create agents and assign them to their own team
let finalRole = role || 'agent';
let finalTeamId = team_id || null;
if (req.user.role === 'manager') {
finalRole = 'agent'; // Force manager creations to be agents
finalTeamId = req.user.team_id; // Force assignment to manager's team
}
try {
// 1. Verificar se e-mail já existe
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
if (existing.length > 0) return res.status(400).json({ error: 'E-mail já cadastrado.' });
const uid = `u_${crypto.randomUUID().split('-')[0]}`;
const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${crypto.randomBytes(4).toString('hex')}`;
const placeholderHash = 'pending_setup'; // Usuário não pode logar com isso
// 2. Criar Usuário
await pool.query(
'INSERT INTO users (id, tenant_id, team_id, name, email, password_hash, slug, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[uid, effectiveTenantId, finalTeamId, name, email, placeholderHash, slug, finalRole, 'active']
);
// 3. Gerar Token de Setup de Senha (reusando lógica de reset)
const token = crypto.randomBytes(32).toString('hex');
await pool.query(
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))',
[email, token]
);
// 4. Enviar E-mail de Boas-vindas
const setupLink = `${getBaseUrl(req)}/#/reset-password?token=${token}`;
await transporter.sendMail({
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: email,
subject: 'Bem-vindo ao Fasto - Finalize seu cadastro',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
<h2 style="color: #0f172a;">Olá, ${name}!</h2>
<p style="color: #475569;">Você foi convidado para participar da equipe no Fasto.</p>
<p style="color: #475569;">Clique no botão abaixo para definir sua senha e acessar sua conta:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos. Se você não esperava este convite, ignore este e-mail.</p>
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
</div>
</div>
`
});
res.status(201).json({ id: uid, message: 'Convite enviado com sucesso.' });
} catch (error) {
console.error('Invite error:', error);
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/users/:id', async (req, res) => {
const { name, bio, role, team_id, status, email, sound_enabled } = req.body;
try {
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
const isSelf = req.user.id === req.params.id;
const isManagerOrAdmin = ['admin', 'owner', 'manager', 'super_admin'].includes(req.user.role);
if (!isSelf && !isManagerOrAdmin) {
return res.status(403).json({ error: 'Acesso negado.' });
}
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
}
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role;
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
const finalEmail = email !== undefined ? email : existing[0].email;
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
if (finalEmail !== existing[0].email) {
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
if (emailCheck.length > 0) return res.status(400).json({ error: 'E-mail já está em uso.' });
}
await pool.query(
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?',
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id]
);
// Trigger Notification for Team Change
if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') {
const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]);
if (team.length > 0) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/']
);
}
}
res.json({ message: 'User updated successfully.' });
} catch (error) { console.error('Update user error:', error);
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/users/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
try {
const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
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 users WHERE id = ?', [req.params.id]);
res.json({ message: 'User deleted successfully.' });
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({ error: error.message });
}
});
// Upload de Avatar
apiRouter.post('/users/:id/avatar', upload.single('avatar'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
// Validar se o usuário está alterando o próprio avatar (ou super_admin)
if (req.user.id !== req.params.id && req.user.role !== 'super_admin') {
return res.status(403).json({ error: 'Acesso negado.' });
}
const avatarUrl = `/uploads/${req.file.filename}`;
await pool.query('UPDATE users SET avatar_url = ? WHERE id = ?', [avatarUrl, req.params.id]);
res.json({ avatarUrl });
} catch (error) {
console.error('Avatar upload error:', error);
res.status(500).json({ error: error.message });
}
});
// --- Notifications Routes ---
apiRouter.get('/notifications', async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50',
[req.user.id]
);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/notifications/read-all', async (req, res) => {
try {
await pool.query(
'UPDATE notifications SET is_read = true WHERE user_id = ?',
[req.user.id]
);
res.json({ message: 'All notifications marked as read' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/notifications/:id', async (req, res) => {
try {
await pool.query(
'UPDATE notifications SET is_read = true WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
res.json({ message: 'Notification marked as read' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/notifications/clear-all', async (req, res) => {
try {
await pool.query(
'DELETE FROM notifications WHERE user_id = ?',
[req.user.id]
);
res.json({ message: 'All notifications deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/notifications/:id', async (req, res) => {
try {
await pool.query(
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
res.json({ message: 'Notification deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- 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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', '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' || req.user.role === 'owner') {
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' || req.user.role === 'owner') {
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 ?', [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' || req.user.role === 'owner') {
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);
}
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_')) {
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 * FROM attendances WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
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', 'owner', '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, CONCAT(SUBSTRING(secret_key, 1, 14), "...") 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', 'owner', '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) VALUES (?, ?, ?, ?)',
[id, effectiveTenantId, name || 'Nova Integração API', 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', 'owner', '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 [origins] = await pool.query('SELECT name FROM origins WHERE tenant_id = ? ORDER BY created_at ASC', [req.user.tenant_id]);
res.json(origins.map(o => o.name));
} 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 pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), m.id, 'success', 'Venda Fechada!', `${agentName} converteu um lead em ${funnel_stage}.`, `/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 });
}
});
// --- Tenant Routes ---
apiRouter.get('/tenants', requireRole(['super_admin']), async (req, res) => {
try {
const q = 'SELECT t.*, (SELECT COUNT(*) FROM users u WHERE u.tenant_id = t.id) as user_count, (SELECT COUNT(*) FROM attendances a WHERE a.tenant_id = t.id) as attendance_count FROM tenants t';
const [rows] = await pool.query(q);
res.json(rows);
} catch (error) { res.status(500).json({ error: error.message }); }
});
// --- Team Routes ---
apiRouter.get('/teams', async (req, res) => {
try {
const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
let q = 'SELECT * FROM teams';
const params = [];
if (effectiveTenantId && effectiveTenantId !== 'all') {
q += ' WHERE tenant_id = ?';
params.push(effectiveTenantId);
}
// Strict RBAC: Managers can only see their own team
if (req.user.role === 'manager') {
if (req.user.team_id) {
q += (params.length > 0 ? ' AND' : ' WHERE') + ' id = ?';
params.push(req.user.team_id);
} else {
// If a manager doesn't have a team, return nothing to prevent showing all teams
q += (params.length > 0 ? ' AND' : ' WHERE') + ' 1=0';
}
}
const [rows] = await pool.query(q, params);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
const { name, description, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try {
const tid = `team_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO teams (id, tenant_id, name, description) VALUES (?, ?, ?, ?)',
[tid, effectiveTenantId, name, description || null]
);
res.status(201).json({ id: tid, message: 'Time criado com sucesso.' });
} catch (error) {
console.error('Create team error:', error);
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
const { name, description } = req.body;
try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
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(
'UPDATE teams SET name = ?, description = ? WHERE id = ?',
[name, description || null, req.params.id]
);
res.json({ message: 'Team updated successfully.' });
} catch (error) {
console.error('Update team error:', error);
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
}
// Set users team_id to NULL to prevent orphan foreign key issues if constrained
await pool.query('UPDATE users SET team_id = NULL WHERE team_id = ?', [req.params.id]);
await pool.query('DELETE FROM teams WHERE id = ?', [req.params.id]);
res.json({ message: 'Team deleted successfully.' });
} catch (error) {
console.error('Delete team error:', error);
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/tenants', requireRole(['super_admin']), async (req, res) => {
const { name, slug, admin_email, status } = req.body;
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const tid = `tenant_${crypto.randomUUID().split('-')[0]}`;
await connection.query('INSERT INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', [tid, name, slug, admin_email, status || 'active']);
// Check if user already exists
const [existingUser] = await connection.query('SELECT id FROM users WHERE email = ?', [admin_email]);
if (existingUser.length === 0) {
const uid = `u_${crypto.randomUUID().split('-')[0]}`;
const userSlug = `admin-${crypto.randomBytes(4).toString('hex')}`;
const placeholderHash = 'pending_setup';
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, slug, role) VALUES (?, ?, ?, ?, ?, ?, ?)', [uid, tid, 'Admin', admin_email, placeholderHash, userSlug, 'admin']);
const token = crypto.randomBytes(32).toString('hex');
// Adicionar aos pending_registrations em vez de criar um usuário direto se quisermos que eles completem o cadastro.
// Ou, se quisermos apenas definir a senha, mantemos o password_resets.
// O usuário quer "like a register", então vamos enviar para uma página onde eles definem a senha.
await connection.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [admin_email, token]);
const setupLink = `${getBaseUrl(req)}/#/setup-account?token=${token}`;
// Add Notification for Super Admins
const [superAdmins] = await connection.query("SELECT id FROM users WHERE role = 'super_admin'");
for (const sa of superAdmins) {
await connection.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), sa.id, 'success', 'Nova Organização', `A organização ${name} foi criada.`, '/super-admin']
);
}
await transporter.sendMail({
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: admin_email,
subject: 'Bem-vindo ao Fasto - Conclua seu cadastro de Admin',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
<h2 style="color: #0f172a;">Sua organização foi criada</h2>
<p style="color: #475569;">Você foi definido como administrador da organização <strong>${name}</strong>.</p>
<p style="color: #475569;">Por favor, clique no botão abaixo para definir sua senha e concluir seu cadastro.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos.</p>
</div>
`
}).catch(err => console.error("Email failed:", err));
}
await connection.commit();
res.status(201).json({ id: tid, message: 'Organização criada e convite enviado por e-mail.' });
} catch (error) { await connection.rollback(); res.status(500).json({ error: error.message }); } finally { connection.release(); }
});
apiRouter.put('/tenants/:id', requireRole(['super_admin']), async (req, res) => {
const { name, slug, admin_email, status } = req.body;
try {
await pool.query(
'UPDATE tenants SET name = ?, slug = ?, admin_email = ?, status = ? WHERE id = ?',
[name, slug || null, admin_email, status, req.params.id]
);
res.json({ message: 'Tenant updated successfully.' });
} catch (error) { res.status(500).json({ error: error.message }); }
});
apiRouter.delete('/tenants/:id', requireRole(['super_admin']), async (req, res) => {
try {
await pool.query('DELETE FROM tenants WHERE id = ?', [req.params.id]);
res.json({ message: 'Tenant deleted successfully.' });
} catch (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);
}
// Add sound_enabled column if it doesn't exist
try {
await connection.query('ALTER TABLE users ADD COLUMN sound_enabled BOOLEAN DEFAULT true');
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', 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,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at timestamp NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY secret_key (secret_key),
KEY tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 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,
expires_at timestamp NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY token (token),
KEY user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 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: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
<h2 style="color: #0f172a;">Conta Super Admin Gerada</h2>
<p style="color: #475569;">Sua conta de suporte (super_admin) foi criada no Fasto.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos.</p>
</div>
`
}).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}`);
});