FEATURE: Implementado serviço de notificação para Admin e colaboradores, Adicionado validador de ticket updade, e modificado email para envio bonito

This commit is contained in:
Rafael Alves Lopes 2026-01-20 17:36:28 -03:00
parent ad28159185
commit 53bdd1c5a5
9 changed files with 344 additions and 120 deletions

View File

@ -45,11 +45,28 @@ async function insertTicket(ticketData) {
const [result] = await db.query(query, values)
return result.insertId
} catch (err) {
logError('Erro ao inserir ticket no GLPI', err)
logError('[GLPI][REPOSITORY]Erro ao inserir ticket no GLPI', err)
throw err
}
}
async function checkTicketUpdateDate(glpiTicketId) {
const query = `
SELECT date_mod
FROM glpi_tickets
WHERE id = ?
`
try {
const [rows] = await db.query(query, [glpiTicketId])
return rows[0]
} catch (err) {
logError('[GLPI][REPOSITORY]Erro ao verificar data de atualização do ticket no GLPI', err)
throw err
}
}
module.exports = {
insertTicket
insertTicket,
checkTicketUpdateDate
}

View File

@ -17,7 +17,7 @@ function buildStatus(kind, type) {
/**
* Verifica se a notificaÇõÇœo ­ foi enviada com sucesso
*/
async function notificationAlreadySentFunc(hubsoftTicketId, type = 'func') {
async function notificationAlreadySent(hubsoftTicketId, type = 'func') {
const status = buildStatus('sent', type);
const query = `
SELECT 1
@ -38,12 +38,12 @@ async function notificationAlreadySentFunc(hubsoftTicketId, type = 'func') {
/**
* Marca tickets como pendente de notificaÇõÇœo
*/
async function markNotificationsAsPendingFunc(hubsoftTicketIds, type = 'func') {
async function markNotificationsAsPending(hubsoftTicketIds, type = 'func') {
if (!hubsoftTicketIds || hubsoftTicketIds.length === 0) {
return;
}
const status = buildStatus('pending', type);
const status = buildStatus('pending', type); // Define o status como 'pending_func' ou 'pending_adm'
const query = `
INSERT INTO watchdog_notifications (ticket_id, notified_at, status)
SELECT
@ -68,7 +68,7 @@ async function markNotificationsAsPendingFunc(hubsoftTicketIds, type = 'func') {
/**
* Marca tickets como notificados com sucesso
*/
async function markNotificationsAsSentFunc(hubsoftTicketIds, type = 'func') {
async function markNotificationsAsSent(hubsoftTicketIds, type = 'func') {
const ids = (hubsoftTicketIds || [])
.map(id => Number(id))
.filter(Number.isFinite);
@ -99,7 +99,7 @@ async function markNotificationsAsSentFunc(hubsoftTicketIds, type = 'func') {
/**
* Marca tickets como falha de notificaÇõÇœo
*/
async function markNotificationsAsFailedFunc(hubsoftTicketIds, type = 'func') {
async function markNotificationsAsFailed(hubsoftTicketIds, type = 'func') {
const ids = (hubsoftTicketIds || [])
.map(id => Number(id))
.filter(Number.isFinite);
@ -127,8 +127,39 @@ async function markNotificationsAsFailedFunc(hubsoftTicketIds, type = 'func') {
}
}
/**
* Marca tickets como fluxo concluido
*/
async function markNotificationsAsCompleted(hubsoftTicketIds, type = 'func') {
const ids = (hubsoftTicketIds || [])
.map(id => Number(id))
.filter(Number.isFinite);
async function getPendingTicketsForNotificationFunc(type = 'func') {
if (ids.length === 0) return;
const status = buildStatus('completed', type);
const query = `
INSERT INTO watchdog_notifications (ticket_id, notified_at, status)
SELECT
unnest($1::bigint[]),
NOW(),
$2
ON CONFLICT (ticket_id)
DO UPDATE SET
notified_at = EXCLUDED.notified_at,
status = $2;
`;
try {
await db.query(query, [ids, status]);
} catch (error) {
logError('[WATCHDOG][REPOSITORY] Erro ao marcar notificaÇÎÇæÇÎÇÝes como concluido', error);
throw error;
}
}
async function getPendingTicketsForNotification(type = 'func') {
const status = buildStatus('pending', type);
const query = `
SELECT
@ -154,10 +185,51 @@ async function getPendingTicketsForNotificationFunc(type = 'func') {
}
}
/**
* Busca chamados com status sent_func com notified_at anterior a thresholdDate
*/
async function getSentNotificationsBefore(thresholdDate, type = 'func') {
const status = buildStatus('sent', type);
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 = $1 AND wn.notified_at > $2;
`;
//
try {
const { rows } = await db.query(query, [status, thresholdDate]);
return rows;
} catch (error) {
logError('Erro ao buscar notificaÇõÇœes enviadas antes da data limite', error);
throw error;
}
}
async function getTicketsPendingResponse(sinceMinutes = 30, type = 'func') {
const thresholdDate = new Date(Date.now() - sinceMinutes * 60 * 1000);
return getSentNotificationsBefore(thresholdDate, type);
}
module.exports = {
notificationAlreadySent: notificationAlreadySentFunc,
markNotificationsAsSent: markNotificationsAsSentFunc,
markNotificationsAsFailed: markNotificationsAsFailedFunc,
markNotificationsAsPending: markNotificationsAsPendingFunc,
getPendingTicketsForNotification: getPendingTicketsForNotificationFunc
notificationAlreadySent,
markNotificationsAsSent,
markNotificationsAsFailed,
markNotificationsAsPending,
markNotificationsAsCompleted,
getPendingTicketsForNotification,
getSentNotificationsBefore,
getTicketsPendingResponse
};

View File

@ -101,18 +101,18 @@ async function getTicketsClosedSince(thresholdDate) {
if (process.env.HUBSOFT_MOCK_ENABLED === 'true') {
return [
{
id_atendimento: 2780,
protocolo: '20260106155510498970',
id_atendimento: 2949,
protocolo: '20260120133512641803',
hubsoft_closed_at: new Date('2026-01-06T08:20:45')
},
{
id_atendimento: 2769,
protocolo: '20260105170715994323',
id_atendimento: 2950,
protocolo: '20260120133618108141',
hubsoft_closed_at: new Date('2026-01-06T10:02:13')
},
{
id_atendimento: 2779,
protocolo: '20260106145016864639',
id_atendimento: 2955,
protocolo: '20260120134024457448',
hubsoft_closed_at: new Date('2025-12-18T14:35:56')
}
]

View File

@ -65,7 +65,13 @@ async function syncTicketsUseCase() {
if (!service) continue
const glpiId = await service.sendToGlpi(ticket)
if (process.env.NODE_ENV !== 'production') {
logInfo(`[USECASE] Ambiente de desenvolvimento. Ignorando notificação do ticket ${ticket.id_atendimento}`)
continue
}
await notifyTicketCreated.notifyTicketCreated(ticket.id_atendimento, glpiId)
} catch (err) {
logError(err, `[USECASE] Falha ao processar ticket ${ticket.id_atendimento}`)
}

View File

@ -3,67 +3,18 @@
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')
const { notifyCollaborators } = require('../services/notifyCollaborators.service.js')
const { notifyAdmins } = require('../services/notifyAdmins.service.js')
async function runWatchdog() {
const { sentCount: sentFunc } = await notifyCollaborators({ thresholdMinutes: 31 })
const { sentCount: sentAdm } = await notifyAdmins({ sinceMinutes: 30 })
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`)
let notificationType = 'func'
for (const ticket of closedTickets) {
const hubGlpiTicket = await repository.checkTicketInHubGlpi(ticket.id_atendimento)
if (!hubGlpiTicket.exists) {
logInfo(`[WATCHDOG] [JOB] Chamado ${ticket.id_atendimento} nao encontrado no HubGlpi. Ignorando.`)
continue
}
if (hubGlpiTicket.status === 'closed') {
logInfo(`[WATCHDOG] [JOB] Chamado ${ticket.id_atendimento} ja esta fechado no HubGlpi. Ignorando.`)
continue
}
if (await repository.notificationAlreadySent(ticket.id_atendimento, notificationType)) {
logInfo(`[WATCHDOG] [JOB] NotificaÇõÇœo jÇ­ enviada para o chamado ${ticket.id_atendimento}.`)
continue
}
await repository.markNotificationsAsPending([ticket.id_atendimento], notificationType)
}
const ticketsToNotify = await repository.getPendingTicketsForNotification(notificationType)
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, notificationType)
} catch (err) {
logError('[WATCHDOG] Erro ao enviar notificaÇõÇæes', err)
await repository.markNotificationsAsFailed(hubsoftTicketIds, notificationType)
}
logInfo(`[WATCHDOG] [JOB] Enviadas ${ticketsToNotify.length} notificaÇõÇæes`)
logInfo(`[WATCHDOG] [JOB] Enviadas ${sentFunc} notificacoes para colaboradores e ${sentAdm} para ADM.`)
}
runWatchdog().catch((error) => {
logError('[WATCHDOG] [JOB] Erro ao executar o job do Watchdog', error)
})
module.exports = { runWatchdog}
module.exports = { runWatchdog }

View File

@ -1,83 +1,118 @@
// src/modules/watchdog/model/email.model.js
function prepareNotificationPayload(tickets) {
return {
subject: buildSubject(tickets),
bodyEmail: buildBodyEmail(tickets),
recipients: getRecipients(),
cc: getCc()
};
function prepareNotificationPayload(tickets, type = 'func') {
const normalizedType = String(type || 'func').toLowerCase()
const isAdm = normalizedType === 'adm'
return {
subject: buildSubject(tickets, normalizedType),
bodyEmail: isAdm ? buildBodyEmailAdm(tickets) : buildBodyEmail(tickets),
recipients: getRecipients(normalizedType),
cc: getCc(normalizedType)
}
}
function buildSubject(tickets) {
function buildSubject(tickets, type = 'func') {
if (type === 'adm') {
if (tickets.length === 0) {
return '[GOLEIRO] Nenhum chamado foi agarrado';
return '🧤⚽ [GOLEIRO ADM] Nenhum chamado foi para os pênaltis'
}
return `🧤 [GOLEIRO] ${tickets.length} chamados foram agarrados`;
return `🚨⚽ [GOLEIRO ADM] ${tickets.length} chamados foram para os pênaltis`
}
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>
<p>🧤 <strong>Atenção, time!</strong></p>
<p>O goleiro entrou em ação e 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)}
🧾 <strong>Protocolo Hubsoft:</strong> ${ticket.protocolo_hub}<br>
🧠 <strong>Mundiale ID:</strong> ${ticket.ticket_mundiale}<br>
🛠 <strong>GLPI ID:</strong> ${ticket.glpi_ticket_id ?? 'não encontrado'}<br>
<strong>Fechado em:</strong> ${formatDate(ticket.closed_at)}
</li>
<br>
`;
});
`
})
body += `
</ul>
<br>
<p> Favor verificar e alinhar os status no GLPI.</p>
<p> Favor verificar e alinhar os status no GLPI.</p>
<p><em>Watchdog Hub × GLPI</em></p>
`;
`
return body;
return body
}
function buildBodyEmailAdm(tickets) {
let body = `
<p>🚨 <strong>Pênalti!</strong></p>
<p>Os seguintes chamados passaram da defesa inicial:</p>
<p>
Esses chamados foram <strong>fechados no Hubsoft mais de 1 hora</strong>
e ainda <strong>não tiveram atualização no GLPI</strong>.
</p>
<br>
<ul>
`
tickets.forEach(ticket => {
body += `
<li>
🧾 <strong>Protocolo Hubsoft:</strong> ${ticket.protocolo_hub}<br>
🧠 <strong>Mundiale ID:</strong> ${ticket.ticket_mundiale}<br>
🛠 <strong>GLPI ID:</strong> ${ticket.glpi_ticket_id ?? 'não encontrado'}<br>
<strong>Fechado em:</strong> ${formatDate(ticket.closed_at)}
</li>
<br>
`
})
body += `
</ul>
<br>
<p>📢 Favor acionar o time responsável 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';
if (!value) return 'não informado'
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value); // fallback seguro
const d = new Date(value)
if (Number.isNaN(d.getTime())) return String(value)
return d.toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' });
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(',') || [];
return process.env.WATCHDOG_RECIPIENT_EMAILS?.split(',') || []
}
function getCc() {
return process.env.WATCHDOG_CC_EMAILS?.split(',') || [];
return process.env.WATCHDOG_CC_EMAILS?.split(',') || []
}
module.exports = {
prepareNotificationPayload
};
prepareNotificationPayload
}

View File

@ -5,6 +5,8 @@ const wdRepository = require('../../../infra/db/repositories/hubglpi/watchdog.re
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')
const glpiRepository = require('../../../infra/db/repositories/glpi/tickets.repository.js')
/**
* Busca tickets encerrados no Hubsoft a partir de uma data
@ -71,13 +73,34 @@ async function getPendingTicketsForNotification(type = 'func') {
return wdRepository.getPendingTicketsForNotification(type);
}
async function getTicketsPendingResponse(sinceMinutes = 30, type = 'func') {
return wdRepository.getTicketsPendingResponse(sinceMinutes, type);
}
async function checkTicketIsUpdated(glpiTicketId, sinceMinutes = 30) {
const ticketUpdateDate = await glpiRepository.checkTicketUpdateDate(glpiTicketId);
if (ticketUpdateDate.date_mod < new Date(Date.now() - sinceMinutes * 60000)) {
return false;
}
return true;
}
async function markNotificationAsCompleted(hubsoftTicketId, type = 'func') {
return wdRepository.markNotificationsAsCompleted([hubsoftTicketId], type);
}
module.exports = {
getClosedTicketsSince,
checkTicketInHubGlpi,
checkTicketIsUpdated,
notificationAlreadySent,
sendClosureNotifications,
markNotificationsAsSent,
markNotificationsAsFailed,
markNotificationsAsPending,
getPendingTicketsForNotification
getPendingTicketsForNotification,
getTicketsPendingResponse,
markNotificationAsCompleted
};

View File

@ -0,0 +1,62 @@
// src/modules/watchdog/services/notifyAdmins.service.js
const { logError, logInfo } = require('../../../shared/utils/logger')
const repository = require('../repository/watchdog.repository.js')
const model = require('../model/email.model.js')
async function notifyAdmins({ sinceMinutes = 30 } = {}) {
logInfo(`[WATCHDOG] [ADM] Verificando chamados sem interacao ha mais de ${sinceMinutes} minutos`)
const ticketsPendingVerify = await repository.getTicketsPendingResponse(sinceMinutes, 'func')
logInfo(`[WATCHDOG] [ADM] ${ticketsPendingVerify.length} chamados com notificacao enviada e pendentes de resposta.`)
const notificationType = 'adm'
for (const ticket of ticketsPendingVerify) {
const hubsoftTicketId = Number(ticket.hubsoft_ticket_id ?? ticket.id_atendimento)
const glpiTicketId = Number(ticket.glpi_ticket_id)
if (!Number.isFinite(hubsoftTicketId)) {
logInfo('[WATCHDOG] [ADM] Ticket pendente sem id valido. Ignorando.')
continue
}
const isUpdated = await repository.checkTicketIsUpdated(glpiTicketId, sinceMinutes)
if (isUpdated) {
logInfo(`[WATCHDOG] [ADM] Chamado ${hubsoftTicketId} atualizado no GLPI. Encerrando fluxo.`)
await repository.markNotificationAsCompleted(hubsoftTicketId, 'func')
continue
}
if (await repository.notificationAlreadySent(hubsoftTicketId, notificationType)) {
logInfo(`[WATCHDOG] [ADM] Notificacao ADM ja enviada para o chamado ${hubsoftTicketId}.`)
continue
}
await repository.markNotificationsAsPending([hubsoftTicketId], notificationType)
}
const ticketsToNotify = await repository.getPendingTicketsForNotification(notificationType)
logInfo(`[WATCHDOG] [ADM] ${ticketsToNotify.length} chamados pendentes para notificacao.`)
if (!ticketsToNotify.length) {
return { sentCount: 0, failedCount: 0 }
}
const payload = await model.prepareNotificationPayload(ticketsToNotify, notificationType)
const hubsoftTicketIds = ticketsToNotify
.map(t => Number(t.hubsoft_ticket_id))
.filter(Number.isFinite)
try {
await repository.sendClosureNotifications(payload)
await repository.markNotificationsAsSent(hubsoftTicketIds, notificationType)
return { sentCount: ticketsToNotify.length, failedCount: 0 }
} catch (err) {
logError('[WATCHDOG] Erro ao enviar notificacoes para ADM', err)
await repository.markNotificationsAsFailed(hubsoftTicketIds, notificationType)
return { sentCount: 0, failedCount: hubsoftTicketIds.length }
}
}
module.exports = { notifyAdmins }

View File

@ -0,0 +1,58 @@
// src/modules/watchdog/services/notifyCollaborators.service.js
const { logError, logInfo } = require('../../../shared/utils/logger')
const repository = require('../repository/watchdog.repository.js')
const model = require('../model/email.model.js')
async function notifyCollaborators({ thresholdMinutes = 31 } = {}) {
logInfo(`[WATCHDOG] [FUNC] Coletando chamados fechados ha mais de ${thresholdMinutes} minutos`)
const thresholdDate = new Date(Date.now() - thresholdMinutes * 60 * 1000)
const closedTickets = await repository.getClosedTicketsSince(thresholdDate)
logInfo(`[WATCHDOG] [FUNC] Encontrados ${closedTickets.length} chamados fechados`)
const notificationType = 'func'
for (const ticket of closedTickets) {
const hubGlpiTicket = await repository.checkTicketInHubGlpi(ticket.id_atendimento)
if (!hubGlpiTicket.exists) {
logInfo(`[WATCHDOG] [FUNC] Chamado ${ticket.id_atendimento} nao encontrado no HubGlpi. Ignorando.`)
continue
}
if (hubGlpiTicket.status === 'closed') {
logInfo(`[WATCHDOG] [FUNC] Chamado ${ticket.id_atendimento} ja esta fechado no HubGlpi. Ignorando.`)
continue
}
if (await repository.notificationAlreadySent(ticket.id_atendimento, notificationType)) {
logInfo(`[WATCHDOG] [FUNC] Notificacao ja enviada para o chamado ${ticket.id_atendimento}.`)
continue
}
await repository.markNotificationsAsPending([ticket.id_atendimento], notificationType)
}
const ticketsToNotify = await repository.getPendingTicketsForNotification(notificationType)
logInfo(`[WATCHDOG] [FUNC] ${ticketsToNotify.length} chamados pendentes para notificacao.`)
if (!ticketsToNotify.length) {
return { sentCount: 0, failedCount: 0 }
}
const payload = await model.prepareNotificationPayload(ticketsToNotify, notificationType)
const hubsoftTicketIds = ticketsToNotify
.map(t => Number(t.hubsoft_ticket_id))
.filter(Number.isFinite)
try {
await repository.sendClosureNotifications(payload)
await repository.markNotificationsAsSent(hubsoftTicketIds, notificationType)
return { sentCount: ticketsToNotify.length, failedCount: 0 }
} catch (err) {
logError('[WATCHDOG] Erro ao enviar notificacoes para colaboradores', err)
await repository.markNotificationsAsFailed(hubsoftTicketIds, notificationType)
return { sentCount: 0, failedCount: hubsoftTicketIds.length }
}
}
module.exports = { notifyCollaborators }