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() {
const app = express();
app.use(express.json({ type: '*/*' }));
app.use('/api', router);
app.use('/api', router); // O router agora tem seu próprio middleware de JSON.
return app;
}

View File

@ -8,32 +8,40 @@ const { logInfo, logError } = require('../utils/logger.js');
*/
const closeTicket = async (req, res) => {
try {
let rawData = '';
const bodyRequest = req.body;
let rawData = '';
req.on('data', chunk => {
rawData += chunk;
});
req.on('data', chunk => {
rawData += chunk;
});
req.on('end', async () => {
let bodyRequest;
try {
bodyRequest = JSON.parse(rawData);
} catch (err) {
logError('Erro ao parsear JSON:', err);
bodyRequest = {};
req.on('end', async () => {
try {
if (!rawData) {
logError('Webhook de fechamento recebido com corpo vazio.');
return res.status(400).json({ error: 'Corpo da requisição ausente.' });
}
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;
logInfo(`Ticket ${ticketId} acionado para encerramento.`);
const closingTicket = await fechaTicket(bodyRequest);
res.status(200).json(closingTicket);
});
} catch (error) {
res.status(500).json({ error: error.message });
}
} catch (error) {
if (error instanceof SyntaxError) {
logError('Erro ao parsear JSON do webhook de fechamento:', 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').Response} res - O objeto de resposta do Express.
*/
async function handleNewComment(req, res) {
const body = req.body;
const glpiTicketId = body?.item?.items_id;
const commentContent = body?.item?.content;
let rawData = '';
if (!glpiTicketId || !commentContent) {
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.' });
}
req.on('data', chunk => {
rawData += chunk;
});
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 {
await commentService.syncGlpiCommentToHubsoft(glpiTicketId, commentContent);
res.status(200).json({ status: 'success', message: 'Comentário recebido e processado.' });
} catch (error) {
logError(`Erro ao processar webhook de comentário para o ticket GLPI ID ${glpiTicketId}:`, error);
res.status(500).json({ status: 'error', message: error.message });
}
const body = JSON.parse(rawData);
const { item } = body;
// Validação básica para garantir que os dados necessários existem.
if (!item || !item.items_id || !item.content) {
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 };

View File

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

View File

@ -61,8 +61,13 @@ class CommentModel {
INSERT INTO sync_comments (
sync_data_id, source_system, source_comment_id, content, author
)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (source_system, source_comment_id) DO NOTHING;
VALUES ($1, $2, $3, $4, $5)
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 = [
syncDataId,
@ -73,8 +78,14 @@ class CommentModel {
];
try {
await pool.query(insertQuery, values);
return true;
const result = await pool.query(insertQuery, values);
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) {
logError('Erro ao inserir comentário em sync_comments:', error);
throw error;
@ -125,6 +136,7 @@ class CommentModel {
// Não relançamos o erro para não parar o loop de sincronização.
}
}
}
module.exports = CommentModel;

View File

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

View File

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

View File

@ -1,9 +1,13 @@
const { Router } = require('express');
const express = require('express');
const closureController = require('./controller/closureController.js');
const commentController = require('./controller/commentController'); // Novo
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/new-comment', commentController.handleNewComment); // Nova rota

View File

@ -6,6 +6,7 @@ const hubglpiModel = require('../model/hubglpiModel.js');
const hubsoftService = require('./hubsoftService.js');
const { sanitizeGLPIComment } = require('../utils/commentSanitizer.js');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { log } = require('winston');
/**
* 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) {
try {
// 1. Inserir o comentário diretamente no banco de dados do GLPI
const newGlpiComment = await glpiModel.insertComment({
tickets_id: comment.glpi_ticket_id, // ID do ticket no GLPI
content: comment.content,
// Outros campos necessários, como autor, data, etc.
tickets_id: comment.glpi_ticket_id, //
content: comment.content
});
// Sucesso: atualiza o status no nosso banco
@ -88,28 +87,55 @@ async function sendPendingCommentsToGlpi() {
* @param {number} glpiTicketId - O ID do ticket no GLPI.
* @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
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.`);
// Não lançamos um erro, pois o comentário pode ser de um ticket não sincronizado.
return;
}
const hubsoftTicketId = syncRecord.hubsoftId;
const hubsoftTicketId = syncRecord.hubsoftid;
// 2. Sanitizar o comentário (remover HTML, etc.)
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.
// 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.`);
//3. Envia o comentario para o banco intermediário (sync_comments) com status 'pending'
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 };

View File

@ -88,20 +88,24 @@ async function addMensagem(id_atendimento, mensagem) {
const token = await getAuthToken();
// 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 };
try {
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, {
headers: {
'Authorization': `Bearer ${token}`,
'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) {
logError(`Erro ao adicionar mensagem no atendimento HubSoft ID ${id_atendimento}:`, error.response?.data || error.message);
throw error;