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:
parent
d359880f10
commit
14949bf4df
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,33 +8,41 @@ 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 {
|
||||||
bodyRequest = JSON.parse(rawData);
|
if (!rawData) {
|
||||||
} catch (err) {
|
logError('Webhook de fechamento recebido com corpo vazio.');
|
||||||
logError('Erro ao parsear JSON:', err);
|
return res.status(400).json({ error: 'Corpo da requisição ausente.' });
|
||||||
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) {
|
} 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 });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
module.exports = { closeTicket };
|
module.exports = { closeTicket };
|
||||||
|
|||||||
@ -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) {
|
|
||||||
const body = req.body;
|
|
||||||
const glpiTicketId = body?.item?.items_id;
|
|
||||||
const commentContent = body?.item?.content;
|
|
||||||
|
|
||||||
if (!glpiTicketId || !commentContent) {
|
async function handleNewComment(req, res) {
|
||||||
|
let rawData = '';
|
||||||
|
|
||||||
|
req.on('data', chunk => {
|
||||||
|
rawData += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
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.' });
|
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}.`);
|
const glpiTicketId = item.items_id;
|
||||||
|
const commentContent = item.content;
|
||||||
|
const messageId = item.id;
|
||||||
|
|
||||||
try {
|
logInfo(`Webhook de novo comentário recebido para o ticket GLPI ID ${glpiTicketId}.`);
|
||||||
await commentService.syncGlpiCommentToHubsoft(glpiTicketId, commentContent);
|
await commentService.syncGlpiCommentToHubsoft(glpiTicketId, messageId, commentContent);
|
||||||
res.status(200).json({ status: 'success', message: 'Comentário recebido e processado.' });
|
res.status(200).json({ status: 'success', message: 'Comentário recebido e processado.' });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`Erro ao processar webhook de comentário para o ticket GLPI ID ${glpiTicketId}:`, 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 });
|
res.status(500).json({ status: 'error', message: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { handleNewComment };
|
module.exports = { handleNewComment };
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user