FEAT: Sincronia de comentarios bidirecional finalizada

- Comentários oriundos do GLPI estão sincronizando com Hub através de um webhook
- Comentários oriundos do Hub estão sincronizando com GLPI através de um CronJob
- Validação de comentário existente sendo realizada através do banco de dados intermediário
- Ainda há a necessidade função via nodecron para que verifique os chamados syncError com retry menor igual a 3
This commit is contained in:
Rafael Alves Lopes 2025-12-02 17:43:51 -03:00
parent d359880f10
commit 14949bf4df
11 changed files with 141 additions and 64 deletions

View File

@ -4,8 +4,7 @@ const router = require('./routes.js')
function createApp() { function createApp() {
const app = express(); const app = express();
app.use(express.json({ type: '*/*' })); app.use('/api', router); // O router agora tem seu próprio middleware de JSON.
app.use('/api', router);
return app; return app;
} }

View File

@ -8,32 +8,40 @@ const { logInfo, logError } = require('../utils/logger.js');
*/ */
const closeTicket = async (req, res) => { const closeTicket = async (req, res) => {
try { let rawData = '';
let rawData = '';
const bodyRequest = req.body;
req.on('data', chunk => { req.on('data', chunk => {
rawData += chunk; rawData += chunk;
}); });
req.on('end', async () => { req.on('end', async () => {
let bodyRequest; try {
try { if (!rawData) {
bodyRequest = JSON.parse(rawData); logError('Webhook de fechamento recebido com corpo vazio.');
} catch (err) { return res.status(400).json({ error: 'Corpo da requisição ausente.' });
logError('Erro ao parsear JSON:', err); }
bodyRequest = {};
const bodyRequest = JSON.parse(rawData);
if (!bodyRequest || !bodyRequest.item || !bodyRequest.item.items_id) {
logError('Webhook de fechamento recebido com dados incompletos.', bodyRequest);
return res.status(400).json({ error: 'Dados do ticket ausentes no corpo da requisição.' });
} }
const ticketId = bodyRequest.item.items_id; const ticketId = bodyRequest.item.items_id;
logInfo(`Ticket ${ticketId} acionado para encerramento.`); logInfo(`Ticket ${ticketId} acionado para encerramento.`);
const closingTicket = await fechaTicket(bodyRequest); const closingTicket = await fechaTicket(bodyRequest);
res.status(200).json(closingTicket); res.status(200).json(closingTicket);
}); } catch (error) {
if (error instanceof SyntaxError) {
} catch (error) { logError('Erro ao parsear JSON do webhook de fechamento:', error.message);
res.status(500).json({ error: error.message }); res.status(400).json({ status: 'error', message: 'Corpo da requisição não é um JSON válido.' });
} } else {
logError(`Erro ao processar webhook de fechamento:`, error);
res.status(500).json({ error: error.message });
}
}
});
} }

View File

@ -7,25 +7,49 @@ const { logInfo, logError, logWarning } = require('../utils/logger.js');
* @param {import('express').Request} req - O objeto de requisição do Express. * @param {import('express').Request} req - O objeto de requisição do Express.
* @param {import('express').Response} res - O objeto de resposta do Express. * @param {import('express').Response} res - O objeto de resposta do Express.
*/ */
async function handleNewComment(req, res) { async function handleNewComment(req, res) {
const body = req.body; let rawData = '';
const glpiTicketId = body?.item?.items_id;
const commentContent = body?.item?.content;
if (!glpiTicketId || !commentContent) { req.on('data', chunk => {
logWarning('Webhook de novo comentário recebido com dados incompletos.', body); rawData += chunk;
return res.status(400).json({ error: 'Dados do comentário ou ID do ticket ausentes.' }); });
}
logInfo(`Webhook de novo comentário recebido para o ticket GLPI ID ${glpiTicketId}.`); req.on('end', async () => {
try {
if (!rawData) {
logWarning('Webhook de novo comentário recebido com corpo vazio.');
return res.status(400).json({ error: 'Corpo da requisição ausente.' });
}
try { const body = JSON.parse(rawData);
await commentService.syncGlpiCommentToHubsoft(glpiTicketId, commentContent); const { item } = body;
res.status(200).json({ status: 'success', message: 'Comentário recebido e processado.' });
} catch (error) { // Validação básica para garantir que os dados necessários existem.
logError(`Erro ao processar webhook de comentário para o ticket GLPI ID ${glpiTicketId}:`, error); if (!item || !item.items_id || !item.content) {
res.status(500).json({ status: 'error', message: error.message }); logWarning('Webhook de novo comentário recebido com dados incompletos.', body);
} return res.status(400).json({ error: 'Dados do comentário ou ID do ticket ausentes.' });
}
const glpiTicketId = item.items_id;
const commentContent = item.content;
const messageId = item.id;
logInfo(`Webhook de novo comentário recebido para o ticket GLPI ID ${glpiTicketId}.`);
await commentService.syncGlpiCommentToHubsoft(glpiTicketId, messageId, commentContent);
res.status(200).json({ status: 'success', message: 'Comentário recebido e processado.' });
} catch (error) {
// Verifica se o erro é de parsing de JSON
if (error instanceof SyntaxError) {
logError('Erro ao parsear JSON do webhook de comentário:', error.message);
res.status(400).json({ status: 'error', message: 'Corpo da requisição não é um JSON válido.' });
} else {
logError(`Erro ao processar webhook de comentário:`, error);
res.status(500).json({ status: 'error', message: error.message });
}
}
});
} }
module.exports = { handleNewComment }; module.exports = { handleNewComment };

