diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 7881eb4..42ba6eb 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -8,6 +8,11 @@ import { createAuthClient } from "better-auth/svelte"; import { convexClient } from "@convex-dev/better-auth/client/plugins"; +// O baseURL deve apontar para o frontend (SvelteKit), não para o Convex diretamente +// O Better Auth usa as rotas HTTP do Convex que são acessadas via proxy do SvelteKit +// ou diretamente se configurado. Com o plugin convexClient, o token é gerenciado automaticamente. export const authClient = createAuthClient({ + // baseURL padrão é window.location.origin, que é o correto para SvelteKit + // O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts plugins: [convexClient()], }); diff --git a/apps/web/src/lib/components/AlterarStatusFerias.svelte b/apps/web/src/lib/components/AlterarStatusFerias.svelte index cb4c52a..2eff5bb 100644 --- a/apps/web/src/lib/components/AlterarStatusFerias.svelte +++ b/apps/web/src/lib/components/AlterarStatusFerias.svelte @@ -29,7 +29,8 @@ aprovado: 'badge-success', reprovado: 'badge-error', data_ajustada_aprovada: 'badge-info', - EmFérias: 'badge-info' + EmFérias: 'badge-info', + Cancelado_RH: 'badge-error' }; return badges[status] || 'badge-neutral'; } @@ -40,19 +41,20 @@ aprovado: 'Aprovado', reprovado: 'Reprovado', data_ajustada_aprovada: 'Data Ajustada e Aprovada', - EmFérias: 'Em Férias' + EmFérias: 'Em Férias', + Cancelado_RH: 'Cancelado RH' }; return textos[status] || status; } - async function voltarParaAguardando() { + async function cancelarPorRH() { try { processando = true; erro = ''; await client.mutation(api.ferias.atualizarStatus, { feriasId: solicitacao._id, - novoStatus: 'aguardando_aprovacao', + novoStatus: 'Cancelado_RH', usuarioId: usuarioId }); @@ -150,10 +152,10 @@ {/if} - - {#if solicitacao.status !== 'aguardando_aprovacao'} + + {#if solicitacao.status !== 'Cancelado_RH'}
-
+
-

Alterar Status

+

Cancelar Férias

- Ao voltar para "Aguardando Aprovação", a solicitação ficará disponível para aprovação ou - reprovação pelo gestor. + Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não poderá mais ser processada.
@@ -179,8 +180,8 @@
{:else}
-
+
- Esta solicitação já está aguardando aprovação. + Esta solicitação já foi cancelada pelo RH.
{/if} diff --git a/apps/web/src/lib/components/AprovarAusencias.svelte b/apps/web/src/lib/components/AprovarAusencias.svelte index 7dc5b55..7a67e6e 100644 --- a/apps/web/src/lib/components/AprovarAusencias.svelte +++ b/apps/web/src/lib/components/AprovarAusencias.svelte @@ -138,37 +138,43 @@
-
-
+
+
-
-

- - - +
+

+
+ + + +
Funcionário

-
-
-

Nome

-

+

+
+

+ Nome +

+

{solicitacao.funcionario?.nome || 'N/A'}

