From 1bf3f744abca835e73d8e531b8fefe976184e550 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Mon, 12 Jan 2026 16:42:08 -0300 Subject: [PATCH] WIP: Criado Watchdog/Goleito para detectar chamados que estao fechados no hubsoft mas nao no GLPI, testes pendentes --- package-lock.json | 10 +++ package.json | 3 + src/infra/cron/observer.cron.js | 18 ++++ .../repositories/hubglpi/sync.repository.js | 13 ++- .../hubglpi/tickets.repository.js | 2 + .../hubglpi/watchdog.repository.js | 87 +++++++++++++++++++ .../hubsoft/tickets.repository.js | 35 +++++++- src/infra/mail/sender.js | 50 +++++++++++ src/modules/watchdog/job/job.js | 58 +++++++++++++ src/modules/watchdog/model/email.model.js | 72 +++++++++++++++ .../repository/watchdog.repository.js | 69 +++++++++++++++ src/shared/utils/sendEmail.js | 0 12 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 src/infra/cron/observer.cron.js create mode 100644 src/infra/db/repositories/hubglpi/watchdog.repository.js create mode 100644 src/infra/mail/sender.js create mode 100644 src/modules/watchdog/job/job.js create mode 100644 src/modules/watchdog/model/email.model.js create mode 100644 src/modules/watchdog/repository/watchdog.repository.js create mode 100644 src/shared/utils/sendEmail.js diff --git a/package-lock.json b/package-lock.json index 62580a5..cb26d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express": "^5.1.0", "mysql2": "^3.15.2", "node-cron": "^4.2.1", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "pm2": "^6.0.13", "qs": "^6.14.0", @@ -1890,6 +1891,15 @@ "node": ">=6.0.0" } }, + "node_modules/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", diff --git a/package.json b/package.json index 4853b7d..8f2864a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev:api": "cross-env NODE_ENV=development nodemon src/infra/http/server.js", "start:worker": "cross-env NODE_ENV=production node src/infra/cron/sync.cron.js", "dev:worker": "cross-env NODE_ENV=development nodemon src/infra/cron/sync.cron.js", + "start:watchdog": "cross-env NODE_ENV=production node src/infra/cron/observer.cron.js", + "dev:watchdog": "cross-env NODE_ENV=development nodemon src/infra/cron/observer.cron.js", "test": "echo \"Error: no test specified\" && exit 1", "postinstall": "node -e \"const fs = require('fs'); if (fs.existsSync('.env') && !fs.existsSync('.env.development')) { fs.copyFileSync('.env', '.env.development'); console.log('✅ .env.development criado a partir do .env'); }\"" }, @@ -19,6 +21,7 @@ "express": "^5.1.0", "mysql2": "^3.15.2", "node-cron": "^4.2.1", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "pm2": "^6.0.13", "qs": "^6.14.0", diff --git a/src/infra/cron/observer.cron.js b/src/infra/cron/observer.cron.js new file mode 100644 index 0000000..26771c3 --- /dev/null +++ b/src/infra/cron/observer.cron.js @@ -0,0 +1,18 @@ +// src/infra/cron/observer.cron.js + +const cron = require('node-cron') +const logger = require('../../shared/utils/logger') +const runWatchdog = require('../../modules/watchdog/job/job') + +logger.info('[CRON] 🐶 Watchdog cron iniciado') + +cron.schedule('*/30 * * * *', async () => { + logger.info('[CRON] 🐶 Watchdog executando verificação') + + try { + await runWatchdog() + } catch (error) { + logger.error('[CRON] ❌ Erro no Watchdog', { error }) + } +}) + \ No newline at end of file diff --git a/src/infra/db/repositories/hubglpi/sync.repository.js b/src/infra/db/repositories/hubglpi/sync.repository.js index ad6de75..9373db0 100644 --- a/src/infra/db/repositories/hubglpi/sync.repository.js +++ b/src/infra/db/repositories/hubglpi/sync.repository.js @@ -55,20 +55,27 @@ async function updateSyncDataCreated(hubsoftId, glpiId) { */ async function getSyncIdByHubsoftId(hubsoftTicketId) { const query = ` - SELECT id, glpi_ticket_id + SELECT + id, + glpi_ticket_id, + status_sync FROM sync_data WHERE hubsoft_ticket_id = $1; `; try { const { rows } = await db.query(query, [hubsoftTicketId]); - return rows[0] || null; + return rows.length ? rows[0] : null; } catch (err) { - logError('Erro ao buscar sync_data por hubsoft_ticket_id', err); + logError( + `Erro ao buscar sync_data para hubsoft_ticket_id=${hubsoftTicketId}`, + err + ); throw err; } } + async function lockTicketForClosing(syncId) { const query = ` UPDATE sync_data diff --git a/src/infra/db/repositories/hubglpi/tickets.repository.js b/src/infra/db/repositories/hubglpi/tickets.repository.js index fc7d656..bcb1081 100644 --- a/src/infra/db/repositories/hubglpi/tickets.repository.js +++ b/src/infra/db/repositories/hubglpi/tickets.repository.js @@ -160,6 +160,8 @@ async function updateCloseMessage(hubTicketId, message) { } + + module.exports = { insertTickets, fetchPendingTickets, diff --git a/src/infra/db/repositories/hubglpi/watchdog.repository.js b/src/infra/db/repositories/hubglpi/watchdog.repository.js new file mode 100644 index 0000000..180c6f9 --- /dev/null +++ b/src/infra/db/repositories/hubglpi/watchdog.repository.js @@ -0,0 +1,87 @@ +// src/infra/db/repositories/hubglpi/watchdog.repository.js + +const db = require('../connections/hubglpi.pg.js'); +const { logError } = require('../../../../shared/utils/logger.js'); + +/** + * Verifica se a notificação já foi enviada com sucesso + */ +async function notificationAlreadySent(hubsoftTicketId) { + const query = ` + SELECT 1 + FROM watchdog_notifications + WHERE ticket_id = $1 + AND status = 'SENT' + LIMIT 1; + `; + + try { + const { rowCount } = await db.query(query, [hubsoftTicketId]); + return rowCount > 0; + } catch (error) { + logError('Erro ao verificar notificação já enviada', error); + throw error; + } +} + +/** + * Marca tickets como notificados com sucesso + */ +async function markNotificationsAsSent(hubsoftTicketIds) { + if (!hubsoftTicketIds || hubsoftTicketIds.length === 0) { + return; + } + + const query = ` + INSERT INTO watchdog_notifications (ticket_id, notified_at, status) + SELECT + unnest($1::bigint[]), + NOW(), + 'SENT' + ON CONFLICT (ticket_id) + DO UPDATE SET + notified_at = EXCLUDED.notified_at, + status = 'SENT'; + `; + + try { + await db.query(query, [hubsoftTicketIds]); + } catch (error) { + logError('Erro ao marcar notificações como enviadas', error); + throw error; + } +} + +/** + * Marca tickets como falha de notificação + */ +async function markNotificationsAsFailed(hubsoftTicketIds) { + if (!hubsoftTicketIds || hubsoftTicketIds.length === 0) { + return; + } + + const query = ` + INSERT INTO watchdog_notifications (ticket_id, notified_at, status) + SELECT + unnest($1::bigint[]), + NOW(), + 'FAILED' + ON CONFLICT (ticket_id) + DO UPDATE SET + notified_at = EXCLUDED.notified_at, + status = 'FAILED'; + `; + + try { + await db.query(query, [hubsoftTicketIds]); + } catch (error) { + logError('Erro ao marcar notificações como falha', error); + throw error; + } +} + +module.exports = { + notificationAlreadySent, + markNotificationsAsSent, + markNotificationsAsFailed +}; diff --git a/src/infra/db/repositories/hubsoft/tickets.repository.js b/src/infra/db/repositories/hubsoft/tickets.repository.js index 738722f..8d83d61 100644 --- a/src/infra/db/repositories/hubsoft/tickets.repository.js +++ b/src/infra/db/repositories/hubsoft/tickets.repository.js @@ -95,7 +95,40 @@ async function getTicketsByTipo({ } } +async function getTicketsClosedSince(thresholdDate) { + try { + const mundialeUserId = process.env.HUBSOFT_MUNDIALE_USER_ID; + + if (!mundialeUserId) { + throw new Error('HUBSOFT_MUNDIALE_USER_ID não definido no .env'); + } + + const query = ` + SELECT + id_atendimento, + protocolo, + data_fechamento AS hubsoft_closed_at + FROM atendimento + WHERE id_usuario_abertura = $1 + AND data_fechamento > $2 + `; + + const params = [ + Number(mundialeUserId), + thresholdDate + ]; + + const { rows } = await db.query(query, params); + return rows; + + } catch (error) { + logError('Erro ao buscar tickets fechados HubSoft (Watchdog)', error); + throw error; + } +} + module.exports = { - getTicketsByTipo + getTicketsByTipo, + getTicketsClosedSince } diff --git a/src/infra/mail/sender.js b/src/infra/mail/sender.js new file mode 100644 index 0000000..55985e3 --- /dev/null +++ b/src/infra/mail/sender.js @@ -0,0 +1,50 @@ +// src/infra/mail/sender.js + +const nodemailer = require('nodemailer'); +const logger = require('../../shared/utils/logger.js'); + +const transporter = nodemailer.createTransport({ + host: process.env.MAIL_HOST, + port: Number(process.env.MAIL_PORT), + secure: process.env.MAIL_SECURE === 'true', + auth: { + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASS + } +}); + + +async function send({ subject, bodyEmail, recipients, cc = [] }) { + if (!recipients || !recipients.length) { + throw new Error('[MAIL] Nenhum destinatário informado'); + } + + if (!subject) { + throw new Error('[MAIL] Assunto do email não informado'); + } + + if (!bodyEmail) { + throw new Error('[MAIL] Corpo do email não informado'); + } + + const mailOptions = { + from: process.env.MAIL_FROM, + to: recipients.join(','), + cc: cc.length ? cc.join(',') : undefined, + subject, + html: bodyEmail + }; + + try { + const info = await transporter.sendMail(mailOptions); + + logger.info(`[MAIL] Email enviado com sucesso. MessageId=${info.messageId}`); + + return info; + } catch (error) { + logger.error('[MAIL] Erro ao enviar email', error); + throw error; + } +} + +module.exports = { send }; \ No newline at end of file diff --git a/src/modules/watchdog/job/job.js b/src/modules/watchdog/job/job.js new file mode 100644 index 0000000..272199d --- /dev/null +++ b/src/modules/watchdog/job/job.js @@ -0,0 +1,58 @@ +// src/modules/watchdog/job/job.js + +const logger = require('../../../shared/utils/logger') +const repository = require('../repository/watchdog.repository.js') +const model = require('../model/email.model.js') + +async function runWatchdog() { + + logger.info('[WATCHDOG] [JOB] Coletando chamados fechados há 31 minutos') + + const thresholdDate = new Date(Date.now() - 31 * 60 * 1000) + + const closedTickets = await repository.getClosedTicketsSince(thresholdDate) + logger.info(`[WATCHDOG] [JOB] Encontrados ${closedTickets.length} chamados fechados`) + + for (const ticket of closedTickets) { + + const hubGlpiTicket = await repository.checkTicketInHubGlpi(ticket.id_atendimento) + + if (!hubGlpiTicket.exists) { + logger.info(`[WATCHDOG] [JOB] Chamado ${ticket.id_atendimento} não encontrado no HubGlpi. Ignorando.`) + continue + } + + if (hubGlpiTicket.status === 'closed') { + logger.info(`[WATCHDOG] [JOB] Chamado ${ticket.id_atendimento} já está fechado no HubGlpi. Ignorando.`) + continue + } + if (await repository.notificationAlreadySent(ticket.id_atendimento)) { + logger.info(`[WATCHDOG] [JOB] Notificação já enviada para o chamado ${ticket.id_atendimento}.`) + continue + } + await repository.insertAsPending(ticket.id_atendimento) + + } + const ticketsToNotify = await repository.getPendingTicketsForNotification() + logger.info(`[WATCHDOG] [JOB] ${ticketsToNotify.length} chamados pendentes para notificação.`) + + const payload = await model.prepareNotificationPayload(ticketsToNotify) + + if (!payload.length) { + logger.info('[WATCHDOG] [JOB] Nenhuma notificação para enviar') + return + } + + try { + await repository.sendClosureNotifications(payload) + await repository.markNotificationsAsSent(ticketsToNotify) + } catch (err) { + logger.error('[WATCHDOG] Erro ao enviar notificações', err) + await repository.markNotificationsAsFailed(ticketsToNotify) + } + + logger.info(`[WATCHDOG] [JOB] Enviadas ${payload.length} notificações`) +} + + +module.exports = { runWatchdog} \ No newline at end of file diff --git a/src/modules/watchdog/model/email.model.js b/src/modules/watchdog/model/email.model.js new file mode 100644 index 0000000..a085998 --- /dev/null +++ b/src/modules/watchdog/model/email.model.js @@ -0,0 +1,72 @@ +// src/modules/watchdog/model/email.model.js + +function prepareNotificationPayload(tickets) { + return { + subject: buildSubject(tickets), + bodyEmail: buildBodyEmail(tickets), + recipients: getRecipients(), + cc: getCc() + }; +} + +function buildSubject(tickets) { + + if (tickets.length === 0) { + return '[GOLEIRO] Nenhum chamado foi agarrado'; + } + + return `🧤 [GOLEIRO] ${tickets.length} chamados foram agarrados`; +} + +function buildBodyEmail(tickets) { + let body = ` +

