All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m8s
- Prevented API error messages from leaking system roles. - Updated POST /users to safely allow managers to create users while strictly forcing them to be agents assigned to the manager's team.
803 lines
34 KiB
JavaScript
803 lines
34 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',
|
|
pass: process.env.SMTP_PASS || 'Compor@2017#', // Fallback to your known prod pass if env fails in swarm
|
|
},
|
|
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 = (req, res, next) => {
|
|
// Ignorar rotas de auth
|
|
if (req.path.startsWith('/auth/')) return next();
|
|
|
|
const authHeader = req.headers['authorization'];
|
|
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.' });
|
|
|
|
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: '24h' });
|
|
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]);
|
|
} 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.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.' });
|
|
}
|
|
res.json(rows[0]);
|
|
} catch (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 } = 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;
|
|
|
|
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 = ? WHERE id = ?',
|
|
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, req.params.id]
|
|
);
|
|
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 });
|
|
}
|
|
});
|
|
|
|
|
|
// --- 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 }); }
|
|
});
|
|
|
|
// --- 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.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}`;
|
|
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;
|
|
`);
|
|
|
|
// 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 enum
|
|
try {
|
|
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin ENUM('WhatsApp','Instagram','Website','LinkedIn','Indicação') NOT NULL");
|
|
} catch (err) {
|
|
console.log('Schema update note (origin):', 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}`);
|
|
});
|