Rollback to stable frontend services commit
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m40s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m40s
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
.git
|
||||
.env
|
||||
node_modules
|
||||
backend/node_modules
|
||||
dist
|
||||
backend/uploads
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
@@ -28,16 +28,16 @@ We have transitioned from a mock-based prototype to a **secure, multi-tenant pro
|
||||
- Super Admins can generate persistent `api_keys` for specific tenants.
|
||||
- `GET /api/integration/users`, `/funnels`, and `/origins` allow the n8n AI to dynamically map the tenant's actual agents and workflow stages before processing a chat.
|
||||
- `POST /api/integration/attendances` accepts the AI's final JSON payload (including the `full_summary` text) and injects it directly into the dashboard.
|
||||
- **Activity Feed:**
|
||||
- Replaced the noisy real-time notification tray with an on-demand activity feed (`/api/activity`) that records important operational events without polling, unread state, delete actions, or sound playback.
|
||||
- Automated activity events: Super Admins see new organizations; Admins/Super Admins see new user setups; Agents see team assignment changes; Managers see "Venda Fechada" events when n8n posts a converted lead.
|
||||
- **Real-Time Notification System:**
|
||||
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback (custom `.mp3` loaded via Vite).
|
||||
- Automated Triggers: Super Admins are notified of new organizations; Admins/Super Admins are notified of new user setups; Agents are notified of team assignment changes; Managers get "Venda Fechada" alerts when n8n posts a converted lead.
|
||||
- **Enhanced UI/UX:**
|
||||
- Premium "Onyx & Gold" True Black dark mode (Zinc scale).
|
||||
- Fully collapsible interactive sidebar with memory (`localStorage`).
|
||||
- All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
|
||||
|
||||
## 📌 Roadmap / To-Do
|
||||
- [ ] **Critical Activity Alerts:** Add backend rules for high-signal events such as critically low attendance scores (`score < 50`) or response-time SLA breaches (`first_response_time_min > 60`).
|
||||
- [ ] **Advanced AI Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance payload from n8n receives a critically low quality score (`score < 50`), or breaches a specific Response Time SLA (e.g., `first_response_time_min > 60`).
|
||||
- [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel.
|
||||
- [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically.
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -21,15 +21,13 @@ ENV NODE_ENV=production
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --omit=dev --no-audit
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy backend source directly into root
|
||||
COPY backend/index.js ./index.js
|
||||
COPY backend/db.js ./db.js
|
||||
COPY backend/config ./config
|
||||
COPY backend/middleware ./middleware
|
||||
COPY backend/policies ./policies
|
||||
COPY backend/routes ./routes
|
||||
COPY backend/services ./services
|
||||
COPY backend/utils ./utils
|
||||
|
||||
|
||||
1661
backend/index.js
1661
backend/index.js
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../db');
|
||||
const { jwtSecret } = require('../config/runtime');
|
||||
const { hashSecret } = require('../utils/security');
|
||||
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
if (req.path.startsWith('/auth/')) return next();
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
|
||||
const apiKey = authHeader.split(' ')[1];
|
||||
try {
|
||||
const [keys] = await pool.query(
|
||||
'SELECT * FROM api_keys WHERE secret_hash = ? OR secret_key = ?',
|
||||
[hashSecret(apiKey), apiKey]
|
||||
);
|
||||
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
|
||||
|
||||
await pool.query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [keys[0].id]);
|
||||
|
||||
req.user = {
|
||||
id: 'bot_integration',
|
||||
tenant_id: keys[0].tenant_id,
|
||||
role: 'admin',
|
||||
is_api_key: true
|
||||
};
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('API Key validation error:', error);
|
||||
return res.status(500).json({ error: 'Erro ao validar chave de API.' });
|
||||
}
|
||||
}
|
||||
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
|
||||
|
||||
try {
|
||||
req.user = jwt.verify(token, jwtSecret);
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Token inválido ou expirado.' });
|
||||
}
|
||||
};
|
||||
|
||||
const requireRole = (roles) => (req, res, next) => {
|
||||
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado.' });
|
||||
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();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireRole,
|
||||
};
|
||||
@@ -23,7 +23,6 @@ const canUpdateUser = (actor, targetUser) => {
|
||||
const canManageUserStatus = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
|
||||
const canChangeUserEmail = (actor, targetUser) => actor.id === targetUser.id || actor.role === 'super_admin' || actor.role === 'admin';
|
||||
const canManageUserRoleOrTeam = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
|
||||
const canManageUserPassword = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
|
||||
|
||||
const canReadAttendance = (actor, attendance) => {
|
||||
if (!actor || !attendance || !sameTenant(actor, attendance)) return false;
|
||||
@@ -39,6 +38,5 @@ module.exports = {
|
||||
canManageUserStatus,
|
||||
canChangeUserEmail,
|
||||
canManageUserRoleOrTeam,
|
||||
canManageUserPassword,
|
||||
canReadAttendance,
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
const express = require('express');
|
||||
|
||||
const createActivityRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
const listActivity = async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, type, title, message, link, created_at
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
router.get('/activity', listActivity);
|
||||
router.get('/notifications', listActivity);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createActivityRouter,
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { hashSecret, maskSecret } = require('../utils/security');
|
||||
|
||||
const createApiKeysRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.query;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, created_at, last_used_at, CASE WHEN secret_key LIKE "masked:%" THEN CONCAT("fasto_sk_", RIGHT(secret_key, 6), "...") ELSE CONCAT(SUBSTRING(secret_key, 1, 14), "...") END as masked_key FROM api_keys WHERE tenant_id = ?',
|
||||
[effectiveTenantId]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
const id = `apk_${crypto.randomUUID().split('-')[0]}`;
|
||||
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO api_keys (id, tenant_id, name, secret_key, secret_hash) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, effectiveTenantId, name || 'Nova Integração API', maskSecret(id, secretKey), hashSecret(secretKey)]
|
||||
);
|
||||
|
||||
res.status(201).json({ id, secret_key: secretKey, message: 'Chave criada. Salve-a agora, ela não será exibida novamente.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' });
|
||||
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) return res.status(403).json({ error: 'Acesso negado' });
|
||||
|
||||
await pool.query('DELETE FROM api_keys WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Chave de API revogada com sucesso.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createApiKeysRouter,
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
const express = require('express');
|
||||
const { canReadAttendance } = require('../policies/accessPolicy');
|
||||
|
||||
const parseAttendance = (attendance) => ({
|
||||
...attendance,
|
||||
attention_points: typeof attendance.attention_points === 'string' ? JSON.parse(attendance.attention_points) : attendance.attention_points,
|
||||
improvement_points: typeof attendance.improvement_points === 'string' ? JSON.parse(attendance.improvement_points) : attendance.improvement_points,
|
||||
converted: Boolean(attendance.converted),
|
||||
});
|
||||
|
||||
const createAttendancesRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.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));
|
||||
}
|
||||
|
||||
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') {
|
||||
if (userId.startsWith('u_') || userId.length === 36) {
|
||||
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);
|
||||
res.json(rows.map(parseAttendance));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/attendances/:id', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.id = ?',
|
||||
[req.params.id]
|
||||
);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (!canReadAttendance(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' });
|
||||
|
||||
res.json(parseAttendance(rows[0]));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createAttendancesRouter,
|
||||
};
|
||||
@@ -1,269 +0,0 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { hashSecret, maskSecret } = require('../utils/security');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { recordActivity } = require('../services/activityService');
|
||||
|
||||
const createAuthRouter = ({ pool, transporter, getBaseUrl, jwtSecret }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.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]
|
||||
);
|
||||
|
||||
await transporter.sendMail({
|
||||
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>`
|
||||
});
|
||||
res.json({ message: 'Código enviado.' });
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.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, 'admin']);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
router.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];
|
||||
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 }, jwtSecret, { expiresIn: '15m' });
|
||||
const refreshToken = crypto.randomBytes(40).toString('hex');
|
||||
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO refresh_tokens (id, user_id, token, token_hash, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||
[refreshId, user.id, maskSecret(refreshId, refreshToken), hashSecret(refreshToken)]
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
refreshToken,
|
||||
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/auth/refresh', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
|
||||
|
||||
try {
|
||||
const [tokens] = await pool.query(
|
||||
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE (r.token_hash = ? OR r.token = ?) AND r.expires_at > NOW()',
|
||||
[hashSecret(refreshToken), refreshToken]
|
||||
);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
|
||||
}
|
||||
|
||||
const user = tokens[0];
|
||||
|
||||
if (user.status !== 'active') {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||
return res.status(403).json({ error: 'Sua conta está inativa.' });
|
||||
}
|
||||
|
||||
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||
|
||||
const newAccessToken = jwt.sign(
|
||||
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
|
||||
jwtSecret,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/auth/logout', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
|
||||
}
|
||||
res.json({ message: 'Logout bem-sucedido.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/impersonate/:tenantId', requireRole(['super_admin']), async (req, res) => {
|
||||
try {
|
||||
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.' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, jwtSecret, { 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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.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}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
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>
|
||||
`
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.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) {
|
||||
await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]);
|
||||
|
||||
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 recordActivity(pool, {
|
||||
userId: n.id,
|
||||
type: 'info',
|
||||
title: 'Novo Membro Ativo',
|
||||
message: `${name} concluiu o cadastro e já pode acessar o sistema.`,
|
||||
link: `/users/${user.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.role === 'admin') {
|
||||
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
|
||||
for (const sa of superAdmins) {
|
||||
await recordActivity(pool, {
|
||||
userId: sa.id,
|
||||
type: 'success',
|
||||
title: 'Admin Ativo',
|
||||
message: `O admin ${name} da organização configurou sua conta.`,
|
||||
link: '/super-admin',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = { createAuthRouter };
|
||||
@@ -1,141 +0,0 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
|
||||
const createFunnelsRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.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]);
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/funnels', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/funnels/:id', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/funnels/:id', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/funnels/:id/stages', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/funnel_stages/:id', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/funnel_stages/:id', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createFunnelsRouter,
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { recordActivity } = require('../services/activityService');
|
||||
|
||||
const requireApiKey = (req, res) => {
|
||||
if (req.user.is_api_key) return true;
|
||||
res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
||||
return false;
|
||||
};
|
||||
|
||||
const createIntegrationsRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/integration/users', requireRole(['admin']), async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT u.id, u.name, u.email, t.name as team_name FROM users u LEFT JOIN teams t ON u.team_id = t.id WHERE u.tenant_id = ? AND u.status = "active"',
|
||||
[req.user.tenant_id]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/integration/origins', requireRole(['admin']), async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const [groups] = await pool.query('SELECT id, name FROM origin_groups WHERE tenant_id = ?', [req.user.tenant_id]);
|
||||
if (groups.length === 0) return res.json([]);
|
||||
|
||||
const [items] = await pool.query('SELECT origin_group_id, name FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
|
||||
const [teams] = await pool.query('SELECT id as team_id, name as team_name, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [req.user.tenant_id]);
|
||||
|
||||
const result = groups.map(g => ({
|
||||
group_name: g.name,
|
||||
origins: items.filter(i => i.origin_group_id === g.id).map(i => i.name),
|
||||
assigned_teams: teams.filter(t => t.origin_group_id === g.id).map(t => ({ id: t.team_id, name: t.team_name }))
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/integration/funnels', requireRole(['admin']), async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const [funnels] = await pool.query('SELECT id, name FROM funnels WHERE tenant_id = ?', [req.user.tenant_id]);
|
||||
if (funnels.length === 0) return res.json([]);
|
||||
|
||||
const [stages] = await pool.query('SELECT funnel_id, name, order_index FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]);
|
||||
const [teams] = await pool.query('SELECT id as team_id, name as team_name, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [req.user.tenant_id]);
|
||||
|
||||
const result = funnels.map(f => ({
|
||||
funnel_name: f.name,
|
||||
stages: stages.filter(s => s.funnel_id === f.id).map(s => s.name),
|
||||
assigned_teams: teams.filter(t => t.funnel_id === f.id).map(t => ({ id: t.team_id, name: t.team_name }))
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/integration/attendances', requireRole(['admin']), async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
|
||||
const {
|
||||
user_id,
|
||||
origin,
|
||||
funnel_stage,
|
||||
title,
|
||||
full_summary,
|
||||
score,
|
||||
first_response_time_min,
|
||||
handling_time_min,
|
||||
product_requested,
|
||||
product_sold,
|
||||
converted,
|
||||
attention_points,
|
||||
improvement_points
|
||||
} = req.body;
|
||||
|
||||
if (!user_id || !origin || !funnel_stage || !title) {
|
||||
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, title' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [users] = await pool.query('SELECT id FROM users WHERE id = ? AND tenant_id = ? AND status = "active"', [user_id, req.user.tenant_id]);
|
||||
if (users.length === 0) return res.status(400).json({ error: 'user_id inválido, inativo ou não pertence a esta organização.' });
|
||||
|
||||
const attId = `att_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query(
|
||||
`INSERT INTO attendances (
|
||||
id, tenant_id, user_id, title, full_summary, score,
|
||||
first_response_time_min, handling_time_min,
|
||||
funnel_stage, origin, product_requested, product_sold,
|
||||
converted, attention_points, improvement_points
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
attId,
|
||||
req.user.tenant_id,
|
||||
user_id,
|
||||
title,
|
||||
full_summary || null,
|
||||
score || 0,
|
||||
first_response_time_min || 0,
|
||||
handling_time_min || 0,
|
||||
funnel_stage,
|
||||
origin,
|
||||
product_requested || null,
|
||||
product_sold || null,
|
||||
converted ? 1 : 0,
|
||||
attention_points ? JSON.stringify(attention_points) : null,
|
||||
improvement_points ? JSON.stringify(improvement_points) : null
|
||||
]
|
||||
);
|
||||
|
||||
if (converted) {
|
||||
const [managers] = await pool.query(
|
||||
"SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager') AND id != ?",
|
||||
[req.user.tenant_id, user_id]
|
||||
);
|
||||
const [agentInfo] = await pool.query("SELECT name FROM users WHERE id = ?", [user_id]);
|
||||
const agentName = agentInfo[0]?.name || 'Um agente';
|
||||
|
||||
for (const m of managers) {
|
||||
await recordActivity(pool, {
|
||||
userId: m.id,
|
||||
type: 'success',
|
||||
title: 'Venda Fechada!',
|
||||
message: `${agentName} converteu um lead em ${funnel_stage}.`,
|
||||
link: `/attendances/${attId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ id: attId, message: 'Atendimento registrado com sucesso.' });
|
||||
} catch (error) {
|
||||
console.error('Integration Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createIntegrationsRouter,
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
|
||||
const createOriginsRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/origins', async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.query;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
|
||||
|
||||
const [groups] = await pool.query('SELECT * FROM origin_groups WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]);
|
||||
|
||||
if (groups.length === 0) {
|
||||
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']);
|
||||
|
||||
const defaultOrigins = [
|
||||
{ name: 'WhatsApp', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
|
||||
{ name: 'Instagram', color: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' },
|
||||
{ name: 'Website', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
|
||||
{ name: 'LinkedIn', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
|
||||
{ name: 'Indicação', color: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' }
|
||||
];
|
||||
|
||||
for (const origin of defaultOrigins) {
|
||||
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query(
|
||||
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
|
||||
[oid, gid, origin.name, origin.color]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE teams SET origin_group_id = ? WHERE tenant_id = ? AND origin_group_id IS NULL', [gid, effectiveTenantId]);
|
||||
groups.push({ id: gid, tenant_id: effectiveTenantId, name: 'Origens Padrão' });
|
||||
}
|
||||
|
||||
const [items] = await pool.query('SELECT * FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
|
||||
const [teams] = await pool.query('SELECT id, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [effectiveTenantId]);
|
||||
|
||||
const result = groups.map(g => ({
|
||||
...g,
|
||||
items: items.filter(i => i.origin_group_id === g.id),
|
||||
teamIds: teams.filter(t => t.origin_group_id === g.id).map(t => t.id)
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("GET /origins error:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, name]);
|
||||
res.status(201).json({ id: gid });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, teamIds } = req.body;
|
||||
try {
|
||||
if (name) {
|
||||
await pool.query('UPDATE origin_groups SET name = ? WHERE id = ?', [name, req.params.id]);
|
||||
}
|
||||
if (teamIds && Array.isArray(teamIds)) {
|
||||
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
|
||||
if (teamIds.length > 0) {
|
||||
await pool.query('UPDATE teams SET origin_group_id = ? WHERE id IN (?)', [req.params.id, teamIds]);
|
||||
}
|
||||
}
|
||||
res.json({ message: 'Origin group updated.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]);
|
||||
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
|
||||
await pool.query('DELETE FROM origin_groups WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Origin group deleted.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class } = req.body;
|
||||
try {
|
||||
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query(
|
||||
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
|
||||
[oid, req.params.id, name, color_class || 'bg-zinc-100 text-zinc-800 border-zinc-200']
|
||||
);
|
||||
res.status(201).json({ id: oid });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class } = req.body;
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' });
|
||||
|
||||
await pool.query('UPDATE origin_items SET name = ?, color_class = ? WHERE id = ?', [name || existing[0].name, color_class || existing[0].color_class, req.params.id]);
|
||||
res.json({ message: 'Origin item updated.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Origin item deleted.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createOriginsRouter,
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
const express = require('express');
|
||||
|
||||
const createSearchRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.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 {
|
||||
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 === 'admin') {
|
||||
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;
|
||||
}
|
||||
|
||||
if (req.user.role !== 'agent') {
|
||||
let teamsQ = 'SELECT id, name, description FROM teams WHERE name LIKE ?';
|
||||
const teamsParams = [queryStr];
|
||||
|
||||
if (req.user.role === 'admin') {
|
||||
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;
|
||||
}
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ? LIMIT 5', [queryStr, queryStr]);
|
||||
results.organizations = orgs;
|
||||
}
|
||||
|
||||
let attendancesQ = 'SELECT a.id, a.title, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.title LIKE ?';
|
||||
const attendancesParams = [queryStr];
|
||||
|
||||
if (req.user.role === 'admin') {
|
||||
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 if (req.user.role !== 'super_admin') {
|
||||
attendancesQ += ' AND a.user_id = ?';
|
||||
attendancesParams.push(req.user.id);
|
||||
}
|
||||
|
||||
attendancesQ += ' LIMIT 10';
|
||||
const [attendances] = await pool.query(attendancesQ, attendancesParams);
|
||||
results.attendances = attendances;
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createSearchRouter,
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
|
||||
const createTeamsRouter = ({ pool }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.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);
|
||||
}
|
||||
|
||||
if (req.user.role === 'manager') {
|
||||
if (req.user.team_id) {
|
||||
q += (params.length > 0 ? ' AND' : ' WHERE') + ' id = ?';
|
||||
params.push(req.user.team_id);
|
||||
} else {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/teams', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/teams/:id', requireRole(['admin', '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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) {
|
||||
return res.status(403).json({ error: 'Acesso negado.' });
|
||||
}
|
||||
|
||||
await pool.query('UPDATE users SET team_id = NULL WHERE team_id = ?', [req.params.id]);
|
||||
await pool.query('DELETE FROM teams WHERE id = ?', [req.params.id]);
|
||||
|
||||
res.json({ message: 'Team deleted successfully.' });
|
||||
} catch (error) {
|
||||
console.error('Delete team error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = { createTeamsRouter };
|
||||
@@ -1,103 +0,0 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { recordActivity } = require('../services/activityService');
|
||||
|
||||
const createTenantsRouter = ({ pool, transporter, getBaseUrl }) => {
|
||||
const router = express.Router();
|
||||
|
||||
router.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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.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']);
|
||||
|
||||
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');
|
||||
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}`;
|
||||
|
||||
const [superAdmins] = await connection.query("SELECT id FROM users WHERE role = 'super_admin'");
|
||||
for (const sa of superAdmins) {
|
||||
await recordActivity(connection, {
|
||||
userId: sa.id,
|
||||
type: 'success',
|
||||
title: 'Nova Organização',
|
||||
message: `A organização ${name} foi criada.`,
|
||||
link: '/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();
|
||||
}
|
||||
});
|
||||
|
||||
router.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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.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 });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
module.exports = { createTenantsRouter };
|
||||
@@ -1,235 +0,0 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const {
|
||||
canReadUser,
|
||||
canUpdateUser,
|
||||
canManageUserStatus,
|
||||
canChangeUserEmail,
|
||||
canManageUserRoleOrTeam,
|
||||
canManageUserPassword,
|
||||
} = require('../policies/accessPolicy');
|
||||
const { recordActivity } = require('../services/activityService');
|
||||
|
||||
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, created_at';
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
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, password } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenant_id : req.user.tenant_id;
|
||||
const shouldSetPassword = typeof password === 'string' && password.length > 0;
|
||||
|
||||
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 {
|
||||
if (shouldSetPassword && !canManageUserPassword(req.user)) {
|
||||
return res.status(403).json({ error: 'Apenas admins podem definir senha de usuários.' });
|
||||
}
|
||||
if (shouldSetPassword && password.length < MIN_PASSWORD_LENGTH) {
|
||||
return res.status(400).json({ error: `A senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres.` });
|
||||
}
|
||||
if (finalRole === 'super_admin' && req.user.role !== 'super_admin') {
|
||||
return res.status(403).json({ error: 'Apenas super admins podem criar super admins.' });
|
||||
}
|
||||
|
||||
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 passwordHash = shouldSetPassword ? await bcrypt.hash(password, 10) : '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, passwordHash, slug, finalRole, 'active']
|
||||
);
|
||||
|
||||
if (shouldSetPassword) {
|
||||
return res.status(201).json({ id: uid, message: 'Membro criado com senha definida.' });
|
||||
}
|
||||
|
||||
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: `
|
||||
<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 });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/:id', async (req, res) => {
|
||||
const { name, bio, role, team_id, status, email, password } = req.body;
|
||||
const shouldUpdatePassword = typeof password === 'string' && password.length > 0;
|
||||
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.' });
|
||||
if (shouldUpdatePassword && !canManageUserPassword(req.user)) {
|
||||
return res.status(403).json({ error: 'Apenas admins podem alterar senha de usuários.' });
|
||||
}
|
||||
if (shouldUpdatePassword && password.length < MIN_PASSWORD_LENGTH) {
|
||||
return res.status(400).json({ error: `A senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres.` });
|
||||
}
|
||||
if (role === 'super_admin' && req.user.role !== 'super_admin') {
|
||||
return res.status(403).json({ error: 'Apenas super admins podem definir super admins.' });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
if (shouldUpdatePassword) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, req.params.id]);
|
||||
await pool.query('DELETE FROM password_resets WHERE email = ?', [finalEmail]);
|
||||
}
|
||||
|
||||
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 recordActivity(pool, {
|
||||
userId: req.params.id,
|
||||
type: 'info',
|
||||
title: 'Novo Time',
|
||||
message: `Você foi adicionado ao time ${team[0].name}.`,
|
||||
link: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -1,14 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const recordActivity = async (db, { userId, type = 'info', title, message, link = null }) => {
|
||||
if (!userId || !title || !message) return;
|
||||
|
||||
await db.query(
|
||||
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), userId, type, title, message, link]
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
recordActivity,
|
||||
};
|
||||
@@ -1,273 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const provisionSuperAdmin = async ({ pool, transporter, getStartupBaseUrl, retries = 10, delay = 10000 }) => {
|
||||
const email = 'suporte@blyzer.com.br';
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
|
||||
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;
|
||||
`);
|
||||
|
||||
try {
|
||||
await connection.query('ALTER TABLE users ADD COLUMN slug VARCHAR(255) UNIQUE DEFAULT NULL');
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (slug):', err.message);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL");
|
||||
} catch (err) {
|
||||
console.log('Schema update note (origin):', err.message);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances ADD COLUMN full_summary TEXT DEFAULT NULL");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (full_summary):', err.message);
|
||||
}
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS origin_groups (
|
||||
id varchar(36) NOT NULL,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY tenant_id (tenant_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS origin_items (
|
||||
id varchar(36) NOT NULL,
|
||||
origin_group_id varchar(36) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200',
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY origin_group_id (origin_group_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE origin_items ADD COLUMN color_class VARCHAR(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200'");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (origin_items.color_class):', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.origin_group_id):', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances RENAME COLUMN summary TO title");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_BAD_FIELD_ERROR' && err.code !== 'ER_DUP_FIELDNAME') {
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances CHANGE COLUMN summary title TEXT");
|
||||
} catch (e) {
|
||||
console.log('Schema update note (summary to title):', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
`);
|
||||
|
||||
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;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id varchar(36) NOT NULL,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
secret_key varchar(255) NOT NULL,
|
||||
secret_hash varchar(64) DEFAULT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY secret_key (secret_key),
|
||||
UNIQUE KEY secret_hash (secret_hash),
|
||||
KEY tenant_id (tenant_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE api_keys ADD COLUMN secret_hash VARCHAR(64) DEFAULT NULL");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (api_keys.secret_hash):', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE api_keys ADD UNIQUE KEY secret_hash (secret_hash)");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (api_keys.secret_hash index):', err.message);
|
||||
}
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id varchar(36) NOT NULL,
|
||||
user_id varchar(36) NOT NULL,
|
||||
token varchar(255) NOT NULL,
|
||||
token_hash varchar(64) DEFAULT NULL,
|
||||
expires_at timestamp NOT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY token (token),
|
||||
UNIQUE KEY token_hash (token_hash),
|
||||
KEY user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE refresh_tokens ADD COLUMN token_hash VARCHAR(64) DEFAULT NULL");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (refresh_tokens.token_hash):', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.query("ALTER TABLE refresh_tokens ADD UNIQUE KEY token_hash (token_hash)");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (refresh_tokens.token_hash index):', err.message);
|
||||
}
|
||||
|
||||
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();
|
||||
connection = null;
|
||||
|
||||
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');
|
||||
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;
|
||||
} catch (error) {
|
||||
if (connection) connection.release();
|
||||
console.error(`Failed to provision super_admin (Attempt ${i + 1}/${retries}):`, error.message);
|
||||
if (i < retries - 1) {
|
||||
await new Promise(res => setTimeout(res, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
provisionSuperAdmin,
|
||||
};
|
||||
@@ -6,7 +6,6 @@ const {
|
||||
canManageUserStatus,
|
||||
canChangeUserEmail,
|
||||
canManageUserRoleOrTeam,
|
||||
canManageUserPassword,
|
||||
canReadAttendance,
|
||||
} = require('../policies/accessPolicy');
|
||||
|
||||
@@ -49,13 +48,6 @@ test('only admins can manage role, team, and status fields', () => {
|
||||
assert.equal(canChangeUserEmail(manager, agent), false);
|
||||
});
|
||||
|
||||
test('only admins can manage user passwords', () => {
|
||||
assert.equal(canManageUserPassword(superAdmin), true);
|
||||
assert.equal(canManageUserPassword(admin), true);
|
||||
assert.equal(canManageUserPassword(manager), false);
|
||||
assert.equal(canManageUserPassword(agent), false);
|
||||
});
|
||||
|
||||
test('attendance detail policy matches role boundaries', () => {
|
||||
const ownAttendance = { id: 'att_1', tenant_id: 'tenant_a', user_id: 'u_agent', team_id: 'team_a' };
|
||||
const teamAttendance = { id: 'att_2', tenant_id: 'tenant_a', user_id: 'u_another', team_id: 'team_a' };
|
||||
|
||||
@@ -20,6 +20,10 @@ services:
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- MAIL_FROM=${MAIL_FROM}
|
||||
- SMTP_DEBUG=${SMTP_DEBUG:-false}
|
||||
volumes:
|
||||
- ./dist:/app/dist # Map local build to container
|
||||
- ./backend/index.js:/app/index.js
|
||||
- ./backend/db.js:/app/db.js
|
||||
command: ["node", "index.js"]
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
82
package-lock.json
generated
82
package-lock.json
generated
@@ -1444,9 +1444,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -1457,7 +1457,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -1932,9 +1932,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -2027,14 +2027,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -2053,7 +2053,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -2261,9 +2261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -2590,9 +2590,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -2694,9 +2694,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
|
||||
"integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -2745,9 +2745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@@ -2813,9 +2813,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -2913,9 +2913,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -2935,12 +2935,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
|
||||
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.17.0"
|
||||
"react-router": "7.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -3201,13 +3201,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3440,9 +3440,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
||||
BIN
src/assets/audio/notification.mp3
Normal file
BIN
src/assets/audio/notification.mp3
Normal file
Binary file not shown.
@@ -7,10 +7,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
||||
getActivityEvents, returnToSuperAdmin
|
||||
getNotifications, markNotificationAsRead, markAllNotificationsAsRead,
|
||||
deleteNotification, clearAllNotifications, returnToSuperAdmin
|
||||
} from '../services/dataService';
|
||||
import { User } from '../types';
|
||||
import type { ActivityEvent } from '../services/activityService';
|
||||
import notificationSound from '../assets/audio/notification.mp3';
|
||||
|
||||
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
||||
<NavLink
|
||||
@@ -51,19 +52,53 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||
|
||||
// Activity Feed State
|
||||
const [activityEvents, setActivityEvents] = useState<ActivityEvent[]>([]);
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
// Notifications State
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||
const previousUnreadCountRef = React.useRef(0);
|
||||
const isInitialLoadRef = React.useRef(true);
|
||||
|
||||
// Pre-initialize audio to ensure it's loaded and ready
|
||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const loadActivityEvents = async () => {
|
||||
const data = await getActivityEvents();
|
||||
setActivityEvents(data);
|
||||
const playNotificationSound = () => {
|
||||
if (currentUser?.sound_enabled !== false && audioRef.current) {
|
||||
// Reset time to 0 to allow rapid replays
|
||||
audioRef.current.currentTime = 0;
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(e => console.log('Audio play blocked by browser policy:', e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivityClick = async () => {
|
||||
const willOpen = !showActivity;
|
||||
setShowActivity(willOpen);
|
||||
if (willOpen) await loadActivityEvents();
|
||||
const loadNotifications = async () => {
|
||||
const data = await getNotifications();
|
||||
|
||||
const newUnreadCount = data.filter((n: any) => !n.is_read).length;
|
||||
|
||||
// Only play sound if it's NOT the first load AND the count actually increased
|
||||
if (!isInitialLoadRef.current && newUnreadCount > previousUnreadCountRef.current) {
|
||||
playNotificationSound();
|
||||
}
|
||||
|
||||
setNotifications(data);
|
||||
previousUnreadCountRef.current = newUnreadCount;
|
||||
isInitialLoadRef.current = false;
|
||||
};
|
||||
|
||||
const handleBellClick = async () => {
|
||||
const willOpen = !showNotifications;
|
||||
setShowNotifications(willOpen);
|
||||
|
||||
if (willOpen && unreadCount > 0) {
|
||||
// Optimistic update
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
previousUnreadCountRef.current = 0;
|
||||
await markAllNotificationsAsRead();
|
||||
loadNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,7 +144,9 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
loadActivityEvents();
|
||||
loadNotifications();
|
||||
const interval = setInterval(loadNotifications, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -462,52 +499,94 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleActivityClick}
|
||||
onClick={handleBellClick}
|
||||
className="p-2 text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-full relative transition-colors"
|
||||
title="Atividades"
|
||||
>
|
||||
<Bell size={20} />
|
||||
> <Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1.5 right-2 w-2.5 h-2.5 bg-brand-yellow rounded-full border-2 border-white dark:border-dark-header"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showActivity && (
|
||||
{showNotifications && (
|
||||
<div className="absolute top-full mt-2 right-0 w-80 bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="p-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
|
||||
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Atividades</h3>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Notificações</h3>
|
||||
<div className="flex gap-3">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await markAllNotificationsAsRead();
|
||||
loadNotifications();
|
||||
}}
|
||||
className="text-xs text-brand-yellow hover:underline"
|
||||
>
|
||||
Marcar lidas
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await clearAllNotifications();
|
||||
loadNotifications();
|
||||
}}
|
||||
className="text-xs text-zinc-400 hover:text-red-500 hover:underline transition-colors"
|
||||
>
|
||||
Limpar tudo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{activityEvents.length > 0 ? (
|
||||
activityEvents.map(event => (
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map(n => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="w-full relative group p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0"
|
||||
key={n.id}
|
||||
className={`w-full relative group p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0 ${!n.is_read ? 'bg-brand-yellow/5 dark:bg-brand-yellow/5' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={event.link ? 'cursor-pointer' : ''}
|
||||
onClick={() => {
|
||||
if (event.link) navigate(event.link);
|
||||
setShowActivity(false);
|
||||
className="cursor-pointer pr-6"
|
||||
onClick={async () => {
|
||||
if (!n.is_read) await markNotificationAsRead(n.id);
|
||||
if (n.link) navigate(n.link);
|
||||
setShowNotifications(false);
|
||||
loadNotifications();
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||
event.type === 'success' ? 'text-green-500' :
|
||||
event.type === 'warning' ? 'text-orange-500' :
|
||||
event.type === 'error' ? 'text-red-500' : 'text-blue-500'
|
||||
n.type === 'success' ? 'text-green-500' :
|
||||
n.type === 'warning' ? 'text-orange-500' :
|
||||
n.type === 'error' ? 'text-red-500' : 'text-blue-500'
|
||||
}`}>
|
||||
{event.type === 'success' ? 'SUCESSO' : event.type === 'warning' ? 'AVISO' : event.type === 'error' ? 'ERRO' : 'INFO'}
|
||||
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-dark-muted">
|
||||
{new Date(event.created_at).toLocaleDateString('pt-BR')}
|
||||
{new Date(n.created_at).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{event.title}</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{event.message}</p>
|
||||
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{n.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await deleteNotification(n.id);
|
||||
loadNotifications();
|
||||
}}
|
||||
className="absolute top-4 right-4 p-1 text-zinc-300 hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
title="Remover notificação"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
||||
Nenhuma atividade por enquanto.
|
||||
Nenhuma notificação por enquanto.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -515,7 +594,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActivity && <div className="fixed inset-0 z-40" onClick={() => setShowActivity(false)} />}
|
||||
{/* Close notifications when clicking outside */}
|
||||
{showNotifications && <div className="fixed inset-0 z-40" onClick={() => setShowNotifications(false)} />}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -533,6 +613,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hidden Audio Player for Notifications */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={notificationSound}
|
||||
preload="auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users, Plus, Search, X, Edit, Trash2, Loader2, AlertTriangle, KeyRound } from 'lucide-react';
|
||||
import { Users, Plus, Mail, Search, X, Edit, Trash2, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { getUsers, getTeams, createMember, deleteUser, updateUser, getUserById, getTenants } from '../services/dataService';
|
||||
import { User, Tenant } from '../types';
|
||||
|
||||
@@ -24,9 +24,7 @@ export const TeamManagement: React.FC = () => {
|
||||
role: 'agent' as any,
|
||||
team_id: '',
|
||||
status: 'active' as any,
|
||||
tenant_id: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
tenant_id: ''
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -69,36 +67,12 @@ export const TeamManagement: React.FC = () => {
|
||||
try {
|
||||
const tid = localStorage.getItem('ctms_tenant_id') || '';
|
||||
const finalTenantId = currentUser?.role === 'super_admin' ? formData.tenant_id : tid;
|
||||
const password = formData.password.trim();
|
||||
const confirmPassword = formData.confirmPassword.trim();
|
||||
|
||||
if (password || confirmPassword) {
|
||||
if (password.length < 8) {
|
||||
alert('A senha deve ter pelo menos 8 caracteres.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
alert('As senhas não conferem.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
team_id: formData.team_id,
|
||||
status: formData.status,
|
||||
tenant_id: finalTenantId
|
||||
};
|
||||
|
||||
if (password) payload.password = password;
|
||||
|
||||
if (editingUser) {
|
||||
const success = await updateUser(editingUser.id, payload);
|
||||
const success = await updateUser(editingUser.id, { ...formData, tenant_id: finalTenantId });
|
||||
if (success) { setIsModalOpen(false); loadData(); }
|
||||
} else {
|
||||
await createMember(payload);
|
||||
await createMember({ ...formData, tenant_id: finalTenantId });
|
||||
setIsModalOpen(false);
|
||||
loadData();
|
||||
}
|
||||
@@ -120,7 +94,6 @@ export const TeamManagement: React.FC = () => {
|
||||
if (loading && users.length === 0) return <div className="flex h-screen items-center justify-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando...</div>;
|
||||
|
||||
const canManage = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' || currentUser?.role === 'manager';
|
||||
const canManagePasswords = currentUser?.role === 'admin' || currentUser?.role === 'super_admin';
|
||||
const filtered = users.filter(u =>
|
||||
(u.name.toLowerCase().includes(searchTerm.toLowerCase()) || u.email.toLowerCase().includes(searchTerm.toLowerCase())) &&
|
||||
(tenantFilter === 'all' || u.tenant_id === tenantFilter)
|
||||
@@ -149,7 +122,7 @@ export const TeamManagement: React.FC = () => {
|
||||
<p className="text-zinc-500 dark:text-dark-muted text-sm">Visualize e gerencie as funções dos membros da sua organização.</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button onClick={() => { setEditingUser(null); setFormData({name:'', email:'', role:'agent', team_id:'', status:'active', tenant_id:'', password:'', confirmPassword:''}); setIsModalOpen(true); }} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm hover:opacity-90 transition-all">
|
||||
<button onClick={() => { setEditingUser(null); setFormData({name:'', email:'', role:'agent', team_id:'', status:'active'}); setIsModalOpen(true); }} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm hover:opacity-90 transition-all">
|
||||
<Plus size={16} /> Adicionar Membro
|
||||
</button>
|
||||
)}
|
||||
@@ -222,7 +195,7 @@ export const TeamManagement: React.FC = () => {
|
||||
{canManage && (
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2 transition-opacity">
|
||||
<button onClick={() => { setEditingUser(user); setFormData({name:user.name, email:user.email, role:user.role as any, team_id:user.team_id||'', status:user.status as any, tenant_id: user.tenant_id||'', password:'', confirmPassword:''}); setIsModalOpen(true); }} className="p-2 hover:bg-zinc-100 dark:hover:bg-dark-border text-zinc-400 hover:text-zinc-900 dark:hover:text-dark-text rounded-lg transition-colors"><Edit size={16} /></button>
|
||||
<button onClick={() => { setEditingUser(user); setFormData({name:user.name, email:user.email, role:user.role as any, team_id:user.team_id||'', status:user.status as any, tenant_id: user.tenant_id||''}); setIsModalOpen(true); }} className="p-2 hover:bg-zinc-100 dark:hover:bg-dark-border text-zinc-400 hover:text-zinc-900 dark:hover:text-dark-text rounded-lg transition-colors"><Edit size={16} /></button>
|
||||
<button onClick={() => { setUserToDelete(user); setDeleteConfirmName(''); setIsDeleteModalOpen(true); }} className="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 text-zinc-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg transition-colors"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -236,7 +209,7 @@ export const TeamManagement: React.FC = () => {
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-dark-card rounded-2xl p-8 w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl animate-in zoom-in duration-200 transition-colors border border-transparent dark:border-dark-border">
|
||||
<div className="bg-white dark:bg-dark-card rounded-2xl p-8 w-full max-w-lg shadow-2xl animate-in zoom-in duration-200 transition-colors border border-transparent dark:border-dark-border">
|
||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-dark-text mb-6">{editingUser ? 'Editar Usuário' : 'Novo Membro'}</h3>
|
||||
<form onSubmit={handleSave} className="space-y-5">
|
||||
<div>
|
||||
@@ -302,38 +275,6 @@ export const TeamManagement: React.FC = () => {
|
||||
<option value="inactive">Inativo</option>
|
||||
</select>
|
||||
</div>
|
||||
{canManagePasswords && (
|
||||
<div className="space-y-4 rounded-xl border border-zinc-200 dark:border-dark-border bg-zinc-50 dark:bg-dark-bg/50 p-4">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase">
|
||||
<KeyRound size={14} className="text-brand-yellow" />
|
||||
{editingUser ? 'Alterar senha' : 'Senha inicial'}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Senha</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({...formData, password:e.target.value})}
|
||||
className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-dark-text outline-none focus:ring-2 focus:ring-brand-yellow/20"
|
||||
placeholder={editingUser ? 'Deixe em branco para manter' : 'Opcional'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Confirmar</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={e => setFormData({...formData, confirmPassword:e.target.value})}
|
||||
className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-dark-text outline-none focus:ring-2 focus:ring-brand-yellow/20"
|
||||
placeholder="Repita a senha"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-6 border-t dark:border-dark-border mt-6">
|
||||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-6 py-2.5 text-sm font-medium text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg transition-colors">Cancelar</button>
|
||||
<button type="submit" disabled={isSaving} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-8 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all">{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Salvar Alterações'}</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
|
||||
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
|
||||
import { User, Tenant } from '../types';
|
||||
|
||||
@@ -273,6 +273,32 @@ export const UserProfile: React.FC = () => {
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 text-right">{bio.length}/500 caracteres</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-900/50 rounded-xl border border-zinc-200 dark:border-zinc-800">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<Bell size={16} className="text-brand-yellow" /> Notificações Sonoras
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Reproduzir um som quando você receber uma nova notificação.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={user.sound_enabled ?? true}
|
||||
onChange={async (e) => {
|
||||
const newStatus = e.target.checked;
|
||||
setUser({...user, sound_enabled: newStatus});
|
||||
await updateUser(user.id, { sound_enabled: newStatus });
|
||||
}}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-zinc-200 peer-focus:outline-none rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-zinc-600 peer-checked:bg-brand-yellow"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex items-center justify-end border-t border-zinc-100 dark:border-zinc-800 mt-6 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string;
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getActivityEvents = async (): Promise<ActivityEvent[]> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/activity`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch activity');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("API Error (getActivityEvents):", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
export * from './apiClient';
|
||||
export * from './activityService';
|
||||
export * from './attendancesService';
|
||||
export * from './authService';
|
||||
export * from './funnelsService';
|
||||
export * from './integrationsService';
|
||||
export * from './notificationsService';
|
||||
export * from './originsService';
|
||||
export * from './teamsService';
|
||||
export * from './tenantsService';
|
||||
|
||||
66
src/services/notificationsService.ts
Normal file
66
src/services/notificationsService.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||
|
||||
export const getNotifications = async (): Promise<any[]> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch notifications');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("API Error (getNotifications):", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (markNotificationAsRead):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (markAllNotificationsAsRead):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (deleteNotification):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAllNotifications = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (clearAllNotifications):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -49,6 +49,7 @@ export interface User {
|
||||
team_id: string;
|
||||
bio?: string;
|
||||
status: 'active' | 'inactive';
|
||||
sound_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
|
||||
Reference in New Issue
Block a user