// src/services/servicenowService.js const axios = require('axios'); const apiConfig = require('../../config/apiConfig'); const { stripHTML } = require('../utils/commentSanitizer'); const TicketSnModel = require('../models/ticketSnModel'); const { logInfo, logError, logSync } = require('../utils/logger'); const TicketUpdateModel = require('../models/ticketUpdateModel'); const TicketSyncModel = require('../models/ticketSyncModel'); const { log } = require('winston'); const fetchTicketsFromServiceNow = async (watermark) => { try { const watermarkDate = new Date(watermark); const watermarkBRT = new Date(watermarkDate.getTime()); const formattedWatermark = watermarkBRT.toISOString().replace('T', ' ').substring(0, 19); const fields = 'number,sys_id,short_description,state,description,caller_id,caller_id.email,caller_id.phone,caller_id.mobile_phone,location,opened_at,sys_updated_on'; const baseUrl = apiConfig.snTableIncidentConfig.baseUrl.replace(/\/$/, ""); const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${formattedWatermark}`; const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`; const ticketsResponse = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); return ticketsResponse.data.result || []; } catch (error) { logError(error, 'SERVICE NOW Tickets'); throw error; } } const fetchRequestsFromServiceNow = async (watermark) => { try { const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${watermark}`; const fields ='number,request,opened_at,short_description,sys_id,assignment_group,opened_by,opened_by.display_value,opened_by.email,description,location,state,sys_updated_on,variables'; const baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, ""); const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`; const requestsResponse = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); return requestsResponse.data.result || []; } catch (error) { logError(error, 'SERVICE NOW Requests'); throw error; } } const getTicketSysId = async (ticketId) => { try { const ticket = await TicketSnModel.findById(ticketId); if (!ticket) { logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketSysId'); return null; } return ticket.sys_id; } catch (error) { logError(error, '🚨Falha ao obter sys_id do ticket'); return null; } } const getTicketTypeById = async (ticketId) => { try { const ticket = await TicketSnModel.findById(ticketId); if (!ticket) { logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketTypeById'); return null; } const ticketType = ticket.tipo; logInfo(`⚠️ Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType }); return ticketType; } catch (error) { logError(error, '🚨Falha ao obter tipo do ticket'); return null; } } const getCommentId = async (sysId, comment) => { try { const url = `${apiConfig.snTableJournalConfig.baseUrl}?sysparm_query=element_id=${sysId}^element=comments^ORDERBYDESCsys_created_on&sysparm_fields=sys_id,element,value,sys_created_on,sys_created_by,element_id`; const response = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); const data = response.data; const comments = data.result; // Procurar o comentário exato na lista retornada for (const item of comments) { if (item.value && item.value.includes(comment)) { return item.sys_id; // Retorna o sys_id do comentário encontrado } } } catch (error) { logError(error, '🚨Falha ao obter ID do comentário no ServiceNow'); return null; } } const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => { try { let url = ''; let payload = {}; if (ticketType == 'incidente') { url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`; payload = { comments: comment }; } else if (ticketType == 'requisicao') { url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`; payload = { comments: comment }; } else { logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); return null; } const response = await axios.patch(url, payload, { auth: apiConfig.servicenowAuthentication.auth }); await TicketSyncModel.updateLastSync(snId, 'servicenow'); // Atualiza o timestamp // Verificar se retorna status 200 para sucesso if (response.status !== 200) { logError(`❌ Falha ao criar comentário. Status: ${response.status}`, { sysId, ticketType }); return null; } // retornar id do comentário criado const commentId = await getCommentId(sysId, comment); if (!commentId) { logError('❌ Comentário criado, mas não foi possível obter o ID do comentário', { sysId }); return null; } logInfo(`✅ Comentário criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType }); return commentId; } catch (error) { logError(error, '🚨Falha ao criar comentário no ServiceNow'); } } const syncCommentToServiceNow = async (ticketId, comment) => { logInfo(`Iniciando sincronização do comentário para o ticket SN Ticket: ${ticketId}`); const snSysId = await getTicketSysId(ticketId); const ticketType = await getTicketTypeById(ticketId); if (!snSysId) { logError(`❌ sys_id do ServiceNow não encontrado para o ticket SN Ticket: ${ticketId}`); return null; } const snCommentId = await createCommentInServiceNow(ticketId,snSysId, comment, ticketType); return snCommentId; } const existingCommentInServiceNow = async (ticketId, comment) => { try { const sys_id = await getTicketSysId(ticketId); const commentExists = await getCommentId(sys_id, comment); return commentExists; } catch (error) { logError(error, '🚨Falha ao verificar existência do comentário no ServiceNow'); return false; } } const fetchCommentsFromServiceNow = async (ticket) => { try { logInfo(`Iniciando busca de comentários do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`); const sys_id = ticket.sys_id; const allComments = []; const pageSize = 100; let offset = 0; let page = 0; const internalMaxPages = 1000; // safeguard interno para evitar loop infinito while (true) { page++; const params = { sysparm_query: `element_id=${encodeURIComponent(sys_id)}^element=comments`, sysparm_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id', sysparm_limit: pageSize, sysparm_offset: offset }; logInfo(`Buscando página ${page} de comentários do ticket SN Ticket: ${ticket.glpi_ticket_id}`); const response = await axios.get(apiConfig.snTableJournalConfig.baseUrl, { auth: apiConfig.servicenowAuthentication.auth, params }); await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'servicenow'); // Atualiza o timestamp const comments = response?.data?.result || []; if (!Array.isArray(comments) || comments.length === 0) { break; // não há mais comentários } allComments.push(...comments); if (comments.length < pageSize) { break; // última página } // avançar para próxima página offset += pageSize; // proteção extra para evitar loop infinito em caso de comportamento inesperado da API if (page >= internalMaxPages) { logError(new Error('Max pages reached while paginating comments'), { ticketId, page }); break; } } // Traz apenas os tickets que não são do sys_created_by: "SOTHIS.CAOA" const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA'); if (filteredComments.length === 0) { logInfo(`Nenhum comentário encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); return []; } logInfo(`Encontrado ${filteredComments.length} comentários para o ticket SN Ticket: ${ticket.glpi_ticket_id}`) const commentsInserted = []; // Insere cada comment no banco intermediario for (const comment of filteredComments) { const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id); if (existingComment) { logInfo(`Comentário: ${comment.sys_id} já existe no banco de dados`, { step: 3 }); continue; } else { const commentData = { ticket_sync_id: ticket.id, update_type: 'comment', source_system: 'servicenow', content: comment.value, created_at: new Date(comment.sys_created_on), author: "CAOA", updated_at: new Date(), source_id: comment.sys_id, destiny_id: null }; const commentInserted = await TicketUpdateModel.insert(commentData); if (commentInserted.success) { commentsInserted.push(commentData); logInfo(`✅ Comentário ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 }); } else { logError(`❌ Erro ao inserir comentário ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 }); return []; } } } return commentsInserted; } catch (error) { logError(error, '🚨Falha ao buscar comentários do ServiceNow'); return []; } }; const fetchAndProcessClosedTicketsFromSN = async () => { try { logInfo('📡 Verificando status de tickets sincronizados no ServiceNow...'); // 1. Busca apenas os tickets que estão abertos e sincronizados no nosso banco. const syncedTickets = await TicketSyncModel.getTicketsSynced(); if (syncedTickets.length === 0) { logInfo('Nenhum ticket sincronizado para verificar status no ServiceNow.'); return; } // 2. Para cada ticket, verifica seu status real no ServiceNow. for (const ticket of syncedTickets) { const ticketSnDetails = await TicketSnModel.findById(ticket.sn_ticket_id); // 3. Se o status for de fechamento, inicia o processo. if (ticketSnDetails && ticketSnDetails.status === 'Encerrado') { // 'Encerrado' mapeia para 'Resolved' (6) logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Resolvido. Marcando como 'solved'...`); await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'solved', 'SNOW'); } else if (ticketSnDetails && ticketSnDetails.status === 'Encerrado - Omitido') { // 'Encerrado - Omitido' mapeia para 'Closed' (7) logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Fechado permanentemente. Marcando para sincronização de fechamento...`); // Sanitiza o conteúdo da nota de fechamento antes de salvar. const sanitizedContent = stripHTML(ticketSnDetails.close_notes || 'Chamado encerrado sem notas.'); // Salva a nota de fechamento no nosso banco intermediário. await TicketUpdateModel.insert({ ticket_sync_id: ticket.id, source_system: 'servicenow', update_type: 'close_message', content: sanitizedContent, author: 'ServiceNow', created_at: ticketSnDetails.updated_at, source_id: `close-${ticketSnDetails.sys_id}`, }); // Atualiza o status no nosso banco para 'pending_close' no GLPI. await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'pending_close', 'closed', 'SNOW'); } } } catch (error) { logError(error, '🚨Falha ao buscar chamados encerrados do ServiceNow'); } }; const updateStatusInServiceNow = async (snTicketId, state) => { // Mapeamento de status de string para código numérico do ServiceNow const stateMap = { 'Aguardando Atendimento': '1', // New 'Em Atendimento': '2', // In Progress 'Em Espera': '-5', // On Hold 'Encerrado': '6', // Resolved 'Encerrado - Omitido': '7' // Closed }; const stateCode = stateMap[state] || state; // Usa o código mapeado ou o valor original se não encontrar let sysId = null; // Declarar sysId fora do try para estar disponível no catch try { const ticketSn = await TicketSnModel.findById(snTicketId); if (!ticketSn) { logError(`Ticket SN com ID ${snTicketId} não encontrado para atualização de status.`, 'updateStatusInServiceNow'); return null; } sysId = ticketSn.sys_id; // Atribuir valor const ticketType = ticketSn.tipo; let url = ''; let payload = {}; if (ticketType === 'incidente') { url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`; payload = { state: stateCode, incident_state: stateCode }; // Envia state e incident_state } else if (ticketType === 'requisicao') { url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`; payload = { state: stateCode }; // Requisições podem ter um fluxo diferente } else { logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); } const response = await axios.patch(url, payload, { auth: apiConfig.servicenowAuthentication.auth }); await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Atualiza o timestamp if (response.status !== 200) { logError(`❌ Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode }); throw new Error(`Falha ao atualizar status no ServiceNow: ${response.status} - ${response.statusText}`); } logInfo(`✅ Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode }); return true; } catch (error) { logError(error, `🚨 Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`); return null; } }; const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => { let sysId = null; // Declarar sysId fora do try try { const ticketSn = await TicketSnModel.findById(snTicketId); if (!ticketSn) { logError(`Ticket SN com ID ${snTicketId} não encontrado para fechamento.`, 'closeTicketInServiceNow'); return null; } sysId = ticketSn.sys_id; const ticketType = ticketSn.tipo; let url = ''; // Declarar url fora do if let payload = {}; // O ID do usuário que está resolvendo o chamado. Pegue o sys_id do usuário da integração no SN. // O nome do usuário que está resolvendo o chamado. const resolvedBySysId = 'b131d6ed1b2ba510c5e163923b4bcb77'; // sys_id do usuário que resolveu if (ticketType === 'incidente') { url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`; payload = { state: '6', // 6 = Resolved incident_state: '6', // 6 = Resolved resolved_by: resolvedBySysId, resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '), close_code: 'Closed/Resolved by caller', close_notes: closeNotes, work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}` }; } else if (ticketType === 'requisicao') { url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`; payload = { state: '6', resolved_by: resolvedBySysId, resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '), closeNotes: closeNotes, work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`, }; } else { logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType }); throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); } const response = await axios.patch(url, payload, { auth: apiConfig.servicenowAuthentication.auth, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Corrigido para usar snTicketId if (response.status !== 200) { logError(`❌ Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType }); throw new Error(`Falha ao fechar ticket no ServiceNow: ${response.status} - ${response.statusText}`); } logInfo(`✅ Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType }); return true; } catch (error) { logError(error, `🚨 Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`); return null; } }; const addWorkNoteToServiceNow = async (snTicketId, workNote) => { let sysId = null; try { const ticketSn = await TicketSnModel.findById(snTicketId); if (!ticketSn) { logError(`Ticket SN com ID ${snTicketId} não encontrado para adicionar work note.`, 'addWorkNoteToServiceNow'); return null; } sysId = ticketSn.sys_id; const ticketType = ticketSn.tipo; let url = ''; if (ticketType === 'incidente') { url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`; } else if (ticketType === 'requisicao') { url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`; } else { throw new Error(`Tipo de ticket desconhecido: ${ticketType}`); } const payload = { work_notes: workNote }; await axios.patch(url, payload, { auth: apiConfig.servicenowAuthentication.auth, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); logInfo(`✅ Work note adicionada com sucesso ao ticket SN ${sysId}.`); return true; } catch (error) { logError(error, `🚨 Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`); return null; } }; /** * Busca o valor de uma variável de catálogo (sc_item_option) para um Request Item (RITM) específico. * @param {string} ritmSysId - O sys_id do sc_req_item (RITM). * @param {string} variableName - O nome interno (name) da variável de catálogo. * @returns {Promise} O valor da variável ou null se não encontrada/erro. */ const fetchScItemOptionValue = async (ritmSysId, variableName) => { try { const baseUrl = apiConfig.snScItemOptionConfig.baseUrl.replace(/\/$/, ""); const url = `${baseUrl}?sysparm_query=request_item=${ritmSysId}^sc_item_option.item_option_new.question_textINPlease justify the need,Telephone,Select what you want&sysparm_fields=sc_item_option.item_option_new.question_text,sc_item_option.value`; const response = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); const result = response.data.result; return (result && result.length > 0) ? result : null; } catch (error) { logError(error, `🚨 Falha ao buscar valor da variável '${variableName}' para RITM ${ritmSysId}`); return null; } }; module.exports = { fetchTicketsFromServiceNow, fetchRequestsFromServiceNow, getTicketSysId, getTicketTypeById, createCommentInServiceNow, getCommentId, existingCommentInServiceNow, syncCommentToServiceNow, fetchCommentsFromServiceNow, fetchAndProcessClosedTicketsFromSN, closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow, fetchScItemOptionValue }; /** * @module servicenowService * @description Este serviço encapsula todas as interações com a API REST do ServiceNow. * Ele é responsável por buscar, criar e atualizar dados na plataforma ServiceNow. * * Principais Funcionalidades: * - **Busca de Tickets**: `fetchTicketsFromServiceNow` e `fetchRequestsFromServiceNow` buscam incidentes e requisições, respectivamente, utilizando uma marca d'água para obter apenas os registros novos ou atualizados. * - **Busca de Comentários (`fetchCommentsFromServiceNow`)**: Busca todos os comentários (journal entries) de um ticket, com lógica de paginação para lidar com grandes volumes de dados. Ele também filtra os comentários gerados pelo próprio sistema de integração para evitar loops. * - **Criação de Comentários**: `createCommentInServiceNow` e a função wrapper `syncCommentToServiceNow` adicionam comentários (work notes/comments) a um ticket existente no ServiceNow. * - **Atualização de Status**: `updateStatusInServiceNow` modifica o estado de um ticket (ex: para 'Em Atendimento'). * - **Fechamento de Tickets (`closeTicketInServiceNow`)**: Altera o estado de um ticket para "Resolvido", preenchendo os campos obrigatórios como notas de fechamento e data de resolução. * - **Adição de Notas (`addWorkNoteToServiceNow`)**: Adiciona uma nota de trabalho a um ticket sem alterar seu status, útil para registrar informações ou avisos. * - **Busca de Variáveis de Catálogo (`fetchScItemOptionValue`)**: Função especializada para buscar valores de variáveis em itens de requisição (RITMs), usada para enriquecer a descrição de tickets. * * Todas as funções de interação com a API são envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuração. */