Compare commits
5
Commits
9b4c62f73d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ef042538 | ||
|
|
a6c031f087 | ||
|
|
10a614ed47 | ||
|
|
5c7a8c925b | ||
|
|
ac6eb1a100 |
@@ -29,11 +29,11 @@
|
||||
---
|
||||
|
||||
## Incidente #4: Error 403 al hacer push a `git.telcotronics.net` (Push to create no habilitado)
|
||||
- **Fecha:** 2026-10-17
|
||||
- **Fecha:** 2026-10-17 (Resuelto: 2026-10-23)
|
||||
- **Síntoma:** Al ejecutar `git push -u origin main` hacia `https://git.telcotronics.net/pablinux/help_desk.git`, el servidor rechaza con `remote: Push to create is not enabled for users. fatal: no es posible acceder... The requested URL returned error: 403`.
|
||||
- **Causa confirmada:** La instancia de Gitea no tiene activada la directiva global `ENABLE_PUSH_TO_CREATE = true` para crear repositorios dinámicamente vía push SSH/HTTPS.
|
||||
- **Solución / Acción requerida:** El usuario o administrador de Gitea debe crear el repositorio vacío `pablinux/help_desk` desde la interfaz web de `https://git.telcotronics.net`. Una vez creado el repositorio vacío, el push funcionará de inmediato.
|
||||
- **Estado:** Documentado / Pendiente de creación en interfaz web.
|
||||
- **Solución:** Se creó formalmente el repositorio `pablinux/help_desk` (ID 38) utilizando la API de Gitea con el token autorizado del usuario `pablinux`, y se ejecutó `git push -u origin main`.
|
||||
- **Estado:** ✅ Resuelto. Repositorio activo y sincronizado en Gitea.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-2
@@ -10,6 +10,10 @@
|
||||
|
||||
| Fecha | Tarea / Entregable | Responsable | Archivos Afectados |
|
||||
|---|---|---|---|
|
||||
| 2026-10-27 | **Integración M2M Help Desk ↔ sigma-robot (API `/api/external/tickets`, Idempotencia, Worker y Cron de 30min)** | Agente IA | `routes/api.js`, `models/Ticket.js`, `config/setup.js`, `scripts/sigma_robot_worker.js`, `app.js` |
|
||||
| 2026-10-23 | **Publicación y Creación de Repositorio en Gitea (`git.telcotronics.net/pablinux/help_desk.git`)** | Agente IA | Git remoto, Gitea API (Repo ID 38) |
|
||||
| 2026-10-23 | **Conexión y Verificación de Bitácora de Errores (`ak_helpdesk_...` en API-SIGMA-WEBCONTROL)** | Agente IA | `.env`, `webControl.api_key`, `scripts/consultar_errores.js` |
|
||||
| 2026-10-23 | **Registro de Aplicación en `siax-system.net` (`webControl.aplicaciones`, ID 51)** | Agente IA | `webControl.aplicaciones` |
|
||||
| 2026-10-23 | **Sincronización sigma-robot y Confirmación de Sesión Tripartita (Ideas 82, Tareas 89, Help Desk 90)** | Agente IA | `sigma-robot` (msg #264, #265), `.agente/tareas.md`, `agents.md` |
|
||||
| 2026-10-23 | **Configuración de 3 Empresas Oficiales (Telcotronics, XSYSTEM, DOMUS) y Enrutamiento por Origen** | Agente IA | `config/setup.js`, `routes/auth.js`, `models/Company.js`, `views/partials/navbar.ejs`, `views/saas/companies.ejs`, `public/css/style.css` |
|
||||
| 2026-10-20 | **Registro Oficial de Identidad en `sigma-robot` (Agente ID 90) y Coordinación Tripartita (Ideas <-> Tareas <-> Help Desk)** | Agente IA | `id_agente.md`, `.env`, `.env.example`, `agents.md`, `.agente/ideas.md` |
|
||||
@@ -36,6 +40,8 @@
|
||||
|
||||
| Prioridad | Tarea | Descripción | Dependencia |
|
||||
|---|---|---|---|
|
||||
| **Alta** | **Creación de Repositorio Vacío en Gitea** | Crear el repositorio vacío `pablinux/help_desk` en `https://git.telcotronics.net` para habilitar el `git push -u origin main`. | Humano (Pablinux) |
|
||||
| **Media** | **Integración SSE / WebSockets para Timeline en Vivo** | Actualización en tiempo real sin requerir clic manual en "Sincronizar estado con Hub". | FSM Módulo |
|
||||
| **Media** | **Contrato Help Desk → Ideas (Feature Requests saneados)** | Implementar botón/modal "Proponer como Idea" en tickets para enviar propuestas a `app_ideas` como `candidata_revision` tras la sesión de las 16:00. | Reunión de diseño hoy 16:00 |
|
||||
| **Media** | **Canalización / Onboarding para Clientes de DOMUS** | Permitir que al crear tickets se pueda seleccionar el área de destino (XSYSTEM, Telcotronics, DOMUS) o invitación por enlace a la tienda DOMUS. | Decisión de negocio |
|
||||
| **Media** | **Reasignación / Transferencia de Tickets entre Empresas** | Permitir a los agentes/admins transferir un ticket de una empresa a otra con un clic si el requerimiento corresponde a otro tenant. | Multi-tenant |
|
||||
| **Baja** | **Integración SSE / WebSockets para Timeline en Vivo** | Actualización en tiempo real sin requerir clic manual en "Sincronizar estado con Hub". | FSM Módulo |
|
||||
| **Baja** | **SLA y Tiempos de Resolución** | Contador de tiempo transcurrido y alerta de vencimiento de SLA según prioridad del ticket. | Ninguna |
|
||||
|
||||
@@ -172,11 +172,12 @@ Para inicializar las tablas y datos semilla de la base de datos:
|
||||
```bash
|
||||
node config/setup.js
|
||||
```
|
||||
El script verifica la conexión, crea las tablas si no existen, aplica migraciones de columnas multi-tenant y siembra la empresa `demo`, departamentos y los cuatro usuarios base:
|
||||
- `super@helpdesk.com` / `123456` (superadmin)
|
||||
- `admin@helpdesk.com` / `123456` (admin)
|
||||
- `agent@helpdesk.com` / `123456` (agent)
|
||||
- `client@helpdesk.com` / `123456` (client)
|
||||
El script verifica la conexión, crea las tablas si no existen, aplica migraciones de columnas multi-tenant y siembra las 3 empresas oficiales con sus administradores y departamentos:
|
||||
- **Telcotronics (ID 1):** `admin@telcotronics.com` / `admin@telcotronics.local`
|
||||
- **XSYSTEM (ID 2):** `admin@xsystem.com`
|
||||
- **DOMUS (ID 3):** `admin@domus.com`
|
||||
- **Superadmin Global:** `super@helpdesk.com` / `123456`
|
||||
- **Personal de soporte:** `agent@helpdesk.com` / `123456`, `client@helpdesk.com` / `123456`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const agentRoutes = require('./routes/agent');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const saasRoutes = require('./routes/saas');
|
||||
const companyRoutes = require('./routes/company');
|
||||
const apiRoutes = require('./routes/api');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -51,6 +52,7 @@ app.use('/agent', agentRoutes);
|
||||
app.use('/admin', adminRoutes);
|
||||
app.use('/saas', saasRoutes);
|
||||
app.use('/company', companyRoutes);
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
const { reportarError } = require('./config/errorReporter');
|
||||
|
||||
@@ -86,6 +88,10 @@ app.use((err, req, res, next) => {
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
if (require.main === module) {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Servidor corriendo en puerto ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
|
||||
+23
-1
@@ -76,10 +76,16 @@ async function setup() {
|
||||
await migrateColumn('users', 'invite_token', 'invite_token VARCHAR(100) NULL');
|
||||
await migrateColumn('users', 'invite_token_expires', 'invite_token_expires TIMESTAMP NULL');
|
||||
await migrateColumn('users', 'invited_by', 'invited_by INT NULL');
|
||||
await migrateColumn('users', 'provider', "provider ENUM('local', 'webcontrol', 'google', 'facebook') DEFAULT 'local'");
|
||||
await migrateColumn('users', 'provider', "provider VARCHAR(50) DEFAULT 'local'");
|
||||
await migrateColumn('users', 'provider_id', "provider_id VARCHAR(150) NULL");
|
||||
await migrateColumn('users', 'avatar_url', "avatar_url VARCHAR(255) NULL");
|
||||
|
||||
try {
|
||||
await conn.query("ALTER TABLE users MODIFY COLUMN provider VARCHAR(50) DEFAULT 'local'");
|
||||
} catch (e) {
|
||||
// Ignorar
|
||||
}
|
||||
|
||||
try {
|
||||
await conn.query("ALTER TABLE users MODIFY COLUMN password VARCHAR(255) NULL");
|
||||
console.log(' + users.password modificado a nullable');
|
||||
@@ -114,6 +120,22 @@ async function setup() {
|
||||
await conn.query('ALTER TABLE tickets ADD FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE');
|
||||
} catch (e) { if (e.errno !== 1826 && e.errno !== 1005) throw e; }
|
||||
|
||||
// Columnas M2M / sigma-robot en tickets
|
||||
await migrateColumn('tickets', 'origen', "origen VARCHAR(50) DEFAULT 'web'");
|
||||
await migrateColumn('tickets', 'origen_externo_id', 'origen_externo_id VARCHAR(100) NULL');
|
||||
await migrateColumn('tickets', 'app_origen', 'app_origen VARCHAR(100) NULL');
|
||||
await migrateColumn('tickets', 'solicitante_nombre', 'solicitante_nombre VARCHAR(150) NULL');
|
||||
await migrateColumn('tickets', 'solicitante_email', 'solicitante_email VARCHAR(150) NULL');
|
||||
await migrateColumn('tickets', 'solicitante_telefono', 'solicitante_telefono VARCHAR(50) NULL');
|
||||
await migrateColumn('tickets', 'tipo', "tipo VARCHAR(50) DEFAULT 'consulta'");
|
||||
|
||||
try {
|
||||
await conn.query('ALTER TABLE tickets ADD UNIQUE KEY idx_tickets_origen_ext (company_id, origen, origen_externo_id)');
|
||||
console.log(' + indice unico idx_tickets_origen_ext creado');
|
||||
} catch (e) {
|
||||
if (e.errno === 1061) console.log(' ~ indice idx_tickets_origen_ext ya existe');
|
||||
}
|
||||
|
||||
// Create ticket_visitas table
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_visitas (
|
||||
|
||||
+23
-3
@@ -48,13 +48,33 @@ const Ticket = {
|
||||
`, [id]);
|
||||
return rows[0];
|
||||
},
|
||||
create: async (companyId, userId, departmentId, asunto, mensaje, prioridad = 'media') => {
|
||||
create: async (companyId, userId, departmentId, asunto, mensaje, prioridad = 'media', extra = {}) => {
|
||||
const {
|
||||
origen = 'web',
|
||||
origen_externo_id = null,
|
||||
app_origen = null,
|
||||
solicitante_nombre = null,
|
||||
solicitante_email = null,
|
||||
solicitante_telefono = null,
|
||||
tipo = 'consulta'
|
||||
} = extra;
|
||||
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO tickets (company_id, user_id, department_id, asunto, mensaje, prioridad) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[companyId, userId, departmentId, asunto, mensaje, prioridad]
|
||||
`INSERT INTO tickets
|
||||
(company_id, user_id, department_id, asunto, mensaje, prioridad, origen, origen_externo_id, app_origen, solicitante_nombre, solicitante_email, solicitante_telefono, tipo)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[companyId, userId, departmentId, asunto, mensaje, prioridad, origen, origen_externo_id, app_origen, solicitante_nombre, solicitante_email, solicitante_telefono, tipo]
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
findByOrigenExterno: async (companyId, origen, origenExternoId) => {
|
||||
if (!origenExternoId) return null;
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM tickets WHERE company_id = ? AND origen = ? AND origen_externo_id = ?',
|
||||
[companyId, origen, String(origenExternoId)]
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
update: async (id, data) => {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
+69
-14
@@ -786,21 +786,31 @@ body {
|
||||
}
|
||||
|
||||
.btn-social-fb {
|
||||
border: 1px solid #cbd5e1;
|
||||
border: 1px solid #dadce0;
|
||||
background: #ffffff;
|
||||
color: #1877f2;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem 1rem;
|
||||
transition: all 0.2s ease;
|
||||
color: #3c4043;
|
||||
font-weight: 500;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 4px;
|
||||
height: 40px;
|
||||
padding: 0 0.85rem;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(60, 64, 67, 0.08);
|
||||
}
|
||||
|
||||
.btn-social-fb:hover {
|
||||
background: #f0f6ff;
|
||||
background: #f8fafd;
|
||||
border-color: #1877f2;
|
||||
color: #0c56be;
|
||||
color: #1877f2;
|
||||
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.15);
|
||||
}
|
||||
|
||||
.btn-social-fb i {
|
||||
color: #1877f2;
|
||||
}
|
||||
|
||||
.staff-badge-pill {
|
||||
@@ -831,19 +841,63 @@ body {
|
||||
|
||||
.social-auth-wrapper {
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.social-buttons-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.social-auth-wrapper .g_id_signin {
|
||||
max-width: 100%;
|
||||
.social-btn-box {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.social-auth-wrapper .g_id_signin iframe {
|
||||
.social-google-box .g_id_signin {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.social-google-box .g_id_signin iframe {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Tablet y Desktop: botones alineados horizontalmente (min-width: 576px) */
|
||||
@media (min-width: 576px) {
|
||||
.social-buttons-grid {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.social-btn-box {
|
||||
flex: 1 1 50%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.social-google-box {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.social-fb-box {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-social-fb {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================================================
|
||||
Mobile & Responsive Optimization for Login & App
|
||||
============================================================================== */
|
||||
@@ -977,8 +1031,9 @@ body {
|
||||
}
|
||||
|
||||
.btn-social-fb {
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
height: 40px;
|
||||
font-size: 0.82rem;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
|
||||
+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;
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/sigma_robot_worker.js
|
||||
* Worker de comunicación e integración entre Help Desk y sigma-robot.
|
||||
*
|
||||
* Funciones:
|
||||
* 1. Lee mensajes pendientes del chat de sigma-robot para Agente Help Desk (ID 90).
|
||||
* 2. Procesa solicitudes de creación de tickets y las registra en Help Desk.
|
||||
* 3. Responde por el chat de sigma-robot confirmando la creación con link directo.
|
||||
* 4. Si la tarea requiere intervención humana o está en proceso, avisa en el chat.
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const Ticket = require('../models/Ticket');
|
||||
const Company = require('../models/Company');
|
||||
const Department = require('../models/Department');
|
||||
const User = require('../models/User');
|
||||
|
||||
const SIGMA_BOT_URL = process.env.SIGMA_BOT_URL || 'https://sigma-bot.telcotronics.com';
|
||||
const TOKEN = process.env.SIGMA_AGENTE_TOKEN;
|
||||
const AGENTE_ID = 90; // Agente Help Desk
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('❌ Error: SIGMA_AGENTE_TOKEN no configurado en .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consulta mensajes pendientes en sigma-robot
|
||||
*/
|
||||
async function leerMensajesPendientes() {
|
||||
const res = await fetch(`${SIGMA_BOT_URL}/ecosistema/v1/mensajes/pendientes`, {
|
||||
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Error ${res.status} leyendo pendientes: ${await res.text()}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.mensajes || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un mensaje a sigma-robot
|
||||
*/
|
||||
async function enviarMensaje({ para, agente_id, mensaje }) {
|
||||
const body = { para, mensaje };
|
||||
if (para === 'agente' && agente_id) {
|
||||
body.agente_id = parseInt(agente_id);
|
||||
}
|
||||
const res = await fetch(`${SIGMA_BOT_URL}/ecosistema/v1/mensajes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Error enviando mensaje: ${await res.text()}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea el texto del mensaje para extraer parámetros de ticket si existen
|
||||
*/
|
||||
function parsearSolicitudTicket(texto) {
|
||||
const raw = String(texto || '').trim();
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
const esTicket = lower.includes('ticket') || lower.includes('incidencia') || lower.includes('soporte');
|
||||
const esCrear = lower.includes('crear ticket') || lower.includes('abrir ticket') || lower.includes('nuevo ticket') || lower.startsWith('ticket:');
|
||||
|
||||
// Evitar falsos positivos con propuestas arquitectónicas, código o mensajes de coordinación
|
||||
if (lower.includes('propongo') || lower.includes('contrato') || lower.includes('revisé tu código') ||
|
||||
lower.includes('preguntas para cerrar') || lower.includes('fase 2') || lower.includes('herramienta') ||
|
||||
lower.includes('simulado') || lower.includes('recibido y aplicado') || lower.includes('prueba real')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!esTicket || !esCrear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Detectar Empresa
|
||||
let companyId = 1; // Default: Telcotronics
|
||||
if (lower.includes('xsystem') || lower.includes('empresa 2') || lower.includes('empresa=2')) {
|
||||
companyId = 2;
|
||||
} else if (lower.includes('domus') || lower.includes('empresa 3') || lower.includes('empresa=3')) {
|
||||
companyId = 3;
|
||||
} else if (lower.includes('telcotronics') || lower.includes('empresa 1') || lower.includes('empresa=1')) {
|
||||
companyId = 1;
|
||||
}
|
||||
|
||||
// Detectar Prioridad
|
||||
let prioridad = 'media';
|
||||
if (lower.includes('prioridad=alta') || lower.includes('prioridad: alta') || lower.includes('alta')) prioridad = 'alta';
|
||||
if (lower.includes('prioridad=critica') || lower.includes('prioridad: critica') || lower.includes('critica') || lower.includes('urgente')) prioridad = 'critica';
|
||||
if (lower.includes('prioridad=baja') || lower.includes('prioridad: baja') || lower.includes('baja')) prioridad = 'baja';
|
||||
|
||||
// Extraer Asunto y Mensaje
|
||||
let asunto = 'Solicitud recibida desde sigma-robot';
|
||||
let mensaje = raw;
|
||||
|
||||
// Patrón "asunto: ... | mensaje: ..." o "crear ticket: [asunto] - [mensaje]"
|
||||
const matchAsunto = raw.match(/asunto\s*[:=]\s*([^|\n]+)/i);
|
||||
const matchMensaje = raw.match(/(?:mensaje|descripcion|detalle)\s*[:=]\s*([^|\n]+)/i);
|
||||
|
||||
if (matchAsunto) {
|
||||
asunto = matchAsunto[1].trim();
|
||||
}
|
||||
if (matchMensaje) {
|
||||
mensaje = matchMensaje[1].trim();
|
||||
} else if (!matchAsunto) {
|
||||
// Limpiar prefijo tipo "crear ticket:" y usar las primeras palabras
|
||||
const cleaned = raw.replace(/^(?:@?\w+\s+)?(?:por\s+favor\s+)?(?:crear|abrir|generar)?\s*(?:un\s+)?ticket(?:\s*[:\-])?\s*/i, '').trim();
|
||||
if (cleaned.length > 0) {
|
||||
const lineas = cleaned.split('\n').filter(Boolean);
|
||||
asunto = lineas[0].substring(0, 80);
|
||||
mensaje = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
asunto: asunto.substring(0, 150),
|
||||
mensaje,
|
||||
prioridad
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta un ciclo de revisión y procesamiento
|
||||
*/
|
||||
async function procesarChat() {
|
||||
console.log(`[${new Date().toISOString()}] 🔍 Revisando buzón de chat en sigma-robot...`);
|
||||
|
||||
const pendientes = await leerMensajesPendientes();
|
||||
console.log(` 📨 Mensajes nuevos recibidos: ${pendientes.length}`);
|
||||
|
||||
if (pendientes.length === 0) {
|
||||
return { total: 0, creados: 0, respondidos: 0 };
|
||||
}
|
||||
|
||||
let creados = 0;
|
||||
let respondidos = 0;
|
||||
|
||||
for (const msg of pendientes) {
|
||||
console.log(`\n--- Mensaje #${msg.id} de ${msg.remitente_nombre || msg.remitente_usuario || 'desconocido'} ---`);
|
||||
console.log(`Texto: "${msg.mensaje}"`);
|
||||
|
||||
// Si es un mensaje propio del agente, ignorar
|
||||
if (msg.remitente_agente_id === AGENTE_ID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const solicitud = parsearSolicitudTicket(msg.mensaje);
|
||||
|
||||
// Destino para la respuesta
|
||||
const esDirecto = msg.destino_tipo === 'agente';
|
||||
const canalRespuesta = esDirecto
|
||||
? (msg.remitente_tipo === 'pablinux' ? 'pablinux' : 'agente')
|
||||
: 'general';
|
||||
const agenteDestinoId = msg.remitente_agente_id;
|
||||
|
||||
if (solicitud) {
|
||||
console.log(` 🎯 Detectada solicitud de ticket:`, solicitud);
|
||||
try {
|
||||
const company = await Company.findById(solicitud.companyId) || await Company.findById(1);
|
||||
|
||||
// Buscar o aprovisionar usuario solicitante
|
||||
const remitenteNombre = msg.remitente_nombre || msg.remitente_usuario || 'Agente Externo';
|
||||
const remitenteEmail = `sigmabot_${(msg.remitente_proyecto || 'robot').toLowerCase()}@sigmac.app`;
|
||||
|
||||
let user = await User.findByEmail(remitenteEmail);
|
||||
if (!user) {
|
||||
user = await User.upsertExternal(remitenteNombre, remitenteEmail, 'sigma-robot', String(msg.id), null, company.id);
|
||||
}
|
||||
|
||||
// Departamento por defecto
|
||||
const depts = await Department.findAll(company.id);
|
||||
const deptId = depts.length > 0 ? depts[0].id : null;
|
||||
|
||||
const ticketId = await Ticket.create(
|
||||
company.id,
|
||||
user.id,
|
||||
deptId,
|
||||
solicitud.asunto,
|
||||
solicitud.mensaje,
|
||||
solicitud.prioridad
|
||||
);
|
||||
|
||||
const ticketUrl = `https://soporte.sigmac.app/agent/ticket/${ticketId}`;
|
||||
console.log(` ✓ Ticket #${ticketId} creado exitosamente en ${company.name}`);
|
||||
|
||||
const respuestaTexto = `✅ Ticket #${ticketId} creado en ${company.name}.\n• Asunto: "${solicitud.asunto}"\n• Prioridad: ${solicitud.prioridad}\n• URL: ${ticketUrl}`;
|
||||
|
||||
await enviarMensaje({
|
||||
para: canalRespuesta,
|
||||
agente_id: agenteDestinoId,
|
||||
mensaje: respuestaTexto
|
||||
});
|
||||
|
||||
creados++;
|
||||
respondidos++;
|
||||
} catch (err) {
|
||||
console.error(` ❌ Error creando ticket para mensaje #${msg.id}:`, err.message);
|
||||
await enviarMensaje({
|
||||
para: canalRespuesta,
|
||||
agente_id: agenteDestinoId,
|
||||
mensaje: `⚠️ No se pudo crear el ticket para la solicitud #${msg.id}: ${err.message}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No es un comando de ticket directo.
|
||||
// Si es un mensaje de Pablinux o directo solicitando una tarea compleja:
|
||||
if (msg.remitente_tipo === 'pablinux') {
|
||||
console.log(' 💬 Mensaje directo de Pablinux recibido.');
|
||||
// Si requiere confirmación o está en curso:
|
||||
if (msg.mensaje.toLowerCase().includes('recibido') || msg.mensaje.toLowerCase().includes('continua')) {
|
||||
console.log(' ✓ Instrucción de continuidad reconocida.');
|
||||
} else {
|
||||
const respuestaTexto = `👍 Entendido @pablinux. Mensaje recibido por Agente Help Desk (ID 90). Tarea en curso / esperando tus indicaciones para ejecutar.`;
|
||||
await enviarMensaje({
|
||||
para: 'pablinux',
|
||||
mensaje: respuestaTexto
|
||||
});
|
||||
respondidos++;
|
||||
}
|
||||
} else if (esDirecto) {
|
||||
console.log(` ℹ️ Mensaje informativo/coordinación de otro agente (${msg.remitente_nombre || msg.remitente_agente_id}). Sin auto-respuesta para evitar spam de agentes.`);
|
||||
// No respondemos automáticamente con mensaje genérico para no spamear ni ciclar con otros agentes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Ciclo finalizado. Total: ${pendientes.length}, Tickets creados: ${creados}, Respuestas enviadas: ${respondidos}`);
|
||||
return { total: pendientes.length, creados, respondidos };
|
||||
}
|
||||
|
||||
// Ejecución
|
||||
if (require.main === module) {
|
||||
procesarChat()
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
console.error('❌ Error fatal en sigma_robot_worker:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { procesarChat, parsearSolicitudTicket };
|
||||
@@ -167,7 +167,7 @@
|
||||
<span>O regístrate / accede con</span>
|
||||
</div>
|
||||
|
||||
<!-- Google Identity Services Button -->
|
||||
<!-- Social Auth: horizontal en tablet y desktop, vertical en móvil -->
|
||||
<div class="social-auth-wrapper">
|
||||
<div id="g_id_onload"
|
||||
data-client_id="<%= googleClientId %>"
|
||||
@@ -176,6 +176,9 @@
|
||||
data-callback="handleGoogleCredentialResponse"
|
||||
data-auto_prompt="false">
|
||||
</div>
|
||||
|
||||
<div class="social-buttons-grid">
|
||||
<div class="social-btn-box social-google-box">
|
||||
<div class="g_id_signin w-100 d-flex justify-content-center"
|
||||
data-type="standard"
|
||||
data-shape="rectangular"
|
||||
@@ -184,15 +187,17 @@
|
||||
data-size="large"
|
||||
data-logo_alignment="left">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botón de Facebook OAuth -->
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-social-fb w-100 d-flex align-items-center justify-content-center gap-2 shadow-sm" onclick="handleFacebookLogin()">
|
||||
<div class="social-btn-box social-fb-box">
|
||||
<button type="button" class="btn btn-social-fb d-flex align-items-center justify-content-center gap-2 shadow-sm" onclick="handleFacebookLogin()">
|
||||
<i class="fab fa-facebook fs-5"></i>
|
||||
<span>Continuar con Facebook</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3 pt-2">
|
||||
<span class="text-muted small">¿Nueva empresa o sucursal?</span>
|
||||
|
||||
Reference in New Issue
Block a user