From fe4462c3237dd53adc6f5377201961a832255bcb Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Fri, 14 Nov 2025 10:34:38 -0300 Subject: [PATCH] =?UTF-8?q?WIP:=20L=C3=B3gica=20de=20sincroniza=C3=A7?= =?UTF-8?q?=C3=A3o=20bidirecional=20criada.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .env.development | 6 +- src/controller/commentController.js | 31 +++++++ src/cron.js | 19 +++- src/model/commentModel.js | 130 ++++++++++++++++++++++++++++ src/model/commentSyncModel.js | 0 src/model/glpiModel.js | 19 +++- src/model/hubsoftModel.js | 24 ++++- src/routes.js | 9 +- src/scripts/data/database.sql | 43 +++++++-- src/services/commentService.js | 115 ++++++++++++++++++++++++ src/services/hubsoftService.js | 34 +++++++- 11 files changed, 408 insertions(+), 22 deletions(-) create mode 100644 src/controller/commentController.js create mode 100644 src/model/commentModel.js create mode 100644 src/model/commentSyncModel.js create mode 100644 src/services/commentService.js diff --git a/.env.development b/.env.development index 5913d57..cac7532 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/src/controller/commentController.js b/src/controller/commentController.js new file mode 100644 index 0000000..2ae298f --- /dev/null +++ b/src/controller/commentController.js @@ -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 }; \ No newline at end of file diff --git a/src/cron.js b/src/cron.js index 011b60c..4fcb4ae 100644 --- a/src/cron.js +++ b/src/cron.js @@ -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 { diff --git a/src/model/commentModel.js b/src/model/commentModel.js new file mode 100644 index 0000000..668e6ad --- /dev/null +++ b/src/model/commentModel.js @@ -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} 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>} 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; diff --git a/src/model/commentSyncModel.js b/src/model/commentSyncModel.js new file mode 100644 index 0000000..e69de29 diff --git a/src/model/glpiModel.js b/src/model/glpiModel.js index c7212d3..9bb5e39 100644 --- a/src/model/glpiModel.js +++ b/src/model/glpiModel.js @@ -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; \ No newline at end of file +module.exports = GlpiModel; diff --git a/src/model/hubsoftModel.js b/src/model/hubsoftModel.js index 4b9d197..b05cfc1 100644 --- a/src/model/hubsoftModel.js +++ b/src/model/hubsoftModel.js @@ -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 }; \ No newline at end of file diff --git a/src/routes.js b/src/routes.js index 9c0c6a0..c8386fa 100644 --- a/src/routes.js +++ b/src/routes.js @@ -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; \ No newline at end of file diff --git a/src/scripts/data/database.sql b/src/scripts/data/database.sql index c3ed9d0..4c249b6 100644 --- a/src/scripts/data/database.sql +++ b/src/scripts/data/database.sql @@ -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'); + diff --git a/src/services/commentService.js b/src/services/commentService.js new file mode 100644 index 0000000..43f6342 --- /dev/null +++ b/src/services/commentService.js @@ -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 }; diff --git a/src/services/hubsoftService.js b/src/services/hubsoftService.js index 907f374..d185b87 100644 --- a/src/services/hubsoftService.js +++ b/src/services/hubsoftService.js @@ -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} 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 };