feat(api): implementar endpoints M2M /api/external/tickets para sigma-robot y worker de integracion
This commit is contained in:
+354
@@ -0,0 +1,354 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Ticket = require('../models/Ticket');
|
||||
const Company = require('../models/Company');
|
||||
const Department = require('../models/Department');
|
||||
const User = require('../models/User');
|
||||
|
||||
/**
|
||||
* Middleware para validar llamadas M2M mediante API Key o Token de Agente
|
||||
*/
|
||||
async function authenticateM2M(req, res, next) {
|
||||
const apiKey = req.header('x-api-key');
|
||||
const authHeader = req.header('Authorization');
|
||||
const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
||||
const provided = apiKey || token;
|
||||
|
||||
if (!provided) {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
success: false,
|
||||
error: 'Autenticación requerida. Proporcione header x-api-key o Authorization: Bearer <token>'
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Validar contra variables locales de entorno autorizadas
|
||||
const validTokens = [
|
||||
process.env.WEBCONTROL_API_KEY,
|
||||
process.env.SIGMA_AGENTE_TOKEN
|
||||
].filter(Boolean);
|
||||
|
||||
if (validTokens.includes(provided)) {
|
||||
req.authSource = 'local_env';
|
||||
return next();
|
||||
}
|
||||
|
||||
// 2. Validar contra la base de datos central webControl si está disponible
|
||||
try {
|
||||
const mysql = require('mysql2/promise');
|
||||
const conn = await mysql.createConnection({
|
||||
host: '192.168.10.149',
|
||||
user: 'admin',
|
||||
password: 'Dx.1706%',
|
||||
database: 'webControl'
|
||||
});
|
||||
const [rows] = await conn.query(
|
||||
'SELECT * FROM api_key WHERE key_value = ? AND is_active = 1 AND revoked_at IS NULL',
|
||||
[provided]
|
||||
);
|
||||
await conn.end();
|
||||
|
||||
if (rows.length > 0) {
|
||||
req.authSource = 'webcontrol_db';
|
||||
req.apiKeyData = rows[0];
|
||||
return next();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[API Auth] Error consultando webControl.api_key:', e.message);
|
||||
}
|
||||
|
||||
return res.status(403).json({
|
||||
ok: false,
|
||||
success: false,
|
||||
error: 'Credencial inválida o no autorizada para el servicio de Help Desk'
|
||||
});
|
||||
}
|
||||
|
||||
// Aplicar autenticación M2M a todas las rutas de /api
|
||||
router.use(authenticateM2M);
|
||||
|
||||
/**
|
||||
* Función auxiliar para resolver o crear el usuario técnico de integración
|
||||
*/
|
||||
async function getTechnicalUser(companyId) {
|
||||
const email = `bot_sigma-robot@sigmac.app`;
|
||||
let user = await User.findByEmail(email);
|
||||
if (!user) {
|
||||
const id = await User.create(
|
||||
companyId,
|
||||
'Bot Soporte (sigma-robot)',
|
||||
email,
|
||||
null,
|
||||
'client',
|
||||
'bot',
|
||||
'sigma-robot-m2m'
|
||||
);
|
||||
user = await User.findById(id);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Función auxiliar para resolver la empresa por ID, slug o dominio de app_origen
|
||||
*/
|
||||
async function resolveCompany(empresa, app_origen, company_id) {
|
||||
if (company_id) {
|
||||
const c = await Company.findById(parseInt(company_id));
|
||||
if (c) return c;
|
||||
}
|
||||
if (empresa) {
|
||||
if (!isNaN(empresa)) {
|
||||
const c = await Company.findById(parseInt(empresa));
|
||||
if (c) return c;
|
||||
} else {
|
||||
const c = await Company.findBySlug(String(empresa).toLowerCase().trim());
|
||||
if (c) return c;
|
||||
}
|
||||
}
|
||||
if (app_origen) {
|
||||
const originLower = String(app_origen).toLowerCase();
|
||||
if (originLower.includes('domus')) {
|
||||
const c = await Company.findBySlug('domus');
|
||||
if (c) return c;
|
||||
}
|
||||
if (originLower.includes('xsystem') || originLower.includes('webcontrol')) {
|
||||
const c = await Company.findBySlug('xsystem');
|
||||
if (c) return c;
|
||||
}
|
||||
}
|
||||
// Default a Telcotronics (ID 1)
|
||||
return (await Company.findById(1)) || { id: 1, name: 'Telcotronics', slug: 'telcotronics' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Función auxiliar para resolver departamento según tipo
|
||||
*/
|
||||
async function resolveDepartment(companyId, tipo, departamento) {
|
||||
const depts = await Department.findAll(companyId);
|
||||
if (depts.length === 0) return null;
|
||||
|
||||
if (departamento) {
|
||||
const found = depts.find(d =>
|
||||
d.nombre.toLowerCase().includes(String(departamento).toLowerCase())
|
||||
);
|
||||
if (found) return found.id;
|
||||
}
|
||||
|
||||
const tipoLower = String(tipo || '').toLowerCase();
|
||||
let preferredName = 'Soporte Técnico';
|
||||
if (tipoLower === 'cobro' || tipoLower === 'facturacion') preferredName = 'Facturación';
|
||||
if (tipoLower === 'consulta' || tipoLower === 'otro') preferredName = 'General';
|
||||
|
||||
const match = depts.find(d => d.nombre.toLowerCase().includes(preferredName.toLowerCase()));
|
||||
return match ? match.id : depts[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/empresas — Lista de empresas del ecosistema para canalizar tickets
|
||||
*/
|
||||
router.get('/empresas', async (req, res) => {
|
||||
try {
|
||||
const companies = await Company.findAll();
|
||||
const simplified = companies.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
plan: c.plan
|
||||
}));
|
||||
res.json({ ok: true, success: true, empresas: simplified });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/external/tickets y POST /api/tickets
|
||||
* Crea un ticket M2M con soporte para idempotencia, solicitante y categorización
|
||||
*/
|
||||
async function handleCreateTicket(req, res) {
|
||||
try {
|
||||
const {
|
||||
origen = 'sigma-robot',
|
||||
origen_externo_id = null,
|
||||
empresa,
|
||||
company_id,
|
||||
app_origen = null,
|
||||
solicitante = {},
|
||||
asunto,
|
||||
mensaje,
|
||||
descripcion,
|
||||
prioridad = 'media',
|
||||
tipo = 'consulta',
|
||||
departamento,
|
||||
department_id
|
||||
} = req.body;
|
||||
|
||||
const ticketAsunto = asunto || (mensaje ? String(mensaje).substring(0, 60) + '...' : null);
|
||||
const ticketMensaje = mensaje || descripcion;
|
||||
|
||||
if (!ticketAsunto || !ticketMensaje) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
success: false,
|
||||
error: 'Los campos asunto y mensaje son obligatorios'
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Resolver Empresa
|
||||
const company = await resolveCompany(empresa, app_origen, company_id);
|
||||
|
||||
// 2. Idempotencia: Verificar si ya existe este origen_externo_id
|
||||
if (origen_externo_id) {
|
||||
const existing = await Ticket.findByOrigenExterno(company.id, origen, origen_externo_id);
|
||||
if (existing) {
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
success: true,
|
||||
duplicado: true,
|
||||
ticket_id: existing.id,
|
||||
estado: existing.status,
|
||||
empresa: company.name,
|
||||
url: `https://soporte.sigmac.app/agent/ticket/${existing.id}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolver Usuario Técnico y Solicitante
|
||||
const techUser = await getTechnicalUser(company.id);
|
||||
const solNombre = solicitante.nombre || req.body.cliente_nombre || null;
|
||||
const solEmail = solicitante.email || req.body.cliente_email || null;
|
||||
const solTelefono = solicitante.telefono || req.body.cliente_telefono || null;
|
||||
|
||||
// 4. Resolver Departamento
|
||||
const deptId = department_id ? parseInt(department_id) : await resolveDepartment(company.id, tipo, departamento);
|
||||
|
||||
// 5. Prioridad normalizada
|
||||
const cleanPriority = ['baja', 'media', 'alta', 'critica'].includes(String(prioridad).toLowerCase())
|
||||
? String(prioridad).toLowerCase()
|
||||
: 'media';
|
||||
|
||||
// 6. Crear Ticket
|
||||
const ticketId = await Ticket.create(
|
||||
company.id,
|
||||
techUser.id,
|
||||
deptId,
|
||||
ticketAsunto.trim(),
|
||||
ticketMensaje.trim(),
|
||||
cleanPriority,
|
||||
{
|
||||
origen,
|
||||
origen_externo_id: origen_externo_id ? String(origen_externo_id) : null,
|
||||
app_origen,
|
||||
solicitante_nombre: solNombre,
|
||||
solicitante_email: solEmail,
|
||||
solicitante_telefono: solTelefono,
|
||||
tipo
|
||||
}
|
||||
);
|
||||
|
||||
const url = `https://soporte.sigmac.app/agent/ticket/${ticketId}`;
|
||||
|
||||
return res.status(201).json({
|
||||
ok: true,
|
||||
success: true,
|
||||
ticket_id: ticketId,
|
||||
estado: 'abierto',
|
||||
empresa: company.name,
|
||||
company_id: company.id,
|
||||
asunto: ticketAsunto.trim(),
|
||||
prioridad: cleanPriority,
|
||||
url
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[API Tickets] Error:', err);
|
||||
return res.status(500).json({ ok: false, success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/external/tickets', handleCreateTicket);
|
||||
router.post('/tickets', handleCreateTicket);
|
||||
|
||||
/**
|
||||
* GET /api/external/tickets/:id y GET /api/tickets/:id
|
||||
* Consulta el estado y última respuesta de un ticket
|
||||
*/
|
||||
async function handleGetTicket(req, res) {
|
||||
try {
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const ticket = await Ticket.findById(ticketId);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ ok: false, success: false, error: 'Ticket no encontrado' });
|
||||
}
|
||||
|
||||
const respuestas = await Ticket.getResponses(ticketId);
|
||||
const ultimaRespuesta = respuestas.length > 0 ? respuestas[respuestas.length - 1] : null;
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
estado: ticket.status,
|
||||
asunto: ticket.asunto,
|
||||
prioridad: ticket.prioridad,
|
||||
asignado_a: ticket.asignado_nombre || null,
|
||||
actualizado: ticket.updated_at || ticket.created_at,
|
||||
origen: ticket.origen,
|
||||
solicitante: {
|
||||
nombre: ticket.solicitante_nombre || ticket.usuario_nombre,
|
||||
email: ticket.solicitante_email || ticket.usuario_email
|
||||
},
|
||||
ultima_respuesta: ultimaRespuesta ? {
|
||||
autor: ultimaRespuesta.usuario_nombre,
|
||||
mensaje: ultimaRespuesta.mensaje,
|
||||
fecha: ultimaRespuesta.created_at
|
||||
} : null,
|
||||
url: `https://soporte.sigmac.app/agent/ticket/${ticket.id}`
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/external/tickets/:id', handleGetTicket);
|
||||
router.get('/tickets/:id', handleGetTicket);
|
||||
|
||||
/**
|
||||
* POST /api/external/tickets/:id/notas y POST /api/tickets/:id/respuestas
|
||||
* Agrega una nota o seguimiento al ticket
|
||||
*/
|
||||
async function handleAddNote(req, res) {
|
||||
try {
|
||||
const ticketId = parseInt(req.params.id);
|
||||
const { texto, mensaje, autor = 'sigma-robot' } = req.body;
|
||||
const contenido = texto || mensaje;
|
||||
|
||||
if (!contenido) {
|
||||
return res.status(400).json({ ok: false, success: false, error: 'El campo texto es obligatorio' });
|
||||
}
|
||||
|
||||
const ticket = await Ticket.findById(ticketId);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ ok: false, success: false, error: 'Ticket no encontrado' });
|
||||
}
|
||||
|
||||
const techUser = await getTechnicalUser(ticket.company_id);
|
||||
const notaCompleta = `[${autor}]: ${contenido}`;
|
||||
const respId = await Ticket.addResponse(ticketId, techUser.id, notaCompleta);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
success: true,
|
||||
message: 'Nota agregada exitosamente',
|
||||
respuesta_id: respId,
|
||||
ticket_id: ticketId
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/external/tickets/:id/notas', handleAddNote);
|
||||
router.post('/tickets/:id/respuestas', handleAddNote);
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user