View File

@ -4,6 +4,7 @@ const hubglpiModel = require('../model/hubglpiModel.js');
const glpiModel = require('../model/glpiModel.js'); const glpiModel = require('../model/glpiModel.js');
const { logError, logInfo } = require('../utils/logger'); const { logError, logInfo } = require('../utils/logger');
// ================================================================================ // ================================================================================
// Constantes e Configurações // Constantes e Configurações
// ================================================================================ // ================================================================================
@ -112,7 +113,7 @@ const formatTicketDataForGlpi = async (ticketData) => {
...ticketData, ...ticketData,
status_atendimento: statusAtendimentoGLPI[ticketData.status_atendimento] || 1, status_atendimento: statusAtendimentoGLPI[ticketData.status_atendimento] || 1,
date_mod: new Date(), date_mod: new Date(),
user_id_recipient: 1100, user_id_recipient: process.env.GLPI_USER || 1,
descricao_abertura: formatDescription(ticketData), descricao_abertura: formatDescription(ticketData),
urgency: 3, urgency: 3,
impact: 3, impact: 3,

View File

@ -62,7 +62,12 @@ class CommentModel {
sync_data_id, source_system, source_comment_id, content, author sync_data_id, source_system, source_comment_id, content, author
) )
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (source_system, source_comment_id) DO NOTHING; ON CONFLICT (source_system, source_comment_id)
DO UPDATE SET
-- Atualização "falsa" para forçar o RETURNING a funcionar no conflito.
-- Isso não altera o valor, mas permite obter o ID da linha existente.
source_comment_id = EXCLUDED.source_comment_id
RETURNING id, sync_status;
`; `;
const values = [ const values = [
syncDataId, syncDataId,
@ -73,8 +78,14 @@ class CommentModel {
]; ];
try { try {
await pool.query(insertQuery, values); const result = await pool.query(insertQuery, values);
return true; if (result.rows.length > 0) {
// Retorna o objeto inteiro com { id, sync_status }
return result.rows[0];
} else {
// Este bloco agora é menos provável de ser atingido, a menos que haja um problema diferente.
return false;
}
} catch (error) { } catch (error) {
logError('Erro ao inserir comentário em sync_comments:', error); logError('Erro ao inserir comentário em sync_comments:', error);
throw error; throw error;
@ -125,6 +136,7 @@ class CommentModel {
// Não relançamos o erro para não parar o loop de sincronização. // Não relançamos o erro para não parar o loop de sincronização.
} }
} }
} }
module.exports = CommentModel; module.exports = CommentModel;

View File

