diff --git a/package-lock.json b/package-lock.json index 62580a5..2cdad64 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", @@ -2080,7 +2090,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -3104,7 +3113,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", 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..b4fdf05 --- /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 {logError, logInfo} = require('../../shared/utils/logger') +const runWatchdog = require('../../modules/watchdog/job/job') + +logInfo('[CRON] 🐶 Watchdog cron iniciado') + +cron.schedule('*/30 * * * *', async () => { + logInfo('[CRON] 🐶 Watchdog executando verificação') + + try { + await runWatchdog() + } catch (error) { + logError('[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..700ad30 --- /dev/null +++ b/src/infra/db/repositories/hubglpi/watchdog.repository.js @@ -0,0 +1,147 @@ +// 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 pendente de notificação + */ +async function markNotificationsAsPending(hubsoftTicketIds) { + if (!hubsoftTicketIds || hubsoftTicketIds.length === 0) { + return; + } + + const query = ` + INSERT INTO watchdog_notifications (ticket_id, notified_at, status) + SELECT + unnest($1::bigint[]), + NOW(), + 'pending' + ON CONFLICT (ticket_id) + DO UPDATE SET + notified_at = EXCLUDED.notified_at, + status = 'pending'; + `; + + try { + await db.query(query, [hubsoftTicketIds]); + } catch (error) { + logError('Erro ao marcar notificações como falha', error); + throw error; + } +} + + +/** + * Marca tickets como notificados com sucesso + */ +async function markNotificationsAsSent(hubsoftTicketIds) { + const ids = (hubsoftTicketIds || []) + .map(id => Number(id)) + .filter(Number.isFinite); + + if (ids.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, [ids]); + } catch (error) { + logError('[WATCHDOG][REPOSITORY] Erro ao marcar notificações como enviadas', error); + throw error; + } +} + +/** + * Marca tickets como falha de notificação + */ +async function markNotificationsAsFailed(hubsoftTicketIds) { + const ids = (hubsoftTicketIds || []) + .map(id => Number(id)) + .filter(Number.isFinite); + + if (ids.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 = 'failed'; + `; + + try { + await db.query(query, [ids]); + } catch (error) { + logError('[WATCHDOG][REPOSITORY] Erro ao marcar notificações como enviadas', error); + throw error; + } +} + + +async function getPendingTicketsForNotification() { + const query = ` + SELECT + wn.ticket_id AS hubsoft_ticket_id, + ht.protocolo_hub as protocolo_hub, + ht.ticket_mundiale AS ticket_mundiale, + wn.notified_at AS closed_at, + sd.glpi_ticket_id AS glpi_ticket_id + FROM watchdog_notifications wn + INNER JOIN sync_data sd + ON wn.ticket_id = sd.hubsoft_ticket_id + INNER JOIN hubsoft_tickets ht + ON wn.ticket_id = ht.id_atendimento + WHERE wn.status = 'pending'; + `; + + try { + const { rows } = await db.query(query); + return rows; + } catch (error) { + logError('Erro ao buscar tickets pendentes para notificação', error); + throw error; + } +} + +module.exports = { + notificationAlreadySent, + markNotificationsAsSent, + markNotificationsAsFailed, + markNotificationsAsPending, + getPendingTicketsForNotification +}; diff --git a/src/infra/db/repositories/hubsoft/tickets.repository.js b/src/infra/db/repositories/hubsoft/tickets.repository.js index 738722f..4668b60 100644 --- a/src/infra/db/repositories/hubsoft/tickets.repository.js +++ b/src/infra/db/repositories/hubsoft/tickets.repository.js @@ -95,7 +95,64 @@ async function getTicketsByTipo({ } } +async function getTicketsClosedSince(thresholdDate) { + try { + + if (process.env.HUBSOFT_MOCK_ENABLED === 'true') { + return [ + { + id_atendimento: 2780, + protocolo: '20260106155510498970', + hubsoft_closed_at: new Date('2026-01-06T08:20:45') + }, + { + id_atendimento: 2769, + protocolo: '20260105170715994323', + hubsoft_closed_at: new Date('2026-01-06T10:02:13') + }, + { + id_atendimento: 2779, + protocolo: '20260106145016864639', + hubsoft_closed_at: new Date('2025-12-18T14:35:56') + } + ] + } + + + + + 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..caf4df4 --- /dev/null +++ b/src/infra/mail/sender.js @@ -0,0 +1,49 @@ +// src/infra/mail/sender.js + +const nodemailer = require('nodemailer'); +const {logError, logInfo} = require('../../shared/utils/logger.js'); + +const transporter = nodemailer.createTransport({ + host: process.env.MAIL_HOST, + port: Number(process.env.MAIL_PORT), + secure: Number(process.env.MAIL_PORT) === 465, // 465 = TLS direto; 587/25 = STARTTLS + tls: { + rejectUnauthorized: false, // aceita self-signed + }, +}); + + +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); + + logInfo(`[MAIL] Email enviado com sucesso. MessageId=${info.messageId}`); + + return info; + } catch (error) { + logError('[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..92d95f9 --- /dev/null +++ b/src/modules/watchdog/job/job.js @@ -0,0 +1,67 @@ +// src/modules/watchdog/job/job.js + +require('dotenv').config({ path: '.env.development' }) + +const { logError, logInfo } = require('../../../shared/utils/logger') +const repository = require('../repository/watchdog.repository.js') +const model = require('../model/email.model.js') + +async function runWatchdog() { + + logInfo('[WATCHDOG] [JOB] Coletando chamados fechados há 31 minutos') + + const thresholdDate = new Date(Date.now() - 31 * 60 * 1000) + + const closedTickets = await repository.getClosedTicketsSince(thresholdDate) + logInfo(`[WATCHDOG] [JOB] Encontrados ${closedTickets.length} chamados fechados`) + + for (const ticket of closedTickets) { + + const hubGlpiTicket = await repository.checkTicketInHubGlpi(ticket.id_atendimento) + + if (!hubGlpiTicket.exists) { + logInfo(`[WATCHDOG] [JOB] Chamado ${ticket.id_atendimento} não encontrado no HubGlpi. Ignorando.`) + continue + } + + if (hubGlpiTicket.status === 'closed') { + logInfo(`[WATCHDOG] [JOB] Chamado ${ticket.id_atendimento} já está fechado no HubGlpi. Ignorando.`) + continue + } + if (await repository.notificationAlreadySent(ticket.id_atendimento)) { + logInfo(`[WATCHDOG] [JOB] Notificação já enviada para o chamado ${ticket.id_atendimento}.`) + continue + } + await repository.markNotificationsAsPending([ticket.id_atendimento]) + + } + const ticketsToNotify = await repository.getPendingTicketsForNotification() + logInfo(`[WATCHDOG] [JOB] ${ticketsToNotify.length} chamados pendentes para notificação.`) + + if (!ticketsToNotify.length) { + logInfo('[WATCHDOG] [JOB] Nenhum chamado pendente para notificação') + return + } + + const payload = await model.prepareNotificationPayload(ticketsToNotify) + const hubsoftTicketIds = ticketsToNotify.map(t => Number(t.hubsoft_ticket_id)).filter(Number.isFinite); + + try { + await repository.sendClosureNotifications(payload) + + + await repository.markNotificationsAsSent(hubsoftTicketIds) + } catch (err) { + logError('[WATCHDOG] Erro ao enviar notificações', err) + await repository.markNotificationsAsFailed(hubsoftTicketIds) + } + + logInfo(`[WATCHDOG] [JOB] Enviadas ${ticketsToNotify.length} notificações`) +} + + +runWatchdog().catch((error) => { + logError('[WATCHDOG] [JOB] Erro ao executar o job do Watchdog', error) +}) + +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..1e97d27 --- /dev/null +++ b/src/modules/watchdog/model/email.model.js @@ -0,0 +1,83 @@ +// 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(value) { + if (!value) return 'não informado'; + + const d = new Date(value); + if (Number.isNaN(d.getTime())) return String(value); // fallback seguro + + return d.toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }); +} + + + +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..7a78998 --- /dev/null +++ b/src/modules/watchdog/repository/watchdog.repository.js @@ -0,0 +1,83 @@ +// 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'); +const senderMail = require('../../../infra/mail/sender.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.getSyncIdByHubsoftId(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 senderMail.send(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); +} + +/** + * Marca notificações como pendentes + */ +async function markNotificationsAsPending(hubsoftTicketId, error = null) { + return wdRepository.markNotificationsAsPending(hubsoftTicketId, error); +} + +async function getPendingTicketsForNotification() { + return wdRepository.getPendingTicketsForNotification(); +} + +module.exports = { + getClosedTicketsSince, + checkTicketInHubGlpi, + notificationAlreadySent, + sendClosureNotifications, + markNotificationsAsSent, + markNotificationsAsFailed, + markNotificationsAsPending, + getPendingTicketsForNotification +};