// 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'); // Função para buscar incidentes do ServiceNow 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; } } //Função para buscar requsições do ServiceNow 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; } } // Função para obter o sys_id de um ticket pela tabela de sincronização do banco de dados local 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; } } // Função para obter o tipo de um ticket pela tabela de sincronização do banco de dados local 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; } } // Função para obter o código de um comentário dentro de um ticket do ServiceNow 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; } } // Função para criar um comentário em um ticket no ServiceNow 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'); } } // Função para criar um comentário 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; } // Função que verifica se o comentário já existe no ServiceNow antes de criar 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; } } // Função para buscar todos os comentários de tickets no ServiceNow a partir do ticket id no banco local // Deve-se atentar para a paginação da API do ServiceNow 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 []; } }; // Adiciona no final do arquivo src/services/servicenowService.js 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'); } }; //Atualiza o status (state) de um ticket no ServiceNow (incidente ou requisição). 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; } }; /** * Resolve um ticket no ServiceNow (incidente ou requisição), * atualizando o estado para 'Resolved' (6) e enviando as notas de resolução. */ 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; } }; /** * Adiciona uma work_note a um ticket no ServiceNow sem alterar seu status. */ 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, createCommentInServiceNow, getCommentId, existingCommentInServiceNow, syncCommentToServiceNow, fetchCommentsFromServiceNow, fetchAndProcessClosedTicketsFromSN, closeTicketInServiceNow, // Adicionei a vírgula aqui updateStatusInServiceNow, // Adicionei a vírgula aqui addWorkNoteToServiceNow, // Adicionei a vírgula aqui fetchScItemOptionValue // Nova função };