@alvarobr/n8n-nodes-glpi-connect 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Creado por **Álvaro Blanca Rodríguez**.
28
28
  - **Detalle**: técnicos, grupos, seguimientos y tareas públicos, soluciones, aprobaciones y activos asociados.
29
29
  - **Crear**: incidencia o solicitud con el usuario como solicitante y, opcionalmente, su equipo afectado. Antes de crearla avisa de posibles duplicados.
30
30
  - **Añadir comentario**: seguimiento firmado con el nombre del usuario. En un ticket resuelto solicita su reapertura.
31
- - **Cerrar o cancelar**: registra la solución («solucionado» o «cancelado») y cierra el ticket.
31
+ - **Cerrar o cancelar**: registra un seguimiento con la confirmación del usuario, asigna el ticket a la cuenta técnica de la credencial (sin quitar a los técnicos anteriores), añade la solución, lo cierra y vuelve a consultarlo para confirmar el estado final. El motivo puede ser «solucionado», «cancelado» o «acción automática completada».
32
32
  - **Asociar activo**: relación nativa ticket-elemento de GLPI, solo con activos del usuario y sin duplicados.
33
33
 
34
34
  ### Activos
@@ -38,6 +38,8 @@ Creado por **Álvaro Blanca Rodríguez**.
38
38
  ### Otros
39
39
  - **Usuario → Identificar por correo**: usuario de GLPI asociado a un correo.
40
40
  - **Base de conocimiento → Buscar preguntas frecuentes**: artículos FAQ públicos.
41
+ - **Base de conocimiento → Índice de preguntas frecuentes**: títulos e ID de todas las FAQ públicas. Páselo al prompt del agente para que sepa qué artículos existen y no busque a ciegas.
42
+ - **Base de conocimiento → Leer artículo**: contenido completo de una FAQ pública por su ID.
41
43
  - **Petición API → Petición personalizada**: cualquier ruta de `apirest.php` con la sesión ya gestionada. No comprueba permisos por usuario, así que **no debe conectarse a un agente de IA**.
42
44
 
43
45
  ## Seguridad
@@ -23,6 +23,7 @@ class GlpiClient {
23
23
  this.config = config;
24
24
  this.http = http;
25
25
  this.sessionToken = null;
26
+ this.sessionUser = null;
26
27
  const base = config.url.trim().replace(/\/+$/, '').replace(/\/apirest\.php$/, '');
27
28
  this.api = `${base}/apirest.php`;
28
29
  this.front = `${base}/front`;
@@ -59,6 +60,18 @@ class GlpiClient {
59
60
  // La sesión caducará sola en GLPI
60
61
  }
61
62
  this.sessionToken = null;
63
+ this.sessionUser = null;
64
+ }
65
+ /** ID del usuario de GLPI con el que se ha iniciado sesión (la cuenta técnica del token). */
66
+ async sessionUserId() {
67
+ if (this.sessionUser)
68
+ return this.sessionUser;
69
+ const body = await this.get('getFullSession');
70
+ const id = Number(body?.session?.glpiID);
71
+ if (!Number.isSafeInteger(id) || id <= 0)
72
+ throw new GlpiError('GLPI no ha indicado el usuario de la sesión.', 200, 'NO_SESSION_USER');
73
+ this.sessionUser = id;
74
+ return id;
62
75
  }
63
76
  /** Petición que devuelve el estado y el cuerpo sin lanzar error (para decidir según la respuesta). */
