WIP: Lógica de sincronização bidirecional criada.

-  Tabelas de updates criada e adicionado ao script
-  Tabela de marca d'água criada
-  Fluxo de coleta de mensagens do HubSoft criado
-  Fluxo de coleta de comentários do GLPI criado.
This commit is contained in:
Rafael Alves Lopes 2025-11-14 10:34:38 -03:00
parent 4d931bdddf
commit fe4462c323
11 changed files with 408 additions and 22 deletions

View File

@ -39,9 +39,9 @@ HUBGLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W
# BANCO DE DADOS FINAL - GLPI (MySQL - Desenvolvimento)
# ==============================================================================
GLPI_DB_TYPE=mysql
GLPI_DB_HOST=177.73.177.32
GLPI_DB_HOST=177.73.177.44
GLPI_DB_PORT=3306
GLPI_DB_USER=snglpi
GLPI_DB_PASSWORD=j2633669
GLPI_DB_USER=desenvolvimento
GLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W
GLPI_DB_NAME=glpi_data
GLPI_DB_CHARSET=utf8mb4

View File

@ -0,0 +1,31 @@
// src/controller/commentController.js
const commentService = require('../services/commentService.js');
const { logInfo, logError, logWarning } = require('../utils/logger.js');
/**
* Controller para lidar com o webhook de novo comentário do GLPI.
* @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;
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.' });
}
logInfo(`Webhook de novo comentário recebido para o ticket GLPI ID ${glpiTicketId}.`);
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 });
}
}
module.exports = { handleNewComment };

View File

@ -3,23 +3,34 @@ loadEnv();
const cron = require('node-cron');
const { processaAtendimentos } = require('./controller/processController.js');
const commentService = require('./services/commentService.js'); // 1. Importar o novo serviço
const { logInfo, logError } = require('./utils/logger.js');
let isCronRunning = false;
logInfo('⏰ Agendando cron job para processar atendimentos a cada 5 minutos.');
logInfo('⏰ Agendando cron job para processar atendimentos a cada 1 minuto.');
cron.schedule('*/5 * * * *', async () => {
cron.schedule('* * * * *', async () => {
if (isCronRunning) {
logInfo('CRON: Tentativa de início, mas o processo anterior ainda está em execução. Pulando esta rodada.');
return;
}
isCronRunning = true;
logInfo('CRON: Iniciando processamento de atendimentos...');
logInfo('CRON: Iniciando ciclo de sincronização...');
try {
// --- Tarefa 1: Sincronizar criação de tickets ---
logInfo('CRON (Etapa 1/2): Processando criação de tickets...');
await processaAtendimentos();
logInfo('CRON: Processamento concluído com sucesso.');
logInfo('CRON (Etapa 1/2): Criação de tickets concluída.');
// --- Tarefa 2: Sincronizar comentários ---
logInfo('CRON (Etapa 2/2): Processando sincronização de comentários...');
await commentService.syncHubsoftCommentsToLocalDB();
await commentService.sendPendingCommentsToGlpi(); // E outras direções se necessário
logInfo('CRON (Etapa 2/2): Sincronização de comentários concluída.');
logInfo('CRON: Ciclo de sincronização concluído com sucesso.');
} catch (error) {
logError('CRON: Erro durante o processamento de atendimentos.', error);
} finally {

130
src/model/commentModel.js Normal file
View File

@ -0,0 +1,130 @@
const { logError, logInfo } = require('../utils/logger');
const pool = require('../data/hubglpiDataBase');
class CommentModel {
/**
* Busca o timestamp da última execução de um job de sincronização.
* @param {string} jobName - O nome do job (ex: 'hubsoft_comments_sync').
* @returns {Promise<Date>} O timestamp da última execução.
*/
static async getLastRunTimestamp(jobName) {
const query = 'SELECT last_run_timestamp FROM sync_control WHERE job_name = $1';
try {
const { rows } = await pool.query(query, [jobName]);
if (rows.length === 0) {
throw new Error(`Job de controle '${jobName}' não encontrado na tabela sync_control.`);
}
return rows[0].last_run_timestamp;
} catch (error) {
logError(`Erro ao buscar último timestamp para o job '${jobName}':`, error);
throw error;
}
}
/**
* Atualiza o timestamp da última execução de um job de sincronização.
* @param {string} jobName - O nome do job.
* @param {Date} timestamp - O novo timestamp.
*/
static async updateLastRunTimestamp(jobName, timestamp) {
const query = 'UPDATE sync_control SET last_run_timestamp = $1 WHERE job_name = $2';
try {
await pool.query(query, [timestamp, jobName]);
} catch (error) {
logError(`Erro ao atualizar o timestamp para o job '${jobName}':`, error);
throw error;
}
}
/**
* Insere um novo comentário na fila de sincronização.
* Usa ON CONFLICT para evitar duplicatas com base no sistema de origem e ID do comentário.
* @param {object} commentData - Dados do comentário.
* @param {number} commentData.hubsoftAtendimentoId - ID do atendimento no HubSoft.
* @param {string} commentData.sourceSystem - 'hubsoft' ou 'glpi'.
* @param {string} commentData.sourceCommentId - ID do comentário na origem.
* @param {string} commentData.content - Conteúdo do comentário.
* @param {string} [commentData.author] - Autor do comentário.
*/
static async insertComment(commentData) {
// Primeiro, precisamos do ID do sync_data correspondente ao atendimento do HubSoft.
const syncDataQuery = 'SELECT id FROM sync_data WHERE hubsoft_ticket_id = $1';
const syncDataRes = await pool.query(syncDataQuery, [commentData.hubsoftAtendimentoId]);
if (syncDataRes.rows.length === 0) {
logInfo(`Nenhum registro de sincronização encontrado para o atendimento HubSoft ID ${commentData.hubsoftAtendimentoId}. Comentário não será inserido.`);
return false;
}
const syncDataId = syncDataRes.rows[0].id;
const insertQuery = `
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;
`;
const values = [
syncDataId,
commentData.sourceSystem,
commentData.sourceCommentId,
commentData.content,
commentData.author
];
try {
await pool.query(insertQuery, values);
return true;
} catch (error) {
logError('Erro ao inserir comentário em sync_comments:', error);
throw error;
}
}
/**
* Busca comentários pendentes para um sistema de destino específico.
* @param {string} destinationSystem - O sistema de destino ('glpi' ou 'hubsoft').
* @returns {Promise<Array<object>>} Uma lista de comentários pendentes.
*/
static async getPendingCommentsForDestination(destinationSystem) {
const sourceSystem = destinationSystem === 'glpi' ? 'hubsoft' : 'glpi';
const query = `
SELECT sc.id, sc.content, sc.author, sd.glpi_ticket_id, sd.hubsoft_ticket_id
FROM sync_comments sc
JOIN sync_data sd ON sc.sync_data_id = sd.id
WHERE sc.source_system = $1
AND sc.sync_status = 'pending_sync'
AND sc.sync_attempts < 5; -- Evita retentativas infinitas
`;
const { rows } = await pool.query(query, [sourceSystem]);
return rows;
}
/**
* Atualiza o status de sincronização de um comentário.
* @param {number} commentId - O ID do comentário em sync_comments.
* @param {string} status - O novo status ('synced', 'sync_error').
* @param {string|null} destinationId - O ID do comentário no sistema de destino.
* @param {string|null} errorMessage - A mensagem de erro, se houver.
*/
static async updateCommentSyncStatus(commentId, status, destinationId = null, errorMessage = null) {
const query = `
UPDATE sync_comments
SET
sync_status = $1,
destination_comment_id = $2,
error_message = $3,
sync_attempts = sync_attempts + 1,
updated_at = NOW()
WHERE id = $4;
`;
try {
await pool.query(query, [status, destinationId, errorMessage, commentId]);
} catch (error) {
logError(`Erro ao atualizar status do comentário ID ${commentId}:`, error);
// Não relançamos o erro para não parar o loop de sincronização.
}
}
}
module.exports = CommentModel;

View File

View File

@ -123,7 +123,20 @@ class GlpiModel {
throw err;
}
}
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()];
try {
const [rows] = await pool.query(query, values);
return rows && rows.insertId ? { id: rows.insertId } : null;
}
catch (err) {
logError(`Erro ao inserir comentário no GLPI: ${err}`);
throw err;
}
}
}
module.exports = GlpiModel;
module.exports = GlpiModel;

