feat: implement n8n api integration endpoints and api key management
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m6s

- Added api_keys table to database schema.

- Added API Key authentication middleware to express router.

- Created GET /api/integration/users endpoint for n8n to map agents.

- Created POST /api/integration/attendances endpoint to accept webhooks from n8n.

- Added UI in UserProfile (for Admins/Owners) to generate, view, and revoke API keys.
This commit is contained in:
Cauê Faleiros
2026-03-16 14:29:21 -03:00
parent 2ae0e9fdac
commit ef6d1582b3
3 changed files with 397 additions and 3 deletions

View File

@@ -101,11 +101,37 @@ app.use('/uploads', express.static(uploadDir, {
const apiRouter = express.Router();
// Middleware de autenticação
const authenticateToken = (req, res, next) => {
const authenticateToken = async (req, res, next) => {
// Ignorar rotas de auth
if (req.path.startsWith('/auth/')) return next();
const authHeader = req.headers['authorization'];
// API Key Authentication for n8n/External Integrations
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
const apiKey = authHeader.split(' ')[1];
try {
const [keys] = await pool.query('SELECT * FROM api_keys WHERE secret_key = ?', [apiKey]);
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
// Update last used timestamp
await pool.query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [keys[0].id]);
// Attach a "system/bot" user identity to the request based on the tenant
req.user = {
id: 'bot_integration',
tenant_id: keys[0].tenant_id,
role: 'admin', // Give integration admin privileges within its tenant
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.' });
}
}
// Standard JWT Authentication
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
@@ -305,6 +331,17 @@ apiRouter.post('/auth/reset-password', async (req, res) => {
[crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`]
);
}
// If the new user is an admin, notify super_admins too
if (user.role === 'admin') {
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
for (const sa of superAdmins) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`]
);
}
}
}
} else {
// Standard password reset, just update the hash
@@ -459,6 +496,18 @@ apiRouter.put('/users/:id', async (req, res) => {
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?',
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id]
);
// Trigger Notification for Team Change
if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') {
const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]);
if (team.length > 0) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/']
);
}
}
res.json({ message: 'User updated successfully.' });
} catch (error) { console.error('Update user error:', error);
res.status(500).json({ error: error.message });
@@ -842,6 +891,145 @@ apiRouter.get('/attendances/:id', async (req, res) => {
} catch (error) { res.status(500).json({ error: error.message }); }
});
// --- API Key Management Routes ---
apiRouter.get('/api-keys', requireRole(['admin', 'owner', '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, CONCAT(SUBSTRING(secret_key, 1, 14), "...") 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', 'owner', '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) VALUES (?, ?, ?, ?)',
[id, effectiveTenantId, name || 'Nova Integração API', 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', 'owner', '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.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,
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 || !summary) {
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, summary' });
}
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, 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,
summary,
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 {
@@ -1138,6 +1326,21 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
) 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,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at timestamp NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY secret_key (secret_key),
KEY tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Add funnel_id to teams
try {
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");