diff --git a/backend/index.js b/backend/index.js
index 686e4b6..1b164eb 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -12,17 +12,10 @@ const { createCorsMiddleware } = require('./config/cors');
const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime');
const { authenticateToken, requireRole } = require('./middleware/auth');
const { createAuthRouter } = require('./routes/authRoutes');
-const {
- canReadUser,
- canUpdateUser,
- canManageUserStatus,
- canChangeUserEmail,
- canManageUserRoleOrTeam,
- canReadAttendance,
-} = require('./policies/accessPolicy');
+const { createUsersRouter } = require('./routes/usersRoutes');
+const { canReadAttendance } = require('./policies/accessPolicy');
const app = express();
-const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
app.use(createCorsMiddleware({ allowedOrigins, isProduction }));
app.use(express.json());
@@ -77,193 +70,7 @@ apiRouter.use(authenticateToken);
apiRouter.use(createAuthRouter({ pool, transporter, getBaseUrl, jwtSecret: JWT_SECRET }));
-// --- 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 ${USER_PUBLIC_FIELDS} 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 ${USER_PUBLIC_FIELDS} 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 || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
-
- if (!canReadUser(req.user, rows[0])) 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') {
- if (!req.user.team_id) return res.status(403).json({ error: 'Gerente sem time não pode criar membros.' });
- 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: `
-
-
Olá, ${name}!
-
Você foi convidado para participar da equipe no Fasto.
-
Clique no botão abaixo para definir sua senha e acessar sua conta:
-
-
Este link expira em 15 minutos. Se você não esperava este convite, ignore este e-mail.
-
-
- `
- });
- 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' });
-
- if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' });
-
- // Only Admins can change roles and teams. Managers can only edit basic info of their team members.
- const finalRole = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role;
- const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id;
- const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status;
- const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email;
- const finalSoundEnabled = req.user.id === req.params.id && 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', '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 });
- }
-});
-
+apiRouter.use(createUsersRouter({ pool, upload, transporter, getBaseUrl }));
// --- Notifications Routes ---
apiRouter.get('/notifications', async (req, res) => {
diff --git a/backend/routes/usersRoutes.js b/backend/routes/usersRoutes.js
new file mode 100644
index 0000000..8c45046
--- /dev/null
+++ b/backend/routes/usersRoutes.js
@@ -0,0 +1,198 @@
+const express = require('express');
+const crypto = require('crypto');
+const { requireRole } = require('../middleware/auth');
+const {
+ canReadUser,
+ canUpdateUser,
+ canManageUserStatus,
+ canChangeUserEmail,
+ canManageUserRoleOrTeam,
+} = require('../policies/accessPolicy');
+
+const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
+
+const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => {
+ const router = express.Router();
+
+ router.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 ${USER_PUBLIC_FIELDS} FROM users`;
+ const params = [];
+ if (effectiveTenantId && effectiveTenantId !== 'all') {
+ q += ' WHERE tenant_id = ?';
+ params.push(effectiveTenantId);
+ }
+
+ 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 });
+ }
+ });
+
+ router.get('/users/:idOrSlug', async (req, res) => {
+ try {
+ const [rows] = await pool.query(`SELECT ${USER_PUBLIC_FIELDS} 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 || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
+
+ if (!canReadUser(req.user, rows[0])) 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 });
+ }
+ });
+
+ router.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;
+
+ let finalRole = role || 'agent';
+ let finalTeamId = team_id || null;
+
+ if (req.user.role === 'manager') {
+ if (!req.user.team_id) return res.status(403).json({ error: 'Gerente sem time não pode criar membros.' });
+ finalRole = 'agent';
+ finalTeamId = req.user.team_id;
+ }
+ 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 uid = `u_${crypto.randomUUID().split('-')[0]}`;
+ const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${crypto.randomBytes(4).toString('hex')}`;
+ const placeholderHash = 'pending_setup';
+
+ 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']
+ );
+
+ 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 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: `
+
+
Olá, ${name}!
+
Você foi convidado para participar da equipe no Fasto.
+
Clique no botão abaixo para definir sua senha e acessar sua conta:
+
+
Este link expira em 15 minutos. Se você não esperava este convite, ignore este e-mail.
+
+
+ `
+ });
+ 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 });
+ }
+ });
+
+ router.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' });
+
+ if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' });
+
+ const finalRole = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role;
+ const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id;
+ const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status;
+ const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email;
+ const finalSoundEnabled = req.user.id === req.params.id && 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]
+ );
+
+ 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 });
+ }
+ });
+
+ router.delete('/users/:id', requireRole(['admin', '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 });
+ }
+ });
+
+ router.post('/users/:id/avatar', upload.single('avatar'), async (req, res) => {
+ try {
+ if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
+
+ 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 });
+ }
+ });
+
+ return router;
+};
+
+module.exports = { createUsersRouter };