🚨 Atenção!

+

O goleiro defendeu os seguintes chamados:

+

Esses chamados foram fechados no Hubsoft, mas ainda constam como abertos no GLPI.

+
+ +
+

⚽ Favor verificar e alinhar os status no GLPI.

+

Watchdog Hub × GLPI

+ `; + + return body; +} + + +function formatDate(date) { + try { + return new Date(date).toLocaleString('pt-BR'); + } catch { + return date; + } +} + + +function getRecipients() { + return process.env.WATCHDOG_RECIPIENT_EMAILS?.split(',') || []; +} + +function getCc() { + return process.env.WATCHDOG_CC_EMAILS?.split(',') || []; +} + + +module.exports = { + prepareNotificationPayload +}; \ No newline at end of file diff --git a/src/modules/watchdog/repository/watchdog.repository.js b/src/modules/watchdog/repository/watchdog.repository.js new file mode 100644 index 0000000..33669e2 --- /dev/null +++ b/src/modules/watchdog/repository/watchdog.repository.js @@ -0,0 +1,69 @@ +// Facade de acesso a dados do Watchdog +// Centraliza interações com Hubsoft, HubGLPI e controle de notificações + +const wdRepository = require('../../../infra/db/repositories/hubglpi/watchdog.repository.js'); +const hubsoftTicketsRepo = require('../../../infra/db/repositories/hubsoft/tickets.repository.js'); +const hubglpiSyncRepo = require('../../../infra/db/repositories/hubglpi/sync.repository.js'); + +/** + * Busca tickets encerrados no Hubsoft a partir de uma data + */ +async function getClosedTicketsSince(thresholdDate) { + return hubsoftTicketsRepo.getTicketsClosedSince(thresholdDate); +} + +/** + * Verifica se um ticket do Hubsoft existe no GLPI e seu status de sync + */ +async function checkTicketInHubGlpi(hubsoftTicketId) { + const syncData = await hubglpiSyncRepo.getSyncByHubsoftTicketId(hubsoftTicketId); + + if (!syncData) { + return { + exists: false + }; + } + + return { + exists: true, + glpiTicketId: syncData.glpi_ticket_id, + status: syncData.status_sync + }; +} + +/** + * Verifica se a notificação de encerramento já foi enviada + */ +async function notificationAlreadySent(hubsoftTicketId) { + return wdRepository.notificationAlreadySent(hubsoftTicketId); +} + +/** + * Envia notificações de encerramento + */ +async function sendClosureNotifications(payload) { + return enviadordeEmailNaoseicomoserafeito.sendClosureNotifications(payload); +} + +/** + * Marca notificações como enviadas com sucesso + */ +async function markNotificationsAsSent(hubsoftTicketIds) { + return wdRepository.markNotificationsAsSent(hubsoftTicketIds); +} + +/** + * Marca notificações como falhas + */ +async function markNotificationsAsFailed(hubsoftTicketIds, error = null) { + return wdRepository.markNotificationsAsFailed(hubsoftTicketIds, error); +} + +module.exports = { + getClosedTicketsSince, + checkTicketInHubGlpi, + notificationAlreadySent, + sendClosureNotifications, + markNotificationsAsSent, + markNotificationsAsFailed +}; diff --git a/src/shared/utils/sendEmail.js b/src/shared/utils/sendEmail.js new file mode 100644 index 0000000..e69de29