// 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 resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77'; 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} nao encontrado.`, 'getTicketSysId'); return null; } return ticket.sys_id; } catch (error) { logError(error, 'ERRO: 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} nao encontrado.`, 'getTicketTypeById'); return null; } const ticketType = ticket.tipo; logInfo(`INFO: Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType }); return ticketType; } catch (error) { logError(error, 'ERRO: 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 comentario 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 comentario encontrado } } } catch (error) { logError(error, 'ERRO: Falha ao obter ID do comentario 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(`ERRO: Falha ao criar comentario. Status: ${response.status}`, { sysId, ticketType }); return null; } // retornar id do comentario criado const commentId = await getCommentId(sysId, comment); if (!commentId) { logError('ERRO: Comentario criado, mas nao foi possivel obter o ID do comentario', { sysId }); return null; } logInfo(`OK: Comentario criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType }); return commentId; } catch (error) { logError(error, 'ERRO: Falha ao criar comentario no ServiceNow'); } } const syncCommentToServiceNow = async (ticketId, comment) => { logInfo(`Iniciando sincronizacao do comentario para o ticket SN Ticket: ${ticketId}`); const snSysId = await getTicketSysId(ticketId); const ticketType = await getTicketTypeById(ticketId); if (!snSysId) { logError(`ERRO: sys_id do ServiceNow nao 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, 'ERRO: Falha ao verificar existencia do comentario no ServiceNow'); return false; } } const fetchCommentsFromServiceNow = async (ticket) => { try { logInfo(`Iniciando busca de comentarios 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 pagina ${page} de comentarios 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; // nao ha mais comentarios } allComments.push(...comments); if (comments.length < pageSize) { break; // ultima pagina } // avancar para proxima pagina offset += pageSize; // protecao 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 nao sao do sys_created_by: "SOTHIS.CAOA" const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA'); if (filteredComments.length === 0) { logInfo(`Nenhum comentario encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`); return []; } logInfo(`Encontrado ${filteredComments.length} comentarios 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(`Comentario: ${comment.sys_id} ja 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(`OK: Comentario ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 }); } else { logError(`ERRO: Erro ao inserir comentario ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 }); return []; } } } return commentsInserted; } catch (error) { logError(error, 'ERRO: Falha ao buscar comentarios do ServiceNow'); return []; } }; const fetchAndProcessClosedTicketsFromSN = async () => { try { logInfo('INFO: Verificando status de tickets sincronizados no ServiceNow...'); // 1. Busca apenas os tickets que estao 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 sincronizacao de fechamento...`); // Sanitiza o conteudo 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 intermediario. 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, 'ERRO: Falha ao buscar chamados encerrados do ServiceNow'); } }; const updateStatusInServiceNow = async (snTicketId, state) => { // Mapeamento de status de string para codigo numerico 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 codigo mapeado ou o valor original se nao encontrar let sysId = null; // Declarar sysId fora do try para estar disponivel no catch try { const ticketSn = await TicketSnModel.findById(snTicketId); if (!ticketSn) { logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizacao 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 }; // Requisicoes 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(`ERRO: 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(`OK: Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode }); return true; } catch (error) { logError(error, `ERRO: 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} nao encontrado para fechamento.`, 'closeTicketInServiceNow'); return null; } sysId = ticketSn.sys_id; const ticketType = ticketSn.tipo; let url = ''; // Declarar url fora do if let payload = {}; 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 Tecnico 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 Tecnico 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(`ERRO: 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(`OK: Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType }); return true; } catch (error) { logError(error, `ERRO: 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} nao 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(`OK: Work note adicionada com sucesso ao ticket SN ${sysId}.`); return true; } catch (error) { logError(error, `ERRO: Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`); return null; } }; /** * Busca o valor de uma variavel de catalogo (sc_item_option) para um Request Item (RITM) especifico. * @param {string} ritmSysId - O sys_id do sc_req_item (RITM). * @param {string} variableName - O nome interno (name) da variavel de catalogo. * @returns {Promise} O valor da variavel ou null se nao 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.item_option_new.name,sc_item_option.value`; const response = await axios.get(url, { auth: apiConfig.servicenowAuthentication.auth }); const result = response.data.result || []; if (result.length === 0) return null; const normalize = (value) => { if (value === null || value === undefined) return ''; if (typeof value === 'string') return value; if (typeof value === 'object') { return value.value || value.display_value || ''; } return String(value); }; return result.map((item) => ({ question_text: normalize(item['sc_item_option.item_option_new.question_text']), variable_name: normalize(item['sc_item_option.item_option_new.name']), value: normalize(item['sc_item_option.value']) })); } catch (error) { logError(error, `ERRO: Falha ao buscar valor da variavel '${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 servico encapsula todas as interacoes com a API REST do ServiceNow. * Ele e responsavel por buscar, criar e atualizar dados na plataforma ServiceNow. * * Principais Funcionalidades: * - **Busca de Tickets**: `fetchTicketsFromServiceNow` e `fetchRequestsFromServiceNow` buscam incidentes e requisicoes, respectivamente, utilizando uma marca d'agua para obter apenas os registros novos ou atualizados. * - **Busca de Comentarios (`fetchCommentsFromServiceNow`)**: Busca todos os comentarios (journal entries) de um ticket, com logica de paginacao para lidar com grandes volumes de dados. Ele tambem filtra os comentarios gerados pelo proprio sistema de integracao para evitar loops. * - **Criacao de Comentarios**: `createCommentInServiceNow` e a funcao wrapper `syncCommentToServiceNow` adicionam comentarios (work notes/comments) a um ticket existente no ServiceNow. * - **Atualizacao 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 obrigatorios como notas de fechamento e data de resolucao. * - **Adicao de Notas (`addWorkNoteToServiceNow`)**: Adiciona uma nota de trabalho a um ticket sem alterar seu status, util para registrar informacoes ou avisos. * - **Busca de Variaveis de Catalogo (`fetchScItemOptionValue`)**: Funcao especializada para buscar valores de variaveis em itens de requisicao (RITMs), usada para enriquecer a descricao de tickets. * * Todas as funcoes de interacao com a API sao envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuracao. */