From 4c2d12f44383f1d1adda856e8181938e1a9087a2 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 09:50:53 -0300 Subject: [PATCH] feat: implement template filtering for notifications based on channel type and enhance email rendering with HTML wrapper, ensuring chat messages are sent as plain text --- .../(dashboard)/ti/notificacoes/+page.svelte | 47 ++++++++++- .../notificacoes/templates/[id]/+page.svelte | 17 +++- .../notificacoes/templates/novo/+page.svelte | 16 +++- packages/backend/convex/chamados.ts | 14 ++++ packages/backend/convex/email.ts | 37 +++------ packages/backend/convex/monitoramento.ts | 37 ++++++++- packages/backend/convex/templatesMensagens.ts | 79 +++++++++++++++++++ 7 files changed, 213 insertions(+), 34 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index 39cef46..deacc99 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -95,6 +95,34 @@ return []; }); + function templateDisponivelParaCanal( + template: Doc<'templatesMensagens'>, + canalAtual: 'chat' | 'email' | 'ambos' + ): boolean { + const categoria = template.categoria as 'email' | 'chat' | 'ambos' | undefined; + + // Se não tiver categoria definida, considerar disponível para qualquer canal + if (!categoria) { + return true; + } + + if (canalAtual === 'ambos') { + // No modo "ambos", aceitar templates marcados para qualquer canal ou ambos + return categoria === 'chat' || categoria === 'email' || categoria === 'ambos'; + } + + // Para canal específico, aceitar templates daquele canal ou "ambos" + if (categoria === 'ambos') { + return true; + } + + return categoria === canalAtual; + } + + const templatesParaCanal = $derived.by(() => + templates.filter((t) => templateDisponivelParaCanal(t as Doc<'templatesMensagens'>, canal)) + ); + // Estados de carregamento e erro const carregandoTemplates = $derived(templatesQuery === undefined || templatesQuery === null); const carregandoUsuarios = $derived(usuariosQuery === undefined || usuariosQuery === null); @@ -211,6 +239,17 @@ return resultado; } + // Versão específica para CHAT: garante texto puro (sem HTML) + function renderizarTemplateChatLocal( + template: string, + variaveis: Record + ): string { + const textoComVariaveis = renderizarTemplate(template, variaveis); + // Remove quaisquer tags HTML que possam ter sido inseridas por engano + const textoPuro = textoComVariaveis.replace(/<[^>]*>/g, ''); + return textoPuro; + } + // Função para mostrar mensagens function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) { mensagem = { tipo, texto }; @@ -885,10 +924,10 @@ }); if (conversaId) { - // Renderizar template com variáveis do destinatário + // Renderizar template com variáveis do destinatário (chat sempre em TEXTO PURO) const mensagem = usarTemplate && templateSelecionado - ? renderizarTemplate(templateSelecionado.corpo, { + ? renderizarTemplateChatLocal(templateSelecionado.corpo, { nome: destinatario.nome, matricula: destinatario.matricula || '' }) @@ -1314,8 +1353,8 @@ {#if carregandoTemplates} - {:else if templates.length > 0} - {#each templates as template (template._id)} + {:else if templatesParaCanal.length > 0} + {#each templatesParaCanal as template (template._id)} diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte index 6430ee2..0c456fa 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte @@ -203,6 +203,13 @@ +
@@ -213,8 +220,16 @@ id="corpo" bind:value={corpo} class="textarea textarea-bordered h-40" - placeholder="Digite o conteúdo da mensagem. Você pode usar {{variavel}} para valores dinâmicos." + placeholder="Digite o conteúdo em TEXTO. Você pode usar {{variavel}} para valores dinâmicos." > +
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte index c29def9..ef98c0a 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte @@ -180,6 +180,13 @@ +
@@ -190,8 +197,15 @@ id="corpo" bind:value={corpo} class="textarea textarea-bordered h-40" - placeholder="Digite o conteúdo da mensagem. Você pode usar {{variavel}} para valores dinâmicos." + placeholder="Digite o conteúdo em TEXTO. Você pode usar {{variavel}} para valores dinâmicos." > +
diff --git a/packages/backend/convex/chamados.ts b/packages/backend/convex/chamados.ts index 536644d..d0c5504 100644 --- a/packages/backend/convex/chamados.ts +++ b/packages/backend/convex/chamados.ts @@ -170,6 +170,20 @@ async function registrarNotificacoes( criadaEm: Date.now(), }); + // Se o ticket estiver associado a uma conversa, registrar também uma mensagem de chat + // Isso garante o "duplo canal": email + chat para notificações importantes. + if (ticket.conversaId) { + const conteudoChat = mensagem.length > 0 ? `${titulo}: ${mensagem}` : titulo; + + await ctx.db.insert("mensagens", { + conversaId: ticket.conversaId, + remetenteId: usuarioEvento, + tipo: "texto", + conteudo: conteudoChat, + enviadaEm: Date.now(), + }); + } + // Notificar responsável (se houver) if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) { const responsavel = await ctx.db.get(ticket.responsavelId); diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index b178228..dee978b 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -1,8 +1,10 @@ import { v } from "convex/values"; import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server"; import { internal, api } from "./_generated/api"; -import { renderizarTemplate } from "./templatesMensagens"; -import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper"; +import { + renderizarTemplateEmailFromDoc, + type VariaveisTemplate, +} from "./templatesMensagens"; import type { Doc, Id } from "./_generated/dataModel"; // ========== INTERNAL QUERIES ========== @@ -212,37 +214,24 @@ export const enviarEmailComTemplate = action({ } // Renderizar template com variáveis - const variaveisTemplate = args.variaveis || {}; - + const variaveisTemplate: VariaveisTemplate = args.variaveis ?? {}; + // Garantir que urlSistema sempre tenha protocolo se presente - if (variaveisTemplate.urlSistema && !variaveisTemplate.urlSistema.match(/^https?:\/\//i)) { + if ( + typeof variaveisTemplate.urlSistema === "string" && + !variaveisTemplate.urlSistema.match(/^https?:\/\//i) + ) { variaveisTemplate.urlSistema = `http://${variaveisTemplate.urlSistema}`; } - - const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate); - const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate); - // Usar htmlCorpo se disponível, senão gerar do corpo - let corpoHTML = template.htmlCorpo; - if (corpoHTML) { - // Renderizar variáveis no HTML - corpoHTML = renderizarTemplate(corpoHTML, variaveisTemplate); - } else { - // Gerar HTML do corpo renderizado - if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) { - corpoHTML = wrapEmailHTML(corpoRenderizado, tituloRenderizado); - } else { - const corpoHTMLFormatado = textToHTML(corpoRenderizado); - corpoHTML = wrapEmailHTML(corpoHTMLFormatado, tituloRenderizado); - } - } + const emailRenderizado = renderizarTemplateEmailFromDoc(template, variaveisTemplate); // Enfileirar email via mutation const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, { destinatario: args.destinatario, destinatarioId: args.destinatarioId, - assunto: tituloRenderizado, - corpo: corpoHTML, // Usar HTML completo + assunto: emailRenderizado.titulo, + corpo: emailRenderizado.html, // HTML completo com wrapper templateId: template._id, // template._id sempre existe se template não é null enviadoPor: args.enviadoPor, agendadaPara: args.agendadaPara, diff --git a/packages/backend/convex/monitoramento.ts b/packages/backend/convex/monitoramento.ts index a3bb14f..b47209b 100644 --- a/packages/backend/convex/monitoramento.ts +++ b/packages/backend/convex/monitoramento.ts @@ -363,10 +363,39 @@ export const verificarAlertasInternal = internalMutation({ } } - // TODO: Enviar email se configurado (integração com sistema de email) - // if (alerta.notifyByEmail) { - // await enviarEmailAlerta(alerta, metricValue); - // } + // Enviar email se configurado (usar template HTML padronizado) + if (alerta.notifyByEmail) { + // Buscar usuários administradores/TI para receber o alerta por email + const rolesAdminOuTi = await ctx.db + .query('roles') + .filter((q) => q.lte(q.field('nivel'), 1)) + .collect(); + + const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id)); + const usuarios = await ctx.db.query('usuarios').collect(); + const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId) && !!u.email); + + for (const usuario of usuariosTI) { + const email = usuario.email; + if (!email) continue; + + // Montar variáveis para template de alerta de sistema + const variaveisEmail = { + destinatarioNome: usuario.nome, + metricName: alerta.metricName, + metricValue: metricValue.toFixed(2), + threshold: alerta.threshold.toString() + }; + + await ctx.scheduler.runAfter(0, internal.email.enviarEmailComTemplate, { + destinatario: email, + destinatarioId: usuario._id, + templateCodigo: 'monitoramento_alerta_sistema', + variaveis: variaveisEmail, + enviadoPor: usuario._id + }); + } + } } } diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index a7fec7a..d6a0387 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -224,6 +224,71 @@ export function renderizarTemplate(template: string, variaveis: Record; + +export interface EmailRenderizado { + titulo: string; + html: string; +} + +/** + * Renderizar template para EMAIL (HTML padronizado) + * - Usa `htmlCorpo` se existir, senão gera HTML a partir de `corpo` (texto ou HTML simples) + * - Sempre aplica o wrapper visual de email + */ +export function renderizarTemplateEmailFromDoc( + template: Doc<"templatesMensagens">, + variaveis: VariaveisTemplate, +): EmailRenderizado { + const variaveisTemplate: VariaveisTemplate = { ...variaveis }; + + const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate); + + // Base para o corpo: se existir htmlCorpo usamos ele, senão usamos corpo + const baseCorpo = template.htmlCorpo ?? template.corpo ?? ""; + const corpoRenderizado = renderizarTemplate(baseCorpo, variaveisTemplate); + + let htmlFinal: string; + + if (template.htmlCorpo) { + // htmlCorpo já é HTML completo de email (com ou sem wrapper) – apenas aplica variáveis + htmlFinal = corpoRenderizado.includes("")) { + htmlFinal = wrapEmailHTML(corpoRenderizado, tituloRenderizado); + } else { + const corpoHTML = textToHTML(corpoRenderizado); + htmlFinal = wrapEmailHTML(corpoHTML, tituloRenderizado); + } + } + + return { + titulo: tituloRenderizado, + html: htmlFinal, + }; +} + +/** + * Renderizar template para CHAT (texto puro) + * - Usa sempre `corpo` como fonte + * - Remove quaisquer tags HTML residuais + */ +export function renderizarTemplateChatFromDoc( + template: Doc<"templatesMensagens">, + variaveis: VariaveisTemplate, +): string { + const corpoBase = template.corpo ?? ""; + const textoComVariaveis = renderizarTemplate(corpoBase, variaveis); + + // Garantir texto puro para o chat (sem tags HTML) + const textoPuro = textoComVariaveis.replace(/<[^>]*>/g, ""); + + return textoPuro; +} + /** * Criar templates padrão do sistema (chamado no seed) */ @@ -432,6 +497,20 @@ export const criarTemplatesPadrao = mutation({ + "
", variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"], }, + { + codigo: "monitoramento_alerta_sistema", + nome: "Alerta de Sistema (Monitoramento)", + titulo: "⚠️ Alerta de Sistema: {{metricName}}", + corpo: + "Olá {{destinatarioNome}},\n\n" + + "A métrica {{metricName}} atingiu o valor {{metricValue}} (limite configurado: {{threshold}}).\n\n" + + "Recomenda-se verificar o painel de monitoramento do SGSE para detalhes adicionais e, se necessário, " + + "executar ações corretivas.\n\n" + + "Esta é uma notificação automática do sistema de monitoramento SGSE.", + variaveis: ["destinatarioNome", "metricName", "metricValue", "threshold"], + categoria: "email" as const, + tags: ["monitoramento", "alerta", "sistema", "ti"], + }, { codigo: "ausencia_solicitada", nome: "Ausência Solicitada",