View File

@ -49,6 +49,27 @@ const validateMensagensByAtendimento = async (id_atendimento) => {
return rows;
}
const getNewMessagesFromDB = async (lastFetchTimestamp) => {
// Busca novas mensagens e o id_atendimento correspondente
const query = `
SELECT
m.id_atendimento_mensagem,
m.id_atendimento,
m.mensagem,
m.data_cadastro
FROM
atendimento_mensagem AS m
INNER JOIN
atendimento AS a ON m.id_atendimento = a.id_atendimento
WHERE
m.data_cadastro > $1
AND a.id_tipo_atendimento = 4
AND a.id_usuario_abertura = 248;
`;
const { rows } = await pool.query(query, [lastFetchTimestamp]);
return rows;
}
const updateFechaAtendimento = async (id_atendimento, closingMessage) => {
const query = `
UPDATE atendimento
@ -76,5 +97,6 @@ module.exports = {
getAtendimentosFromDB,
validateAtendimentoStatus,
validateMensagensByAtendimento,
updateFechaAtendimento // Exportando a função
updateFechaAtendimento, // Exportando a função
getNewMessagesFromDB
};

View File

@ -1,8 +1,13 @@
const { Router } = require('express');
const ticketController = require('./controller/closureController.js');
const closureController = require('./controller/closureController.js');
const commentController = require('./controller/commentController'); // Novo
const router = Router();
router.post('/close-ticket', ticketController.closeTicket);
router.post('/webhook/close-ticket', closureController.closeTicket);
router.post('/webhook/new-comment', commentController.handleNewComment); // Nova rota
module.exports = router;

View File

@ -31,14 +31,6 @@ CREATE TABLE hubsoft_tickets (
-- =============================================
CREATE TYPE source_last_enum AS ENUM ('hubsoft', 'glpi');
CREATE TYPE status_sync_enum AS ENUM (
'pending_create',
'created_glpi',
'pending_close',
'closed_glpi',
'sync_error',
'need_update'
);
CREATE TABLE sync_data (
id SERIAL PRIMARY KEY,
@ -54,8 +46,43 @@ CREATE TABLE sync_data (
UNIQUE (hubsoft_ticket_id, glpi_ticket_id)
);
-- =============================================
-- TABELA: sync_comments
-- DESCRIÇÃO: Armazena o estado de comentarios entre HubSoft e GLPI
-- =============================================
CREATE TABLE sync_comments (
id SERIAL PRIMARY KEY,
sync_data_id INTEGER NOT NULL REFERENCES sync_data(id),
source_system VARCHAR(20) NOT NULL, -- 'hubsoft' ou 'glpi'
source_comment_id VARCHAR(255) NOT NULL, -- ID do comentário no sistema de origem
destination_comment_id VARCHAR(255), -- ID do comentário no sistema de destino
content TEXT NOT NULL,
author VARCHAR(255), -- Nome do autor do comentário (opcional, mas útil)
sync_status VARCHAR(50) NOT NULL DEFAULT 'pending_sync', -- ex: 'pending_sync', 'synced', 'sync_error'
sync_attempts INTEGER DEFAULT 0,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índice para evitar duplicatas e otimizar buscas
CREATE UNIQUE INDEX idx_unique_source_comment ON sync_comments(source_system, source_comment_id);
CREATE INDEX idx_sync_status ON sync_comments(sync_status, sync_attempts);
-- =============================================
-- TABELA: sync_data
-- DESCRIÇÃO: Armazena o estado de comentarios entre HubSoft e GLPI
-- =============================================
CREATE TABLE sync_control (
job_name VARCHAR(100) PRIMARY KEY,
last_run_timestamp TIMESTAMPTZ NOT NULL
);
-- Inserir o registro inicial para o nosso novo cron de comentários
INSERT INTO sync_control (job_name, last_run_timestamp) VALUES ('hubsoft_comments_sync', '2024-01-01 00:00:00');

View File

@ -0,0 +1,115 @@
// src/services/commentService.js
const hubsoftModel = require('../model/hubsoftModel.js');
const commentModel = require('../model/commentModel.js'); // a ser criado
const glpiModel = require('../model/glpiModel.js');
const hubglpiModel = require('../model/hubglpiModel.js');
const hubsoftService = require('./hubsoftService.js');
const { sanitizeGLPIComment } = require('../utils/commentSanitizer.js');
const { logInfo, logError, logWarning } = require('../utils/logger');
/**
* Busca novos comentários no HubSoft e os salva no banco intermediário.
*/
async function syncHubsoftCommentsToLocalDB() {
logInfo('Iniciando sincronização de comentários do HubSoft...');
const lastRun = await commentModel.getLastRunTimestamp('hubsoft_comments_sync');
const newMessages = await hubsoftModel.getNewMessagesFromDB(lastRun);
if (newMessages.length === 0) {
logInfo('Nenhum comentário novo encontrado no HubSoft.');
return;
}
logInfo(`Encontrados ${newMessages.length} novos comentários no HubSoft.`);
for (const message of newMessages) {
try {
// A função insertComment deve ter uma cláusula ON CONFLICT para não duplicar
const commentInserted = await commentModel.insertComment({
hubsoftAtendimentoId: message.id_atendimento,
sourceSystem: 'hubsoft',
sourceCommentId: message.id_atendimento_mensagem,
content: message.mensagem,
// author: ... se disponível
});
if (commentInserted){
logInfo(`Comentário HubSoft ID ${message.id_atendimento_mensagem} salvo para sincronização.`);
}else{
logInfo(`Chamado HubSoft ID ${message.id_atendimento} não possui registro de sincronização. Comentário ignorado.`);
}
} catch (error) {
// Se o erro for de violação de chave única, apenas ignoramos.
if (error.code !== '23505') { // Código de erro do PostgreSQL para unique_violation
logError(`Erro ao salvar comentário HubSoft ID ${message.id_atendimento_mensagem}:`, error);
}
}
}
// Atualiza o timestamp da última execução
await commentModel.updateLastRunTimestamp('hubsoft_comments_sync', new Date());
logInfo('Sincronização de comentários do HubSoft para o banco local concluída.');
}
/**
* Envia comentários pendentes do banco local para o GLPI.
*/
async function sendPendingCommentsToGlpi() {
// Busca comentários com origem 'hubsoft' que estão pendentes de envio para o GLPI
const pendingComments = await commentModel.getPendingCommentsForDestination('glpi');
if (pendingComments.length === 0) {
return; // Nenhum comentário para processar
}
logInfo(`Encontrados ${pendingComments.length} comentários pendentes para envio ao GLPI.`);
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.
});
// Sucesso: atualiza o status no nosso banco
await commentModel.updateCommentSyncStatus(comment.id, 'synced', newGlpiComment.id);
logInfo(`Comentário ID ${comment.id} sincronizado com sucesso para o GLPI. Novo ID de comentário no GLPI: ${newGlpiComment.id}`);
} catch (error) {
// Falha: atualiza o status para erro e incrementa a tentativa no nosso banco
await commentModel.updateCommentSyncStatus(comment.id, 'sync_error', null, error.message);
logError(`Falha ao sincronizar comentário ID ${comment.id} para o GLPI:`, error);
}
}
}
/**
* Recebe um novo comentário do GLPI (via webhook) e o envia para o HubSoft.
* @param {number} glpiTicketId - O ID do ticket no GLPI.
* @param {string} rawContent - O conteúdo bruto do comentário.
*/
async function syncGlpiCommentToHubsoft(glpiTicketId, rawContent) {
// 1. Encontrar o registro de sincronização para obter o ID do HubSoft
const syncRecord = await hubglpiModel.getIdByGlpiID(glpiTicketId);
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;
// 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.`);
}
module.exports = { syncHubsoftCommentsToLocalDB, sendPendingCommentsToGlpi, syncGlpiCommentToHubsoft };

View File

@ -77,7 +77,39 @@ const closeAtendimento = async (id_atendimento, closingMessage) => {
}
};
/**
* Adiciona uma nova mensagem a um atendimento no HubSoft.
* @param {number} id_atendimento - O ID do atendimento no HubSoft.
* @param {string} mensagem - O conteúdo da mensagem a ser adicionada.
* @returns {Promise<object>} A resposta da API do HubSoft.
*/
async function addMensagem(id_atendimento, mensagem) {
// 1. Obter o token de autenticação
const token = await getAuthToken();
// 2. Construir a URL completa do endpoint
const url = `${apiConfig.hubsoft.baseUrl}/integracao/atendimento/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
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;
} catch (error) {
logError(`Erro ao adicionar mensagem no atendimento HubSoft ID ${id_atendimento}:`, error.response?.data || error.message);
throw error;
}
}
module.exports = {
updateAtendimentoStatus,
closeAtendimento
closeAtendimento,
addMensagem
};