feat: implement customizable funnel stages per tenant

- Modified attendances.funnel_stage in DB from ENUM to VARCHAR.

- Created tenant_funnels table and backend API routes to manage custom stages.

- Added /admin/funnels page for Admins/Managers to create, edit, order, and color-code their funnel stages.

- Updated Dashboard, UserDetail, and AttendanceDetail to fetch and render dynamic funnel stages instead of hardcoded enums.

- Added defensive checks and logging to GET /users/:idOrSlug to fix sporadic 500 errors during impersonation handoffs.
This commit is contained in:
Cauê Faleiros
2026-03-13 10:25:23 -03:00
parent 1d49161a05
commit 7ab54053db
18 changed files with 588 additions and 33 deletions

View File

@@ -350,12 +350,17 @@ apiRouter.get('/users', async (req, res) => {
apiRouter.get('/users/:idOrSlug', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (!req.user) return res.status(401).json({ error: 'Não autenticado' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
}
res.json(rows[0]);
} catch (error) { res.status(500).json({ error: error.message }); }
} catch (error) {
console.error('Error in GET /users/:idOrSlug:', error);
res.status(500).json({ error: error.message });
}
});
// Convidar Novo Membro (Admin criando usuário)
@@ -558,6 +563,88 @@ apiRouter.delete('/notifications', async (req, res) => {
}
});
// --- 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 [rows] = await pool.query('SELECT * FROM tenant_funnels WHERE tenant_id = ? ORDER BY order_index ASC', [effectiveTenantId]);
// If no funnels exist for this tenant, seed the default ones
if (rows.length === 0) {
const defaultFunnels = [
{ 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 f of defaultFunnels) {
const fid = `funnel_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO tenant_funnels (id, tenant_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)',
[fid, effectiveTenantId, f.name, f.color, f.order]
);
}
const [newRows] = await pool.query('SELECT * FROM tenant_funnels WHERE tenant_id = ? ORDER BY order_index ASC', [effectiveTenantId]);
return res.json(newRows);
}
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
const { name, color_class, order_index, 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 tenant_funnels (id, tenant_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)',
[fid, effectiveTenantId, name, color_class, order_index || 0]
);
res.status(201).json({ id: fid, message: 'Etapa do funil criada com sucesso.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body;
try {
const [existing] = await pool.query('SELECT * FROM tenant_funnels WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Etapa 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(
'UPDATE tenant_funnels 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: 'Etapa do funil atualizada.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
try {
const [existing] = await pool.query('SELECT * FROM tenant_funnels WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Etapa 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 tenant_funnels WHERE id = ?', [req.params.id]);
res.json({ message: 'Etapa do funil excluída.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- Global Search ---
apiRouter.get('/search', async (req, res) => {
const { q } = req.query;
@@ -948,9 +1035,29 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
} catch (err) {
console.log('Schema update note (origin):', err.message);
}
connection.release();
// Convert funnel_stage to VARCHAR for custom funnels
try {
await connection.query("ALTER TABLE attendances MODIFY COLUMN funnel_stage VARCHAR(255) NOT NULL");
} catch (err) {
console.log('Schema update note (funnel_stage):', err.message);
}
// Create tenant_funnels table
await connection.query(`
CREATE TABLE IF NOT EXISTS tenant_funnels (
id varchar(36) NOT NULL,
tenant_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 tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
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']);