1126 lines
47 KiB
JavaScript
1126 lines
47 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const multer = require('multer');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const fs = require('fs');
|
|
const pool = require('./db');
|
|
const { hashSecret, maskSecret } = require('./utils/security');
|
|
const transporter = require('./services/mailer');
|
|
const { createCorsMiddleware } = require('./config/cors');
|
|
const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime');
|
|
const { authenticateToken, requireRole } = require('./middleware/auth');
|
|
const { createAuthRouter } = require('./routes/authRoutes');
|
|
const { createUsersRouter } = require('./routes/usersRoutes');
|
|
const { createTeamsRouter } = require('./routes/teamsRoutes');
|
|
const { canReadAttendance } = require('./policies/accessPolicy');
|
|
|
|
const app = express();
|
|
|
|
app.use(createCorsMiddleware({ allowedOrigins, isProduction }));
|
|
app.use(express.json());
|
|
|
|
// Logger de Requisições
|
|
app.use((req, res, next) => {
|
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
|
next();
|
|
});
|
|
|
|
// --- Configuração Multer (Upload Seguro) ---
|
|
const uploadDir = path.join(__dirname, 'uploads');
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
cb(null, uploadDir);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
cb(null, `${uuidv4()}${ext}`);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB
|
|
fileFilter: (req, file, cb) => {
|
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
|
if (allowedTypes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Tipo de arquivo inválido. Apenas JPG, PNG e WEBP são permitidos.'));
|
|
}
|
|
}
|
|
});
|
|
|
|
app.use('/uploads', express.static(uploadDir, {
|
|
setHeaders: (res) => {
|
|
res.set('X-Content-Type-Options', 'nosniff');
|
|
}
|
|
}));
|
|
|
|
// --- API Router ---
|
|
const apiRouter = express.Router();
|
|
|
|
apiRouter.use(authenticateToken);
|
|
|
|
// --- Auth Routes ---
|
|
|
|
apiRouter.use(createAuthRouter({ pool, transporter, getBaseUrl, jwtSecret: JWT_SECRET }));
|
|
|
|
apiRouter.use(createUsersRouter({ pool, upload, transporter, getBaseUrl }));
|
|
|
|
apiRouter.use(createTeamsRouter({ pool }));
|
|
|
|
// --- Notifications Routes ---
|
|
apiRouter.get('/notifications', async (req, res) => {
|
|
try {
|
|
const [rows] = await pool.query(
|
|
'SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50',
|
|
[req.user.id]
|
|
);
|
|
res.json(rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.put('/notifications/read-all', async (req, res) => {
|
|
try {
|
|
await pool.query(
|
|
'UPDATE notifications SET is_read = true WHERE user_id = ?',
|
|
[req.user.id]
|
|
);
|
|
res.json({ message: 'All notifications marked as read' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.put('/notifications/:id', async (req, res) => {
|
|
try {
|
|
await pool.query(
|
|
'UPDATE notifications SET is_read = true WHERE id = ? AND user_id = ?',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
res.json({ message: 'Notification marked as read' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/notifications/clear-all', async (req, res) => {
|
|
try {
|
|
await pool.query(
|
|
'DELETE FROM notifications WHERE user_id = ?',
|
|
[req.user.id]
|
|
);
|
|
res.json({ message: 'All notifications deleted' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/notifications/:id', async (req, res) => {
|
|
try {
|
|
await pool.query(
|
|
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
res.json({ message: 'Notification deleted' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// --- Origin Routes (Groups & Items) ---
|
|
apiRouter.get('/origins', async (req, res) => {
|
|
try {
|
|
const { tenantId } = req.query;
|
|
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
|
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
|
|
|
|
const [groups] = await pool.query('SELECT * FROM origin_groups WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]);
|
|
|
|
// Seed default origin group if none exists
|
|
if (groups.length === 0) {
|
|
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']);
|
|
|
|
const defaultOrigins = [
|
|
{ name: 'WhatsApp', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
|
|
{ name: 'Instagram', color: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' },
|
|
{ name: 'Website', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
|
|
{ name: 'LinkedIn', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
|
|
{ name: 'Indicação', color: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' }
|
|
];
|
|
for (const origin of defaultOrigins) {
|
|
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query(
|
|
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
|
|
[oid, gid, origin.name, origin.color]
|
|
);
|
|
}
|
|
|
|
// Update all teams of this tenant to use this origin group if they have none
|
|
await pool.query('UPDATE teams SET origin_group_id = ? WHERE tenant_id = ? AND origin_group_id IS NULL', [gid, effectiveTenantId]);
|
|
|
|
groups.push({ id: gid, tenant_id: effectiveTenantId, name: 'Origens Padrão' });
|
|
}
|
|
|
|
const [items] = await pool.query('SELECT * FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
|
|
const [teams] = await pool.query('SELECT id, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [effectiveTenantId]);
|
|
|
|
const result = groups.map(g => ({
|
|
...g,
|
|
items: items.filter(i => i.origin_group_id === g.id),
|
|
teamIds: teams.filter(t => t.origin_group_id === g.id).map(t => t.id)
|
|
}));
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error("GET /origins error:", error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, tenantId } = req.body;
|
|
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
|
try {
|
|
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, name]);
|
|
res.status(201).json({ id: gid });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, teamIds } = req.body;
|
|
try {
|
|
if (name) {
|
|
await pool.query('UPDATE origin_groups SET name = ? WHERE id = ?', [name, req.params.id]);
|
|
}
|
|
if (teamIds && Array.isArray(teamIds)) {
|
|
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
|
|
if (teamIds.length > 0) {
|
|
await pool.query('UPDATE teams SET origin_group_id = ? WHERE id IN (?)', [req.params.id, teamIds]);
|
|
}
|
|
}
|
|
res.json({ message: 'Origin group updated.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]);
|
|
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
|
|
await pool.query('DELETE FROM origin_groups WHERE id = ?', [req.params.id]);
|
|
res.json({ message: 'Origin group deleted.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, color_class } = req.body;
|
|
try {
|
|
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query(
|
|
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
|
|
[oid, req.params.id, name, color_class || 'bg-zinc-100 text-zinc-800 border-zinc-200']
|
|
);
|
|
res.status(201).json({ id: oid });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, color_class } = req.body;
|
|
try {
|
|
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
|
|
if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' });
|
|
|
|
await pool.query('UPDATE origin_items SET name = ?, color_class = ? WHERE id = ?', [name || existing[0].name, color_class || existing[0].color_class, req.params.id]);
|
|
res.json({ message: 'Origin item updated.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]);
|
|
res.json({ message: 'Origin item deleted.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// --- Funnel Routes ---
|
|
apiRouter.get('/funnels', async (req, res) => {
|
|
try {
|
|
const { tenantId } = req.query;
|
|
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
|
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
|
|
|
|
const [funnels] = await pool.query('SELECT * FROM funnels WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]);
|
|
|
|
// Seed default funnel if none exists
|
|
if (funnels.length === 0) {
|
|
const fid = `funnel_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query('INSERT INTO funnels (id, tenant_id, name) VALUES (?, ?, ?)', [fid, effectiveTenantId, 'Funil Padrão']);
|
|
|
|
const defaultStages = [
|
|
{ name: 'Sem atendimento', color: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border', order: 0 },
|
|
{ name: 'Identificação', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800', order: 1 },
|
|
{ name: 'Negociação', color: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800', order: 2 },
|
|
{ name: 'Ganhos', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800', order: 3 },
|
|
{ name: 'Perdidos', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800', order: 4 }
|
|
];
|
|
|
|
for (const s of defaultStages) {
|
|
const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query(
|
|
'INSERT INTO funnel_stages (id, funnel_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)',
|
|
[sid, fid, s.name, s.color, s.order]
|
|
);
|
|
}
|
|
|
|
// Update all teams of this tenant to use this funnel if they have none
|
|
await pool.query('UPDATE teams SET funnel_id = ? WHERE tenant_id = ? AND funnel_id IS NULL', [fid, effectiveTenantId]);
|
|
|
|
funnels.push({ id: fid, tenant_id: effectiveTenantId, name: 'Funil Padrão' });
|
|
}
|
|
|
|
const [stages] = await pool.query('SELECT * FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]);
|
|
const [teams] = await pool.query('SELECT id, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [effectiveTenantId]);
|
|
|
|
const result = funnels.map(f => ({
|
|
...f,
|
|
stages: stages.filter(s => s.funnel_id === f.id),
|
|
teamIds: teams.filter(t => t.funnel_id === f.id).map(t => t.id)
|
|
}));
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error("GET /funnels error:", error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.post('/funnels', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, tenantId } = req.body;
|
|
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
|
try {
|
|
const fid = `funnel_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query('INSERT INTO funnels (id, tenant_id, name) VALUES (?, ?, ?)', [fid, effectiveTenantId, name]);
|
|
res.status(201).json({ id: fid });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, teamIds } = req.body;
|
|
try {
|
|
if (name) {
|
|
await pool.query('UPDATE funnels SET name = ? WHERE id = ?', [name, req.params.id]);
|
|
}
|
|
if (teamIds && Array.isArray(teamIds)) {
|
|
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
|
|
if (teamIds.length > 0) {
|
|
await pool.query('UPDATE teams SET funnel_id = ? WHERE id IN (?)', [req.params.id, teamIds]);
|
|
}
|
|
}
|
|
res.json({ message: 'Funnel updated.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]);
|
|
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
|
|
await pool.query('DELETE FROM funnels WHERE id = ?', [req.params.id]);
|
|
res.json({ message: 'Funnel deleted.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, color_class, order_index } = req.body;
|
|
try {
|
|
const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query(
|
|
'INSERT INTO funnel_stages (id, funnel_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)',
|
|
[sid, req.params.id, name, color_class, order_index || 0]
|
|
);
|
|
res.status(201).json({ id: sid });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
const { name, color_class, order_index } = req.body;
|
|
try {
|
|
const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]);
|
|
if (existing.length === 0) return res.status(404).json({ error: 'Stage not found' });
|
|
|
|
await pool.query(
|
|
'UPDATE funnel_stages SET name = ?, color_class = ?, order_index = ? WHERE id = ?',
|
|
[name || existing[0].name, color_class || existing[0].color_class, order_index !== undefined ? order_index : existing[0].order_index, req.params.id]
|
|
);
|
|
res.json({ message: 'Stage updated.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]);
|
|
res.json({ message: 'Stage deleted.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// --- Global Search ---
|
|
apiRouter.get('/search', async (req, res) => {
|
|
const { q } = req.query;
|
|
if (!q || q.length < 2) return res.json({ members: [], teams: [], attendances: [], organizations: [] });
|
|
|
|
const queryStr = `%${q}%`;
|
|
const results = { members: [], teams: [], attendances: [], organizations: [] };
|
|
|
|
try {
|
|
// 1. Search Members (only for roles above agent)
|
|
if (req.user.role !== 'agent') {
|
|
let membersQ = 'SELECT id, name, email, slug, role, team_id, avatar_url FROM users WHERE (name LIKE ? OR email LIKE ?)';
|
|
const membersParams = [queryStr, queryStr];
|
|
|
|
if (req.user.role === 'super_admin') {
|
|
// No extra filters
|
|
} else if (req.user.role === 'admin') {
|
|
membersQ += ' AND tenant_id = ?';
|
|
membersParams.push(req.user.tenant_id);
|
|
} else if (req.user.role === 'manager') {
|
|
membersQ += ' AND tenant_id = ? AND (team_id = ? OR id = ?)';
|
|
membersParams.push(req.user.tenant_id, req.user.team_id, req.user.id);
|
|
}
|
|
const [members] = await pool.query(membersQ, membersParams);
|
|
results.members = members;
|
|
}
|
|
|
|
// 2. Search Teams (only for roles above agent)
|
|
if (req.user.role !== 'agent') {
|
|
let teamsQ = 'SELECT id, name, description FROM teams WHERE name LIKE ?';
|
|
const teamsParams = [queryStr];
|
|
|
|
if (req.user.role === 'super_admin') {
|
|
// No extra filters
|
|
} else if (req.user.role === 'admin') {
|
|
teamsQ += ' AND tenant_id = ?';
|
|
teamsParams.push(req.user.tenant_id);
|
|
} else if (req.user.role === 'manager') {
|
|
teamsQ += ' AND tenant_id = ? AND id = ?';
|
|
teamsParams.push(req.user.tenant_id, req.user.team_id);
|
|
}
|
|
const [teams] = await pool.query(teamsQ, teamsParams);
|
|
results.teams = teams;
|
|
}
|
|
|
|
// 3. Search Organizations (only for super_admin)
|
|
if (req.user.role === 'super_admin') {
|
|
const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ? LIMIT 5', [queryStr, queryStr]);
|
|
results.organizations = orgs;
|
|
}
|
|
|
|
// 4. Search Attendances
|
|
let attendancesQ = 'SELECT a.id, a.title, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.title LIKE ?';
|
|
const attendancesParams = [queryStr];
|
|
|
|
if (req.user.role === 'super_admin') {
|
|
// No extra filters
|
|
} else if (req.user.role === 'admin') {
|
|
attendancesQ += ' AND a.tenant_id = ?';
|
|
attendancesParams.push(req.user.tenant_id);
|
|
} else if (req.user.role === 'manager') {
|
|
attendancesQ += ' AND a.tenant_id = ? AND u.team_id = ?';
|
|
attendancesParams.push(req.user.tenant_id, req.user.team_id);
|
|
} else {
|
|
attendancesQ += ' AND a.user_id = ?';
|
|
attendancesParams.push(req.user.id);
|
|
}
|
|
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 });
|
|
}
|
|
});
|
|
|
|
|
|
// --- Attendance Routes ---
|
|
apiRouter.get('/attendances', async (req, res) => {
|
|
try {
|
|
const { tenantId, userId, teamId, startDate, endDate, funnelStage, origin } = req.query;
|
|
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
|
|
|
let q = 'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.tenant_id = ?';
|
|
const params = [effectiveTenantId];
|
|
|
|
if (startDate && endDate) { q += ' AND a.created_at BETWEEN ? AND ?'; params.push(new Date(startDate), new Date(endDate)); }
|
|
|
|
// Strict RBAC: Agents can ONLY see their own data, regardless of what they request
|
|
if (req.user.role === 'agent') {
|
|
q += ' AND a.user_id = ?';
|
|
params.push(req.user.id);
|
|
} else {
|
|
if (req.user.role === 'manager') {
|
|
q += ' AND u.team_id = ?';
|
|
params.push(req.user.team_id);
|
|
} else if (teamId && teamId !== 'all') {
|
|
q += ' AND u.team_id = ?';
|
|
params.push(teamId);
|
|
}
|
|
|
|
if (userId && userId !== 'all') {
|
|
// check if it's a slug or id
|
|
if (userId.startsWith('u_') || 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);
|
|
const processed = rows.map(r => ({
|
|
...r,
|
|
attention_points: typeof r.attention_points === 'string' ? JSON.parse(r.attention_points) : r.attention_points,
|
|
improvement_points: typeof r.improvement_points === 'string' ? JSON.parse(r.improvement_points) : r.improvement_points,
|
|
converted: Boolean(r.converted)
|
|
}));
|
|
res.json(processed);
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
apiRouter.get('/attendances/:id', async (req, res) => {
|
|
try {
|
|
const [rows] = await pool.query(
|
|
'SELECT 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.' });
|
|
|
|
const r = rows[0];
|
|
res.json({
|
|
...r,
|
|
attention_points: typeof r.attention_points === 'string' ? JSON.parse(r.attention_points) : r.attention_points,
|
|
improvement_points: typeof r.improvement_points === 'string' ? JSON.parse(r.improvement_points) : r.improvement_points,
|
|
converted: Boolean(r.converted)
|
|
});
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// --- API Key Management Routes ---
|
|
apiRouter.get('/api-keys', requireRole(['admin', '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 });
|
|
}
|
|
});
|
|
|
|
apiRouter.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]}`;
|
|
// Generate a strong, random 32-byte hex string for the secret key
|
|
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
|
|
|
|
await pool.query(
|
|
'INSERT INTO api_keys (id, tenant_id, name, secret_key, secret_hash) VALUES (?, ?, ?, ?, ?)',
|
|
[id, effectiveTenantId, name || 'Nova Integração API', maskSecret(id, secretKey), hashSecret(secretKey)]
|
|
);
|
|
|
|
// We only return the actual secret key ONCE during creation.
|
|
res.status(201).json({ id, secret_key: secretKey, message: 'Chave criada. Salve-a agora, ela não será exibida novamente.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
|
try {
|
|
const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]);
|
|
if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' });
|
|
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) return res.status(403).json({ error: 'Acesso negado' });
|
|
|
|
await pool.query('DELETE FROM api_keys WHERE id = ?', [req.params.id]);
|
|
res.json({ message: 'Chave de API revogada com sucesso.' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// --- External Integration API (n8n) ---
|
|
apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) => {
|
|
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
|
try {
|
|
const [rows] = await pool.query(
|
|
'SELECT u.id, u.name, u.email, t.name as team_name FROM users u LEFT JOIN teams t ON u.team_id = t.id WHERE u.tenant_id = ? AND u.status = "active"',
|
|
[req.user.tenant_id]
|
|
);
|
|
res.json(rows);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => {
|
|
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
|
try {
|
|
const [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 });
|
|
}
|
|
});
|
|
|
|
apiRouter.get('/integration/funnels', requireRole(['admin']), async (req, res) => {
|
|
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
|
try {
|
|
const [funnels] = await pool.query('SELECT id, name FROM funnels WHERE tenant_id = ?', [req.user.tenant_id]);
|
|
if (funnels.length === 0) return res.json([]);
|
|
|
|
const [stages] = await pool.query('SELECT funnel_id, name, order_index FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]);
|
|
const [teams] = await pool.query('SELECT id as team_id, name as team_name, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [req.user.tenant_id]);
|
|
|
|
const result = funnels.map(f => ({
|
|
funnel_name: f.name,
|
|
stages: stages.filter(s => s.funnel_id === f.id).map(s => s.name),
|
|
assigned_teams: teams.filter(t => t.funnel_id === f.id).map(t => ({ id: t.team_id, name: t.team_name }))
|
|
}));
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, res) => {
|
|
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
|
|
|
const {
|
|
user_id,
|
|
origin,
|
|
funnel_stage,
|
|
title,
|
|
full_summary,
|
|
score,
|
|
first_response_time_min,
|
|
handling_time_min,
|
|
product_requested,
|
|
product_sold,
|
|
converted,
|
|
attention_points,
|
|
improvement_points
|
|
} = req.body;
|
|
|
|
if (!user_id || !origin || !funnel_stage || !title) {
|
|
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, title' });
|
|
}
|
|
|
|
try {
|
|
// Validate user belongs to the API Key's tenant
|
|
const [users] = await pool.query('SELECT id FROM users WHERE id = ? AND tenant_id = ? AND status = "active"', [user_id, req.user.tenant_id]);
|
|
if (users.length === 0) return res.status(400).json({ error: 'user_id inválido, inativo ou não pertence a esta organização.' });
|
|
|
|
const attId = `att_${crypto.randomUUID().split('-')[0]}`;
|
|
await pool.query(
|
|
`INSERT INTO attendances (
|
|
id, tenant_id, user_id, title, full_summary, score,
|
|
first_response_time_min, handling_time_min,
|
|
funnel_stage, origin, product_requested, product_sold,
|
|
converted, attention_points, improvement_points
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
attId,
|
|
req.user.tenant_id,
|
|
user_id,
|
|
title,
|
|
full_summary || null,
|
|
score || 0,
|
|
first_response_time_min || 0,
|
|
handling_time_min || 0,
|
|
funnel_stage,
|
|
origin,
|
|
product_requested || null,
|
|
product_sold || null,
|
|
converted ? 1 : 0,
|
|
attention_points ? JSON.stringify(attention_points) : null,
|
|
improvement_points ? JSON.stringify(improvement_points) : null
|
|
]
|
|
);
|
|
|
|
// Automation Trigger: "Venda Fechada!" (Ganhos)
|
|
if (converted) {
|
|
// Find the user's manager/admin
|
|
const [managers] = await pool.query(
|
|
"SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager') AND id != ?",
|
|
[req.user.tenant_id, user_id]
|
|
);
|
|
const [agentInfo] = await pool.query("SELECT name FROM users WHERE id = ?", [user_id]);
|
|
const agentName = agentInfo[0]?.name || 'Um agente';
|
|
|
|
for (const m of managers) {
|
|
await pool.query(
|
|
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[crypto.randomUUID(), m.id, 'success', 'Venda Fechada!', `${agentName} converteu um lead em ${funnel_stage}.`, `/attendances/${attId}`]
|
|
);
|
|
}
|
|
}
|
|
|
|
res.status(201).json({ id: attId, message: 'Atendimento registrado com sucesso.' });
|
|
} catch (error) {
|
|
console.error('Integration Error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// --- Tenant Routes ---
|
|
apiRouter.get('/tenants', requireRole(['super_admin']), async (req, res) => {
|
|
try {
|
|
const q = 'SELECT t.*, (SELECT COUNT(*) FROM users u WHERE u.tenant_id = t.id) as user_count, (SELECT COUNT(*) FROM attendances a WHERE a.tenant_id = t.id) as attendance_count FROM tenants t';
|
|
const [rows] = await pool.query(q);
|
|
res.json(rows);
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
apiRouter.post('/tenants', requireRole(['super_admin']), async (req, res) => {
|
|
const { name, slug, admin_email, status } = req.body;
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
const tid = `tenant_${crypto.randomUUID().split('-')[0]}`;
|
|
await connection.query('INSERT INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', [tid, name, slug, admin_email, status || 'active']);
|
|
|
|
// Check if user already exists
|
|
const [existingUser] = await connection.query('SELECT id FROM users WHERE email = ?', [admin_email]);
|
|
if (existingUser.length === 0) {
|
|
const uid = `u_${crypto.randomUUID().split('-')[0]}`;
|
|
const userSlug = `admin-${crypto.randomBytes(4).toString('hex')}`;
|
|
const placeholderHash = 'pending_setup';
|
|
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, slug, role) VALUES (?, ?, ?, ?, ?, ?, ?)', [uid, tid, 'Admin', admin_email, placeholderHash, userSlug, 'admin']);
|
|
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
// Adicionar aos pending_registrations em vez de criar um usuário direto se quisermos que eles completem o cadastro.
|
|
// Ou, se quisermos apenas definir a senha, mantemos o password_resets.
|
|
// O usuário quer "like a register", então vamos enviar para uma página onde eles definem a senha.
|
|
await connection.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [admin_email, token]);
|
|
|
|
const setupLink = `${getBaseUrl(req)}/#/setup-account?token=${token}`;
|
|
|
|
// Add Notification for Super Admins
|
|
const [superAdmins] = await connection.query("SELECT id FROM users WHERE role = 'super_admin'");
|
|
for (const sa of superAdmins) {
|
|
await connection.query(
|
|
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[crypto.randomUUID(), sa.id, 'success', 'Nova Organização', `A organização ${name} foi criada.`, '/super-admin']
|
|
);
|
|
}
|
|
|
|
await transporter.sendMail({
|
|
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
|
to: admin_email,
|
|
subject: 'Bem-vindo ao Fasto - Conclua seu cadastro de Admin',
|
|
html: `
|
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
|
|
<h2 style="color: #0f172a;">Sua organização foi criada</h2>
|
|
<p style="color: #475569;">Você foi definido como administrador da organização <strong>${name}</strong>.</p>
|
|
<p style="color: #475569;">Por favor, clique no botão abaixo para definir sua senha e concluir seu cadastro.</p>
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
|
|
</div>
|
|
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos.</p>
|
|
</div>
|
|
`
|
|
}).catch(err => console.error("Email failed:", err));
|
|
}
|
|
|
|
await connection.commit();
|
|
res.status(201).json({ id: tid, message: 'Organização criada e convite enviado por e-mail.' });
|
|
} catch (error) { await connection.rollback(); res.status(500).json({ error: error.message }); } finally { connection.release(); }
|
|
});
|
|
|
|
apiRouter.put('/tenants/:id', requireRole(['super_admin']), async (req, res) => {
|
|
const { name, slug, admin_email, status } = req.body;
|
|
try {
|
|
await pool.query(
|
|
'UPDATE tenants SET name = ?, slug = ?, admin_email = ?, status = ? WHERE id = ?',
|
|
[name, slug || null, admin_email, status, req.params.id]
|
|
);
|
|
res.json({ message: 'Tenant updated successfully.' });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
apiRouter.delete('/tenants/:id', requireRole(['super_admin']), async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM tenants WHERE id = ?', [req.params.id]);
|
|
res.json({ message: 'Tenant deleted successfully.' });
|
|
} catch (error) { res.status(500).json({ error: error.message }); }
|
|
});
|
|
|
|
// Mount the API Router
|
|
app.use('/api', apiRouter);
|
|
|
|
// Serve static files
|
|
if (process.env.NODE_ENV === 'production') {
|
|
app.use(express.static(path.join(__dirname, 'dist')));
|
|
app.get('*', (req, res) => {
|
|
// Avoid hijacking API requests
|
|
if (req.url.startsWith('/api')) return res.status(404).json({ error: 'API route not found' });
|
|
res.sendFile(path.join(__dirname, 'dist/index.html'));
|
|
});
|
|
}
|
|
|
|
// Auto-provision Super Admin
|
|
const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
|
const email = 'suporte@blyzer.com.br';
|
|
|
|
for (let i = 0; i < retries; i++) {
|
|
try {
|
|
// Test connection first
|
|
const connection = await pool.getConnection();
|
|
|
|
// Auto-create missing tables to prevent issues with outdated Docker configs/volumes
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS password_resets (
|
|
email varchar(255) NOT NULL,
|
|
token varchar(255) NOT NULL,
|
|
expires_at timestamp NOT NULL,
|
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (token),
|
|
KEY email (email)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
`);
|
|
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS pending_registrations (
|
|
email varchar(255) NOT NULL,
|
|
password_hash varchar(255) NOT NULL,
|
|
full_name varchar(255) NOT NULL,
|
|
organization_name varchar(255) NOT NULL,
|
|
verification_code varchar(10) NOT NULL,
|
|
expires_at timestamp NOT NULL,
|
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (email)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
`);
|
|
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS notifications (
|
|
id varchar(36) NOT NULL,
|
|
user_id varchar(36) NOT NULL,
|
|
type enum('success', 'info', 'warning', 'error') DEFAULT 'info',
|
|
title varchar(255) NOT NULL,
|
|
message text NOT NULL,
|
|
link varchar(255) DEFAULT NULL,
|
|
is_read boolean DEFAULT false,
|
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY user_id (user_id),
|
|
KEY created_at (created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
`);
|
|
|
|
// Add slug column if it doesn't exist
|
|
try {
|
|
await connection.query('ALTER TABLE users ADD COLUMN slug VARCHAR(255) UNIQUE DEFAULT NULL');
|
|
} catch (err) {
|
|
// Ignore error if column already exists (ER_DUP_FIELDNAME)
|
|
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (slug):', err.message);
|
|
}
|
|
|
|
// Populate empty slugs
|
|
try {
|
|
await connection.query(`UPDATE users SET slug = CONCAT(LOWER(REPLACE(name, ' ', '-')), '-', SUBSTRING(MD5(RAND()), 1, 8)) WHERE slug IS NULL`);
|
|
} catch (err) {
|
|
console.log('Schema update note (populate slugs):', err.message);
|
|
}
|
|
|
|
// Add sound_enabled column if it doesn't exist
|
|
try {
|
|
await connection.query('ALTER TABLE users ADD COLUMN sound_enabled BOOLEAN DEFAULT true');
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message);
|
|
}
|
|
|
|
// Update origin to VARCHAR for custom origins
|
|
try {
|
|
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL");
|
|
} catch (err) {
|
|
console.log('Schema update note (origin):', err.message);
|
|
}
|
|
|
|
// Convert funnel_stage to VARCHAR for custom funnels
|
|
try {
|
|
await connection.query("ALTER TABLE attendances MODIFY COLUMN funnel_stage VARCHAR(255) NOT NULL");
|
|
} catch (err) {
|
|
console.log('Schema update note (funnel_stage):', err.message);
|
|
}
|
|
|
|
// Add full_summary column for detailed AI analysis
|
|
try {
|
|
await connection.query("ALTER TABLE attendances ADD COLUMN full_summary TEXT DEFAULT NULL");
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (full_summary):', err.message);
|
|
}
|
|
|
|
// Create origin_groups table
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS origin_groups (
|
|
id varchar(36) NOT NULL,
|
|
tenant_id varchar(36) NOT NULL,
|
|
name varchar(255) NOT NULL,
|
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY tenant_id (tenant_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
`);
|
|
|
|
// Create origin_items table
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS origin_items (
|
|
id varchar(36) NOT NULL,
|
|
origin_group_id varchar(36) NOT NULL,
|
|
name varchar(255) NOT NULL,
|
|
color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200',
|
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY origin_group_id (origin_group_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
`);
|
|
|
|
// Attempt to add color_class if table already existed without it
|
|
try {
|
|
await connection.query("ALTER TABLE origin_items ADD COLUMN color_class VARCHAR(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200'");
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (origin_items.color_class):', err.message);
|
|
}
|
|
|
|
// Add origin_group_id to teams
|
|
try {
|
|
await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL");
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.origin_group_id):', err.message);
|
|
}
|
|
|
|
// Rename summary to title
|
|
try {
|
|
await connection.query("ALTER TABLE attendances RENAME COLUMN summary TO title");
|
|
} catch (err) {
|
|
if (err.code !== 'ER_BAD_FIELD_ERROR' && err.code !== 'ER_DUP_FIELDNAME') {
|
|
// If RENAME COLUMN fails (older mysql), try CHANGE
|
|
try {
|
|
await connection.query("ALTER TABLE attendances CHANGE COLUMN summary title TEXT");
|
|
} catch (e) {
|
|
console.log('Schema update note (summary to title):', e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create funnels table
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS funnels (
|
|
id varchar(36) NOT NULL,
|
|
tenant_id varchar(36) NOT NULL,
|
|
name varchar(255) NOT NULL,
|
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY tenant_id (tenant_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
`);
|
|
|
|
// Create funnel_stages table
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS funnel_stages (
|
|
id varchar(36) NOT NULL,
|
|
funnel_id varchar(36) NOT NULL,
|
|
name varchar(255) NOT NULL,
|
|
color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200',
|
|
order_index int DEFAULT 0,
|
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY funnel_id (funnel_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
`);
|
|
|
|
// Create api_keys table for external integrations (n8n)
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
id varchar(36) NOT NULL,
|
|
tenant_id varchar(36) NOT NULL,
|
|
name varchar(255) NOT NULL,
|
|
secret_key varchar(255) NOT NULL,
|
|
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);
|
|
}
|
|
|
|
// Create refresh_tokens table for persistent sessions
|
|
await connection.query(`
|
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
id varchar(36) NOT NULL,
|
|
user_id varchar(36) NOT NULL,
|
|
token varchar(255) NOT NULL,
|
|
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);
|
|
}
|
|
|
|
// Add funnel_id to teams
|
|
try {
|
|
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.funnel_id):', err.message);
|
|
}
|
|
|
|
connection.release();
|
|
// Ensure system tenant exists
|
|
await pool.query('INSERT IGNORE INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', ['system', 'System Admin', 'system', email, 'active']);
|
|
|
|
const [existing] = await pool.query('SELECT id, password_hash FROM users WHERE email = ?', [email]);
|
|
if (existing.length === 0 || existing[0].password_hash === 'pending_setup') {
|
|
console.log('Provisioning default super_admin or resending email...');
|
|
|
|
if (existing.length === 0) {
|
|
const uid = `u_${crypto.randomUUID().split('-')[0]}`;
|
|
const placeholderHash = 'pending_setup';
|
|
const superAdminSlug = 'suporte-blyzer';
|
|
|
|
await pool.query(
|
|
'INSERT INTO users (id, tenant_id, name, email, password_hash, slug, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
[uid, 'system', 'Blyzer Suporte', email, placeholderHash, superAdminSlug, 'super_admin', 'active']
|
|
);
|
|
}
|
|
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
// Delete any old unused tokens for this email to prevent buildup
|
|
await pool.query('DELETE FROM password_resets WHERE email = ?', [email]);
|
|
|
|
await pool.query(
|
|
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))',
|
|
[email, token]
|
|
);
|
|
|
|
const setupLink = `${getStartupBaseUrl()}/#/setup-account?token=${token}`;
|
|
console.log(`\n\n=== SUPER ADMIN SETUP LINK ===\n${setupLink}\n==============================\n\n`);
|
|
|
|
await transporter.sendMail({
|
|
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
|
|
to: email,
|
|
subject: 'Conta Super Admin Criada - Fasto',
|
|
html: `
|
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
|
|
<h2 style="color: #0f172a;">Conta Super Admin Gerada</h2>
|
|
<p style="color: #475569;">Sua conta de suporte (super_admin) foi criada no Fasto.</p>
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
|
|
</div>
|
|
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos.</p>
|
|
</div>
|
|
`
|
|
}).catch(err => console.error("Failed to send super_admin email:", err));
|
|
}
|
|
return; // Success, exit the retry loop
|
|
} catch (error) {
|
|
console.error(`Failed to provision super_admin (Attempt ${i + 1}/${retries}):`, error.message);
|
|
if (i < retries - 1) {
|
|
await new Promise(res => setTimeout(res, delay));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
app.listen(PORT, async () => {
|
|
await provisionSuperAdmin();
|
|
console.log(`🚀 Servidor Backend rodando em http://localhost:${PORT}`);
|
|
});
|