64
77
  async raw(method, path, options = {}) {
@@ -80,7 +80,7 @@ const properties = [
80
80
  { name: 'Detalle', value: 'detalle', action: 'Ver el detalle de un ticket del usuario', description: 'Técnicos, seguimientos, tareas, soluciones, aprobaciones y activos asociados' },
81
81
  { name: 'Crear', value: 'crear', action: 'Crear un ticket para el usuario', description: 'Crea una incidencia o solicitud, opcionalmente asociada a un activo del usuario' },
82
82
  { name: 'Añadir comentario', value: 'comentar', action: 'Añadir un comentario a un ticket del usuario', description: 'Añade un seguimiento; en un ticket resuelto solicita su reapertura' },
83
- { name: 'Cerrar o cancelar', value: 'cerrar', action: 'Cerrar o cancelar un ticket del usuario', description: 'Registra la solución y cierra el ticket' },
83
+ { name: 'Cerrar o cancelar', value: 'cerrar', action: 'Cerrar o cancelar un ticket del usuario', description: 'Registra un seguimiento, asigna el ticket a la cuenta de la integración, lo cierra y comprueba el estado final' },
84
84
  { name: 'Asociar activo', value: 'asociarActivo', action: 'Asociar un activo del usuario a su ticket', description: 'Crea la relación nativa ticket-elemento de GLPI' },
85
85
  ],
86
86
  default: 'listar',
@@ -112,7 +112,11 @@ const properties = [
112
112
  type: 'options',
113
113
  noDataExpression: true,
114
114
  displayOptions: show(['conocimiento']),
115
- options: [{ name: 'Buscar preguntas frecuentes', value: 'buscar', action: 'Buscar en la base de conocimiento' }],
115
+ options: [
116
+ { name: 'Buscar preguntas frecuentes', value: 'buscar', action: 'Buscar en la base de conocimiento' },
117
+ { name: 'Índice de preguntas frecuentes', value: 'indice', action: 'Listar los títulos de la base de conocimiento', description: 'Títulos e ID de las preguntas frecuentes públicas, para que la IA sepa qué hay antes de consultar' },
118
+ { name: 'Leer artículo', value: 'leer', action: 'Leer un artículo de la base de conocimiento', description: 'Contenido completo de una pregunta frecuente por su ID' },
119
+ ],
116
120
  default: 'buscar',
117
121
  },
118
122
  {
@@ -262,6 +266,7 @@ const properties = [
262
266
  options: [
263
267
  { name: 'Solucionado', value: 'solucionado' },
264
268
  { name: 'Cancelado (ya no se necesita)', value: 'cancelado' },
269
+ { name: 'Acción automática completada', value: 'automatizado' },
265
270
  ],
266
271
  default: 'solucionado',
267
272
  displayOptions: show(['ticket'], ['cerrar']),
@@ -349,7 +354,15 @@ const properties = [
349
354
  required: true,
350
355
  default: '',
351
356
  placeholder: 'vpn',
352
- displayOptions: show(['conocimiento']),
357
+ displayOptions: show(['conocimiento'], ['buscar']),
358
+ },
359
+ {
360
+ displayName: 'ID del artículo',
361
+ name: 'articuloId',
362
+ type: 'number',
363
+ required: true,
364
+ default: 0,
365
+ displayOptions: show(['conocimiento'], ['leer']),
353
366
  },
354
367
  // --- Petición API ---------------------------------------------------
355
368
  {
@@ -461,6 +474,10 @@ async function runOperation(ctx, client, users, i) {
461
474
  const resource = ctx.getNodeParameter('resource', i);
462
475
  const operation = ctx.getNodeParameter('operation', i);
463
476
  if (resource === 'conocimiento') {
477
+ if (operation === 'indice')
478
+ return ops.faqIndex(client);
479
+ if (operation === 'leer')
480
+ return ops.readFaq(client, Number(ctx.getNodeParameter('articuloId', i)));
464
481
  return ops.searchFaq(client, ctx.getNodeParameter('busqueda', i));
465
482
  }
466
483
  if (resource === 'api') {
@@ -21,6 +21,8 @@ exports.linkAsset = linkAsset;
21
21
  exports.createTicket = createTicket;
22
22
  exports.myAssets = myAssets;
23
23
  exports.computerSheet = computerSheet;
24
+ exports.faqIndex = faqIndex;
25
+ exports.readFaq = readFaq;
24
26
  exports.searchFaq = searchFaq;
25
27
  exports.apiRequest = apiRequest;
26
28
  const GlpiClient_1 = require("./GlpiClient");
@@ -341,6 +343,28 @@ async function addFollowup(client, user, ticketId, text, control) {
341
343
  return { ok: false, ejecutado: false, error: 'GLPI no ha aceptado el comentario. No se ha modificado el ticket.', detalle: glpiDetail(error), ticket };
342
344
  }
343
345
  }
346
+ const CLOSE_TEXTS = {
347
+ solucionado: {
348
+ label: 'Cerrar como solucionado',
349
+ followup: 'El usuario ha confirmado la resolución de la incidencia.',
350
+ solution: 'El usuario indica que el problema se ha solucionado.',
351
+ },
352
+ cancelado: {
353
+ label: 'Cancelar',
354
+ followup: 'El usuario ha confirmado que ya no necesita esta solicitud y pide cancelarla.',
355
+ solution: 'Solicitud cancelada por el usuario: ya no la necesita.',
356
+ },
357
+ automatizado: {
358
+ label: 'Cerrar tras la acción automática',
359
+ followup: 'Acción ejecutada automáticamente por el asistente a petición del usuario.',
360
+ solution: 'Resuelto mediante una acción automática del asistente de soporte.',
361
+ },
362
+ };
363
+ /**
364
+ * Cierre completo: seguimiento, asignación a la cuenta técnica de la integración,
365
+ * solución, estado Cerrado y comprobación final en GLPI.
366
+ * Los técnicos, seguimientos y soluciones anteriores se conservan.
367
+ */
344
368
  async function closeTicket(client, user, ticketId, reason, text, control) {
345
369
  const access = await loadEditableTicket(client, user, ticketId, true);
346
370
  if (!isAccess(access))
@@ -348,34 +372,81 @@ async function closeTicket(client, user, ticketId, reason, text, control) {
348
372
  const ticket = access.summary;
349
373
  if (ticket.estado_id === 6)
350
374
  return { ok: true, ejecutado: false, mensaje: `El ticket #${ticket.id} ya estaba cerrado.`, ticket };
375
+ const texts = CLOSE_TEXTS[reason] ?? CLOSE_TEXTS.solucionado;
351
376
  const explanation = String(text ?? '').trim();
352
- const label = reason === 'cancelado' ? 'Cancelar' : 'Cerrar como solucionado';
377
+ const extra = explanation ? `<p>${(0, utils_1.escapeHtml)(explanation)}</p>` : '';
353
378
  const { execute, warning } = decide(control);
354
379
  if (!execute) {
355
- return preview(`${label} el ticket #${ticket.id} «${ticket.titulo}»${explanation ? ` indicando: "${explanation}"` : ''}`, warning, { ticket });
380
+ return preview(`${texts.label} el ticket #${ticket.id} «${ticket.titulo}»${explanation ? ` indicando: "${explanation}"` : ''}`, warning, { ticket });
381
+ }
382
+ // 1. Seguimiento con la confirmación del usuario
383
+ try {
384
+ await client.call('POST', 'ITILFollowup', {
385
+ body: { input: { itemtype: 'Ticket', items_id: ticket.id, is_private: 0, content: `<p>${texts.followup}</p>${extra}${signature(user)}` } },
386
+ });
387
+ }
388
+ catch (error) {
389
+ return { ok: false, ejecutado: false, error: `GLPI no ha permitido registrar el seguimiento del ticket #${ticket.id}. No se ha modificado.`, detalle: glpiDetail(error), ticket };
390
+ }
391
+ // 2. Asignar a la cuenta técnica (se añade; los técnicos anteriores se mantienen)
392
+ const avisos = [];
393
+ try {
394
+ const agentId = await client.sessionUserId();
395
+ const actors = await client.get(`Ticket/${ticket.id}/Ticket_User`).catch(() => []);
396
+ const assigned = (Array.isArray(actors) ? actors : []).some((a) => a && Number(a.users_id) === agentId && Number(a.type) === 2);
397
+ if (!assigned)
398
+ await client.call('POST', 'Ticket_User', { body: { input: { tickets_id: ticket.id, users_id: agentId, type: 2 } } });
399
+ }
400
+ catch (error) {
401
+ avisos.push(`No se ha podido asignar el ticket a la cuenta técnica: ${glpiDetail(error)}`);
356
402
  }
357
- // Primero la solución (si no la tiene), después el cierre
403
+ // 3. Solución (GLPI la necesita para pasar a Resuelto) y 4. Cerrado
358
404
  if (ticket.estado_id !== 5) {
359
- const heading = reason === 'cancelado' ? 'Solicitud cancelada por el usuario: ya no la necesita.' : 'El usuario indica que el problema se ha solucionado.';
360
405
  try {
361
406
  await client.call('POST', 'ITILSolution', {
362
- body: { input: { itemtype: 'Ticket', items_id: ticket.id, content: `<p>${heading}</p>${explanation ? `<p>${(0, utils_1.escapeHtml)(explanation)}</p>` : ''}${signature(user)}` } },
407
+ body: { input: { itemtype: 'Ticket', items_id: ticket.id, content: `<p>${texts.solution}</p>${extra}${signature(user)}` } },
363
408
  });
364
409
  }
365
410
  catch (error) {
366
- return { ok: false, ejecutado: false, error: `GLPI no ha permitido cerrar el ticket #${ticket.id}. No se ha modificado.`, detalle: glpiDetail(error), ticket };
411
+ return (0, utils_1.compact)({
412
+ ok: false,
413
+ ejecutado: 'parcial',
414
+ error: `Se ha registrado el seguimiento, pero GLPI no ha permitido resolver el ticket #${ticket.id}, así que sigue abierto.`,
415
+ detalle: glpiDetail(error),
416
+ avisos: avisos.length ? avisos : undefined,
417
+ ticket,
418
+ });
367
419
  }
368
420
  }
369
- const closed = await client.raw('PUT', `Ticket/${ticket.id}`, { body: { input: { id: ticket.id, status: 6 } } });
370
- const closedOk = closed.statusCode < 300 && Array.isArray(closed.body) && closed.body.some((x) => x && x[String(ticket.id)] === true);
371
- if (closedOk)
372
- return { ok: true, ejecutado: true, mensaje: `El ticket #${ticket.id} ha quedado cerrado.`, ticket };
373
- return {
374
- ok: true,
421
+ await client.raw('PUT', `Ticket/${ticket.id}`, { body: { input: { id: ticket.id, status: 6 } } });
422
+ // 5. Comprobar en GLPI el estado final antes de dar el cierre por hecho
423
+ const check = await client.raw('GET', `Ticket/${ticket.id}`);
424
+ const finalStatus = check.statusCode < 300 && check.body && !Array.isArray(check.body) ? Number(check.body.status) : NaN;
425
+ const final = { ...ticket, estado: utils_1.TICKET_STATUS[finalStatus] ?? 'Desconocido', estado_id: finalStatus };
426
+ const notes = avisos.length ? avisos : undefined;
427
+ if (finalStatus === 6) {
428
+ return (0, utils_1.compact)({ ok: true, ejecutado: true, verificado: true, mensaje: `GLPI confirma que el ticket #${ticket.id} está cerrado.`, avisos: notes, ticket: final });
429
+ }
430
+ if (finalStatus === 5) {
431
+ return (0, utils_1.compact)({
432
+ ok: true,
433
+ ejecutado: 'parcial',
434
+ verificado: true,
435
+ mensaje: `El ticket #${ticket.id} ha quedado como Resuelto, pero GLPI no ha permitido cerrarlo directamente. Se cerrará automáticamente según la configuración de GLPI.`,
436
+ avisos: notes,
437
+ ticket: final,
438
+ });
439
+ }
440
+ return (0, utils_1.compact)({
441
+ ok: false,
375
442
  ejecutado: 'parcial',
376
- mensaje: `Se ha registrado la solución y el ticket #${ticket.id} ha quedado como Resuelto, pero GLPI no ha permitido cerrarlo directamente. Se cerrará automáticamente según la configuración de GLPI.`,
377
- ticket,
378
- };
443
+ verificado: Number.isFinite(finalStatus),
444
+ error: Number.isFinite(finalStatus)
445
+ ? `GLPI indica que el ticket #${ticket.id} sigue en estado «${final.estado}». No se ha cerrado.`
446
+ : `No se ha podido comprobar en GLPI el estado final del ticket #${ticket.id}.`,
447
+ avisos: notes,
448
+ ticket: final,
449
+ });
379
450
  }
380
451
  /** Comprueba que un activo esté asignado al usuario. */
381
452
  async function loadOwnAsset(client, user, itemtype, itemsId) {
@@ -638,6 +709,37 @@ async function computerSheet(client, user, computerId, search) {
638
709
  // ----------------------------------------------------------------------
639
710
  // Base de conocimiento
640
711
  // ----------------------------------------------------------------------
712
+ const FAQ_INDEX_LIMIT = 200;
713
+ /** Solo las preguntas frecuentes se consideran públicas para el usuario. */
714
+ function isPublicFaq(article) {
715
+ return !!article && typeof article === 'object' && (article.is_faq === undefined || Number(article.is_faq) === 1);
716
+ }
717
+ /** Títulos de todas las preguntas frecuentes, para que la IA sepa qué existe antes de consultar. */
718
+ async function faqIndex(client) {
719
+ const response = await client.raw('GET', 'KnowbaseItem', { qs: { range: `0-${FAQ_INDEX_LIMIT - 1}`, sort: 'name', order: 'ASC' } });
720
+ if (response.statusCode >= 300 || !Array.isArray(response.body)) {
721
+ return { ok: false, error: 'No se ha podido consultar la base de conocimiento.' };
722
+ }
723
+ const articles = response.body.filter(isPublicFaq).map((a) => ({ id: Number(a.id), titulo: (0, utils_1.plainText)(a.name, 200) }));
724
+ return (0, utils_1.compact)({
725
+ ok: true,
726
+ total: articles.length,
727
+ articulos: articles,
728
+ aviso: response.body.length >= FAQ_INDEX_LIMIT ? `Solo se muestran los ${FAQ_INDEX_LIMIT} primeros artículos.` : undefined,
729
+ });
730
+ }
731
+ /** Contenido completo de una pregunta frecuente. */
732
+ async function readFaq(client, articleId) {
733
+ const id = Number(articleId);
734
+ if (!Number.isSafeInteger(id) || id <= 0)
735
+ return { ok: false, error: 'Falta el ID del artículo. Use el índice de la base de conocimiento.' };
736
+ const response = await client.raw('GET', `KnowbaseItem/${id}`);
737
+ if (response.statusCode >= 300 || !response.body || Array.isArray(response.body) || !isPublicFaq(response.body)) {
738
+ return { ok: false, error: `No existe una pregunta frecuente pública con el ID ${id}.` };
739
+ }
740
+ const a = response.body;
741
+ return { ok: true, articulo: { id, titulo: (0, utils_1.plainText)(a.name, 200), contenido: (0, utils_1.plainText)(a.answer, 8000), enlace: `${client.front}/helpdesk.faq.php?id=${id}` } };
742
+ }
641
743
  async function searchFaq(client, text) {
642
744
  const term = String(text ?? '').trim();
643
745
  if (term.length < 2)
@@ -651,10 +753,9 @@ async function searchFaq(client, text) {
651
753
  if (r.statusCode >= 300 || !Array.isArray(r.body))
652
754
  continue;
653
755
  for (const a of r.body) {
654
- if (!a || typeof a !== 'object' || seen.has(a.id))
756
+ if (!a || seen.has(a.id))
655
757
  continue;
656
- // Solo preguntas frecuentes (públicas)
657
- if (a.is_faq !== undefined && Number(a.is_faq) !== 1)
758
+ if (!isPublicFaq(a))
658
759
  continue;
659
760
  seen.add(a.id);
660
761
  articles.push({ id: a.id, titulo: (0, utils_1.plainText)(a.name, 200), contenido: (0, utils_1.plainText)(a.answer, 1500), enlace: `${client.front}/helpdesk.faq.php?id=${a.id}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alvarobr/n8n-nodes-glpi-connect",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Nodo de n8n para GLPI: autoservicio IT seguro para flujos y agentes de IA. Tickets, inventario, software y base de conocimiento con sesión automática, permisos por usuario y confirmación de cambios.",
5
5
  "keywords": [
6
6
  "n8n-community-node-package",