@alvarobr/n8n-nodes-glpi-connect 1.0.1 → 1.1.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
@@ -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',
@@ -262,6 +262,7 @@ const properties = [
262
262
  options: [
263
263
  { name: 'Solucionado', value: 'solucionado' },
264
264
  { name: 'Cancelado (ya no se necesita)', value: 'cancelado' },
265
+ { name: 'Acción automática completada', value: 'automatizado' },
265
266
  ],
266
267
  default: 'solucionado',
267
268
  displayOptions: show(['ticket'], ['cerrar']),
@@ -341,6 +341,28 @@ async function addFollowup(client, user, ticketId, text, control) {
341
341
  return { ok: false, ejecutado: false, error: 'GLPI no ha aceptado el comentario. No se ha modificado el ticket.', detalle: glpiDetail(error), ticket };
342
342
  }
343
343
  }
344
+ const CLOSE_TEXTS = {
345
+ solucionado: {
346
+ label: 'Cerrar como solucionado',
347
+ followup: 'El usuario ha confirmado la resolución de la incidencia.',
348
+ solution: 'El usuario indica que el problema se ha solucionado.',
349
+ },
350
+ cancelado: {
351
+ label: 'Cancelar',
352
+ followup: 'El usuario ha confirmado que ya no necesita esta solicitud y pide cancelarla.',
353
+ solution: 'Solicitud cancelada por el usuario: ya no la necesita.',
354
+ },
355
+ automatizado: {
356
+ label: 'Cerrar tras la acción automática',
357
+ followup: 'Acción ejecutada automáticamente por el asistente a petición del usuario.',
358
+ solution: 'Resuelto mediante una acción automática del asistente de soporte.',
359
+ },
360
+ };
361
+ /**
362
+ * Cierre completo: seguimiento, asignación a la cuenta técnica de la integración,
363
+ * solución, estado Cerrado y comprobación final en GLPI.
364
+ * Los técnicos, seguimientos y soluciones anteriores se conservan.
365
+ */
344
366
  async function closeTicket(client, user, ticketId, reason, text, control) {
345
367
  const access = await loadEditableTicket(client, user, ticketId, true);
346
368
  if (!isAccess(access))
@@ -348,34 +370,81 @@ async function closeTicket(client, user, ticketId, reason, text, control) {
348
370
  const ticket = access.summary;
349
371
  if (ticket.estado_id === 6)
350
372
  return { ok: true, ejecutado: false, mensaje: `El ticket #${ticket.id} ya estaba cerrado.`, ticket };
373
+ const texts = CLOSE_TEXTS[reason] ?? CLOSE_TEXTS.solucionado;
351
374
  const explanation = String(text ?? '').trim();
352
- const label = reason === 'cancelado' ? 'Cancelar' : 'Cerrar como solucionado';
375
+ const extra = explanation ? `<p>${(0, utils_1.escapeHtml)(explanation)}</p>` : '';
353
376
  const { execute, warning } = decide(control);
354
377
  if (!execute) {
355
- return preview(`${label} el ticket #${ticket.id} «${ticket.titulo}»${explanation ? ` indicando: "${explanation}"` : ''}`, warning, { ticket });
378
+ return preview(`${texts.label} el ticket #${ticket.id} «${ticket.titulo}»${explanation ? ` indicando: "${explanation}"` : ''}`, warning, { ticket });
379
+ }
380
+ // 1. Seguimiento con la confirmación del usuario
381
+ try {
382
+ await client.call('POST', 'ITILFollowup', {
383
+ body: { input: { itemtype: 'Ticket', items_id: ticket.id, is_private: 0, content: `<p>${texts.followup}</p>${extra}${signature(user)}` } },
384
+ });
385
+ }
386
+ catch (error) {
387
+ 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 };
388
+ }
389
+ // 2. Asignar a la cuenta técnica (se añade; los técnicos anteriores se mantienen)
390
+ const avisos = [];
391
+ try {
392
+ const agentId = await client.sessionUserId();
393
+ const actors = await client.get(`Ticket/${ticket.id}/Ticket_User`).catch(() => []);
394
+ const assigned = (Array.isArray(actors) ? actors : []).some((a) => a && Number(a.users_id) === agentId && Number(a.type) === 2);
395
+ if (!assigned)
396
+ await client.call('POST', 'Ticket_User', { body: { input: { tickets_id: ticket.id, users_id: agentId, type: 2 } } });
397
+ }
398
+ catch (error) {
399
+ avisos.push(`No se ha podido asignar el ticket a la cuenta técnica: ${glpiDetail(error)}`);
356
400
  }
357
- // Primero la solución (si no la tiene), después el cierre
401
+ // 3. Solución (GLPI la necesita para pasar a Resuelto) y 4. Cerrado
358
402
  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
403
  try {
361
404
  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)}` } },
405
+ body: { input: { itemtype: 'Ticket', items_id: ticket.id, content: `<p>${texts.solution}</p>${extra}${signature(user)}` } },
363
406
  });
364
407
  }
365
408
  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 };
409
+ return (0, utils_1.compact)({
410
+ ok: false,
411
+ ejecutado: 'parcial',
412
+ error: `Se ha registrado el seguimiento, pero GLPI no ha permitido resolver el ticket #${ticket.id}, así que sigue abierto.`,
413
+ detalle: glpiDetail(error),
414
+ avisos: avisos.length ? avisos : undefined,
415
+ ticket,
416
+ });
367
417
  }
368
418
  }
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,
419
+ await client.raw('PUT', `Ticket/${ticket.id}`, { body: { input: { id: ticket.id, status: 6 } } });
420
+ // 5. Comprobar en GLPI el estado final antes de dar el cierre por hecho
421
+ const check = await client.raw('GET', `Ticket/${ticket.id}`);
422
+ const finalStatus = check.statusCode < 300 && check.body && !Array.isArray(check.body) ? Number(check.body.status) : NaN;
423
+ const final = { ...ticket, estado: utils_1.TICKET_STATUS[finalStatus] ?? 'Desconocido', estado_id: finalStatus };
424
+ const notes = avisos.length ? avisos : undefined;
425
+ if (finalStatus === 6) {
426
+ 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 });
427
+ }
428
+ if (finalStatus === 5) {
429
+ return (0, utils_1.compact)({
430
+ ok: true,
431
+ ejecutado: 'parcial',
432
+ verificado: true,
433
+ 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.`,
434
+ avisos: notes,
435
+ ticket: final,
436
+ });
437
+ }
438
+ return (0, utils_1.compact)({
439
+ ok: false,
375
440
  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
- };
441
+ verificado: Number.isFinite(finalStatus),
442
+ error: Number.isFinite(finalStatus)
443
+ ? `GLPI indica que el ticket #${ticket.id} sigue en estado «${final.estado}». No se ha cerrado.`
444
+ : `No se ha podido comprobar en GLPI el estado final del ticket #${ticket.id}.`,
445
+ avisos: notes,
446
+ ticket: final,
447
+ });
379
448
  }
380
449
  /** Comprueba que un activo esté asignado al usuario. */
381
450
  async function loadOwnAsset(client, user, itemtype, itemsId) {
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.1.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",