@ -124,11 +124,8 @@ class GlpiModel {
} }
} }
static async insertComment(commentData) { static async insertComment(commentData) {
const query = ` const query = `INSERT INTO glpi_itilfollowups(itemtype, items_id, date, users_id, content, date_creation, date_mod) VALUES('Ticket',? , NOW(), ?, ?, NOW(), NOW())`;
INSERT INTO glpi_tickets_comments (tickets_id, content, date_creation) const values = [commentData.tickets_id, parseInt(process.env.GLPI_USER), commentData.content];
VALUES (?, ?, ?)
`;
const values = [commentData.tickets_id, commentData.content, new Date()];
try { try {
const [rows] = await pool.query(query, values); const [rows] = await pool.query(query, values);
return rows && rows.insertId ? { id: rows.insertId } : null; return rows && rows.insertId ? { id: rows.insertId } : null;

View File

@ -144,7 +144,9 @@ class HubglpiModel {
FROM sync_data FROM sync_data
WHERE glpi_ticket_id = $1; WHERE glpi_ticket_id = $1;
`; `;
const values = [glpi_ticket_id]; const values = [parseInt(glpi_ticket_id)];
try { try {
const { rows } = await pool.query(query, values); const { rows } = await pool.query(query, values);
if (rows.length > 0) { if (rows.length > 0) {

View File

@ -1,9 +1,13 @@
const { Router } = require('express'); const { Router } = require('express');
const express = require('express');
const closureController = require('./controller/closureController.js'); const closureController = require('./controller/closureController.js');
const commentController = require('./controller/commentController'); // Novo const commentController = require('./controller/commentController'); // Novo
const router = Router(); const router = Router();
// Aplica o middleware de parsing de JSON para todas as rotas deste router.
// Força o parsing como JSON mesmo que o Content-Type não seja application/json.
router.use(express.json({ type: '*/*' }));
router.post('/webhook/close-ticket', closureController.closeTicket); router.post('/webhook/close-ticket', closureController.closeTicket);
router.post('/webhook/new-comment', commentController.handleNewComment); // Nova rota router.post('/webhook/new-comment', commentController.handleNewComment); // Nova rota

View File

@ -6,6 +6,7 @@ const hubglpiModel = require('../model/hubglpiModel.js');
const hubsoftService = require('./hubsoftService.js'); const hubsoftService = require('./hubsoftService.js');
const { sanitizeGLPIComment } = require('../utils/commentSanitizer.js'); const { sanitizeGLPIComment } = require('../utils/commentSanitizer.js');
const { logInfo, logError, logWarning } = require('../utils/logger'); const { logInfo, logError, logWarning } = require('../utils/logger');
const { log } = require('winston');
/** /**
* Busca novos comentários no HubSoft e os salva no banco intermediário. * Busca novos comentários no HubSoft e os salva no banco intermediário.
@ -65,11 +66,9 @@ async function sendPendingCommentsToGlpi() {
for (const comment of pendingComments) { for (const comment of pendingComments) {
try { try {
// 1. Inserir o comentário diretamente no banco de dados do GLPI
const newGlpiComment = await glpiModel.insertComment({ const newGlpiComment = await glpiModel.insertComment({
tickets_id: comment.glpi_ticket_id, // ID do ticket no GLPI tickets_id: comment.glpi_ticket_id, //
content: comment.content, content: comment.content
// Outros campos necessários, como autor, data, etc.
}); });
// Sucesso: atualiza o status no nosso banco // Sucesso: atualiza o status no nosso banco
@ -88,28 +87,55 @@ async function sendPendingCommentsToGlpi() {
* @param {number} glpiTicketId - O ID do ticket no GLPI. * @param {number} glpiTicketId - O ID do ticket no GLPI.
* @param {string} rawContent - O conteúdo bruto do comentário. * @param {string} rawContent - O conteúdo bruto do comentário.
*/ */
async function syncGlpiCommentToHubsoft(glpiTicketId, rawContent) { async function syncGlpiCommentToHubsoft(glpiTicketId, glpiMessageId, rawContent) {
// 1. Encontrar o registro de sincronização para obter o ID do HubSoft // 1. Encontrar o registro de sincronização para obter o ID do HubSoft
const syncRecord = await hubglpiModel.getIdByGlpiID(glpiTicketId); const syncRecord = await hubglpiModel.getIdByGlpiID(glpiTicketId);
if (!syncRecord || !syncRecord.hubsoftId) { if (!syncRecord || !syncRecord.hubsoftid) {
logWarning(`Recebido comentário para o ticket GLPI ID ${glpiTicketId}, mas não há registro de sincronização correspondente. Ignorando.`); logWarning(`Recebido comentário para o ticket GLPI ID ${glpiTicketId}, mas não há registro de sincronização correspondente. Ignorando.`);
// Não lançamos um erro, pois o comentário pode ser de um ticket não sincronizado. // Não lançamos um erro, pois o comentário pode ser de um ticket não sincronizado.
return; return;
} }
const hubsoftTicketId = syncRecord.hubsoftId; const hubsoftTicketId = syncRecord.hubsoftid;
// 2. Sanitizar o comentário (remover HTML, etc.) // 2. Sanitizar o comentário (remover HTML, etc.)
const sanitizedContent = sanitizeGLPIComment({ content: rawContent }); const sanitizedContent = sanitizeGLPIComment({ content: rawContent });
// 3. Enviar o comentário para a API do HubSoft
logInfo(`Enviando comentário do GLPI Ticket ${glpiTicketId} para o HubSoft Atendimento ${hubsoftTicketId}.`);
await hubsoftService.addMensagem(hubsoftTicketId, sanitizedContent);
// Opcional: Salvar o comentário no banco intermediário (sync_comments) para ter um log. //3. Envia o comentario para o banco intermediário (sync_comments) com status 'pending'
// Isso pode ser útil para auditoria, mas não é estritamente necessário para o envio.
logInfo(`Comentário do GLPI Ticket ${glpiTicketId} enviado com sucesso para o HubSoft.`); try {
const commentRecord = await commentModel.insertComment({
hubsoftAtendimentoId: hubsoftTicketId,
sourceSystem: 'glpi',
sourceCommentId: glpiMessageId,
content: sanitizedContent
});
if (!commentRecord) {
throw new Error('Não foi possível inserir o comentário na fila de sincronização.');
}
// Se o comentário já existia e estava sincronizado, não fazemos nada.
if (commentRecord && commentRecord.sync_status === 'synced') {
logWarning(`Comentário do GLPI Ticket ${glpiTicketId} já existe e está sincronizado. Ignorando envio duplicado.`);
return;
}
// 4. Enviar o comentário para a API do HubSoft
logInfo(`Enviando comentário do GLPI Ticket ${glpiTicketId} para o HubSoft Atendimento ${hubsoftTicketId}.`);
const hubsoftMessageId = await hubsoftService.addMensagem(hubsoftTicketId, sanitizedContent);
logInfo(`Comentário do GLPI Ticket ${glpiTicketId} enviado com sucesso para o HubSoft. Novo ID de mensagem: ${hubsoftMessageId}`);
// 5. Atualizar o status no banco local com o ID do comentário e o ID da mensagem de destino
await commentModel.updateCommentSyncStatus(commentRecord.id, 'synced', hubsoftMessageId, null);
logInfo(`Comentário do GLPI Ticket ${glpiTicketId} (ID local: ${commentRecord.id}) marcado como sincronizado.`);
} catch (error) {
logError(`Erro no processo de sincronização do comentário do GLPI Ticket ${glpiTicketId}:`, error);
// Não relançamos o erro para não quebrar o webhook, mas o erro já foi logado.
}
} }
module.exports = { syncHubsoftCommentsToLocalDB, sendPendingCommentsToGlpi, syncGlpiCommentToHubsoft }; module.exports = { syncHubsoftCommentsToLocalDB, sendPendingCommentsToGlpi, syncGlpiCommentToHubsoft };

View File

@ -88,20 +88,24 @@ async function addMensagem(id_atendimento, mensagem) {
const token = await getAuthToken(); const token = await getAuthToken();
// 2. Construir a URL completa do endpoint // 2. Construir a URL completa do endpoint
const url = `${apiConfig.hubsoft.baseUrl}/integracao/atendimento/adicionar_mensagem/${id_atendimento}`; const url = `${apiConfig.hubsoft.atendimentosUrl}adicionar_mensagem/${id_atendimento}`;
const payload = { mensagem }; const payload = { mensagem };
try { try {
logInfo(`Enviando nova mensagem para o atendimento HubSoft ID ${id_atendimento}...`); logInfo(`Enviando nova mensagem para o atendimento HubSoft ID ${id_atendimento}...`);
// 3. Usar a instância 'axios' e passar o token no header
//PRint emulando o curl inteiro que será enviado
const response = await axios.post(url, payload, { const response = await axios.post(url, payload, {
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}); });
logInfo(`Resposta da API HubSoft para adição de mensagem: ${JSON.stringify(response.data)}`);
return response.data;
const idAtendimentoMensagem = response.data.atendimento_mensagem.id_atendimento_mensagem;
return idAtendimentoMensagem;
} catch (error) { } catch (error) {
logError(`Erro ao adicionar mensagem no atendimento HubSoft ID ${id_atendimento}:`, error.response?.data || error.message); logError(`Erro ao adicionar mensagem no atendimento HubSoft ID ${id_atendimento}:`, error.response?.data || error.message);
throw error; throw error;