feat: implement relational lead origins with team assignments
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m51s

- Dropped simple origins table in favor of origin_groups and origin_items to match the Funnels architecture.

- Added origin_group_id to teams table to assign specific origins to specific teams.

- Updated /admin/origins page to support creating origin groups, adding origin items to them, and assigning teams to groups.

- Updated Dashboard and UserDetail pages to dynamically load the exact origin items belonging to the active team/user.
This commit is contained in:
Cauê Faleiros
2026-03-18 11:18:30 -03:00
parent 64c4ca8fb5
commit 1d3315a1d0
8 changed files with 641 additions and 27 deletions

View File

@@ -612,6 +612,128 @@ apiRouter.delete('/notifications/:id', async (req, res) => {
}
});
// --- 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 = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'];
for (const name of defaultOrigins) {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)',
[oid, gid, name]
);
}
// 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', 'owner', 'manager', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try {
const 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', 'owner', 'manager', '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', 'owner', 'manager', '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', 'owner', 'manager', 'super_admin']), async (req, res) => {
const { name } = req.body;
try {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)',
[oid, req.params.id, name]
);
res.status(201).json({ id: oid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
const { name } = 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 = ? WHERE id = ?', [name || existing[0].name, 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', 'owner', 'manager', '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 {
@@ -952,6 +1074,37 @@ apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) =>
}
});
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 [origins] = await pool.query('SELECT name FROM origins WHERE tenant_id = ? ORDER BY created_at ASC', [req.user.tenant_id]);
res.json(origins.map(o => o.name));
} 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.' });
@@ -1288,9 +1441,9 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message);
}
// Update origin enum
// Update origin to VARCHAR for custom origins
try {
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin ENUM('WhatsApp','Instagram','Website','LinkedIn','Indicação') NOT NULL");
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL");
} catch (err) {
console.log('Schema update note (origin):', err.message);
}
@@ -1309,6 +1462,37 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
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,
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;
`);
// 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");