snglpi/src/services/servicenowService.js

560 lines
23 KiB
JavaScript
Raw Normal View History

// src/services/servicenowService.js
2025-08-30 19:04:35 -03:00
const axios = require('axios');
const apiConfig = require('../../config/apiConfig');
const TicketSnModel = require('../models/ticketSnModel');
const { logInfo, logError, logSync } = require('../utils/logger');
2025-10-10 08:12:26 -03:00
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const { log } = require('winston');
2025-08-30 19:04:35 -03:00
const fetchTicketsFromServiceNow = async (watermark) => {
2025-08-30 19:04:35 -03:00
try {
// A watermark vem como string 'YYYY-MM-DD HH:MM:SS'. Converte para Date.
const watermarkDate = new Date(watermark);
// Ajusta para o fuso horário correto se necessário e formata para a query do SN.
const watermarkBRT = new Date(watermarkDate.getTime()); // Se precisar de ajuste de fuso, faça aqui.
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 query = `assignment_group=1132c9e01bfad410e77eb9121b4bcbab^sys_updated_on>=${formattedWatermark}`;
const url = `${apiConfig.snTableIncidentConfig.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; // Lança o erro para que o controller não atualize a watermark
}
}
const fetchRequestsFromServiceNow = async (watermark) => {
try {
// A tabela de requisições (sc_req_item) não possui o campo 'active'. A query deve ser apenas pela data.
// Construímos a query completa aqui, combinando o grupo de atribuição com a watermark.
const assignmentGroupSysId = '1132c9e01bfad410e77eb9121b4bcbab'; // Sys ID do grupo 'Sothis'
const query = `assignment_group=${assignmentGroupSysId}^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';
// Garante que a URL base não tenha uma barra no final para evitar URL malformada.
const baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, "");
//const url = `${baseUrl}?sysparm_query=${query}&sysparm_display_value=true&sysparm_fields=${fields}`;
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 || [];
2025-08-30 19:04:35 -03:00
} catch (error) {
logError(error, 'SERVICE NOW Requests');
throw error; // Lança o erro
2025-08-30 19:04:35 -03:00
}
}
2025-08-30 19:04:35 -03:00
// 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
// para fins de teste, gerar um arquivo JSON com os comentários de um ticket específico
const fetchCommentsFromServiceNow = async (ticket) => {
try {
logInfo(`Iniciando busca de comentários do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
2025-10-10 08:12:26 -03:00
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 || [];
2025-10-13 06:06:52 -03:00
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 [];
}
2025-10-13 06:06:52 -03:00
logInfo(`Encontrado ${filteredComments.length} comentários para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
const commentsInserted = [];
2025-10-10 08:12:26 -03:00
// Insere cada comment no banco intermediario
for (const comment of filteredComments) {
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
2025-10-10 08:12:26 -03:00
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',
2025-10-10 08:12:26 -03:00
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
};
2025-10-13 06:06:52 -03:00
const commentInserted = await TicketUpdateModel.insert(commentData);
2025-10-10 08:12:26 -03:00
if (commentInserted.success) {
commentsInserted.push(commentData);
logInfo(`✅ Comentário ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
2025-10-10 08:12:26 -03:00
} else {
logError(`❌ Erro ao inserir comentário ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
return [];
2025-10-10 08:12:26 -03:00
}
}
}
return commentsInserted;
} catch (error) {
logError(error, '🚨Falha ao buscar comentários do ServiceNow');
return [];
}
};
// Adiciona no final do arquivo src/services/servicenowService.js
const { stripHTML } = require('../utils/commentSanitizer');
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);
2025-10-10 08:12:26 -03:00
// 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');
2025-10-10 08:12:26 -03:00
}
}
} 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}`,
};
console.log(url);
console.log(payload);
} 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<string|null>} 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;
}
};
2025-08-30 19:04:35 -03:00
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
};