Merge branch 'feature/watchdog-mundiale-closure'

This commit is contained in:
Rafael Alves Lopes 2026-01-15 09:33:36 -03:00
commit 3ea1511734
11 changed files with 530 additions and 6 deletions

12
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 })
}
})

View File

@ -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

View File

@ -160,6 +160,8 @@ async function updateCloseMessage(hubTicketId, message) {
}
module.exports = {
insertTickets,
fetchPendingTickets,

View File

@ -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 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
};

View File

@ -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
}

49
src/infra/mail/sender.js Normal file
View File

@ -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 };

View File

@ -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}

View File

@ -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 = `
<p>🚨 <strong>Atenção!</strong></p>
<p>O goleiro defendeu os seguintes chamados:</p>
<p>Esses chamados foram <strong>fechados no Hubsoft</strong>, mas ainda constam como <strong>abertos no GLPI</strong>.</p>
<br>
<ul>
`;
tickets.forEach(ticket => {
body += `
<li>
Protocolo Hubsoft: <strong>${ticket.protocolo_hub}</strong><br>
Mundiale ID: <strong>${ticket.ticket_mundiale}</strong><br>
GLPI ID: <strong>${ticket.glpi_ticket_id ?? 'não encontrado'}</strong><br>
Fechado em: ${formatDate(ticket.closed_at)}
</li>
<br>
`;
});
body += `
</ul>
<br>
<p> Favor verificar e alinhar os status no GLPI.</p>
<p><em>Watchdog Hub × GLPI</em></p>
`;
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
};

View File

@ -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 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
};