diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index 498b105..39cef46 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -134,6 +134,9 @@ let processando = $state(false); let criandoTemplates = $state(false); let progressoEnvio = $state({ total: 0, enviados: 0, falhas: 0 }); + + // Aba ativa + let abaAtiva = $state<'enviar' | 'templates' | 'agendamentos'>('enviar'); // Estrutura de dados para logs de envio type StatusLog = 'sucesso' | 'erro' | 'fila' | 'info' | 'enviando'; @@ -1173,6 +1176,36 @@ {/if} + +
+ + + +
+ + + {#if abaAtiva === 'enviar'}
@@ -1645,7 +1678,28 @@
+ {/if} + + {#if abaAtiva === 'templates'} +
+
+
+

Templates de Mensagens

+ + Gerenciar Templates + +
+

+ Acesse a página de gerenciamento de templates para criar, editar e excluir templates de emails e + mensagens. +

+
+
+ {/if} + + + {#if abaAtiva === 'agendamentos'}
@@ -1864,6 +1918,7 @@ {/if}
+ {/if}
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte new file mode 100644 index 0000000..eaca790 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte @@ -0,0 +1,314 @@ + + +
+ +
+
+
+ + + +
+
+

Gerenciar Templates

+

Criar, editar e excluir templates de emails e mensagens

+
+
+ + + + + Voltar + +
+ + + {#if mensagem} +
+ {mensagem.texto} + +
+ {/if} + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+

Templates ({templatesFiltrados.length})

+ + + + + Novo Template + +
+ + {#if templatesFiltrados.length === 0} +
+

Nenhum template encontrado.

+
+ {:else} +
+ + + + + + + + + + + + + {#each templatesFiltrados as template (template._id)} + + + + + + + + + {/each} + +
CódigoNomeTipoCategoriaVariáveisAções
+ {template.codigo} + +
{template.nome}
+
{template.titulo}
+
+ {#if template.tipo === 'sistema'} + Sistema + {:else} + Customizado + {/if} + + {#if template.categoria} + {template.categoria} + {:else} + - + {/if} + + {#if template.variaveis && template.variaveis.length > 0} +
+ {#each template.variaveis.slice(0, 3) as var} + {{var}} + {/each} + {#if template.variaveis.length > 3} + +{template.variaveis.length - 3} + {/if} +
+ {:else} + - + {/if} +
+
+ + + + + + {#if template.tipo === 'customizado'} + + {/if} +
+
+
+ {/if} +
+
+
+ diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 770c2b4..e6d551e 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -57,7 +57,10 @@ import type * as templatesMensagens from "../templatesMensagens.js"; import type * as times from "../times.js"; import type * as todos from "../todos.js"; import type * as usuarios from "../usuarios.js"; +import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js"; +import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js"; import type * as utils_getClientIP from "../utils/getClientIP.js"; +import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js"; import type * as verificarMatriculas from "../verificarMatriculas.js"; import type { @@ -116,7 +119,10 @@ declare const fullApi: ApiFromModules<{ times: typeof times; todos: typeof todos; usuarios: typeof usuarios; + "utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper; + "utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper; "utils/getClientIP": typeof utils_getClientIP; + "utils/scanEmailSenders": typeof utils_scanEmailSenders; verificarMatriculas: typeof verificarMatriculas; }>; diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts index c48a5d0..d3981fd 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -358,20 +358,45 @@ export const criarSolicitacao = mutation({ .first(); if (gestorUsuario && funcionarioUsuario) { - // Enviar email ao gestor - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: gestorUsuario.email, - destinatarioId: gestorId, - assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`, - corpo: `

Olá ${gestorUsuario.nome},

-

O funcionário ${funcionario.nome} solicitou uma ausência:

- -

Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.

`, - enviadoPor: funcionarioUsuario._id, - }); + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + + // Enviar email ao gestor usando template + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: gestorUsuario.email, + destinatarioId: gestorId, + templateCodigo: "ausencia_solicitada", + variaveis: { + gestorNome: gestorUsuario.nome, + funcionarioNome: funcionario.nome, + dataInicio: new Date(args.dataInicio).toLocaleDateString("pt-BR"), + dataFim: new Date(args.dataFim).toLocaleDateString("pt-BR"), + motivo: args.motivo, + urlSistema, + }, + enviadoPor: funcionarioUsuario._id, + }); + } catch (error) { + // Fallback para envio direto se template não existir + console.warn("Template ausencia_solicitada não encontrado, usando envio direto:", error); + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: gestorUsuario.email, + destinatarioId: gestorId, + assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`, + corpo: `

Olá ${gestorUsuario.nome},

+

O funcionário ${funcionario.nome} solicitou uma ausência:

+ +

Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.

`, + enviadoPor: funcionarioUsuario._id, + }); + } // Criar ou obter conversa entre gestor e funcionário const conversasExistentes = await ctx.db @@ -475,19 +500,44 @@ export const aprovar = mutation({ const gestorUsuario = await ctx.db.get(args.gestorId); if (gestorUsuario) { - // Enviar email ao funcionário - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: funcionarioUsuario.email, - destinatarioId: funcionarioUsuario._id, - assunto: "Solicitação de Ausência Aprovada", - corpo: `

Olá ${funcionarioUsuario.nome},

-

Sua solicitação de ausência foi aprovada pelo gestor ${gestorUsuario.nome}:

- `, - enviadoPor: args.gestorId, - }); + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + + // Enviar email ao funcionário usando template + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + templateCodigo: "ausencia_aprovada", + variaveis: { + funcionarioNome: funcionarioUsuario.nome, + gestorNome: gestorUsuario.nome, + dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"), + dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"), + motivo: solicitacao.motivo, + urlSistema, + }, + enviadoPor: args.gestorId, + }); + } catch (error) { + // Fallback para envio direto se template não existir + console.warn("Template ausencia_aprovada não encontrado, usando envio direto:", error); + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + assunto: "Solicitação de Ausência Aprovada", + corpo: `

Olá ${funcionarioUsuario.nome},

+

Sua solicitação de ausência foi aprovada pelo gestor ${gestorUsuario.nome}:

+ `, + enviadoPor: args.gestorId, + }); + } // Criar ou obter conversa const conversasExistentes = await ctx.db @@ -593,20 +643,46 @@ export const reprovar = mutation({ const gestorUsuario = await ctx.db.get(args.gestorId); if (gestorUsuario) { - // Enviar email ao funcionário - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: funcionarioUsuario.email, - destinatarioId: funcionarioUsuario._id, - assunto: "Solicitação de Ausência Reprovada", - corpo: `

Olá ${funcionarioUsuario.nome},

-

Sua solicitação de ausência foi reprovada pelo gestor ${gestorUsuario.nome}:

- `, - enviadoPor: args.gestorId, - }); + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + + // Enviar email ao funcionário usando template + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + templateCodigo: "ausencia_reprovada", + variaveis: { + funcionarioNome: funcionarioUsuario.nome, + gestorNome: gestorUsuario.nome, + dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"), + dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"), + motivo: solicitacao.motivo, + motivoReprovacao: args.motivoReprovacao, + urlSistema, + }, + enviadoPor: args.gestorId, + }); + } catch (error) { + // Fallback para envio direto se template não existir + console.warn("Template ausencia_reprovada não encontrado, usando envio direto:", error); + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + assunto: "Solicitação de Ausência Reprovada", + corpo: `

Olá ${funcionarioUsuario.nome},

+

Sua solicitação de ausência foi reprovada pelo gestor ${gestorUsuario.nome}:

+ `, + enviadoPor: args.gestorId, + }); + } // Criar ou obter conversa const conversasExistentes = await ctx.db diff --git a/packages/backend/convex/chamados.ts b/packages/backend/convex/chamados.ts index f25df21..f0a9776 100644 --- a/packages/backend/convex/chamados.ts +++ b/packages/backend/convex/chamados.ts @@ -121,15 +121,38 @@ async function registrarNotificacoes( ) { const { ticket, titulo, mensagem, usuarioEvento } = params; + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + // Notificar solicitante if (ticket.solicitanteEmail) { - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: ticket.solicitanteEmail, - destinatarioId: ticket.solicitanteId, - assunto: `${titulo} - Chamado ${ticket.numero}`, - corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, - enviadoPor: usuarioEvento, - }); + // Tentar usar template, senão usar envio direto + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: ticket.solicitanteEmail, + destinatarioId: ticket.solicitanteId, + templateCodigo: "chamado_atualizado", + variaveis: { + solicitante: ticket.solicitanteNome || "Usuário", + numeroTicket: ticket.numero, + mensagem: mensagem, + urlSistema, + }, + enviadoPor: usuarioEvento, + }); + } catch (error) { + // Fallback para envio direto + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: ticket.solicitanteEmail, + destinatarioId: ticket.solicitanteId, + assunto: `${titulo} - Chamado ${ticket.numero}`, + corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, + enviadoPor: usuarioEvento, + }); + } } await ctx.db.insert("notificacoes", { @@ -147,13 +170,30 @@ async function registrarNotificacoes( if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) { const responsavel = await ctx.db.get(ticket.responsavelId); if (responsavel?.email) { - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: responsavel.email, - destinatarioId: ticket.responsavelId, - assunto: `${titulo} - Chamado ${ticket.numero}`, - corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, - enviadoPor: usuarioEvento, - }); + // Tentar usar template, senão usar envio direto + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: responsavel.email, + destinatarioId: ticket.responsavelId, + templateCodigo: "chamado_atualizado", + variaveis: { + solicitante: ticket.solicitanteNome || "Usuário", + numeroTicket: ticket.numero, + mensagem: mensagem, + urlSistema, + }, + enviadoPor: usuarioEvento, + }); + } catch (error) { + // Fallback para envio direto + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: responsavel.email, + destinatarioId: ticket.responsavelId, + assunto: `${titulo} - Chamado ${ticket.numero}`, + corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, + enviadoPor: usuarioEvento, + }); + } } await ctx.db.insert("notificacoes", { diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index c136376..b178228 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -2,6 +2,7 @@ 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 type { Doc, Id } from "./_generated/dataModel"; // ========== INTERNAL QUERIES ========== @@ -221,12 +222,27 @@ export const enviarEmailComTemplate = action({ 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); + } + } + // Enfileirar email via mutation const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, { destinatario: args.destinatario, destinatarioId: args.destinatarioId, assunto: tituloRenderizado, - corpo: corpoRenderizado, + corpo: corpoHTML, // Usar HTML completo templateId: template._id, // template._id sempre existe se template não é null enviadoPor: args.enviadoPor, agendadaPara: args.agendadaPara, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 022cbcf..76294be 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -851,13 +851,19 @@ export default defineSchema({ ), titulo: v.string(), corpo: v.string(), // pode ter variáveis {{variavel}} + htmlCorpo: v.optional(v.string()), // versão HTML do corpo (com wrapper) variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.] + categoria: v.optional( + v.union(v.literal("email"), v.literal("chat"), v.literal("ambos")) + ), // categoria do template + tags: v.optional(v.array(v.string())), // tags para organização criadoPor: v.optional(v.id("usuarios")), criadoEm: v.number(), }) .index("by_codigo", ["codigo"]) .index("by_tipo", ["tipo"]) - .index("by_criado_por", ["criadoPor"]), + .index("by_criado_por", ["criadoPor"]) + .index("by_categoria", ["categoria"]), // Configuração de Email/SMTP configuracaoEmail: defineTable({ diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index 1b92ecf..a7fec7a 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { registrarAtividade } from "./logsAtividades"; import { Doc } from "./_generated/dataModel"; +import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper"; /** * Listar todos os templates @@ -40,7 +41,10 @@ export const criarTemplate = mutation({ nome: v.string(), titulo: v.string(), corpo: v.string(), + htmlCorpo: v.optional(v.string()), variaveis: v.optional(v.array(v.string())), + categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))), + tags: v.optional(v.array(v.string())), criadoPorId: v.id("usuarios"), }, returns: v.union( @@ -58,6 +62,18 @@ export const criarTemplate = mutation({ return { sucesso: false as const, erro: "Código de template já existe" }; } + // Gerar HTML se não fornecido + let htmlCorpo = args.htmlCorpo; + if (!htmlCorpo) { + // Se o corpo já for HTML, usar diretamente, senão converter + if (args.corpo.includes("<") && args.corpo.includes(">")) { + htmlCorpo = wrapEmailHTML(args.corpo, args.titulo); + } else { + const corpoHTML = textToHTML(args.corpo); + htmlCorpo = wrapEmailHTML(corpoHTML, args.titulo); + } + } + // Criar template const templateId = await ctx.db.insert("templatesMensagens", { codigo: args.codigo, @@ -65,7 +81,10 @@ export const criarTemplate = mutation({ tipo: "customizado", titulo: args.titulo, corpo: args.corpo, + htmlCorpo, variaveis: args.variaveis, + categoria: args.categoria || "email", + tags: args.tags, criadoPor: args.criadoPorId, criadoEm: Date.now(), }); @@ -93,7 +112,10 @@ export const editarTemplate = mutation({ nome: v.optional(v.string()), titulo: v.optional(v.string()), corpo: v.optional(v.string()), + htmlCorpo: v.optional(v.string()), variaveis: v.optional(v.array(v.string())), + categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))), + tags: v.optional(v.array(v.string())), editadoPorId: v.id("usuarios"), }, returns: v.union( @@ -116,7 +138,21 @@ export const editarTemplate = mutation({ if (args.nome !== undefined) updates.nome = args.nome; if (args.titulo !== undefined) updates.titulo = args.titulo; if (args.corpo !== undefined) updates.corpo = args.corpo; + if (args.htmlCorpo !== undefined) { + updates.htmlCorpo = args.htmlCorpo; + } else if (args.corpo !== undefined) { + // Se corpo foi atualizado mas htmlCorpo não, regenerar HTML + const titulo = args.titulo || template.titulo; + if (args.corpo.includes("<") && args.corpo.includes(">")) { + updates.htmlCorpo = wrapEmailHTML(args.corpo, titulo); + } else { + const corpoHTML = textToHTML(args.corpo); + updates.htmlCorpo = wrapEmailHTML(corpoHTML, titulo); + } + } if (args.variaveis !== undefined) updates.variaveis = args.variaveis; + if (args.categoria !== undefined) updates.categoria = args.categoria; + if (args.tags !== undefined) updates.tags = args.tags; await ctx.db.patch(args.templateId, updates); @@ -396,6 +432,33 @@ export const criarTemplatesPadrao = mutation({ + "
", variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"], }, + { + codigo: "ausencia_solicitada", + nome: "Ausência Solicitada", + titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}", + corpo: "Olá {{gestorNome}},\n\nO funcionário {{funcionarioNome}} solicitou uma ausência:\n\n\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.", + variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo", "urlSistema"], + categoria: "email" as const, + tags: ["ausencia", "solicitacao", "gestao"], + }, + { + codigo: "ausencia_aprovada", + nome: "Ausência Aprovada", + titulo: "Solicitação de Ausência Aprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi aprovada pelo gestor {{gestorNome}}:\n\n", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "urlSistema"], + categoria: "email" as const, + tags: ["ausencia", "aprovacao", "gestao"], + }, + { + codigo: "ausencia_reprovada", + nome: "Ausência Reprovada", + titulo: "Solicitação de Ausência Reprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi reprovada pelo gestor {{gestorNome}}:\n\n", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao", "urlSistema"], + categoria: "email" as const, + tags: ["ausencia", "reprovacao", "gestao"], + }, ]; for (const template of templatesPadrao) { @@ -418,4 +481,321 @@ export const criarTemplatesPadrao = mutation({ }, }); +/** + * Atualizar HTML de um template + */ +export const atualizarTemplateHTML = mutation({ + args: { + templateId: v.id("templatesMensagens"), + htmlCorpo: v.string(), + editadoPorId: v.id("usuarios"), + }, + returns: v.union( + v.object({ sucesso: v.literal(true) }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) { + return { sucesso: false as const, erro: "Template não encontrado" }; + } + + // Não permite editar templates do sistema + if (template.tipo === "sistema") { + return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" }; + } + + await ctx.db.patch(args.templateId, { + htmlCorpo: args.htmlCorpo, + }); + + await registrarAtividade( + ctx, + args.editadoPorId, + "editar", + "templates", + JSON.stringify({ templateId: args.templateId, campo: "htmlCorpo" }), + args.templateId + ); + + return { sucesso: true as const }; + }, +}); + +/** + * Preview de template renderizado com variáveis de teste + */ +export const previewTemplate = query({ + args: { + templateId: v.id("templatesMensagens"), + variaveisTeste: v.optional(v.record(v.string(), v.string())), + }, + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) { + return null; + } + + // Variáveis padrão para teste + const variaveisPadrao: Record = { + nome: "João Silva", + matricula: "12345", + senha: "Senha123!", + motivo: "Exemplo de motivo", + remetente: "Maria Santos", + mensagem: "Esta é uma mensagem de exemplo para preview do template.", + conversaId: "abc123", + urlSistema: getBaseUrl(), + solicitante: "João Silva", + numeroTicket: "TKT-2024-001", + prioridade: "Alta", + categoria: "Suporte Técnico", + responsavel: "Maria Santos", + descricao: "Exemplo de descrição de chamado", + destinario: "João Silva", + tipoPrazo: "resolução", + prazo: "24 horas", + status: "Em andamento", + rotaAcesso: "/ti/central-chamados", + titulo: "Título de Exemplo", + }; + + const variaveis = { ...variaveisPadrao, ...(args.variaveisTeste || {}) }; + + // Renderizar título e corpo + const tituloRenderizado = renderizarTemplate(template.titulo, variaveis); + const corpoRenderizado = renderizarTemplate(template.corpo, variaveis); + + // Se tiver htmlCorpo, usar ele, senão gerar do corpo + let htmlFinal = template.htmlCorpo; + if (!htmlFinal) { + if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) { + htmlFinal = wrapEmailHTML(corpoRenderizado, tituloRenderizado); + } else { + const corpoHTML = textToHTML(corpoRenderizado); + htmlFinal = wrapEmailHTML(corpoHTML, tituloRenderizado); + } + } else { + htmlFinal = renderizarTemplate(htmlFinal, variaveis); + } + + return { + titulo: tituloRenderizado, + corpo: corpoRenderizado, + html: htmlFinal, + variaveisUsadas: template.variaveis || [], + }; + }, +}); + +/** + * Função auxiliar para obter URL base + */ +function getBaseUrl(): string { + const url = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!url.match(/^https?:\/\//i)) { + return `http://${url}`; + } + return url; +} + +/** + * Exportar templates (JSON) + */ +export const exportarTemplates = query({ + args: { + templateIds: v.optional(v.array(v.id("templatesMensagens"))), + }, + handler: async (ctx, args) => { + let templates; + + if (args.templateIds && args.templateIds.length > 0) { + templates = await Promise.all( + args.templateIds.map((id) => ctx.db.get(id)) + ); + templates = templates.filter((t): t is Doc<"templatesMensagens"> => t !== null); + } else { + templates = await ctx.db.query("templatesMensagens").collect(); + } + + // Remover campos internos e retornar apenas dados exportáveis + return templates.map((t) => ({ + codigo: t.codigo, + nome: t.nome, + tipo: t.tipo, + titulo: t.titulo, + corpo: t.corpo, + htmlCorpo: t.htmlCorpo, + variaveis: t.variaveis, + categoria: t.categoria, + tags: t.tags, + })); + }, +}); + +/** + * Importar templates (JSON) + */ +export const importarTemplates = mutation({ + args: { + templates: v.array( + v.object({ + codigo: v.string(), + nome: v.string(), + tipo: v.optional(v.union(v.literal("sistema"), v.literal("customizado"))), + titulo: v.string(), + corpo: v.string(), + htmlCorpo: v.optional(v.string()), + variaveis: v.optional(v.array(v.string())), + categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))), + tags: v.optional(v.array(v.string())), + }) + ), + importadoPorId: v.id("usuarios"), + sobrescrever: v.optional(v.boolean()), + }, + returns: v.object({ + sucesso: v.boolean(), + importados: v.number(), + atualizados: v.number(), + erros: v.array(v.string()), + }), + handler: async (ctx, args) => { + let importados = 0; + let atualizados = 0; + const erros: string[] = []; + + for (const templateData of args.templates) { + try { + const existente = await ctx.db + .query("templatesMensagens") + .withIndex("by_codigo", (q) => q.eq("codigo", templateData.codigo)) + .first(); + + if (existente) { + if (args.sobrescrever && existente.tipo === "customizado") { + // Atualizar template existente + await ctx.db.patch(existente._id, { + nome: templateData.nome, + titulo: templateData.titulo, + corpo: templateData.corpo, + htmlCorpo: templateData.htmlCorpo, + variaveis: templateData.variaveis, + categoria: templateData.categoria, + tags: templateData.tags, + }); + atualizados++; + } else { + erros.push(`Template ${templateData.codigo} já existe e sobrescrever está desabilitado`); + } + } else { + // Criar novo template + const tipo = templateData.tipo || "customizado"; + + // Gerar HTML se não fornecido + let htmlCorpo = templateData.htmlCorpo; + if (!htmlCorpo) { + if (templateData.corpo.includes("<") && templateData.corpo.includes(">")) { + htmlCorpo = wrapEmailHTML(templateData.corpo, templateData.titulo); + } else { + const corpoHTML = textToHTML(templateData.corpo); + htmlCorpo = wrapEmailHTML(corpoHTML, templateData.titulo); + } + } + + await ctx.db.insert("templatesMensagens", { + codigo: templateData.codigo, + nome: templateData.nome, + tipo, + titulo: templateData.titulo, + corpo: templateData.corpo, + htmlCorpo, + variaveis: templateData.variaveis, + categoria: templateData.categoria || "email", + tags: templateData.tags, + criadoPor: args.importadoPorId, + criadoEm: Date.now(), + }); + importados++; + } + } catch (error) { + const erroMsg = error instanceof Error ? error.message : String(error); + erros.push(`Erro ao importar ${templateData.codigo}: ${erroMsg}`); + } + } + + await registrarAtividade( + ctx, + args.importadoPorId, + "importar", + "templates", + JSON.stringify({ importados, atualizados, erros: erros.length }), + undefined + ); + + return { + sucesso: erros.length === 0, + importados, + atualizados, + erros, + }; + }, +}); + +/** + * Duplicar template + */ +export const duplicarTemplate = mutation({ + args: { + templateId: v.id("templatesMensagens"), + novoCodigo: v.string(), + novoNome: v.optional(v.string()), + criadoPorId: v.id("usuarios"), + }, + returns: v.union( + v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) { + return { sucesso: false as const, erro: "Template não encontrado" }; + } + + // Verificar se novo código já existe + const existente = await ctx.db + .query("templatesMensagens") + .withIndex("by_codigo", (q) => q.eq("codigo", args.novoCodigo)) + .first(); + + if (existente) { + return { sucesso: false as const, erro: "Código de template já existe" }; + } + + const templateId = await ctx.db.insert("templatesMensagens", { + codigo: args.novoCodigo, + nome: args.novoNome || `${template.nome} (Cópia)`, + tipo: "customizado", + titulo: template.titulo, + corpo: template.corpo, + htmlCorpo: template.htmlCorpo, + variaveis: template.variaveis, + categoria: template.categoria, + tags: template.tags, + criadoPor: args.criadoPorId, + criadoEm: Date.now(), + }); + + await registrarAtividade( + ctx, + args.criadoPorId, + "duplicar", + "templates", + JSON.stringify({ templateId, codigo: args.novoCodigo, originalId: args.templateId }), + templateId + ); + + return { sucesso: true as const, templateId }; + }, +}); diff --git a/packages/backend/convex/utils/chatTemplateWrapper.ts b/packages/backend/convex/utils/chatTemplateWrapper.ts new file mode 100644 index 0000000..7c06eef --- /dev/null +++ b/packages/backend/convex/utils/chatTemplateWrapper.ts @@ -0,0 +1,46 @@ +/** + * Wrapper para padronizar mensagens de chat do SGSE + */ + +/** + * Formata mensagem de chat com prefixo padronizado quando necessário + * @param conteudo - Conteúdo da mensagem + * @param tipo - Tipo da mensagem (opcional) + * @returns Mensagem formatada + */ +export function wrapChatMessage(conteudo: string, tipo?: string): string { + // Se já tiver formatação especial, retornar como está + if (conteudo.includes('[SGSE]') || conteudo.includes('[Sistema]')) { + return conteudo; + } + + // Para mensagens do sistema, adicionar prefixo + if (tipo === 'sistema' || tipo === 'notificacao') { + return `[SGSE] ${conteudo}`; + } + + return conteudo; +} + +/** + * Formata mensagem de chat com informações estruturadas + * @param titulo - Título da notificação + * @param conteudo - Conteúdo da mensagem + * @param acao - Ação sugerida (opcional) + * @returns Mensagem formatada + */ +export function formatChatNotification( + titulo: string, + conteudo: string, + acao?: string +): string { + let mensagem = `🔔 ${titulo}\n\n${conteudo}`; + + if (acao) { + mensagem += `\n\n💡 ${acao}`; + } + + return mensagem; +} + + diff --git a/packages/backend/convex/utils/emailTemplateWrapper.ts b/packages/backend/convex/utils/emailTemplateWrapper.ts new file mode 100644 index 0000000..0ba5e2b --- /dev/null +++ b/packages/backend/convex/utils/emailTemplateWrapper.ts @@ -0,0 +1,185 @@ +/** + * Wrapper HTML para templates de email do SGSE + * Aplica estilo governamental profissional com logo e assinatura padronizada + */ + +/** + * Obtém a URL base do sistema para uso em links de email + */ +function getBaseUrl(): string { + // Em produção, usar variável de ambiente + const url = process.env.FRONTEND_URL || "http://localhost:5173"; + // Garantir que tenha protocolo + if (!url.match(/^https?:\/\//i)) { + return `http://${url}`; + } + return url; +} + +/** + * Gera o HTML do header com logo do Governo de PE + */ +function generateHeader(): string { + const baseUrl = getBaseUrl(); + return ` + + + + +
+ + + + +
+ Governo de Pernambuco +
+
+ `; +} + +/** + * Gera o HTML do footer com assinatura SGSE + */ +function generateFooter(): string { + const baseUrl = getBaseUrl(); + const currentYear = new Date().getFullYear(); + + return ` + + + + +
+ + + + +
+

+ SGSE - Sistema de Gerenciamento de Secretaria +

+

+ Secretaria de Esportes do Estado de Pernambuco +

+

+ Este é um email automático do sistema. Por favor, não responda diretamente a este email. +

+
+

+ © ${currentYear} Secretaria de Esportes - Governo de Pernambuco. Todos os direitos reservados. +

+

+ Acessar Sistema | + Central de Notificações +

+
+
+ `; +} + +/** + * Envolve o conteúdo HTML do email com template profissional governamental + * @param conteudoHTML - Conteúdo HTML do corpo do email + * @param titulo - Título do email (usado no meta) + * @returns HTML completo do email pronto para envio + */ +export function wrapEmailHTML(conteudoHTML: string, titulo?: string): string { + // Se o conteúdo já estiver dentro de um wrapper completo, retornar como está + if (conteudoHTML.includes('') || conteudoHTML.includes('${conteudoProcessado}

`; + } + + const header = generateHeader(); + const footer = generateFooter(); + const emailTitle = titulo || "Notificação do SGSE"; + + return ` + + + + + + + ${emailTitle} + + + + + + + + +
+ + + + ${header} + + + + + + + + + + +
+ + + + +
+ ${conteudoProcessado} +
+
+ ${footer} +
+ + + + + + +
+

Se você não solicitou este email, pode ignorá-lo com segurança.

+
+
+ + + `.trim(); +} + +/** + * Converte texto plano em HTML básico + * @param texto - Texto plano + * @returns HTML formatado + */ +export function textToHTML(texto: string): string { + return texto + .split('\n') + .map(linha => { + const linhaTrim = linha.trim(); + if (!linhaTrim) return '
'; + // Detectar links + const linkRegex = /(https?:\/\/[^\s]+)/g; + const linhaComLinks = linhaTrim.replace(linkRegex, '$1'); + return `

${linhaComLinks}

`; + }) + .join(''); +} + + diff --git a/packages/backend/convex/utils/scanEmailSenders.ts b/packages/backend/convex/utils/scanEmailSenders.ts new file mode 100644 index 0000000..14ec74d --- /dev/null +++ b/packages/backend/convex/utils/scanEmailSenders.ts @@ -0,0 +1,189 @@ +/** + * Scanner automático de envios de email e mensagens no código + * Identifica todos os locais onde emails são enviados para gerar templates + */ + +import { Doc } from "../_generated/dataModel"; + +export interface EmailSendLocation { + arquivo: string; + funcao: string; + tipo: "enfileirarEmail" | "enviarEmailComTemplate" | "enviarMensagem" | "html_inline"; + linha?: number; + contexto?: string; + assunto?: string; + corpo?: string; + templateCodigo?: string; + variaveis?: string[]; +} + +/** + * Lista de locais conhecidos onde emails são enviados + * Este é um mapeamento manual baseado na análise do código + */ +export const LOCAIS_ENVIO_EMAIL: EmailSendLocation[] = [ + // Chamados + { + arquivo: "packages/backend/convex/chamados.ts", + funcao: "registrarNotificacoes", + tipo: "enfileirarEmail", + contexto: "Notificação ao solicitante quando chamado é criado/atualizado", + assunto: "Chamado {{numeroTicket}} - {{titulo}}", + corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria", + variaveis: ["numeroTicket", "titulo", "mensagem"], + }, + { + arquivo: "packages/backend/convex/chamados.ts", + funcao: "registrarNotificacoes", + tipo: "enfileirarEmail", + contexto: "Notificação ao responsável quando chamado é atualizado", + assunto: "Chamado {{numeroTicket}} - {{titulo}}", + corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria", + variaveis: ["numeroTicket", "titulo", "mensagem"], + }, + + // Ausências + { + arquivo: "packages/backend/convex/ausencias.ts", + funcao: "solicitar", + tipo: "enfileirarEmail", + contexto: "Notificação ao gestor quando funcionário solicita ausência", + assunto: "Nova Solicitação de Ausência - {{funcionarioNome}}", + corpo: "Olá {{gestorNome}},\n\nO funcionário {{funcionarioNome}} solicitou uma ausência:\n\n\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.", + variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"], + }, + { + arquivo: "packages/backend/convex/ausencias.ts", + funcao: "aprovar", + tipo: "enfileirarEmail", + contexto: "Notificação ao funcionário quando ausência é aprovada", + assunto: "Solicitação de Ausência Aprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi aprovada pelo gestor {{gestorNome}}:\n\n", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"], + }, + { + arquivo: "packages/backend/convex/ausencias.ts", + funcao: "reprovar", + tipo: "enfileirarEmail", + contexto: "Notificação ao funcionário quando ausência é reprovada", + assunto: "Solicitação de Ausência Reprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi reprovada pelo gestor {{gestorNome}}:\n\n", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"], + }, + + // Chat + { + arquivo: "packages/backend/convex/chat.ts", + funcao: "enviarMensagem", + tipo: "enviarEmailComTemplate", + contexto: "Email quando usuário recebe nova mensagem no chat (usuário offline)", + templateCodigo: "chat_mensagem", + variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], + }, + { + arquivo: "packages/backend/convex/chat.ts", + funcao: "enviarMensagem", + tipo: "enviarEmailComTemplate", + contexto: "Email quando usuário é mencionado no chat (usuário offline)", + templateCodigo: "chat_mencao", + variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], + }, + + // Painel de Notificações + { + arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte", + funcao: "enviarNotificacao", + tipo: "enfileirarEmail", + contexto: "Envio manual de notificação via painel de TI", + assunto: "Notificação do Sistema", + corpo: "{{mensagemPersonalizada}}", + variaveis: ["mensagemPersonalizada"], + }, + { + arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte", + funcao: "enviarNotificacao", + tipo: "enviarEmailComTemplate", + contexto: "Envio manual de notificação usando template via painel de TI", + templateCodigo: "{{templateCodigo}}", + variaveis: ["nome", "matricula"], + }, +]; + +/** + * Sugestões de templates baseadas nos locais de envio encontrados + */ +export interface TemplateSuggestion { + codigo: string; + nome: string; + titulo: string; + corpo: string; + categoria: "email" | "chat" | "ambos"; + variaveis: string[]; + tags: string[]; + origem: string; +} + +/** + * Gerar sugestões de templates baseadas nos locais de envio + */ +export function gerarSugestoesTemplates(): TemplateSuggestion[] { + const sugestoes: TemplateSuggestion[] = []; + + // Template para ausência solicitada + sugestoes.push({ + codigo: "ausencia_solicitada", + nome: "Ausência Solicitada", + titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}", + corpo: "Olá {{gestorNome}},\n\nO funcionário {{funcionarioNome}} solicitou uma ausência:\n\n\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.", + categoria: "email", + variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"], + tags: ["ausencia", "solicitacao", "gestao"], + origem: "ausencias.ts - solicitar", + }); + + // Template para ausência aprovada + sugestoes.push({ + codigo: "ausencia_aprovada", + nome: "Ausência Aprovada", + titulo: "Solicitação de Ausência Aprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi aprovada pelo gestor {{gestorNome}}:\n\n", + categoria: "email", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"], + tags: ["ausencia", "aprovacao", "gestao"], + origem: "ausencias.ts - aprovar", + }); + + // Template para ausência reprovada + sugestoes.push({ + codigo: "ausencia_reprovada", + nome: "Ausência Reprovada", + titulo: "Solicitação de Ausência Reprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi reprovada pelo gestor {{gestorNome}}:\n\n", + categoria: "email", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"], + tags: ["ausencia", "reprovacao", "gestao"], + origem: "ausencias.ts - reprovar", + }); + + // Template genérico para notificações de chamados + sugestoes.push({ + codigo: "chamado_notificacao", + nome: "Notificação de Chamado", + titulo: "Chamado {{numeroTicket}} - {{titulo}}", + corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria", + categoria: "email", + variaveis: ["numeroTicket", "titulo", "mensagem"], + tags: ["chamado", "notificacao", "suporte"], + origem: "chamados.ts - registrarNotificacoes", + }); + + return sugestoes; +} + +/** + * Obter todos os locais de envio de email + */ +export function obterLocaisEnvio(): EmailSendLocation[] { + return LOCAIS_ENVIO_EMAIL; +} +