All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m32s
- Updated DB schema to support multiple funnels (funnels table) and their stages (funnel_stages table).
- Added funnel_id to teams table to link teams to specific funnels.
- Redesigned /admin/funnels page ('Meus Funis') to allow creating multiple funnels, managing their stages, and assigning them to teams.
- Updated Dashboard, UserDetail, and AttendanceDetail to dynamically load the correct funnel based on the selected team or user's assigned team.
1190 lines
50 KiB
JavaScript
1190 lines
50 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 = (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 });
|
|
}
|
|
});
|
|
|
|
// 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}`]
|
|
);
|
|
}
|
|
}
|
|
} 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]
|
|
);
|
|
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/: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.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.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 });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/notifications', 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 });
|
|
}
|
|
});
|
|
|
|
// --- 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.summary, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.summary 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 }); }
|
|
});
|
|
|
|
// --- 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}`;
|
|
|
|
// 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 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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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;
|
|
`);
|
|
|
|
// 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}`);
|
|
});
|