{#if solicitacao.time} -
-

Time

+
+

+ Time +

+
-
-

- - - +
+

+
+ + + +
Período da Ausência

-
Data Início
-
+
Data Início
+
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
-
Data Fim
-
+
Data Fim
+
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
-
Total de Dias
-
+
Total de Dias
+
{totalDias}
-
dias corridos
+
dias corridos
-
+
-
-

- - - +
+

+
+ + + +
Motivo da Ausência

-
-
-

{solicitacao.motivo}

+
+
+

+ {solicitacao.motivo} +

-
-
- Status: +
+
+ Status:
{getStatusTexto(solicitacao.status)}
@@ -271,7 +285,7 @@ {#if erro} -
+
{#if solicitacao.status === 'aguardando_aprovacao'} -
+
- -
- - -
- - - - - - - - - - - - - - - - - - - - - -
- - - -
- - - + + + + + + + diff --git a/apps/web/src/lib/components/ProtectedRoute.svelte b/apps/web/src/lib/components/ProtectedRoute.svelte index 6bcf57a..0bbe10f 100644 --- a/apps/web/src/lib/components/ProtectedRoute.svelte +++ b/apps/web/src/lib/components/ProtectedRoute.svelte @@ -20,26 +20,50 @@ let isChecking = $state(true); let hasAccess = $state(false); + let timeoutId: ReturnType | null = null; + let hasCheckedOnce = $state(false); + let lastUserState = $state(undefined); const currentUser = useQuery(api.auth.getCurrentUser, {}); - onMount(() => { - checkAccess(); + // Usar $effect para reagir apenas às mudanças na query currentUser + $effect(() => { + // Não verificar novamente se já tem acesso concedido e usuário está autenticado + if (hasAccess && currentUser?.data) { + lastUserState = currentUser; + return; + } + + // Evitar loop: só verificar se currentUser realmente mudou + // Comparar dados, não o objeto proxy + const currentData = currentUser?.data; + const lastData = lastUserState?.data; + if (currentData !== lastData || (currentUser === undefined) !== (lastUserState === undefined)) { + lastUserState = currentUser; + checkAccess(); + } }); function checkAccess() { - isChecking = true; + // Limpar timeout anterior se existir + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } - // Aguardar um pouco para o authStore carregar do localStorage - setTimeout(() => { - // Verificar autenticação - if (requireAuth && !currentUser?.data) { - const currentPath = window.location.pathname; - window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`; - return; - } + // Se a query ainda está carregando (undefined), aguardar + if (currentUser === undefined) { + isChecking = true; + hasAccess = false; + return; + } + // Marcar que já verificou pelo menos uma vez + hasCheckedOnce = true; + + // Se a query retornou dados, verificar autenticação + if (currentUser?.data) { // Verificar roles - if (allowedRoles.length > 0 && currentUser?.data) { + if (allowedRoles.length > 0) { const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? ''); if (!hasRole) { const currentPath = window.location.pathname; @@ -49,19 +73,49 @@ } // Verificar nível - if ( - currentUser?.data && - currentUser.data.role?.nivel && - currentUser.data.role.nivel > maxLevel - ) { + if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) { const currentPath = window.location.pathname; window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`; return; } + // Se chegou aqui, permitir acesso hasAccess = true; isChecking = false; - }, 100); + return; + } + + // Se não tem dados e requer autenticação + if (requireAuth && !currentUser?.data) { + // Se a query já retornou (não está mais undefined), finalizar estado + if (currentUser !== undefined) { + const currentPath = window.location.pathname; + // Evitar redirecionamento em loop - verificar se já está na URL de erro + const urlParams = new URLSearchParams(window.location.search); + if (!urlParams.has('error')) { + // Só redirecionar se não estiver em loop + if (!hasCheckedOnce || currentUser === null) { + window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`; + return; + } + } + // Se já tem erro na URL, permitir renderização para mostrar o alerta + isChecking = false; + hasAccess = true; + return; + } + + // Se ainda está carregando (undefined), aguardar + isChecking = true; + hasAccess = false; + return; + } + + // Se não requer autenticação, permitir acesso + if (!requireAuth) { + hasAccess = true; + isChecking = false; + } } diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index fbc1386..fe64cde 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -98,6 +98,7 @@ matricula = ''; senha = ''; erroLogin = ''; + carregandoLogin = false; } function closeLoginModal() { @@ -105,6 +106,7 @@ matricula = ''; senha = ''; erroLogin = ''; + carregandoLogin = false; } function openAboutModal() { @@ -137,6 +139,7 @@ } else { erroLogin = 'Erro ao fazer login'; } + carregandoLogin = false; } async function handleLogout() { @@ -203,7 +206,7 @@
@@ -325,7 +328,7 @@ >Contato - Suporte @@ -391,101 +394,142 @@ {#if loginModalStore.showModal} -

Registre reclamações, sugestões, elogios ou chamados técnicos. Toda interação gera - notificações automáticas via e-mail e chat com a assinatura do SGSE. + notificações automáticas via e-mail e chat com a assinatura do SGSE - Sistema de Gerenciamento de Secretaria.

Resposta ágil @@ -164,11 +164,31 @@
-
-

Formulário

-

- Informe os detalhes para que nossa equipe possa priorizar o atendimento. -

+
+
+
+ + + +
+
+

Formulário

+

+ Informe os detalhes para que nossa equipe possa priorizar o atendimento. +

+
+
{#if resetSignal % 2 === 0} @@ -180,13 +200,37 @@
diff --git a/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte b/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte index a2a96d1..f08ff8b 100644 --- a/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte +++ b/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte @@ -5,6 +5,7 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { onMount } from 'svelte'; + import { Key, Eye, EyeOff, CheckCircle2, XCircle, Shield, Lock, AlertCircle, Info } from 'lucide-svelte'; const convex = useConvexClient(); const currentUser = useQuery(api.auth.getCurrentUser, {}); @@ -136,361 +137,288 @@ } -
- +
+
-
- - - -

Alterar Senha

+
+
+
+ +
+
+

Alterar Senha

+

+ Atualize sua senha de acesso ao sistema de forma segura +

+
+
+ + Seguro +
+
-

Atualize sua senha de acesso ao sistema

-
- - {#if notice} -
- - {#if notice.type === 'success'} - - {:else} - - {/if} - - {notice.message} +
+ {#if notice.type === 'success'} + + {:else} + + {/if} + {notice.message}
{/if} - -
-
- - -
- -
- - +
+ +
+
+
+
+
+ +
+

Formulário de Alteração

-
- -
- -
- - -
-
- - Mínimo 8 caracteres, com letras maiúsculas, minúsculas, números e caracteres especiais - -
-
- - -
- -
- - -
-
- - -
- - - -
-

Requisitos de Senha:

-
    -
  • Mínimo de 8 caracteres
  • -
  • Pelo menos uma letra maiúscula (A-Z)
  • -
  • Pelo menos uma letra minúscula (a-z)
  • -
  • Pelo menos um número (0-9)
  • -
  • Pelo menos um caractere especial (!@#$%^&*...)
  • -
-
-
- - -
- - -
- -
-
+ +
+
- -
-
-

- - - - Dicas de Segurança -

-
    -
  • ✅ Nunca compartilhe sua senha com ninguém
  • -
  • ✅ Use uma senha única para cada sistema
  • -
  • ✅ Altere sua senha regularmente
  • -
  • ✅ Não use informações pessoais óbvias (nome, data de nascimento, etc.)
  • -
  • ✅ Considere usar um gerenciador de senhas
  • -
+ +
+ +
+ + +
+
+ + Mínimo 8 caracteres com maiúsculas, minúsculas, números e especiais + +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+ +
+

Requisitos de Senha

+
+
    +
  • + + Mínimo de 8 caracteres +
  • +
  • + + Pelo menos uma letra maiúscula (A-Z) +
  • +
  • + + Pelo menos uma letra minúscula (a-z) +
  • +
  • + + Pelo menos um número (0-9) +
  • +
  • + + Pelo menos um caractere especial (!@#$%...) +
  • +
+
+
+ + +
+
+
+
+ +
+

Dicas de Segurança

+
+
    +
  • + + Nunca compartilhe sua senha +
  • +
  • + + Use uma senha única para cada sistema +
  • +
  • + + Altere sua senha regularmente +
  • +
  • + + Evite informações pessoais óbvias +
  • +
  • + + Considere usar um gerenciador de senhas +
  • +
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/perfil/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/+page.svelte index 2e0f1a7..5709a43 100644 --- a/apps/web/src/routes/(dashboard)/perfil/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/+page.svelte @@ -1,7 +1,7 @@ - Cibersecurity SGSE • Wizcard TI + Cibersecurity SGSE - Sistema de Gerenciamento de Secretaria • Wizcard TI

- Cibersecurity • SGSE + Cibersecurity • SGSE - Sistema de Gerenciamento de Secretaria

Segurança Avançada

diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte index 6e05735..15a7f1a 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte @@ -404,7 +404,7 @@ id="smtp-nome-remetente" type="text" bind:value={nomeRemetente} - placeholder="SGSE - Sistema de Gestão" + placeholder="SGSE - Sistema de Gerenciamento de Secretaria" class="input input-bordered" />

diff --git a/apps/web/src/routes/(dashboard)/ti/monitoramento/+page.svelte b/apps/web/src/routes/(dashboard)/ti/monitoramento/+page.svelte index 32b2e99..8eb9a14 100644 --- a/apps/web/src/routes/(dashboard)/ti/monitoramento/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/monitoramento/+page.svelte @@ -24,7 +24,7 @@
-

Monitoramento SGSE

+

Monitoramento SGSE - Sistema de Gerenciamento de Secretaria

Sistema de monitoramento técnico em tempo real

diff --git a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte index c8a798b..54e5976 100644 --- a/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/painel-administrativo/+page.svelte @@ -122,6 +122,6 @@
- Sistema de Gestão da Secretaria de Esportes - Versão 2.0 com controle avançado de acesso + SGSE - Sistema de Gerenciamento de Secretaria - Versão 2.0 com controle avançado de acesso
diff --git a/packages/backend/convex/actions/email.ts b/packages/backend/convex/actions/email.ts index adc368d..c22ea26 100644 --- a/packages/backend/convex/actions/email.ts +++ b/packages/backend/convex/actions/email.ts @@ -1,233 +1,233 @@ -"use node"; - -import { action } from "../_generated/server"; -import { v } from "convex/values"; -import { internal } from "../_generated/api"; -import { decryptSMTPPasswordNode } from "./utils/nodeCrypto"; -import nodemailer from "nodemailer"; - -export const enviar = action({ - args: { - emailId: v.id("notificacoesEmail"), - }, - returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), - handler: async (ctx, args) => { - "use node"; - - let email; - try { - // Buscar email da fila - email = await ctx.runQuery(internal.email.getEmailById, { - emailId: args.emailId, - }); - - if (!email) { - return { sucesso: false, erro: "Email não encontrado" }; - } - - // Buscar configuração SMTP ativa - const configRaw = await ctx.runQuery( - internal.email.getActiveEmailConfig, - {} - ); - - if (!configRaw) { - console.error( - "❌ Configuração SMTP não encontrada ou inativa para email:", - email.destinatario - ); - return { - sucesso: false, - erro: "Configuração de email não encontrada ou inativa. Verifique as configurações SMTP no painel de TI.", - }; - } - - console.log("📧 Tentando enviar email:", { - para: email.destinatario, - assunto: email.assunto, - servidor: configRaw.servidor, - porta: configRaw.porta, - }); - - // Descriptografar senha usando função compatível com Node.js - let senhaDescriptografada: string; - try { - senhaDescriptografada = await decryptSMTPPasswordNode( - configRaw.senhaHash - ); - } catch (decryptError) { - const decryptErrorMessage = - decryptError instanceof Error - ? decryptError.message - : String(decryptError); - console.error( - "Erro ao descriptografar senha SMTP:", - decryptErrorMessage - ); - return { - sucesso: false, - erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`, - }; - } - - const config = { - ...configRaw, - senha: senhaDescriptografada, - }; - - // Config já foi validado acima - - // Avisar mas não bloquear se não foi testado - if (!config.testadoEm) { - console.warn( - "⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim..." - ); - } - - // Marcar como enviando - await ctx.runMutation(internal.email.markEmailEnviando, { - emailId: args.emailId, - }); - - // Criar transporter do nodemailer com configuração melhorada - const transporterOptions: { - host: string; - port: number; - secure: boolean; - requireTLS?: boolean; - auth: { - user: string; - pass: string; - }; - tls?: { - rejectUnauthorized: boolean; - ciphers?: string; - }; - connectionTimeout: number; - greetingTimeout: number; - socketTimeout: number; - pool?: boolean; - maxConnections?: number; - maxMessages?: number; - } = { - host: config.servidor, - port: config.porta, - secure: config.usarSSL, - auth: { - user: config.usuario, - pass: config.senha, // Senha já descriptografada - }, - connectionTimeout: 15000, // 15 segundos - greetingTimeout: 15000, - socketTimeout: 15000, - pool: true, // Usar pool de conexões - maxConnections: 5, - maxMessages: 100, - }; - - // Adicionar TLS apenas se necessário - if (config.usarTLS) { - transporterOptions.requireTLS = true; - transporterOptions.tls = { - rejectUnauthorized: false, // Permitir certificados autoassinados - }; - } else if (config.usarSSL) { - transporterOptions.tls = { - rejectUnauthorized: false, - }; - } - - const transporter = nodemailer.createTransport(transporterOptions); - - // Verificar conexão antes de enviar - try { - await transporter.verify(); - console.log("✅ Conexão SMTP verificada com sucesso"); - } catch (verifyError) { - const verifyErrorMessage = - verifyError instanceof Error - ? verifyError.message - : String(verifyError); - console.warn( - "⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:", - verifyErrorMessage - ); - // Não bloquear envio por falha na verificação, apenas avisar - } - - // Validar email destinatário antes de enviar - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email.destinatario)) { - throw new Error(`Email destinatário inválido: ${email.destinatario}`); - } - - // Criar versão texto do HTML (remover tags e decodificar entidades básicas) - const textoPlano = email.corpo - .replace(/<[^>]*>/g, "") // Remover tags HTML - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .trim(); - - // Enviar email - const info = await transporter.sendMail({ - from: `"${config.nomeRemetente}" <${config.emailRemetente}>`, - to: email.destinatario, - subject: email.assunto, - html: email.corpo, - text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML - headers: { - "X-Mailer": "SGSE-Sistema", - "X-Priority": "3", - }, - }); - - interface MessageInfo { - messageId?: string; - response?: string; - } - - const messageInfo = info as MessageInfo; - - console.log("✅ Email enviado com sucesso!", { - para: email.destinatario, - assunto: email.assunto, - messageId: messageInfo.messageId, - response: messageInfo.response, - }); - - // Marcar como enviado - await ctx.runMutation(internal.email.markEmailEnviado, { - emailId: args.emailId, - }); - - return { sucesso: true }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; - - console.error("❌ Erro ao enviar email:", { - emailId: args.emailId, - destinatario: email?.destinatario, - erro: errorMessage, - stack: errorStack, - }); - - // Marcar como falha com detalhes completos - const erroCompleto = errorStack - ? `${errorMessage}\n\nStack: ${errorStack}` - : errorMessage; - - await ctx.runMutation(internal.email.markEmailFalha, { - emailId: args.emailId, - erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro - }); - - return { sucesso: false, erro: errorMessage }; - } - }, -}); +"use node"; + +import { action } from "../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { decryptSMTPPasswordNode } from "./utils/nodeCrypto"; +import nodemailer from "nodemailer"; + +export const enviar = action({ + args: { + emailId: v.id("notificacoesEmail"), + }, + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args) => { + "use node"; + + let email; + try { + // Buscar email da fila + email = await ctx.runQuery(internal.email.getEmailById, { + emailId: args.emailId, + }); + + if (!email) { + return { sucesso: false, erro: "Email não encontrado" }; + } + + // Buscar configuração SMTP ativa + const configRaw = await ctx.runQuery( + internal.email.getActiveEmailConfig, + {} + ); + + if (!configRaw) { + console.error( + "❌ Configuração SMTP não encontrada ou inativa para email:", + email.destinatario + ); + return { + sucesso: false, + erro: "Configuração de email não encontrada ou inativa. Verifique as configurações SMTP no painel de TI.", + }; + } + + console.log("📧 Tentando enviar email:", { + para: email.destinatario, + assunto: email.assunto, + servidor: configRaw.servidor, + porta: configRaw.porta, + }); + + // Descriptografar senha usando função compatível com Node.js + let senhaDescriptografada: string; + try { + senhaDescriptografada = await decryptSMTPPasswordNode( + configRaw.senhaHash + ); + } catch (decryptError) { + const decryptErrorMessage = + decryptError instanceof Error + ? decryptError.message + : String(decryptError); + console.error( + "Erro ao descriptografar senha SMTP:", + decryptErrorMessage + ); + return { + sucesso: false, + erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`, + }; + } + + const config = { + ...configRaw, + senha: senhaDescriptografada, + }; + + // Config já foi validado acima + + // Avisar mas não bloquear se não foi testado + if (!config.testadoEm) { + console.warn( + "⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim..." + ); + } + + // Marcar como enviando + await ctx.runMutation(internal.email.markEmailEnviando, { + emailId: args.emailId, + }); + + // Criar transporter do nodemailer com configuração melhorada + const transporterOptions: { + host: string; + port: number; + secure: boolean; + requireTLS?: boolean; + auth: { + user: string; + pass: string; + }; + tls?: { + rejectUnauthorized: boolean; + ciphers?: string; + }; + connectionTimeout: number; + greetingTimeout: number; + socketTimeout: number; + pool?: boolean; + maxConnections?: number; + maxMessages?: number; + } = { + host: config.servidor, + port: config.porta, + secure: config.usarSSL, + auth: { + user: config.usuario, + pass: config.senha, // Senha já descriptografada + }, + connectionTimeout: 15000, // 15 segundos + greetingTimeout: 15000, + socketTimeout: 15000, + pool: true, // Usar pool de conexões + maxConnections: 5, + maxMessages: 100, + }; + + // Adicionar TLS apenas se necessário + if (config.usarTLS) { + transporterOptions.requireTLS = true; + transporterOptions.tls = { + rejectUnauthorized: false, // Permitir certificados autoassinados + }; + } else if (config.usarSSL) { + transporterOptions.tls = { + rejectUnauthorized: false, + }; + } + + const transporter = nodemailer.createTransport(transporterOptions); + + // Verificar conexão antes de enviar + try { + await transporter.verify(); + console.log("✅ Conexão SMTP verificada com sucesso"); + } catch (verifyError) { + const verifyErrorMessage = + verifyError instanceof Error + ? verifyError.message + : String(verifyError); + console.warn( + "⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:", + verifyErrorMessage + ); + // Não bloquear envio por falha na verificação, apenas avisar + } + + // Validar email destinatário antes de enviar + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email.destinatario)) { + throw new Error(`Email destinatário inválido: ${email.destinatario}`); + } + + // Criar versão texto do HTML (remover tags e decodificar entidades básicas) + const textoPlano = email.corpo + .replace(/<[^>]*>/g, "") // Remover tags HTML + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + + // Enviar email + const info = await transporter.sendMail({ + from: `"${config.nomeRemetente}" <${config.emailRemetente}>`, + to: email.destinatario, + subject: email.assunto, + html: email.corpo, + text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML + headers: { + "X-Mailer": "SGSE-Sistema-de-Gerenciamento-de-Secretaria", + "X-Priority": "3", + }, + }); + + interface MessageInfo { + messageId?: string; + response?: string; + } + + const messageInfo = info as MessageInfo; + + console.log("✅ Email enviado com sucesso!", { + para: email.destinatario, + assunto: email.assunto, + messageId: messageInfo.messageId, + response: messageInfo.response, + }); + + // Marcar como enviado + await ctx.runMutation(internal.email.markEmailEnviado, { + emailId: args.emailId, + }); + + return { sucesso: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + + console.error("❌ Erro ao enviar email:", { + emailId: args.emailId, + destinatario: email?.destinatario, + erro: errorMessage, + stack: errorStack, + }); + + // Marcar como falha com detalhes completos + const erroCompleto = errorStack + ? `${errorMessage}\n\nStack: ${errorStack}` + : errorMessage; + + await ctx.runMutation(internal.email.markEmailFalha, { + emailId: args.emailId, + erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro + }); + + return { sucesso: false, erro: errorMessage }; + } + }, +}); diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts new file mode 100644 index 0000000..bd83fe6 --- /dev/null +++ b/packages/backend/convex/autenticacao.ts @@ -0,0 +1,72 @@ +import { mutation } from './_generated/server'; +import { v } from 'convex/values'; +import { updatePassword } from './auth'; +import { authComponent } from './auth'; + +/** + * Alterar senha do usuário autenticado + */ +export const alterarSenha = mutation({ + args: { + token: v.string(), // Token não é usado, mas mantido para compatibilidade + senhaAtual: v.string(), + novaSenha: v.string() + }, + returns: v.union( + v.object({ sucesso: v.literal(true) }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + try { + // Verificar se o usuário está autenticado + const authUser = await authComponent.safeGetAuthUser(ctx); + if (!authUser) { + return { + sucesso: false as const, + erro: 'Usuário não autenticado' + }; + } + + // Validar que a nova senha não está vazia + if (!args.novaSenha || args.novaSenha.trim().length === 0) { + return { + sucesso: false as const, + erro: 'A nova senha não pode estar vazia' + }; + } + + // Chamar a função de atualização de senha + await updatePassword(ctx, { + currentPassword: args.senhaAtual, + newPassword: args.novaSenha + }); + + return { + sucesso: true as const + }; + } catch (error: any) { + // Capturar erros específicos do Better Auth + let mensagemErro = 'Erro ao alterar senha'; + + if (error?.message) { + mensagemErro = error.message; + } else if (typeof error === 'string') { + mensagemErro = error; + } + + // Mensagens de erro mais amigáveis + if (mensagemErro.toLowerCase().includes('password') || + mensagemErro.toLowerCase().includes('senha') || + mensagemErro.toLowerCase().includes('incorrect') || + mensagemErro.toLowerCase().includes('incorreta')) { + mensagemErro = 'Senha atual incorreta'; + } + + return { + sucesso: false as const, + erro: mensagemErro + }; + } + } +}); + diff --git a/packages/backend/convex/chamados.ts b/packages/backend/convex/chamados.ts index 7bfe140..f25df21 100644 --- a/packages/backend/convex/chamados.ts +++ b/packages/backend/convex/chamados.ts @@ -127,7 +127,7 @@ async function registrarNotificacoes( destinatario: ticket.solicitanteEmail, destinatarioId: ticket.solicitanteId, assunto: `${titulo} - Chamado ${ticket.numero}`, - corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE`, + corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, enviadoPor: usuarioEvento, }); } @@ -151,7 +151,7 @@ async function registrarNotificacoes( destinatario: responsavel.email, destinatarioId: ticket.responsavelId, assunto: `${titulo} - Chamado ${ticket.numero}`, - corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE`, + corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, enviadoPor: usuarioEvento, }); } diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts index 762e4af..a89e9d0 100644 --- a/packages/backend/convex/ferias.ts +++ b/packages/backend/convex/ferias.ts @@ -588,7 +588,8 @@ export const atualizarStatus = mutation({ v.literal("aguardando_aprovacao"), v.literal("aprovado"), v.literal("reprovado"), - v.literal("data_ajustada_aprovada") + v.literal("data_ajustada_aprovada"), + v.literal("Cancelado_RH") ), usuarioId: v.id("usuarios"), }, diff --git a/packages/backend/convex/monitoramento.ts b/packages/backend/convex/monitoramento.ts index 6189bed..0cbd46f 100644 --- a/packages/backend/convex/monitoramento.ts +++ b/packages/backend/convex/monitoramento.ts @@ -636,7 +636,7 @@ export const getStatusSistema = query({ /** * Atividade do banco no último minuto (agregada em buckets) - * Usa mensagensPorMinuto como proxy de atividade quando disponível. + * Usa logsAtividades e systemMetrics para calcular atividade real. */ export const getAtividadeBancoDados = query({ args: {}, @@ -652,6 +652,14 @@ export const getAtividadeBancoDados = query({ const agora = Date.now(); const haUmMinuto = agora - 60 * 1000; + // Buscar atividades reais do sistema + const atividadesRecentes = await ctx.db + .query('logsAtividades') + .withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto)) + .order('asc') + .collect(); + + // Buscar métricas também (para mensagens se houver) const metricasRecentes = await ctx.db .query('systemMetrics') .withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto)) @@ -666,15 +674,30 @@ export const getAtividadeBancoDados = query({ for (let i = 0; i < numBuckets; i++) { const inicio = haUmMinuto + i * bucketSizeMs; const fim = inicio + bucketSizeMs; + + // Contar atividades de criação/inserção (entradas) + const atividadesBucket = atividadesRecentes.filter( + (a) => a.timestamp >= inicio && a.timestamp < fim + ); + const entradasAtividades = atividadesBucket.filter( + a => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar' + ).length; + + // Contar atividades de exclusão/remoção (saídas) + const saidasAtividades = atividadesBucket.filter( + a => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar' + ).length; + + // Usar mensagensPorMinuto como adicional se disponível const bucketMetricas = metricasRecentes.filter( (m) => m.timestamp >= inicio && m.timestamp < fim ); - - // Usar mensagensPorMinuto como proxy de "entradas"; "saídas" como fração const somaMensagens = bucketMetricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0) || 0; - const entradas = Math.max(0, Math.round(somaMensagens)); - const saidas = Math.max(0, Math.round(entradas * 0.6)); + + // Combinar atividades reais com métricas de mensagens + const entradas = Math.max(0, Math.round(entradasAtividades + somaMensagens * 0.3)); + const saidas = Math.max(0, Math.round(saidasAtividades + somaMensagens * 0.2)); historico.push({ entradas, saidas }); } @@ -684,7 +707,7 @@ export const getAtividadeBancoDados = query({ }); /** - * Distribuição de operações (estimada a partir das métricas) + * Distribuição de operações (calculada a partir de logsAtividades e métricas) */ export const getDistribuicaoRequisicoes = query({ args: {}, @@ -696,21 +719,43 @@ export const getDistribuicaoRequisicoes = query({ }), handler: async (ctx) => { const umaHoraAtras = Date.now() - 60 * 60 * 1000; + + // Buscar atividades reais do sistema + const atividades = await ctx.db + .query('logsAtividades') + .withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras)) + .collect(); + + // Buscar métricas também const metricas = await ctx.db .query('systemMetrics') .withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras)) .order('desc') .take(100); - const totalOps = Math.max( + // Contar operações de leitura (consultas, visualizações) + const leituras = atividades.filter( + a => a.acao === 'consultar' || a.acao === 'visualizar' || a.acao === 'listar' || a.acao === 'buscar' + ).length; + + // Contar operações de escrita (criar, editar, excluir) + const escritas = atividades.filter( + a => a.acao === 'criar' || a.acao === 'editar' || a.acao === 'excluir' || + a.acao === 'inserir' || a.acao === 'atualizar' || a.acao === 'deletar' || + a.acao === 'cadastrar' || a.acao === 'remover' + ).length; + + // Adicionar estimativa baseada em mensagens se disponível + const totalMensagens = Math.max( 0, Math.round(metricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0)) ); - const queries = Math.round(totalOps * 0.7); - const mutations = Math.max(0, totalOps - queries); - const leituras = queries; - const escritas = mutations; + // Queries são leituras + parte das mensagens (como consultas de chat) + const queries = leituras + Math.round(totalMensagens * 0.5); + + // Mutations são escritas + parte das mensagens (como envio de mensagens) + const mutations = escritas + Math.round(totalMensagens * 0.3); return { queries, mutations, leituras, escritas }; } diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 90da20b..e2a8b3d 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -366,7 +366,8 @@ export default defineSchema({ v.literal("aprovado"), v.literal("reprovado"), v.literal("data_ajustada_aprovada"), - v.literal("EmFérias") + v.literal("EmFérias"), + v.literal("Cancelado_RH") ), gestorId: v.optional(v.id("usuarios")), observacao: v.optional(v.string()), diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index c7b027a..1b92ecf 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -1,421 +1,421 @@ -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import { registrarAtividade } from "./logsAtividades"; -import { Doc } from "./_generated/dataModel"; - -/** - * Listar todos os templates - */ -export const listarTemplates = query({ - args: {}, - handler: async (ctx) => { - const templates = await ctx.db.query("templatesMensagens").collect(); - return templates; - }, -}); - -/** - * Obter template por código - */ -export const obterTemplatePorCodigo = query({ - args: { - codigo: v.string(), - }, - handler: async (ctx, args) => { - const template = await ctx.db - .query("templatesMensagens") - .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo)) - .first(); - - return template; - }, -}); - -/** - * Criar template customizado (apenas TI_MASTER) - */ -export const criarTemplate = mutation({ - args: { - codigo: v.string(), - nome: v.string(), - titulo: v.string(), - corpo: v.string(), - variaveis: v.optional(v.array(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) => { - // Verificar se código já existe - const existente = await ctx.db - .query("templatesMensagens") - .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo)) - .first(); - - if (existente) { - return { sucesso: false as const, erro: "Código de template já existe" }; - } - - // Criar template - const templateId = await ctx.db.insert("templatesMensagens", { - codigo: args.codigo, - nome: args.nome, - tipo: "customizado", - titulo: args.titulo, - corpo: args.corpo, - variaveis: args.variaveis, - criadoPor: args.criadoPorId, - criadoEm: Date.now(), - }); - - // Log de atividade - await registrarAtividade( - ctx, - args.criadoPorId, - "criar", - "templates", - JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }), - templateId - ); - - return { sucesso: true as const, templateId }; - }, -}); - -/** - * Editar template customizado (apenas TI_MASTER, não edita templates do sistema) - */ -export const editarTemplate = mutation({ - args: { - templateId: v.id("templatesMensagens"), - nome: v.optional(v.string()), - titulo: v.optional(v.string()), - corpo: v.optional(v.string()), - variaveis: v.optional(v.array(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" }; - } - - // Atualizar template - const updates: Partial> = {}; - 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.variaveis !== undefined) updates.variaveis = args.variaveis; - - await ctx.db.patch(args.templateId, updates); - - // Log de atividade - await registrarAtividade( - ctx, - args.editadoPorId, - "editar", - "templates", - JSON.stringify(updates), - args.templateId - ); - - return { sucesso: true as const }; - }, -}); - -/** - * Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema) - */ -export const excluirTemplate = mutation({ - args: { - templateId: v.id("templatesMensagens"), - excluidoPorId: 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 excluir templates do sistema - if (template.tipo === "sistema") { - return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" }; - } - - // Excluir template - await ctx.db.delete(args.templateId); - - // Log de atividade - await registrarAtividade( - ctx, - args.excluidoPorId, - "excluir", - "templates", - JSON.stringify({ templateId: args.templateId, codigo: template.codigo }), - args.templateId - ); - - return { sucesso: true as const }; - }, -}); - -/** - * Renderizar template com variáveis - */ -export function renderizarTemplate(template: string, variaveis: Record): string { - let resultado = template; - - for (const [chave, valor] of Object.entries(variaveis)) { - const placeholder = `{{${chave}}}`; - resultado = resultado.replace(new RegExp(placeholder, "g"), valor); - } - - return resultado; -} - -/** - * Criar templates padrão do sistema (chamado no seed) - */ -export const criarTemplatesPadrao = mutation({ - args: {}, - handler: async (ctx) => { - const templatesPadrao = [ - { - codigo: "USUARIO_BLOQUEADO", - nome: "Usuário Bloqueado", - titulo: "Sua conta foi bloqueada", - corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.", - variaveis: ["motivo"], - }, - { - codigo: "USUARIO_DESBLOQUEADO", - nome: "Usuário Desbloqueado", - titulo: "Sua conta foi desbloqueada", - corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.", - variaveis: [], - }, - { - codigo: "SENHA_RESETADA", - nome: "Senha Resetada", - titulo: "Sua senha foi resetada", - corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.", - variaveis: ["senha"], - }, - { - codigo: "PERMISSAO_ALTERADA", - nome: "Permissão Alterada", - titulo: "Suas permissões foram atualizadas", - corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.", - variaveis: [], - }, - { - codigo: "AVISO_GERAL", - nome: "Aviso Geral", - titulo: "{{titulo}}", - corpo: "{{mensagem}}", - variaveis: ["titulo", "mensagem"], - }, - { - codigo: "BEM_VINDO", - nome: "Boas-vindas", - titulo: "Bem-vindo ao SGSE", - corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI", - variaveis: ["nome", "matricula", "senha"], - }, - { - codigo: "chat_mensagem", - nome: "Nova Mensagem no Chat", - titulo: "Nova mensagem de {{remetente}}", - corpo: "" - + "
" - + "

Nova mensagem no chat

" - + "

{{remetente}} enviou uma nova mensagem:

" - + "
" - + "

{{mensagem}}

" - + "
" - + "

" - + "" - + "Ver conversa" - + "" - + "

" - + "

" - + "Você está recebendo este email porque não estava online quando a mensagem foi enviada. " - + "Você pode desativar essas notificações nas configurações da conversa." - + "

" - + "
", - variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], - }, - { - codigo: "chat_mencao", - nome: "Menção no Chat", - titulo: "{{remetente}} mencionou você", - corpo: "" - + "
" - + "

Você foi mencionado!

" - + "

{{remetente}} mencionou você em uma mensagem:

" - + "
" - + "

{{mensagem}}

" - + "
" - + "

" - + "" - + "Ver mensagem" - + "" - + "

" - + "

" - + "Você está recebendo este email porque foi mencionado em uma conversa. " - + "Você pode desativar essas notificações nas configurações da conversa." - + "

" - + "
", - variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], - }, - { - codigo: "chamado_registrado", - nome: "Chamado Registrado", - titulo: "Chamado {{numeroTicket}} registrado", - corpo: "" - + "
" - + "

Chamado registrado com sucesso!

" - + "

Olá {{solicitante}},

" - + "

Recebemos sua solicitação e iniciaremos o atendimento em breve.

" - + "
" - + "

Ticket: {{numeroTicket}}

" - + "

Prioridade: {{prioridade}}

" - + "

Categoria: {{categoria}}

" - + "
" - + "

" - + "" - + "Acompanhar chamado" - + "" - + "

" - + "

" - + "Central de Chamados SGSE" - + "

" - + "
", - variaveis: ["solicitante", "numeroTicket", "prioridade", "categoria", "urlSistema"], - }, - { - codigo: "chamado_atualizado", - nome: "Atualização no Chamado", - titulo: "Atualização no chamado {{numeroTicket}}", - corpo: "" - + "
" - + "

Nova atualização no seu chamado

" - + "

Olá {{solicitante}},

" - + "

Há uma nova atualização no seu chamado:

" - + "
" - + "

Ticket: {{numeroTicket}}

" - + "

Mensagem:

" - + "

{{mensagem}}

" - + "
" - + "

" - + "" - + "Ver detalhes" - + "" - + "

" - + "

" - + "Central de Chamados SGSE" - + "

" - + "
", - variaveis: ["solicitante", "numeroTicket", "mensagem", "urlSistema"], - }, - { - codigo: "chamado_atribuido", - nome: "Chamado Atribuído", - titulo: "Chamado {{numeroTicket}} atribuído", - corpo: "" - + "
" - + "

Chamado atribuído

" - + "

Olá {{responsavel}},

" - + "

Um novo chamado foi atribuído para você:

" - + "
" - + "

Ticket: {{numeroTicket}}

" - + "

Solicitante: {{solicitante}}

" - + "

Prioridade: {{prioridade}}

" - + "

Descrição: {{descricao}}

" - + "
" - + "

" - + "" - + "Acessar chamado" - + "" - + "

" - + "

" - + "Central de Chamados SGSE" - + "

" - + "
", - variaveis: ["responsavel", "numeroTicket", "solicitante", "prioridade", "descricao", "urlSistema"], - }, - { - codigo: "chamado_alerta_prazo", - nome: "Alerta de Prazo do Chamado", - titulo: "⚠️ Alerta de prazo - Chamado {{numeroTicket}}", - corpo: "" - + "
" - + "

⚠️ Alerta de prazo

" - + "

Olá {{destinatario}},

" - + "

O chamado abaixo está próximo do prazo de {{tipoPrazo}}:

" - + "
" - + "

Ticket: {{numeroTicket}}

" - + "

Prazo de {{tipoPrazo}}: {{prazo}}

" - + "

Status: {{status}}

" - + "
" - + "

" - + "" - + "Ver chamado" - + "" - + "

" - + "

" - + "Central de Chamados SGSE" - + "

" - + "
", - variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"], - }, - ]; - - for (const template of templatesPadrao) { - // Verificar se já existe - const existente = await ctx.db - .query("templatesMensagens") - .withIndex("by_codigo", (q) => q.eq("codigo", template.codigo)) - .first(); - - if (!existente) { - await ctx.db.insert("templatesMensagens", { - ...template, - tipo: "sistema", - criadoEm: Date.now(), - }); - } - } - - return { sucesso: true }; - }, -}); - - +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { registrarAtividade } from "./logsAtividades"; +import { Doc } from "./_generated/dataModel"; + +/** + * Listar todos os templates + */ +export const listarTemplates = query({ + args: {}, + handler: async (ctx) => { + const templates = await ctx.db.query("templatesMensagens").collect(); + return templates; + }, +}); + +/** + * Obter template por código + */ +export const obterTemplatePorCodigo = query({ + args: { + codigo: v.string(), + }, + handler: async (ctx, args) => { + const template = await ctx.db + .query("templatesMensagens") + .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo)) + .first(); + + return template; + }, +}); + +/** + * Criar template customizado (apenas TI_MASTER) + */ +export const criarTemplate = mutation({ + args: { + codigo: v.string(), + nome: v.string(), + titulo: v.string(), + corpo: v.string(), + variaveis: v.optional(v.array(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) => { + // Verificar se código já existe + const existente = await ctx.db + .query("templatesMensagens") + .withIndex("by_codigo", (q) => q.eq("codigo", args.codigo)) + .first(); + + if (existente) { + return { sucesso: false as const, erro: "Código de template já existe" }; + } + + // Criar template + const templateId = await ctx.db.insert("templatesMensagens", { + codigo: args.codigo, + nome: args.nome, + tipo: "customizado", + titulo: args.titulo, + corpo: args.corpo, + variaveis: args.variaveis, + criadoPor: args.criadoPorId, + criadoEm: Date.now(), + }); + + // Log de atividade + await registrarAtividade( + ctx, + args.criadoPorId, + "criar", + "templates", + JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }), + templateId + ); + + return { sucesso: true as const, templateId }; + }, +}); + +/** + * Editar template customizado (apenas TI_MASTER, não edita templates do sistema) + */ +export const editarTemplate = mutation({ + args: { + templateId: v.id("templatesMensagens"), + nome: v.optional(v.string()), + titulo: v.optional(v.string()), + corpo: v.optional(v.string()), + variaveis: v.optional(v.array(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" }; + } + + // Atualizar template + const updates: Partial> = {}; + 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.variaveis !== undefined) updates.variaveis = args.variaveis; + + await ctx.db.patch(args.templateId, updates); + + // Log de atividade + await registrarAtividade( + ctx, + args.editadoPorId, + "editar", + "templates", + JSON.stringify(updates), + args.templateId + ); + + return { sucesso: true as const }; + }, +}); + +/** + * Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema) + */ +export const excluirTemplate = mutation({ + args: { + templateId: v.id("templatesMensagens"), + excluidoPorId: 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 excluir templates do sistema + if (template.tipo === "sistema") { + return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" }; + } + + // Excluir template + await ctx.db.delete(args.templateId); + + // Log de atividade + await registrarAtividade( + ctx, + args.excluidoPorId, + "excluir", + "templates", + JSON.stringify({ templateId: args.templateId, codigo: template.codigo }), + args.templateId + ); + + return { sucesso: true as const }; + }, +}); + +/** + * Renderizar template com variáveis + */ +export function renderizarTemplate(template: string, variaveis: Record): string { + let resultado = template; + + for (const [chave, valor] of Object.entries(variaveis)) { + const placeholder = `{{${chave}}}`; + resultado = resultado.replace(new RegExp(placeholder, "g"), valor); + } + + return resultado; +} + +/** + * Criar templates padrão do sistema (chamado no seed) + */ +export const criarTemplatesPadrao = mutation({ + args: {}, + handler: async (ctx) => { + const templatesPadrao = [ + { + codigo: "USUARIO_BLOQUEADO", + nome: "Usuário Bloqueado", + titulo: "Sua conta foi bloqueada", + corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.", + variaveis: ["motivo"], + }, + { + codigo: "USUARIO_DESBLOQUEADO", + nome: "Usuário Desbloqueado", + titulo: "Sua conta foi desbloqueada", + corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.", + variaveis: [], + }, + { + codigo: "SENHA_RESETADA", + nome: "Senha Resetada", + titulo: "Sua senha foi resetada", + corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.", + variaveis: ["senha"], + }, + { + codigo: "PERMISSAO_ALTERADA", + nome: "Permissão Alterada", + titulo: "Suas permissões foram atualizadas", + corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.", + variaveis: [], + }, + { + codigo: "AVISO_GERAL", + nome: "Aviso Geral", + titulo: "{{titulo}}", + corpo: "{{mensagem}}", + variaveis: ["titulo", "mensagem"], + }, + { + codigo: "BEM_VINDO", + nome: "Boas-vindas", + titulo: "Bem-vindo ao SGSE", + corpo: "Olá {{nome}},\n\nSeja bem-vindo ao SGSE - Sistema de Gerenciamento de Secretaria!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI", + variaveis: ["nome", "matricula", "senha"], + }, + { + codigo: "chat_mensagem", + nome: "Nova Mensagem no Chat", + titulo: "Nova mensagem de {{remetente}}", + corpo: "" + + "
" + + "

Nova mensagem no chat

" + + "

{{remetente}} enviou uma nova mensagem:

" + + "
" + + "

{{mensagem}}

" + + "
" + + "

" + + "" + + "Ver conversa" + + "" + + "

" + + "

" + + "Você está recebendo este email porque não estava online quando a mensagem foi enviada. " + + "Você pode desativar essas notificações nas configurações da conversa." + + "

" + + "
", + variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], + }, + { + codigo: "chat_mencao", + nome: "Menção no Chat", + titulo: "{{remetente}} mencionou você", + corpo: "" + + "
" + + "

Você foi mencionado!

" + + "

{{remetente}} mencionou você em uma mensagem:

" + + "
" + + "

{{mensagem}}

" + + "
" + + "

" + + "" + + "Ver mensagem" + + "" + + "

" + + "

" + + "Você está recebendo este email porque foi mencionado em uma conversa. " + + "Você pode desativar essas notificações nas configurações da conversa." + + "

" + + "
", + variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], + }, + { + codigo: "chamado_registrado", + nome: "Chamado Registrado", + titulo: "Chamado {{numeroTicket}} registrado", + corpo: "" + + "
" + + "

Chamado registrado com sucesso!

" + + "

Olá {{solicitante}},

" + + "

Recebemos sua solicitação e iniciaremos o atendimento em breve.

" + + "
" + + "

Ticket: {{numeroTicket}}

" + + "

Prioridade: {{prioridade}}

" + + "

Categoria: {{categoria}}

" + + "
" + + "

" + + "" + + "Acompanhar chamado" + + "" + + "

" + + "

" + + "Central de Chamados SGSE - Sistema de Gerenciamento de Secretaria" + + "

" + + "
", + variaveis: ["solicitante", "numeroTicket", "prioridade", "categoria", "urlSistema"], + }, + { + codigo: "chamado_atualizado", + nome: "Atualização no Chamado", + titulo: "Atualização no chamado {{numeroTicket}}", + corpo: "" + + "
" + + "

Nova atualização no seu chamado

" + + "

Olá {{solicitante}},

" + + "

Há uma nova atualização no seu chamado:

" + + "
" + + "

Ticket: {{numeroTicket}}

" + + "

Mensagem:

" + + "

{{mensagem}}

" + + "
" + + "

" + + "" + + "Ver detalhes" + + "" + + "

" + + "

" + + "Central de Chamados SGSE - Sistema de Gerenciamento de Secretaria" + + "

" + + "
", + variaveis: ["solicitante", "numeroTicket", "mensagem", "urlSistema"], + }, + { + codigo: "chamado_atribuido", + nome: "Chamado Atribuído", + titulo: "Chamado {{numeroTicket}} atribuído", + corpo: "" + + "
" + + "

Chamado atribuído

" + + "

Olá {{responsavel}},

" + + "

Um novo chamado foi atribuído para você:

" + + "
" + + "

Ticket: {{numeroTicket}}

" + + "

Solicitante: {{solicitante}}

" + + "

Prioridade: {{prioridade}}

" + + "

Descrição: {{descricao}}

" + + "
" + + "

" + + "" + + "Acessar chamado" + + "" + + "

" + + "

" + + "Central de Chamados SGSE - Sistema de Gerenciamento de Secretaria" + + "

" + + "
", + variaveis: ["responsavel", "numeroTicket", "solicitante", "prioridade", "descricao", "urlSistema"], + }, + { + codigo: "chamado_alerta_prazo", + nome: "Alerta de Prazo do Chamado", + titulo: "⚠️ Alerta de prazo - Chamado {{numeroTicket}}", + corpo: "" + + "
" + + "

⚠️ Alerta de prazo

" + + "

Olá {{destinatario}},

" + + "

O chamado abaixo está próximo do prazo de {{tipoPrazo}}:

" + + "
" + + "

Ticket: {{numeroTicket}}

" + + "

Prazo de {{tipoPrazo}}: {{prazo}}

" + + "

Status: {{status}}

" + + "
" + + "

" + + "" + + "Ver chamado" + + "" + + "

" + + "

" + + "Central de Chamados SGSE - Sistema de Gerenciamento de Secretaria" + + "

" + + "
", + variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"], + }, + ]; + + for (const template of templatesPadrao) { + // Verificar se já existe + const existente = await ctx.db + .query("templatesMensagens") + .withIndex("by_codigo", (q) => q.eq("codigo", template.codigo)) + .first(); + + if (!existente) { + await ctx.db.insert("templatesMensagens", { + ...template, + tipo: "sistema", + criadoEm: Date.now(), + }); + } + } + + return { sucesso: true }; + }, +}); + +