From 5469c50d90fd1d480377d301537da6110e9ea693 Mon Sep 17 00:00:00 2001 From: killer-cf Date: Thu, 30 Oct 2025 14:55:51 -0300 Subject: [PATCH 01/28] feat: add svelte-sonner dependency and enhance NotificationBell component - Added `svelte-sonner` to dependencies for improved notification handling. - Refactored the `NotificationBell.svelte` component for better readability and maintainability, including code formatting and structure improvements. - Updated `package.json` and `bun.lock` to reflect the new dependency. --- .../components/chat/NotificationBell.svelte | 170 ++- apps/web/src/lib/stores/auth.svelte.ts | 22 +- .../routes/(dashboard)/perfil/+page.svelte | 1200 +++++++++++++---- .../(dashboard)/ti/usuarios/+page.svelte | 354 +++-- bun.lock | 1 + package.json | 3 +- packages/backend/convex/ferias.ts | 190 +-- packages/backend/convex/funcionarios.ts | 12 +- 8 files changed, 1393 insertions(+), 559 deletions(-) diff --git a/apps/web/src/lib/components/chat/NotificationBell.svelte b/apps/web/src/lib/components/chat/NotificationBell.svelte index b86c35c..b7056b4 100644 --- a/apps/web/src/lib/components/chat/NotificationBell.svelte +++ b/apps/web/src/lib/components/chat/NotificationBell.svelte @@ -5,18 +5,27 @@ import { formatDistanceToNow } from "date-fns"; import { ptBR } from "date-fns/locale"; import { onMount } from "svelte"; + import { authStore } from "$lib/stores/auth.svelte"; // Queries e Client const client = useConvexClient(); - const notificacoesQuery = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true }); + const notificacoesQuery = useQuery(api.chat.obterNotificacoes, { + apenasPendentes: true, + }); const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {}); let dropdownOpen = $state(false); let notificacoesFerias = $state([]); // Helpers para obter valores das queries - const count = $derived((typeof countQuery === 'number' ? countQuery : countQuery?.data) ?? 0); - const notificacoes = $derived((Array.isArray(notificacoesQuery) ? notificacoesQuery : notificacoesQuery?.data) ?? []); + const count = $derived( + (typeof countQuery === "number" ? countQuery : countQuery?.data) ?? 0 + ); + const notificacoes = $derived( + (Array.isArray(notificacoesQuery) + ? notificacoesQuery + : notificacoesQuery?.data) ?? [] + ); // Atualizar contador no store $effect(() => { @@ -27,11 +36,15 @@ // Buscar notificações de férias async function buscarNotificacoesFerias() { try { - const usuarioStore = await import("$lib/stores/auth.svelte").then(m => m.authStore); + const usuarioStore = authStore; + if (usuarioStore.usuario?._id) { - const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, { - usuarioId: usuarioStore.usuario._id as any, - }); + const notifsFerias = await client.query( + api.ferias.obterNotificacoesNaoLidas, + { + usuarioId: usuarioStore.usuario._id as any, + } + ); notificacoesFerias = notifsFerias || []; } } catch (e) { @@ -61,19 +74,25 @@ await client.mutation(api.chat.marcarTodasNotificacoesLidas, {}); // Marcar todas as notificações de férias como lidas for (const notif of notificacoesFerias) { - await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notif._id }); + await client.mutation(api.ferias.marcarComoLida, { + notificacaoId: notif._id, + }); } dropdownOpen = false; await buscarNotificacoesFerias(); } async function handleClickNotificacao(notificacaoId: string) { - await client.mutation(api.chat.marcarNotificacaoLida, { notificacaoId: notificacaoId as any }); + await client.mutation(api.chat.marcarNotificacaoLida, { + notificacaoId: notificacaoId as any, + }); dropdownOpen = false; } async function handleClickNotificacaoFerias(notificacaoId: string) { - await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notificacaoId as any }); + await client.mutation(api.ferias.marcarComoLida, { + notificacaoId: notificacaoId as any, + }); await buscarNotificacoesFerias(); dropdownOpen = false; // Redirecionar para a página de férias @@ -98,43 +117,6 @@ }); - - - +

{authStore.usuario?.nome}

- + {#if funcionario?.descricaoCargo} -

- - +

+ + {funcionario.descricaoCargo}

{/if} - -

- - + +

+ + {authStore.usuario?.email}

- -
-
+ +
+
{authStore.usuario?.role?.nome || "Usuário"}
- + {#if meuTime} -
- - +
+ + {meuTime.nome}
{/if} - + {#if funcionario?.statusFerias === "em_ferias"} -
+
🏖️ Em Férias
{:else} -
+
✅ Ativo
{/if} @@ -360,45 +439,83 @@
-
+
- + - + {#if ehGestor} @@ -412,60 +529,122 @@
-
+

Seu Perfil

-

{authStore.usuario?.role?.nome || "Usuário"}

+

+ {authStore.usuario?.role?.nome || "Usuário"} +

- - + +
-
+

Seu Time

-

{meuTime?.nome || "Sem time"}

+

+ {meuTime?.nome || "Sem time"} +

- - + +
-
+

Status

-

{funcionario?.statusFerias === "em_ferias" ? "Em Férias" : "Ativo"}

+

+ {funcionario?.statusFerias === "em_ferias" + ? "Em Férias" + : "Ativo"} +

- - + +
-
+

Matrícula

-

{funcionario?.matricula || "---"}

+

+ {funcionario?.matricula || "---"} +

- - + +
@@ -475,46 +654,115 @@
-
-
+
+

- - + + Informações Pessoais

-
- - +
+ +
- Nome Completo -

{authStore.usuario?.nome}

+ Nome Completo +

+ {authStore.usuario?.nome} +

- +
- -
- - + +
+ +
- E-mail Institucional -

{authStore.usuario?.email}

+ E-mail Institucional +

+ {authStore.usuario?.email} +

- +
- -
- - + +
+ +
- Perfil de Acesso -
{authStore.usuario?.role?.nome || "Usuário"}
+ Perfil de Acesso +
+ {authStore.usuario?.role?.nome || "Usuário"} +
@@ -523,70 +771,168 @@ {#if funcionario} -
+

- - + + Dados Funcionais

-
- - +
+ +
- Matrícula -

{funcionario.matricula || "Não informada"}

+ Matrícula +

+ {funcionario.matricula || "Não informada"} +

- +
- -
- - + +
+ +
- CPF -

{funcionario.cpf}

+ CPF +

+ {funcionario.cpf} +

- +
- -
- - + +
+ +
- Time + Time {#if meuTime}
-
+
{meuTime.nome}
-

Gestor: {meuTime.gestor?.nome}

- {:else} -

Não atribuído a um time

+

+ Gestor: {meuTime.gestor?.nome} +

+ {:else} +

+ Não atribuído a um time +

{/if}
- +
- -
- - + +
+ +
- Status Atual + Status Atual {#if funcionario.statusFerias === "em_ferias"} -
🏖️ Em Férias
+
+ 🏖️ Em Férias +
{:else} -
✅ Ativo
+
+ ✅ Ativo +
{/if}
@@ -598,46 +944,105 @@ {#if ehGestor} -
+
-

- - +

+ + Times que Você Gerencia -
{meusTimesGestor.length}
+
+ {meusTimesGestor.length} +

- + {#if meusTimesGestor.length === 0}
- - + + Você não gerencia nenhum time no momento.
{:else} -
+
{#each meusTimesGestor as time} -
+
-

{time.nome}

-

{time.descricao || "Sem descrição"}

-
-
+

+ {time.nome} +

+

+ {time.descricao || "Sem descrição"} +

+
+
- +
- - - - {time.membros?.length || 0} - membros + + + + {time.membros?.length || 0} + membros
-
+
Gestor
@@ -650,7 +1055,6 @@
{/if}
- {:else if abaAtiva === "minhas-ferias"}
@@ -658,62 +1062,110 @@ {#if funcionario} - +
{:else}
- - + +

Perfil de funcionário não encontrado

-
Seu usuário ainda não está associado a um cadastro de funcionário. Entre em contato com o RH.
-
-
+
+ Seu usuário ainda não está associado a um cadastro de + funcionário. Entre em contato com o RH. +
+
+
{/if} {:else} {#if funcionario} - mostrarFormSolicitar = false} + (mostrarFormSolicitar = false)} /> {/if} {/if}
- {:else if abaAtiva === "aprovar-ferias"}

- - + + Solicitações da Equipe -
{solicitacoesSubordinados.length}
+
+ {solicitacoesSubordinados.length} +

{#if solicitacoesSubordinados.length === 0}
- - + + - Nenhuma solicitação pendente no momento. + Nenhuma solicitação pendente no momento.
{:else}
@@ -733,20 +1185,36 @@ {#each solicitacoesSubordinados as solicitacao} -
{solicitacao.funcionario?.nome}
+
+ {solicitacao.funcionario?.nome} +
{#if solicitacao.time} -
+
{solicitacao.time.nome}
{/if} {solicitacao.anoReferencia} - {solicitacao.periodos.length} - {solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)} + {solicitacao.periodos.length} + {solicitacao.periodos.reduce( + (acc: number, p: any) => acc + p.diasCorridos, + 0 + )} -
+
{getStatusTexto(solicitacao.status)}
@@ -755,10 +1223,22 @@ @@ -766,11 +1246,28 @@ @@ -785,7 +1282,7 @@
{/if} -
+
{#if solicitacaoSelecionada} @@ -796,12 +1293,16 @@ solicitacao={solicitacaoSelecionada} gestorId={authStore.usuario._id} onSucesso={recarregar} - onCancelar={() => solicitacaoSelecionada = null} + onCancelar={() => (solicitacaoSelecionada = null)} /> {/if}
{/if} @@ -810,21 +1311,39 @@ {#if mostrarModalFoto} {/if} + + diff --git a/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte b/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte index cfde9d7..b10bee7 100644 --- a/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte @@ -9,65 +9,74 @@ const client = useConvexClient(); const usuarios = useQuery(api.usuarios.listar, {}); - + let filtroNome = $state(""); - let filtroStatus = $state<"todos" | "ativo" | "bloqueado" | "inativo">("todos"); + let filtroStatus = $state<"todos" | "ativo" | "bloqueado" | "inativo">( + "todos" + ); let usuarioSelecionado = $state(null); let modalAberto = $state(false); - let modalAcao = $state<"bloquear" | "desbloquear" | "reset" | "associar">("bloquear"); + let modalAcao = $state<"bloquear" | "desbloquear" | "reset" | "associar">( + "bloquear" + ); let motivo = $state(""); let processando = $state(false); - + // Modal de associar funcionário let modalAssociarAberto = $state(false); let usuarioParaAssociar = $state(null); let funcionarioSelecionadoId = $state(""); let buscaFuncionario = $state(""); - + // Query de funcionários - const funcionarios = useQuery(api.funcionarios.list, {}); + const funcionarios = useQuery(api.funcionarios.getAll, {}); // Usuários filtrados const usuariosFiltrados = $derived.by(() => { if (!usuarios?.data || !Array.isArray(usuarios.data)) return []; - - return usuarios.data.filter(u => { - const matchNome = !filtroNome || + + return usuarios.data.filter((u) => { + const matchNome = + !filtroNome || u.nome.toLowerCase().includes(filtroNome.toLowerCase()) || u.matricula.includes(filtroNome) || u.email?.toLowerCase().includes(filtroNome.toLowerCase()); - - const matchStatus = filtroStatus === "todos" || + + const matchStatus = + filtroStatus === "todos" || (filtroStatus === "ativo" && u.ativo && !u.bloqueado) || (filtroStatus === "bloqueado" && u.bloqueado) || (filtroStatus === "inativo" && !u.ativo); - + return matchNome && matchStatus; }); }); - + // Funcionários filtrados (sem associação ou disponíveis) const funcionariosFiltrados = $derived.by(() => { if (!funcionarios?.data || !Array.isArray(funcionarios.data)) return []; - - return funcionarios.data.filter(f => { - // Filtro por busca - const matchBusca = !buscaFuncionario || - f.nome.toLowerCase().includes(buscaFuncionario.toLowerCase()) || - f.cpf?.includes(buscaFuncionario) || - f.matricula?.includes(buscaFuncionario); - - return matchBusca; - }).sort((a, b) => a.nome.localeCompare(b.nome)); + + return funcionarios.data + .filter((f) => { + // Filtro por busca + const matchBusca = + !buscaFuncionario || + f.nome.toLowerCase().includes(buscaFuncionario.toLowerCase()) || + f.cpf?.includes(buscaFuncionario) || + f.matricula?.includes(buscaFuncionario); + + return matchBusca; + }) + .sort((a, b) => a.nome.localeCompare(b.nome)); }); const stats = $derived.by(() => { if (!usuarios?.data || !Array.isArray(usuarios.data)) return null; return { total: usuarios.data.length, - ativos: usuarios.data.filter(u => u.ativo && !u.bloqueado).length, - bloqueados: usuarios.data.filter(u => u.bloqueado).length, - inativos: usuarios.data.filter(u => !u.ativo).length + ativos: usuarios.data.filter((u) => u.ativo && !u.bloqueado).length, + bloqueados: usuarios.data.filter((u) => u.bloqueado).length, + inativos: usuarios.data.filter((u) => !u.ativo).length, }; }); @@ -83,31 +92,31 @@ usuarioSelecionado = null; motivo = ""; } - + function abrirModalAssociar(usuario: any) { usuarioParaAssociar = usuario; funcionarioSelecionadoId = usuario.funcionarioId || ""; buscaFuncionario = ""; modalAssociarAberto = true; } - + function fecharModalAssociar() { modalAssociarAberto = false; usuarioParaAssociar = null; funcionarioSelecionadoId = ""; buscaFuncionario = ""; } - + async function associarFuncionario() { if (!usuarioParaAssociar || !funcionarioSelecionadoId) return; - + processando = true; try { await client.mutation(api.usuarios.associarFuncionario, { usuarioId: usuarioParaAssociar._id as Id<"usuarios">, - funcionarioId: funcionarioSelecionadoId as Id<"funcionarios"> + funcionarioId: funcionarioSelecionadoId as Id<"funcionarios">, }); - + alert("Funcionário associado com sucesso!"); fecharModalAssociar(); } catch (error: any) { @@ -116,18 +125,19 @@ processando = false; } } - + async function desassociarFuncionario() { if (!usuarioParaAssociar) return; - - if (!confirm("Deseja realmente desassociar o funcionário deste usuário?")) return; - + + if (!confirm("Deseja realmente desassociar o funcionário deste usuário?")) + return; + processando = true; try { await client.mutation(api.usuarios.desassociarFuncionario, { - usuarioId: usuarioParaAssociar._id as Id<"usuarios"> + usuarioId: usuarioParaAssociar._id as Id<"usuarios">, }); - + alert("Funcionário desassociado com sucesso!"); fecharModalAssociar(); } catch (error: any) { @@ -139,32 +149,32 @@ async function executarAcao() { if (!usuarioSelecionado) return; - + if (!authStore.usuario) { alert("Usuário não autenticado"); return; } - + processando = true; try { if (modalAcao === "bloquear") { await client.mutation(api.usuarios.bloquearUsuario, { usuarioId: usuarioSelecionado._id as Id<"usuarios">, motivo, - bloqueadoPorId: authStore.usuario._id as Id<"usuarios"> + bloqueadoPorId: authStore.usuario._id as Id<"usuarios">, }); } else if (modalAcao === "desbloquear") { await client.mutation(api.usuarios.desbloquearUsuario, { usuarioId: usuarioSelecionado._id as Id<"usuarios">, - desbloqueadoPorId: authStore.usuario._id as Id<"usuarios"> + desbloqueadoPorId: authStore.usuario._id as Id<"usuarios">, }); } else if (modalAcao === "reset") { await client.mutation(api.usuarios.resetarSenhaUsuario, { usuarioId: usuarioSelecionado._id as Id<"usuarios">, - resetadoPorId: authStore.usuario._id as Id<"usuarios"> + resetadoPorId: authStore.usuario._id as Id<"usuarios">, }); } - + fecharModal(); } catch (error) { console.error("Erro ao executar ação:", error); @@ -183,8 +193,19 @@

Gerenciar usuários do sistema

- - + + Criar Usuário @@ -220,20 +241,24 @@ -
- +
- @@ -250,7 +275,7 @@

Usuários ({usuariosFiltrados.length})

- +
@@ -270,67 +295,138 @@ - - + + - - + + @@ -1506,9 +2028,13 @@
{usuario.nome} {usuario.email || "-"} - {#if usuario.funcionarioId} + {#if usuario.funcionario?._id}
- - + + Associado
{:else}
- - + + Não associado
{/if}
- +
- - + {#if usuario.bloqueado} - {:else} - {/if} - - @@ -356,14 +452,17 @@ {#if roleRow.nivel <= 1} @@ -574,4 +697,148 @@
{/each} {/if} + + + {#if modalGerenciarPerfisAberto} + + + + + {/if} diff --git a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte index 505d108..91cf110 100644 --- a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte @@ -2,238 +2,111 @@ import { useQuery, useConvexClient } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; - import { authStore } from "$lib/stores/auth.svelte"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; + import { format } from "date-fns"; + import { ptBR } from "date-fns/locale"; + + type Role = { + _id: Id<"roles">; + _creationTime: number; + nome: string; + descricao: string; + nivel: number; + setor?: string; + }; const client = useConvexClient(); - - // Queries - const perfisQuery = useQuery(api.perfisCustomizados.listarPerfisCustomizados, {}); const rolesQuery = useQuery(api.roles.listar, {}); + const roles = $derived(rolesQuery?.data ?? []); + const carregando = $derived(rolesQuery === undefined); - // Estados - let modo = $state<"listar" | "criar" | "editar" | "detalhes">("listar"); - let perfilSelecionado = $state(null); - let processando = $state(false); - let mensagem = $state<{ tipo: "success" | "error" | "warning"; texto: string } | null>(null); - let modalExcluir = $state(false); - let perfilParaExcluir = $state(null); + let busca = $state(""); + let filtroSetor = $state(""); + let roleSelecionada = $state(null); + let modalDetalhesAberto = $state(false); - // Formulário - let formNome = $state(""); - let formDescricao = $state(""); - let formNivel = $state(3); - let formClonarDeRoleId = $state(""); - - // Detalhes do perfil - let detalhesQuery = $state(null); + const setoresDisponiveis = $derived.by(() => { + const setores = new Set(); + roles.forEach((r) => { + if (r.setor) setores.add(r.setor); + }); + return Array.from(setores).sort(); + }); - function mostrarMensagem(tipo: "success" | "error" | "warning", texto: string) { - mensagem = { tipo, texto }; - setTimeout(() => { - mensagem = null; - }, 5000); + const rolesFiltradas = $derived.by(() => { + let resultado = roles; + + // Filtro por busca (nome ou descrição) + if (busca.trim()) { + const buscaLower = busca.toLowerCase(); + resultado = resultado.filter( + (r) => + r.nome.toLowerCase().includes(buscaLower) || + r.descricao.toLowerCase().includes(buscaLower) + ); + } + + // Filtro por setor + if (filtroSetor) { + resultado = resultado.filter((r) => r.setor === filtroSetor); + } + + return resultado.sort((a, b) => { + // Ordenar por nível primeiro (menor nível = maior privilégio) + if (a.nivel !== b.nivel) return a.nivel - b.nivel; + // Depois por nome + return a.nome.localeCompare(b.nome); + }); + }); + + function obterCorNivel(nivel: number): string { + if (nivel === 0) return "badge-error"; + if (nivel === 1) return "badge-warning"; + if (nivel === 2) return "badge-info"; + return "badge-ghost"; } - function abrirCriar() { - modo = "criar"; - formNome = ""; - formDescricao = ""; - formNivel = 3; - formClonarDeRoleId = ""; + function obterTextoNivel(nivel: number): string { + if (nivel === 0) return "Máximo"; + if (nivel === 1) return "Alto"; + if (nivel === 2) return "Médio"; + if (nivel === 3) return "Baixo"; + return `Nível ${nivel}`; } - function abrirEditar(perfil: any) { - modo = "editar"; - perfilSelecionado = perfil; - formNome = perfil.nome; - formDescricao = perfil.descricao; - formNivel = perfil.nivel; + function abrirDetalhes(role: Role) { + roleSelecionada = role; + modalDetalhesAberto = true; } - async function abrirDetalhes(perfil: any) { - modo = "detalhes"; - perfilSelecionado = perfil; - - // Buscar detalhes completos - try { - const detalhes = await client.query(api.perfisCustomizados.obterPerfilComPermissoes, { - perfilId: perfil._id, - }); - detalhesQuery = detalhes; - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao carregar detalhes"); - } - } - - function voltar() { - modo = "listar"; - perfilSelecionado = null; - detalhesQuery = null; - } - - async function criarPerfil() { - if (!formNome.trim() || !formDescricao.trim()) { - mostrarMensagem("warning", "Preencha todos os campos obrigatórios"); - return; - } - - if (formNivel < 3) { - mostrarMensagem("warning", "O nível mínimo para perfis customizados é 3"); - return; - } - - if (!authStore.usuario) { - mostrarMensagem("error", "Usuário não autenticado"); - return; - } - - try { - processando = true; - - const resultado = await client.mutation(api.perfisCustomizados.criarPerfilCustomizado, { - nome: formNome.trim(), - descricao: formDescricao.trim(), - nivel: formNivel, - clonarDeRoleId: formClonarDeRoleId ? (formClonarDeRoleId as Id<"roles">) : undefined, - criadoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil criado com sucesso!"); - voltar(); - } else { - mostrarMensagem("error", resultado.erro); - } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao criar perfil"); - } finally { - processando = false; - } - } - - async function editarPerfil() { - if (!perfilSelecionado) return; - - if (!formNome.trim() || !formDescricao.trim()) { - mostrarMensagem("warning", "Preencha todos os campos obrigatórios"); - return; - } - - if (!authStore.usuario) { - mostrarMensagem("error", "Usuário não autenticado"); - return; - } - - try { - processando = true; - - const resultado = await client.mutation(api.perfisCustomizados.editarPerfilCustomizado, { - perfilId: perfilSelecionado._id, - nome: formNome.trim(), - descricao: formDescricao.trim(), - editadoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil atualizado com sucesso!"); - voltar(); - } else { - mostrarMensagem("error", resultado.erro); - } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao editar perfil"); - } finally { - processando = false; - } - } - - function abrirModalExcluir(perfil: any) { - perfilParaExcluir = perfil; - modalExcluir = true; - } - - function fecharModalExcluir() { - modalExcluir = false; - perfilParaExcluir = null; - } - - async function confirmarExclusao() { - if (!perfilParaExcluir || !authStore.usuario) { - mostrarMensagem("error", "Erro ao excluir perfil"); - return; - } - - try { - processando = true; - modalExcluir = false; - - const resultado = await client.mutation(api.perfisCustomizados.excluirPerfilCustomizado, { - perfilId: perfilParaExcluir._id, - excluidoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil excluído com sucesso!"); - } else { - mostrarMensagem("error", resultado.erro); - } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao excluir perfil"); - } finally { - processando = false; - perfilParaExcluir = null; - } - } - - async function clonarPerfil(perfil: any) { - const novoNome = prompt(`Digite o nome para o novo perfil (clone de "${perfil.nome}"):`); - if (!novoNome?.trim()) return; - - const novaDescricao = prompt("Digite a descrição para o novo perfil:"); - if (!novaDescricao?.trim()) return; - - if (!authStore.usuario) { - mostrarMensagem("error", "Usuário não autenticado"); - return; - } - - try { - processando = true; - - const resultado = await client.mutation(api.perfisCustomizados.clonarPerfil, { - perfilOrigemId: perfil._id, - novoNome: novoNome.trim(), - novaDescricao: novaDescricao.trim(), - criadoPorId: authStore.usuario._id as Id<"usuarios">, - }); - - if (resultado.sucesso) { - mostrarMensagem("success", "Perfil clonado com sucesso!"); - } else { - mostrarMensagem("error", resultado.erro); - } - } catch (e: any) { - mostrarMensagem("error", e.message || "Erro ao clonar perfil"); - } finally { - processando = false; - } + function fecharDetalhes() { + modalDetalhesAberto = false; + roleSelecionada = null; } function formatarData(timestamp: number): string { - return new Date(timestamp).toLocaleString("pt-BR"); + try { + return format(new Date(timestamp), "dd/MM/yyyy HH:mm", { locale: ptBR }); + } catch { + return "Data inválida"; + } + } + + function limparFiltros() { + busca = ""; + filtroSetor = ""; } - +
-
+
-
+
-

Gerenciar Perfis Customizados

-

- Crie e gerencie perfis de acesso personalizados para os usuários -

+

Gestão de Perfis

+

Visualize e gerencie os perfis de acesso do sistema

- -
- {#if modo !== "listar"} - - {/if} - {#if modo === "listar"} - - - - - Voltar para TI - - - {/if} -
- - {#if mensagem} -
- - {#if mensagem.tipo === "success"} - - {:else if mensagem.tipo === "error"} - - {:else} - - {/if} - - {mensagem.texto} -
- {/if} - - - {#if modo === "listar"} -
+ + {#if !carregando && roles.length > 0} +
- {#if !perfisQuery} -
- -
- {:else if perfisQuery.data && perfisQuery.data.length === 0} -
-
📋
-

Nenhum perfil customizado

-

- Crie seu primeiro perfil personalizado clicando no botão acima -

-
- {:else if perfisQuery.data} -
- - - - - - - - - - - - - - {#each perfisQuery.data as perfil} - - - - - - - - - - {/each} - -
NomeDescriçãoNívelUsuáriosCriado PorCriado EmAções
-
{perfil.nome}
-
-
- {perfil.descricao} -
-
-
{perfil.nivel}
-
-
- {perfil.numeroUsuarios} usuário{perfil.numeroUsuarios !== 1 ? "s" : ""} -
-
-
{perfil.criadorNome}
-
-
{formatarData(perfil.criadoEm)}
-
-
- - - - -
-
-
- {/if} -
-
- {/if} - - - {#if modo === "criar"} -
-
-

Criar Novo Perfil Customizado

- -
{ - e.preventDefault(); - criarPerfil(); - }} - > -
- -
- - -
- - -
- - -
- Mínimo: 3 (perfis customizados) -
-
- - -
- - -
- - -
- - -
- Selecione um perfil existente para copiar suas permissões -
-
-
- -
- - -
-
-
-
- {/if} - - - {#if modo === "editar" && perfilSelecionado} -
-
-

Editar Perfil: {perfilSelecionado.nome}

- -
{ - e.preventDefault(); - editarPerfil(); - }} - > -
- -
- - -
- - -
- - -
- - -
- - - - O nível de acesso não pode ser alterado após a criação (Nível: {formNivel}) -
-
- -
- - -
-
-
-
- {/if} - - - {#if modo === "detalhes" && perfilSelecionado} -
- -
-
-

{perfilSelecionado.nome}

-
-
-

Descrição

-

{perfilSelecionado.descricao}

-
-
-

Nível de Acesso

-

- {perfilSelecionado.nivel} -

-
-
-

Criado Por

-

{perfilSelecionado.criadorNome}

-
-
-

Criado Em

-

{formatarData(perfilSelecionado.criadoEm)}

-
-
-

Usuários com este Perfil

-

- {perfilSelecionado.numeroUsuarios} usuário{perfilSelecionado.numeroUsuarios !== - 1 - ? "s" - : ""} -

-
-
-
-
- - - {#if !detalhesQuery} -
-
-
- -
-
-
- {:else} - - {#if detalhesQuery.menuPermissoes && detalhesQuery.menuPermissoes.length > 0} -
-
-

Permissões de Menu

-
- - - - - - - - - - - {#each detalhesQuery.menuPermissoes as perm} - - - - - - - {/each} - -
MenuAcessarConsultarGravar
{perm.menuPath} - {#if perm.podeAcessar} - Sim - {:else} - Não - {/if} - - {#if perm.podeConsultar} - Sim - {:else} - Não - {/if} - - {#if perm.podeGravar} - Sim - {:else} - Não - {/if} -
-
- -
-
- {:else} -
+
+

Filtros de Busca

+
- {/if} + Limpar Filtros + +
- - {#if detalhesQuery.usuarios && detalhesQuery.usuarios.length > 0} -
-
-

Usuários com este Perfil

-
- - - - - - - - - - - {#each detalhesQuery.usuarios as usuario} - - - - - - - {/each} - -
NomeMatrículaEmailStatus
{usuario.nome}{usuario.matricula}{usuario.email} - {#if usuario.ativo && !usuario.bloqueado} - Ativo - {:else if usuario.bloqueado} - Bloqueado - {:else} - Inativo - {/if} -
+
+ +
+ + +
+ + +
+ + +
+
+ +
+ Mostrando {rolesFiltradas.length} de {roles.length} perfil(is) +
+
+
+ {/if} + + + {#if carregando} +
+ +
+ {:else if roles.length === 0} +
+ + + +

Nenhum perfil encontrado

+

Não há perfis cadastrados no sistema.

+
+ {:else} +
+ {#each rolesFiltradas as role} +
abrirDetalhes(role)}> +
+
+

{role.descricao}

+
{obterTextoNivel(role.nivel)}
+
+ +
+
+ Nome técnico: + {role.nome} +
+ + {#if role.setor} +
+ Setor: + {role.setor} +
+ {/if} + +
+ Nível: + {role.nivel}
+ +
+ +
- {/if} - {/if} +
+ {/each}
{/if}
- - {#if modalExcluir && perfilParaExcluir} - - + +
{/if} diff --git a/apps/web/src/routes/(dashboard)/ti/times/+page.svelte b/apps/web/src/routes/(dashboard)/ti/times/+page.svelte index 6f2aa89..4b87a11 100644 --- a/apps/web/src/routes/(dashboard)/ti/times/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/times/+page.svelte @@ -2,7 +2,82 @@ import { useConvexClient, useQuery } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import { authStore } from "$lib/stores/auth.svelte"; + import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import { goto } from "$app/navigation"; + import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel"; + + // Tipos baseados nos retornos das queries do backend + type Usuario = { + _id: Id<"usuarios">; + matricula: string; + nome: string; + email: string; + ativo: boolean; + bloqueado?: boolean; + motivoBloqueio?: string; + primeiroAcesso: boolean; + ultimoAcesso?: number; + criadoEm: number; + role: { + _id: Id<"roles">; + _creationTime?: number; + criadoPor?: Id<"usuarios">; + customizado?: boolean; + descricao: string; + editavel?: boolean; + nome: string; + nivel: number; + setor?: string; + erro?: boolean; + }; + funcionario?: { + _id: Id<"funcionarios">; + nome: string; + matricula?: string; + descricaoCargo?: string; + simboloTipo: "cargo_comissionado" | "funcao_gratificada"; + }; + avisos?: Array<{ + tipo: "erro" | "aviso" | "info"; + mensagem: string; + }>; + }; + + type Funcionario = { + _id: Id<"funcionarios">; + nome: string; + matricula?: string; + cpf?: string; + rg?: string; + nascimento?: string; + email?: string; + telefone?: string; + endereco?: string; + cep?: string; + cidade?: string; + uf?: string; + simboloId: Id<"simbolos">; + simboloTipo: "cargo_comissionado" | "funcao_gratificada"; + admissaoData?: string; + desligamentoData?: string; + descricaoCargo?: string; + }; + + type Gestor = Doc<"usuarios"> | null; + + type TimeComDetalhes = Doc<"times"> & { + gestor: Gestor; + totalMembros: number; + }; + + type MembroTime = Doc<"timesMembros"> & { + funcionario: Doc<"funcionarios"> | null; + }; + + type TimeComMembros = Doc<"times"> & { + gestor: Gestor; + membros: MembroTime[]; + }; const client = useConvexClient(); @@ -11,17 +86,23 @@ const usuariosQuery = useQuery(api.usuarios.listar, {}); const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); - const times = $derived(timesQuery?.data || []); - const usuarios = $derived(usuariosQuery?.data || []); - const funcionarios = $derived(funcionariosQuery?.data || []); + const times = $derived((timesQuery?.data || []) as TimeComDetalhes[]); + const usuarios = $derived((usuariosQuery?.data || []) as Usuario[]); + const funcionarios = $derived((funcionariosQuery?.data || []) as Funcionario[]); + + const carregando = $derived( + timesQuery === undefined || + usuariosQuery === undefined || + funcionariosQuery === undefined + ); // Estados let modoEdicao = $state(false); - let timeEmEdicao = $state(null); + let timeEmEdicao = $state(null); let mostrarModalMembros = $state(false); - let timeParaMembros = $state(null); + let timeParaMembros = $state(null); let mostrarConfirmacaoExclusao = $state(false); - let timeParaExcluir = $state(null); + let timeParaExcluir = $state(null); let processando = $state(false); // Form @@ -32,9 +113,9 @@ // Membros let membrosDisponiveis = $derived( - funcionarios.filter((f: any) => { + funcionarios.filter((f: Funcionario) => { // Verificar se o funcionário já está em algum time ativo - const jaNaEquipe = timeParaMembros?.membros?.some((m: any) => m.funcionario?._id === f._id); + const jaNaEquipe = timeParaMembros?.membros?.some((m: MembroTime) => m.funcionario?._id === f._id); return !jaNaEquipe; }) ); @@ -60,7 +141,7 @@ formCor = coresDisponiveis[Math.floor(Math.random() * coresDisponiveis.length)]; } - function editarTime(time: any) { + function editarTime(time: TimeComDetalhes) { modoEdicao = true; timeEmEdicao = time; formNome = time.nome; @@ -91,26 +172,27 @@ id: timeEmEdicao._id, nome: formNome, descricao: formDescricao || undefined, - gestorId: formGestorId as any, + gestorId: formGestorId as Id<"usuarios">, cor: formCor, }); } else { await client.mutation(api.times.criar, { nome: formNome, descricao: formDescricao || undefined, - gestorId: formGestorId as any, + gestorId: formGestorId as Id<"usuarios">, cor: formCor, }); } cancelarEdicao(); - } catch (e: any) { - alert("Erro ao salvar: " + (e.message || e)); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro ao salvar: " + errorMessage); } finally { processando = false; } } - function confirmarExclusao(time: any) { + function confirmarExclusao(time: TimeComDetalhes) { timeParaExcluir = time; mostrarConfirmacaoExclusao = true; } @@ -123,17 +205,20 @@ await client.mutation(api.times.desativar, { id: timeParaExcluir._id }); mostrarConfirmacaoExclusao = false; timeParaExcluir = null; - } catch (e: any) { - alert("Erro ao excluir: " + (e.message || e)); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro ao excluir: " + errorMessage); } finally { processando = false; } } - async function abrirGerenciarMembros(time: any) { + async function abrirGerenciarMembros(time: TimeComDetalhes) { const detalhes = await client.query(api.times.obterPorId, { id: time._id }); - timeParaMembros = detalhes; - mostrarModalMembros = true; + if (detalhes) { + timeParaMembros = detalhes as TimeComMembros; + mostrarModalMembros = true; + } } async function adicionarMembro(funcionarioId: string) { @@ -143,14 +228,17 @@ try { await client.mutation(api.times.adicionarMembro, { timeId: timeParaMembros._id, - funcionarioId: funcionarioId as any, + funcionarioId: funcionarioId as Id<"funcionarios">, }); // Recarregar detalhes do time const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id }); - timeParaMembros = detalhes; - } catch (e: any) { - alert("Erro: " + (e.message || e)); + if (detalhes) { + timeParaMembros = detalhes as TimeComMembros; + } + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro: " + errorMessage); } finally { processando = false; } @@ -161,13 +249,18 @@ processando = true; try { - await client.mutation(api.times.removerMembro, { membroId: membroId as any }); + await client.mutation(api.times.removerMembro, { membroId: membroId as Id<"timesMembros"> }); // Recarregar detalhes do time - const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id }); - timeParaMembros = detalhes; - } catch (e: any) { - alert("Erro: " + (e.message || e)); + if (timeParaMembros) { + const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id }); + if (detalhes) { + timeParaMembros = detalhes as TimeComMembros; + } + } + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + alert("Erro: " + errorMessage); } finally { processando = false; } @@ -179,7 +272,8 @@ } -
+ +
+
diff --git a/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte b/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte index b10bee7..c15ba71 100644 --- a/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte @@ -1,545 +1,1171 @@ -
- -
-
-

Gestão de Usuários

-

Gerenciar usuários do sistema

-
- - - - - Criar Usuário - -
- - - {#if stats} -
-
-
Total
-
{stats.total}
+ +
+ +
+
+
+ + + +
+
+

Gestão de Usuários

+

Administre os usuários do sistema

+
-
-
Ativos
-
{stats.ativos}
-
-
-
Bloqueados
-
{stats.bloqueados}
-
-
-
Inativos
-
{stats.inativos}
+
- {/if} - -
-
-
-
- - + {#if !carregandoUsuarios && usuariosComProblemas.length > 0} +
+ + -
- -
- - -
-
-
-
- - -
-
-

- Usuários ({usuariosFiltrados.length}) -

- -
- - - - - - - - - - - - - {#each usuariosFiltrados as usuario} - - - - - - - - - {:else} - - - - {/each} - -
MatrículaNomeEmailFuncionárioStatusAções
{usuario.matricula}{usuario.nome}{usuario.email || "-"} - {#if usuario.funcionario?._id} -
- - - - Associado -
- {:else} -
- - - - Não associado -
+ +
+

Atenção: Usuários com Problemas Detectados

+
+

+ {usuariosComProblemas.length} usuário(s) possui(em) problemas que requerem atenção: +

+
    + {#each usuariosComProblemas.slice(0, 3) as usuario} +
  • + {usuario.nome} ({usuario.matricula}) + {#if usuario.avisos && usuario.avisos.length > 0} + - {usuario.avisos[0].mensagem} {/if} -
- - -
- - - - {#if usuario.bloqueado} - - {:else} - - {/if} - - -
-
- Nenhum usuário encontrado -
-
-
-
-
- - -{#if modalAberto} - + {/if} - {#if modalAcao === "reset"} -
+ + {#if mensagem} +
+ {#if mensagem.tipo === "success"} + + + {:else if mensagem.tipo === "error"} + + + + {:else} + + /> - Uma senha temporária será gerada automaticamente. -
- {/if} + {/if} + {mensagem.texto} +
+ {/if} - + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ Mostrando {usuariosFiltrados.length} de {usuarios.length} usuário(s) +
+
+
+ {/if} + + + {#if carregandoUsuarios} +
+ +

Carregando usuários...

+
+ {:else if erroUsuarios} +
+ - Cancelar - - + +
+

Erro ao carregar usuários

+
{erroUsuarios}
+
+ Por favor, recarregue a página ou entre em contato com o suporte técnico se o problema persistir. +
+
+
+ {:else if usuarios.length === 0} +
+ - {#if processando} - + + +

Nenhum usuário encontrado

+

+ Cadastre um usuário para começar a gestão de acessos. +

+
+ {:else} +
+
+

Usuários ({usuarios.length})

+ +
+ + + + + + + + + + + + + + + + + + {#each usuariosFiltrados as usuario} + + + + + + + + + + + + + + {/each} + +
MatrículaNomeEmailRole/PerfilSetorFuncionário VinculadoStatusPrimeiro AcessoÚltimo AcessoData de CriaçãoAções
{usuario.matricula}{usuario.nome}{usuario.email} +
+ {#if usuario.role.erro} +
+ + + + {usuario.role.descricao} +
+ {#if usuario.avisos && usuario.avisos.length > 0} +
+ +
+ {/if} + {:else} +
{usuario.role.nome}
+ {/if} +
+
{usuario.role.setor || "-"} + {#if usuario.funcionario} +
+
+ + + + Associado +
+
{usuario.funcionario.nome}
+ {#if usuario.funcionario.matricula} +
+ Mat: {usuario.funcionario.matricula} +
+ {/if} +
+ {:else} +
+ + + + Não associado +
+ {/if} +
+ + + {#if usuario.primeiroAcesso} +
Sim
+ {:else} +
Não
+ {/if} +
+ {formatarData(usuario.ultimoAcesso)} + + {formatarData(usuario.criadoEm)} + + +
+
+
+
+ {/if} +
+ + + {#if modalAssociarAberto && usuarioSelecionado} + - - - -
-{/if} + {/if} - -{#if modalAssociarAberto && usuarioParaAssociar} - + {/if}
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index b42d0e6..5cb06fe 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -325,7 +325,7 @@ nome: destinatario.nome, matricula: destinatario.matricula, }, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); } @@ -335,7 +335,7 @@ destinatarioId: destinatario._id as any, assunto: "Notificação do Sistema", corpo: mensagemPersonalizada, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); } @@ -433,7 +433,7 @@ nome: destinatario.nome, matricula: destinatario.matricula || "", }, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); sucessosEmail++; @@ -446,7 +446,7 @@ destinatarioId: destinatario._id as any, assunto: "Notificação do Sistema", corpo: mensagemPersonalizada, - enviadoPorId: destinatario._id as any, + enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); sucessosEmail++; diff --git a/packages/backend/convex/actions/email.ts b/packages/backend/convex/actions/email.ts index 9e09c4b..8f27bfa 100644 --- a/packages/backend/convex/actions/email.ts +++ b/packages/backend/convex/actions/email.ts @@ -23,8 +23,8 @@ export const enviar = action({ return { sucesso: false, erro: "Email não encontrado" }; } - // Buscar configuração SMTP ativa - const config = await ctx.runQuery(internal.email.getActiveEmailConfig, {}); + // Buscar configuração SMTP ativa com senha descriptografada + const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {}); if (!config) { return { @@ -52,8 +52,7 @@ export const enviar = action({ secure: config.usarSSL, auth: { user: config.usuario, - // Em produção deve ser armazenado com criptografia reversível - pass: config.senhaHash, + pass: config.senha, // Senha já descriptografada }, tls: { // Permitir certificados autoassinados diff --git a/packages/backend/convex/auth/utils.ts b/packages/backend/convex/auth/utils.ts index 708ea0a..6e5fe29 100644 --- a/packages/backend/convex/auth/utils.ts +++ b/packages/backend/convex/auth/utils.ts @@ -130,3 +130,106 @@ export function validarSenha(senha: string): boolean { return regex.test(senha); } +/** + * Criptografia reversível para senhas SMTP usando AES-GCM + * NOTA: Esta função é usada apenas para senhas SMTP que precisam ser descriptografadas. + * Para senhas de usuários, use hashPassword() que é unidirecional. + */ + +// Chave de criptografia derivada (em produção, deve vir de variável de ambiente) +// Para desenvolvimento, usando uma chave fixa. Em produção, deve ser configurada via env var. +const getEncryptionKey = async (): Promise => { + // Chave base - em produção, isso deve vir de process.env.ENCRYPTION_KEY + // Por enquanto, usando uma chave derivada de um valor fixo + const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024"); + + // Deriva uma chave de 256 bits usando PBKDF2 + const key = await crypto.subtle.importKey( + "raw", + keyMaterial, + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"] + ); + + return await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: new TextEncoder().encode("SGSE-SALT"), + iterations: 100000, + hash: "SHA-256", + }, + key, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +}; + +/** + * Criptografa uma senha SMTP usando AES-GCM + */ +export async function encryptSMTPPassword(password: string): Promise { + try { + const key = await getEncryptionKey(); + const encoder = new TextEncoder(); + const data = encoder.encode(password); + + // Gerar IV (Initialization Vector) aleatório + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Criptografar + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + data + ); + + // Combinar IV + dados criptografados e converter para base64 + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + return btoa(String.fromCharCode(...combined)); + } catch (error) { + console.error("Erro ao criptografar senha SMTP:", error); + throw new Error("Falha ao criptografar senha SMTP"); + } +} + +/** + * Descriptografa uma senha SMTP usando AES-GCM + */ +export async function decryptSMTPPassword(encryptedPassword: string): Promise { + try { + const key = await getEncryptionKey(); + + // Decodificar base64 + const combined = Uint8Array.from(atob(encryptedPassword), (c) => c.charCodeAt(0)); + + // Extrair IV e dados criptografados + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + // Descriptografar + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + encrypted + ); + + // Converter para string + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (error) { + console.error("Erro ao descriptografar senha SMTP:", error); + throw new Error("Falha ao descriptografar senha SMTP"); + } +} + diff --git a/packages/backend/convex/configuracaoEmail.ts b/packages/backend/convex/configuracaoEmail.ts index d17ab8e..4988860 100644 --- a/packages/backend/convex/configuracaoEmail.ts +++ b/packages/backend/convex/configuracaoEmail.ts @@ -1,7 +1,8 @@ import { v } from "convex/values"; -import { mutation, query, action } from "./_generated/server"; -import { hashPassword } from "./auth/utils"; +import { mutation, query } from "./_generated/server"; +import { encryptSMTPPassword } from "./auth/utils"; import { registrarAtividade } from "./logsAtividades"; +import { api } from "./_generated/api"; /** * Obter configuração de email ativa (senha mascarada) @@ -62,8 +63,29 @@ export const salvarConfigEmail = mutation({ return { sucesso: false as const, erro: "Email remetente inválido" }; } - // Criptografar senha - const senhaHash = await hashPassword(args.senha); + // Validar porta + if (args.porta < 1 || args.porta > 65535) { + return { sucesso: false as const, erro: "Porta deve ser um número entre 1 e 65535" }; + } + + // Buscar config ativa anterior para manter senha se não fornecida + const configAtiva = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + // Determinar senhaHash: usar nova senha se fornecida, senão manter a atual + let senhaHash: string; + if (args.senha && args.senha.trim().length > 0) { + // Nova senha fornecida, criptografar usando criptografia reversível (AES) + senhaHash = await encryptSMTPPassword(args.senha); + } else if (configAtiva) { + // Senha não fornecida, manter a atual (já criptografada) + senhaHash = configAtiva.senhaHash; + } else { + // Sem senha e sem config existente - erro + return { sucesso: false as const, erro: "Senha é obrigatória para nova configuração" }; + } // Desativar config anterior const configsAntigas = await ctx.db @@ -105,10 +127,7 @@ export const salvarConfigEmail = mutation({ }); /** - * Testar conexão SMTP (action - precisa de Node.js) - * - * NOTA: Esta action será implementada quando instalarmos nodemailer. - * Por enquanto, retorna sucesso simulado para não bloquear o desenvolvimento. + * Testar conexão SMTP (mutation que chama action real) */ export const testarConexaoSMTP = mutation({ args: { @@ -119,10 +138,65 @@ export const testarConexaoSMTP = mutation({ usarSSL: v.boolean(), usarTLS: v.boolean(), }, + returns: v.union( + v.object({ sucesso: v.literal(true) }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), handler: async (ctx, args) => { - // Delegar para a action de Node em arquivo separado + // Validações básicas + if (!args.servidor || args.servidor.trim().length === 0) { + return { sucesso: false as const, erro: "Servidor SMTP não pode estar vazio" }; + } - return { sucesso: true }; + if (!args.porta || args.porta < 1 || args.porta > 65535) { + return { sucesso: false as const, erro: "Porta inválida. Deve ser entre 1 e 65535" }; + } + + if (!args.usuario || args.usuario.trim().length === 0) { + return { sucesso: false as const, erro: "Usuário não pode estar vazio" }; + } + + if (!args.senha || args.senha.trim().length === 0) { + return { sucesso: false as const, erro: "Senha não pode estar vazia" }; + } + + // Validação de SSL/TLS mutuamente exclusivos + if (args.usarSSL && args.usarTLS) { + return { sucesso: false as const, erro: "SSL e TLS não podem estar habilitados simultaneamente" }; + } + + // Chamar action de teste real (que usa nodemailer) + try { + const resultado = await ctx.scheduler.runAfter(0, api.actions.smtp.testarConexao, { + servidor: args.servidor, + porta: args.porta, + usuario: args.usuario, + senha: args.senha, + usarSSL: args.usarSSL, + usarTLS: args.usarTLS, + }); + + // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm + if (resultado.sucesso) { + const configAtiva = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + if (configAtiva) { + await ctx.db.patch(configAtiva._id, { + testadoEm: Date.now(), + }); + } + } + + return resultado; + } catch (error: any) { + return { + sucesso: false as const, + erro: error.message || "Erro ao conectar com o servidor SMTP" + }; + } }, }); diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index 86a2792..3e622ff 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -229,6 +229,30 @@ export const getActiveEmailConfig = internalQuery({ }, }); +// Query interna para obter configuração com senha descriptografada +export const getActiveEmailConfigWithPassword = internalQuery({ + args: {}, + handler: async (ctx) => { + const { decryptSMTPPassword } = await import("./auth/utils"); + const config = await ctx.db + .query("configuracaoEmail") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); + + if (!config) { + return null; + } + + // Descriptografar senha + const senhaDescriptografada = await decryptSMTPPassword(config.senhaHash); + + return { + ...config, + senha: senhaDescriptografada, + }; + }, +}); + export const markEmailEnviando = internalMutation({ args: { emailId: v.id("notificacoesEmail") }, returns: v.null(), diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index f55345f..676869c 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -471,7 +471,7 @@ export default defineSchema({ servidor: v.string(), // smtp.gmail.com porta: v.number(), // 587, 465, etc. usuario: v.string(), - senhaHash: v.string(), // senha criptografada + senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP emailRemetente: v.string(), nomeRemetente: v.string(), usarSSL: v.boolean(), -- 2.49.1 From 7fb16937175c279c55373c427bbd125768b614b1 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 00:19:35 -0300 Subject: [PATCH 12/28] feat: enhance email notification system with tracking and feedback - Introduced a new feature to track email statuses by implementing a mapping of email IDs. - Added a query to fetch email statuses based on tracked IDs, improving the monitoring of email delivery. - Enhanced the logging system for email and chat notifications, providing detailed feedback on the sending process. - Implemented user feedback messages for various actions, improving the overall user experience. - Refactored the notification sending logic to support better error handling and status updates. --- .../(dashboard)/ti/notificacoes/+page.svelte | 415 ++++++++++++++++-- packages/backend/convex/email.ts | 19 + 2 files changed, 387 insertions(+), 47 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index 5cb06fe..b162d03 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -12,6 +12,16 @@ const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {}); const usuariosQuery = useQuery(api.usuarios.listar, {}); + // Mapa de emailIds para rastrear status + let emailIdsRastreados = $state>(new Set()); + + // Query para buscar status dos emails + const emailIdsArray = $derived(Array.from(emailIdsRastreados)); + const emailsStatusQuery = useQuery( + api.email.buscarEmailsPorIds, + emailIdsArray.length > 0 ? { emailIds: emailIdsArray as any[] } : undefined + ); + // Extrair dados das queries de forma robusta const templates = $derived.by(() => { if (templatesQuery === undefined || templatesQuery === null) { @@ -83,6 +93,21 @@ let criandoTemplates = $state(false); let progressoEnvio = $state({ total: 0, enviados: 0, falhas: 0 }); + // Estrutura de dados para logs de envio + type StatusLog = "sucesso" | "erro" | "fila" | "info" | "enviando"; + type TipoLog = "chat" | "email"; + type LogEnvio = { + timestamp: number; + tipo: TipoLog; + destinatario: string; + status: StatusLog; + mensagem: string; + emailId?: string; // Para emails, guardar o ID para rastrear status + }; + + let logsEnvio = $state([]); + let terminalScrollRef: HTMLDivElement | null = $state(null); + // Estados para agendamento let agendarEnvio = $state(false); let dataAgendamento = $state(""); @@ -120,10 +145,124 @@ let corpoTemplate = $state(""); let variaveisTemplate = $state(""); let criandoNovoTemplate = $state(false); + + // Estado para mensagens de feedback + let mensagem = $state<{ tipo: "success" | "error" | "info"; texto: string } | null>(null); const templateSelecionado = $derived( templates.find(t => t._id === templateId) ); + + // Função para mostrar mensagens + function mostrarMensagem(tipo: "success" | "error" | "info", texto: string) { + mensagem = { tipo, texto }; + setTimeout(() => { + mensagem = null; + }, 5000); + } + + // Função para adicionar log ao terminal + function adicionarLog(tipo: TipoLog, destinatario: string, status: StatusLog, mensagem: string, emailId?: string) { + logsEnvio = [...logsEnvio, { + timestamp: Date.now(), + tipo, + destinatario, + status, + mensagem, + emailId, + }]; + + // Adicionar emailId ao rastreamento se fornecido + if (emailId) { + emailIdsRastreados = new Set([...emailIdsRastreados, emailId]); + } + + // Auto-scroll para o final + setTimeout(() => { + if (terminalScrollRef) { + terminalScrollRef.scrollTop = terminalScrollRef.scrollHeight; + } + }, 10); + } + + // Atualizar logs quando status dos emails mudar + $effect(() => { + if (!emailsStatusQuery || emailsStatusQuery === undefined) return; + + // Extrair dados da query + const emails = Array.isArray(emailsStatusQuery) + ? emailsStatusQuery + : "data" in emailsStatusQuery && Array.isArray(emailsStatusQuery.data) + ? emailsStatusQuery.data + : []; + + if (emails.length === 0) return; + + for (const email of emails) { + if (!email) continue; + + // Encontrar logs relacionados a este email + const logsRelacionados = logsEnvio.filter(log => log.emailId === email._id); + + for (const log of logsRelacionados) { + // Verificar se o status mudou + const novoStatus: StatusLog = + email.status === "enviado" ? "sucesso" : + email.status === "falha" ? "erro" : + email.status === "enviando" ? "enviando" : + email.status === "pendente" ? "fila" : + "info"; + + // Se o status mudou, atualizar o log + if (log.status !== novoStatus) { + const indice = logsEnvio.findIndex(l => l === log); + if (indice !== -1) { + const novaMensagem = + email.status === "enviado" ? "Email enviado com sucesso" : + email.status === "falha" ? `Falha ao enviar: ${email.erroDetalhes || "Erro desconhecido"}` : + email.status === "enviando" ? "Enviando email..." : + email.status === "pendente" ? "Email em fila de envio" : + log.mensagem; + + logsEnvio = logsEnvio.map((l, i) => + i === indice + ? { ...l, status: novoStatus, mensagem: novaMensagem } + : l + ); + } + } + } + } + }); + + // Função para limpar logs + function limparLogs() { + logsEnvio = []; + emailIdsRastreados = new Set(); + } + + // Função para formatar timestamp + function formatarTimestamp(timestamp: number): string { + return format(new Date(timestamp), "HH:mm:ss", { locale: ptBR }); + } + + // Função para obter cor do status + function obterCorStatus(status: StatusLog): string { + switch (status) { + case "sucesso": + return "text-success"; + case "erro": + return "text-error"; + case "fila": + return "text-warning"; + case "enviando": + return "text-info"; + case "info": + return "text-info"; + default: + return "text-base-content"; + } + } async function criarTemplatesPadrao() { if (criandoTemplates) return; @@ -132,14 +271,16 @@ try { const resultado = await client.mutation(api.templatesMensagens.criarTemplatesPadrao, {}); if (resultado.sucesso) { - alert("✅ Templates padrão criados com sucesso! A página será recarregada."); - window.location.reload(); + mostrarMensagem("success", "Templates padrão criados com sucesso! A página será recarregada."); + setTimeout(() => { + window.location.reload(); + }, 2000); } else { - alert("❌ Erro ao criar templates padrão."); + mostrarMensagem("error", "Erro ao criar templates padrão."); } } catch (error: any) { console.error("Erro ao criar templates:", error); - alert("❌ Erro ao criar templates: " + (error.message || "Erro desconhecido")); + mostrarMensagem("error", "Erro ao criar templates: " + (error.message || "Erro desconhecido")); } finally { criandoTemplates = false; } @@ -161,25 +302,25 @@ async function salvarNovoTemplate() { if (!authStore.usuario) { - alert("❌ Você precisa estar autenticado para criar templates."); + mostrarMensagem("error", "Você precisa estar autenticado para criar templates."); return; } // Validações if (!codigoTemplate.trim()) { - alert("❌ O código do template é obrigatório."); + mostrarMensagem("error", "O código do template é obrigatório."); return; } if (!nomeTemplate.trim()) { - alert("❌ O nome do template é obrigatório."); + mostrarMensagem("error", "O nome do template é obrigatório."); return; } if (!tituloTemplate.trim()) { - alert("❌ O título do template é obrigatório."); + mostrarMensagem("error", "O título do template é obrigatório."); return; } if (!corpoTemplate.trim()) { - alert("❌ O corpo do template é obrigatório."); + mostrarMensagem("error", "O corpo do template é obrigatório."); return; } @@ -201,16 +342,18 @@ }); if (resultado.sucesso) { - alert("✅ Template criado com sucesso!"); + mostrarMensagem("success", "Template criado com sucesso!"); fecharModalNovoTemplate(); // Recarregar a página para atualizar a lista - window.location.reload(); + setTimeout(() => { + window.location.reload(); + }, 1500); } else { - alert("❌ Erro ao criar template: " + (resultado.erro || "Erro desconhecido")); + mostrarMensagem("error", "Erro ao criar template: " + (resultado.erro || "Erro desconhecido")); } } catch (error: any) { console.error("Erro ao criar template:", error); - alert("❌ Erro ao criar template: " + (error.message || "Erro desconhecido")); + mostrarMensagem("error", "Erro ao criar template: " + (error.message || "Erro desconhecido")); } finally { criandoNovoTemplate = false; } @@ -218,17 +361,17 @@ async function enviarNotificacao() { if (!enviarParaTodos && !destinatarioId) { - alert("Selecione um destinatário ou marque 'Enviar para todos'"); + mostrarMensagem("error", "Selecione um destinatário ou marque 'Enviar para todos'"); return; } if (usarTemplate && !templateId) { - alert("Selecione um template"); + mostrarMensagem("error", "Selecione um template"); return; } if (!usarTemplate && !mensagemPersonalizada.trim()) { - alert("Digite uma mensagem"); + mostrarMensagem("error", "Digite uma mensagem"); return; } @@ -236,19 +379,19 @@ let agendadaPara: number | undefined = undefined; if (agendarEnvio) { if (!dataAgendamento || !horaAgendamento) { - alert("Preencha a data e hora para agendamento"); + mostrarMensagem("error", "Preencha a data e hora para agendamento"); return; } try { const dataHora = new Date(`${dataAgendamento}T${horaAgendamento}`); if (dataHora.getTime() <= Date.now()) { - alert("A data e hora devem ser futuras"); + mostrarMensagem("error", "A data e hora devem ser futuras"); return; } agendadaPara = dataHora.getTime(); } catch (error) { - alert("Data ou hora inválida"); + mostrarMensagem("error", "Data ou hora inválida"); return; } } @@ -256,6 +399,10 @@ processando = true; progressoEnvio = { total: 0, enviados: 0, falhas: 0 }; + // Limpar logs anteriores quando iniciar novo envio + logsEnvio = []; + emailIdsRastreados = new Set(); + try { // Obter lista de destinatários const destinatarios: typeof usuarios = enviarParaTodos @@ -263,11 +410,19 @@ : usuarios.filter(u => u._id === destinatarioId); if (destinatarios.length === 0) { - alert("Nenhum destinatário encontrado"); + adicionarLog("email", "Sistema", "erro", "Nenhum destinatário encontrado"); + mostrarMensagem("error", "Nenhum destinatário encontrado"); return; } progressoEnvio.total = destinatarios.length; + + // Log inicial + const tipoMensagem = usarTemplate ? `Template: ${templateSelecionado?.nome || ""}` : "Mensagem personalizada"; + const destinatariosText = enviarParaTodos + ? `Todos os usuários (${destinatarios.length})` + : destinatarios.map(d => d.nome).join(", "); + adicionarLog("email", "Sistema", "info", `Iniciando envio de notificação via ${canal} para ${destinatariosText} - ${tipoMensagem}`); // Se for envio para um único usuário if (destinatarios.length === 1) { @@ -278,6 +433,7 @@ // ENVIAR PARA CHAT if (canal === "chat" || canal === "ambos") { try { + adicionarLog("chat", destinatario.nome, "enviando", "Criando/buscando conversa..."); const conversaResult = await client.mutation( api.chat.criarOuBuscarConversaIndividual, { outroUsuarioId: destinatario._id as any } @@ -290,23 +446,31 @@ if (agendadaPara) { // Agendar mensagem + adicionarLog("chat", destinatario.nome, "info", "Agendando mensagem..."); resultadoChat = await client.mutation(api.chat.agendarMensagem, { conversaId: conversaResult.conversaId, conteudo: mensagem, agendadaPara: agendadaPara, }); + const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); + adicionarLog("chat", destinatario.nome, "sucesso", `Mensagem agendada para ${dataFormatada}`); } else { // Envio imediato + adicionarLog("chat", destinatario.nome, "enviando", "Enviando mensagem..."); resultadoChat = await client.mutation(api.chat.enviarMensagem, { conversaId: conversaResult.conversaId, conteudo: mensagem, tipo: "texto", permitirNotificacaoParaSiMesmo: true, }); + adicionarLog("chat", destinatario.nome, "sucesso", "Mensagem enviada com sucesso"); } + } else { + adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa"); } - } catch (error) { + } catch (error: any) { console.error("Erro ao enviar chat:", error); + adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); } } @@ -314,6 +478,7 @@ if (canal === "email" || canal === "ambos") { if (destinatario.email) { try { + adicionarLog("email", destinatario.nome, "enviando", `Enfileirando email para ${destinatario.email}...`); if (usarTemplate && templateId) { const template = templateSelecionado; if (template) { @@ -328,6 +493,18 @@ enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); + if (resultadoEmail?.sucesso && resultadoEmail?.emailId) { + if (agendadaPara) { + const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); + adicionarLog("email", destinatario.nome, "fila", `Email agendado para ${dataFormatada}`, resultadoEmail.emailId); + } else { + adicionarLog("email", destinatario.nome, "fila", "Email enfileirado para envio", resultadoEmail.emailId); + } + } else { + adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email"); + } + } else { + adicionarLog("email", destinatario.nome, "erro", "Template não encontrado"); } } else { resultadoEmail = await client.mutation(api.email.enfileirarEmail, { @@ -338,37 +515,50 @@ enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); + if (resultadoEmail?.sucesso && resultadoEmail?.emailId) { + if (agendadaPara) { + const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); + adicionarLog("email", destinatario.nome, "fila", `Email agendado para ${dataFormatada}`, resultadoEmail.emailId); + } else { + adicionarLog("email", destinatario.nome, "fila", "Email enfileirado para envio", resultadoEmail.emailId); + } + } else { + adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email"); + } } - } catch (error) { + } catch (error: any) { console.error("Erro ao enviar email:", error); + adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); } + } else { + adicionarLog("email", destinatario.nome, "erro", "Destinatário não possui email cadastrado"); } } // Feedback de sucesso - let mensagem = agendadaPara - ? `✅ Notificação agendada com sucesso!` + let mensagemSucesso = agendadaPara + ? `Notificação agendada com sucesso!` : "Notificação enviada com sucesso!"; if (agendadaPara) { const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); - mensagem += `\n\nSerá enviada em: ${dataFormatada}`; + mensagemSucesso += ` Será enviada em: ${dataFormatada}`; } else { if (canal === "ambos") { if (resultadoChat && resultadoEmail) { - mensagem = "✅ Notificação enviada para Chat e Email!"; + mensagemSucesso = "Notificação enviada para Chat e Email!"; } else if (resultadoChat) { - mensagem = "✅ Notificação enviada para Chat. Email falhou."; + mensagemSucesso = "Notificação enviada para Chat. Email falhou."; } else if (resultadoEmail) { - mensagem = "✅ Notificação enviada para Email. Chat falhou."; + mensagemSucesso = "Notificação enviada para Email. Chat falhou."; } } else if (canal === "chat" && resultadoChat) { - mensagem = "✅ Mensagem enviada no Chat!"; + mensagemSucesso = "Mensagem enviada no Chat!"; } else if (canal === "email" && resultadoEmail) { - mensagem = "✅ Email enfileirado para envio!"; + mensagemSucesso = "Email enfileirado para envio!"; } } - alert(mensagem); + mostrarMensagem("success", mensagemSucesso); progressoEnvio.enviados = 1; } else { // ENVIO EM MASSA @@ -377,11 +567,14 @@ let falhasChat = 0; let falhasEmail = 0; + adicionarLog("email", "Sistema", "info", `Processando ${destinatarios.length} destinatários...`); + for (const destinatario of destinatarios) { try { // ENVIAR PARA CHAT if (canal === "chat" || canal === "ambos") { try { + adicionarLog("chat", destinatario.nome, "enviando", "Processando..."); const conversaResult = await client.mutation( api.chat.criarOuBuscarConversaIndividual, { outroUsuarioId: destinatario._id as any } @@ -400,6 +593,8 @@ conteudo: mensagem, agendadaPara: agendadaPara, }); + const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); + adicionarLog("chat", destinatario.nome, "sucesso", `Agendado para ${dataFormatada}`); } else { await client.mutation(api.chat.enviarMensagem, { conversaId: conversaResult.conversaId, @@ -407,13 +602,16 @@ tipo: "texto", permitirNotificacaoParaSiMesmo: true, }); + adicionarLog("chat", destinatario.nome, "sucesso", "Enviado com sucesso"); } sucessosChat++; } else { + adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa"); falhasChat++; } - } catch (error) { + } catch (error: any) { console.error(`Erro ao enviar chat para ${destinatario.nome}:`, error); + adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); falhasChat++; } } @@ -422,10 +620,11 @@ if (canal === "email" || canal === "ambos") { if (destinatario.email) { try { + adicionarLog("email", destinatario.nome, "enviando", `Enfileirando email para ${destinatario.email}...`); if (usarTemplate && templateId) { const template = templateSelecionado; if (template) { - await client.mutation(api.email.enviarEmailComTemplate, { + const resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, { destinatario: destinatario.email, destinatarioId: destinatario._id as any, templateCodigo: template.codigo, @@ -436,12 +635,24 @@ enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); - sucessosEmail++; + if (resultadoEmail?.sucesso && resultadoEmail?.emailId) { + if (agendadaPara) { + const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); + adicionarLog("email", destinatario.nome, "fila", `Agendado para ${dataFormatada}`, resultadoEmail.emailId); + } else { + adicionarLog("email", destinatario.nome, "fila", "Enfileirado para envio", resultadoEmail.emailId); + } + sucessosEmail++; + } else { + adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email"); + falhasEmail++; + } } else { + adicionarLog("email", destinatario.nome, "erro", "Template não encontrado"); falhasEmail++; } } else { - await client.mutation(api.email.enfileirarEmail, { + const resultadoEmail = await client.mutation(api.email.enfileirarEmail, { destinatario: destinatario.email, destinatarioId: destinatario._id as any, assunto: "Notificação do Sistema", @@ -449,13 +660,26 @@ enviadoPorId: authStore.usuario._id as Id<"usuarios">, agendadaPara: agendadaPara, }); - sucessosEmail++; + if (resultadoEmail?.sucesso && resultadoEmail?.emailId) { + if (agendadaPara) { + const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); + adicionarLog("email", destinatario.nome, "fila", `Agendado para ${dataFormatada}`, resultadoEmail.emailId); + } else { + adicionarLog("email", destinatario.nome, "fila", "Enfileirado para envio", resultadoEmail.emailId); + } + sucessosEmail++; + } else { + adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email"); + falhasEmail++; + } } - } catch (error) { + } catch (error: any) { console.error(`Erro ao enviar email para ${destinatario.nome}:`, error); + adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); falhasEmail++; } } else { + adicionarLog("email", destinatario.nome, "erro", "Destinatário não possui email cadastrado"); falhasEmail++; } } @@ -468,25 +692,27 @@ } // Feedback de envio em massa - let mensagem = agendadaPara - ? `✅ Agendamento em massa concluído!\n\n` - : `✅ Envio em massa concluído!\n\n`; + let mensagemMassa = agendadaPara + ? `Agendamento em massa concluído! ` + : `Envio em massa concluído! `; if (agendadaPara) { const dataFormatada = format(new Date(agendadaPara), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); - mensagem += `Será enviado em: ${dataFormatada}\n\n`; + mensagemMassa += `Será enviado em: ${dataFormatada}. `; } if (canal === "ambos") { - mensagem += `Chat: ${sucessosChat} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasChat} falhas\n`; - mensagem += `Email: ${sucessosEmail} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasEmail} falhas`; + mensagemMassa += `Chat: ${sucessosChat} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasChat} falhas. `; + mensagemMassa += `Email: ${sucessosEmail} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasEmail} falhas.`; } else if (canal === "chat") { - mensagem += `Chat: ${sucessosChat} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasChat} falhas`; + mensagemMassa += `Chat: ${sucessosChat} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasChat} falhas.`; } else if (canal === "email") { - mensagem += `Email: ${sucessosEmail} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasEmail} falhas`; + mensagemMassa += `Email: ${sucessosEmail} ${agendadaPara ? 'agendados' : 'enviados'}, ${falhasEmail} falhas.`; } - alert(mensagem); + // Adicionar log resumo + adicionarLog("email", "Sistema", "info", mensagemMassa); + mostrarMensagem("success", mensagemMassa); } // Limpar form @@ -499,10 +725,12 @@ horaAgendamento = ""; } catch (error: any) { console.error("Erro ao enviar notificação:", error); - alert("Erro ao enviar notificação: " + (error.message || "Erro desconhecido")); + adicionarLog("email", "Sistema", "erro", `Erro geral: ${error.message || "Erro desconhecido"}`); + mostrarMensagem("error", "Erro ao enviar notificação: " + (error.message || "Erro desconhecido")); } finally { processando = false; progressoEnvio = { total: 0, enviados: 0, falhas: 0 }; + adicionarLog("email", "Sistema", "info", "Processo de envio finalizado"); } } @@ -523,6 +751,54 @@
+ + {#if mensagem} +
+ + {#if mensagem.tipo === "success"} + + {:else if mensagem.tipo === "error"} + + {:else} + + {/if} + + {mensagem.texto} + +
+ {/if} +
@@ -772,6 +1048,51 @@ {/if}
+ + +
+
+ + {#if logsEnvio.length > 0} + + {/if} +
+
+ {#if logsEnvio.length === 0} +
+ Aguardando envio de notificação... +
+ {:else} + {#each logsEnvio as log} +
+ [{formatarTimestamp(log.timestamp)}] + + {log.tipo.toUpperCase()} + + {log.destinatario}: + + {log.mensagem} + +
+ {/each} + {/if} +
+
diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index 3e622ff..587ffba 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -218,6 +218,25 @@ export const getEmailById = internalQuery({ }, }); +/** + * Buscar emails por IDs (query pública) + */ +export const buscarEmailsPorIds = query({ + args: { + emailIds: v.array(v.id("notificacoesEmail")), + }, + handler: async (ctx, args) => { + const emails = []; + for (const emailId of args.emailIds) { + const email = await ctx.db.get(emailId); + if (email) { + emails.push(email); + } + } + return emails; + }, +}); + export const getActiveEmailConfig = internalQuery({ args: {}, // Tipo inferido automaticamente pelo Convex -- 2.49.1 From 3b89c496c6dfc2c6c5a35d70f4944ff38c5bec0b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 00:43:13 -0300 Subject: [PATCH 13/28] feat: enhance scheduling and management of email notifications - Added functionality to cancel scheduled email notifications, improving user control over their email management. - Implemented a query to list all scheduled emails for the current user, providing better visibility into upcoming notifications. - Enhanced the email schema to support scheduling features, including a timestamp for scheduled delivery. - Improved error handling and user feedback for email scheduling actions, ensuring a smoother user experience. --- .../src/routes/(dashboard)/ti/+page.svelte | 30 +- .../(dashboard)/ti/notificacoes/+page.svelte | 401 ++- .../ti/solicitacoes-acesso/+page.svelte | 693 +++++ packages/backend/convex/chat.ts | 2227 +++++++++-------- packages/backend/convex/email.ts | 139 +- packages/backend/convex/schema.ts | 4 +- 6 files changed, 2385 insertions(+), 1109 deletions(-) create mode 100644 apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 085e8c1..b59a15e 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -9,8 +9,9 @@ | "bell" | "monitor" | "document" - | "teams"; - type PaletteKey = "primary" | "success" | "secondary" | "accent" | "info" | "error"; + | "teams" + | "userPlus"; + type PaletteKey = "primary" | "success" | "secondary" | "accent" | "info" | "error" | "warning"; type FeatureCard = { title: string; @@ -96,6 +97,15 @@ badgeSolid: "badge-error text-error-content", badgeOutline: "badge-outline border-error/30", }, + warning: { + cardBorder: "border-warning/25", + iconBg: "bg-warning/15", + iconRing: "ring-1 ring-warning/25", + iconColor: "text-warning", + button: "btn-warning", + badgeSolid: "badge-warning text-warning-content", + badgeOutline: "badge-outline border-warning/30", + }, }; const iconPaths = { @@ -162,6 +172,13 @@ strokeLinejoin: "round", }, ], + userPlus: [ + { + d: "M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z", + strokeLinecap: "round", + strokeLinejoin: "round", + }, + ], } satisfies Record; const featureCards: Array = [ @@ -210,6 +227,15 @@ palette: "accent", icon: "users", }, + { + title: "Solicitações de Acesso", + description: + "Gerencie e analise solicitações de acesso ao sistema. Aprove ou rejeite novas solicitações de forma eficiente.", + ctaLabel: "Gerenciar Solicitações", + href: "/ti/solicitacoes-acesso", + palette: "warning", + icon: "userPlus", + }, { title: "Gestão de Times", description: diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index b162d03..0c4776e 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -4,7 +4,44 @@ import { format } from "date-fns"; import { ptBR } from "date-fns/locale"; import { authStore } from "$lib/stores/auth.svelte"; - import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; + import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel"; + + // Tipos para agendamentos + type TipoAgendamento = "email" | "chat"; + type StatusAgendamento = "agendado" | "enviado" | "cancelado"; + + interface AgendamentoEmail { + _id: Id<"notificacoesEmail">; + _creationTime: number; + destinatario: string; + destinatarioId: Id<"usuarios"> | undefined; + assunto: string; + corpo: string; + templateId: Id<"templatesMensagens"> | undefined; + status: "pendente" | "enviando" | "enviado" | "falha"; + agendadaPara: number | undefined; + enviadoPor: Id<"usuarios">; + criadoEm: number; + enviadoEm: number | undefined; + destinatarioInfo: Doc<"usuarios"> | null; + templateInfo: Doc<"templatesMensagens"> | null; + } + + interface AgendamentoChat { + _id: Id<"mensagens">; + _creationTime: number; + conversaId: Id<"conversas">; + remetenteId: Id<"usuarios">; + conteudo: string; + agendadaPara: number | undefined; + enviadaEm: number; + conversaInfo: Doc<"conversas"> | null; + destinatarioInfo: Doc<"usuarios"> | null; + } + + type Agendamento = + | { tipo: "email"; dados: AgendamentoEmail } + | { tipo: "chat"; dados: AgendamentoChat }; const client = useConvexClient(); @@ -16,12 +53,20 @@ let emailIdsRastreados = $state>(new Set()); // Query para buscar status dos emails - const emailIdsArray = $derived(Array.from(emailIdsRastreados)); + const emailIdsArray = $derived(Array.from(emailIdsRastreados).map(id => id as Id<"notificacoesEmail">)); const emailsStatusQuery = useQuery( api.email.buscarEmailsPorIds, - emailIdsArray.length > 0 ? { emailIds: emailIdsArray as any[] } : undefined + emailIdsArray.length > 0 ? { emailIds: emailIdsArray } : undefined ); + // Queries para agendamentos + const agendamentosEmailQuery = useQuery(api.email.listarAgendamentosEmail, {}); + const agendamentosChatQuery = useQuery(api.chat.listarAgendamentosChat, {}); + + // Filtro de agendamentos + type FiltroAgendamento = "todos" | "agendados" | "enviados"; + let filtroAgendamento = $state("todos"); + // Extrair dados das queries de forma robusta const templates = $derived.by(() => { if (templatesQuery === undefined || templatesQuery === null) { @@ -241,6 +286,151 @@ emailIdsRastreados = new Set(); } + // Extrair e processar agendamentos + const agendamentosEmail = $derived.by(() => { + if (!agendamentosEmailQuery || agendamentosEmailQuery === undefined) return []; + const dados = Array.isArray(agendamentosEmailQuery) + ? agendamentosEmailQuery + : "data" in agendamentosEmailQuery && Array.isArray(agendamentosEmailQuery.data) + ? agendamentosEmailQuery.data + : []; + return dados as AgendamentoEmail[]; + }); + + const agendamentosChat = $derived.by(() => { + if (!agendamentosChatQuery || agendamentosChatQuery === undefined) return []; + const dados = Array.isArray(agendamentosChatQuery) + ? agendamentosChatQuery + : "data" in agendamentosChatQuery && Array.isArray(agendamentosChatQuery.data) + ? agendamentosChatQuery.data + : []; + return dados as AgendamentoChat[]; + }); + + // Combinar e processar agendamentos + const todosAgendamentos = $derived.by(() => { + const agendamentos: Agendamento[] = []; + + for (const email of agendamentosEmail) { + if (email.agendadaPara) { + agendamentos.push({ + tipo: "email", + dados: email, + }); + } + } + + for (const chat of agendamentosChat) { + if (chat.agendadaPara) { + agendamentos.push({ + tipo: "chat", + dados: chat, + }); + } + } + + // Ordenar: futuros primeiro (mais próximos primeiro), depois passados (mais recentes primeiro) + return agendamentos.sort((a, b) => { + const timestampA = a.tipo === "email" ? (a.dados.agendadaPara ?? 0) : (a.dados.agendadaPara ?? 0); + const timestampB = b.tipo === "email" ? (b.dados.agendadaPara ?? 0) : (b.dados.agendadaPara ?? 0); + const agora = Date.now(); + + const aFuturo = timestampA > agora; + const bFuturo = timestampB > agora; + + // Futuros primeiro + if (aFuturo && !bFuturo) return -1; + if (!aFuturo && bFuturo) return 1; + + // Dentro do mesmo grupo, ordenar por timestamp + if (aFuturo) { + // Futuros: mais próximos primeiro + return timestampA - timestampB; + } else { + // Passados: mais recentes primeiro + return timestampB - timestampA; + } + }); + }); + + // Filtrar agendamentos + const agendamentosFiltrados = $derived.by(() => { + if (filtroAgendamento === "todos") return todosAgendamentos; + + return todosAgendamentos.filter(ag => { + const status = obterStatusAgendamento(ag); + if (filtroAgendamento === "agendados") return status === "agendado"; + if (filtroAgendamento === "enviados") return status === "enviado"; + return true; + }); + }); + + // Função para obter status do agendamento + function obterStatusAgendamento(agendamento: Agendamento): StatusAgendamento { + if (agendamento.tipo === "email") { + const email = agendamento.dados; + if (email.status === "enviado") return "enviado"; + if (email.agendadaPara && email.agendadaPara <= Date.now()) return "enviado"; + return "agendado"; + } else { + const chat = agendamento.dados; + if (chat.agendadaPara && chat.agendadaPara <= Date.now()) return "enviado"; + return "agendado"; + } + } + + // Função para cancelar agendamento + async function cancelarAgendamento(agendamento: Agendamento) { + if (!confirm("Tem certeza que deseja cancelar este agendamento?")) { + return; + } + + try { + if (agendamento.tipo === "email") { + const resultado = await client.mutation(api.email.cancelarAgendamentoEmail, { + emailId: agendamento.dados._id, + }); + if (resultado.sucesso) { + mostrarMensagem("success", "Agendamento de email cancelado com sucesso!"); + } else { + mostrarMensagem("error", resultado.erro || "Erro ao cancelar agendamento"); + } + } else { + const resultado = await client.mutation(api.chat.cancelarMensagemAgendada, { + mensagemId: agendamento.dados._id, + }); + if (resultado.sucesso) { + mostrarMensagem("success", "Agendamento de chat cancelado com sucesso!"); + } else { + mostrarMensagem("error", resultado.erro || "Erro ao cancelar agendamento"); + } + } + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; + mostrarMensagem("error", `Erro ao cancelar agendamento: ${erro}`); + } + } + + // Função para obter nome do destinatário + function obterNomeDestinatario(agendamento: Agendamento): string { + if (agendamento.tipo === "email") { + return agendamento.dados.destinatarioInfo?.nome || agendamento.dados.destinatario || "Usuário"; + } else { + return agendamento.dados.destinatarioInfo?.nome || "Usuário"; + } + } + + // Função para formatar data/hora do agendamento + function formatarDataAgendamento(agendamento: Agendamento): string { + const timestamp = agendamento.tipo === "email" + ? agendamento.dados.agendadaPara + : agendamento.dados.agendadaPara; + + if (!timestamp) return "N/A"; + + return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR }); + } + // Função para formatar timestamp function formatarTimestamp(timestamp: number): string { return format(new Date(timestamp), "HH:mm:ss", { locale: ptBR }); @@ -278,9 +468,10 @@ } else { mostrarMensagem("error", "Erro ao criar templates padrão."); } - } catch (error: any) { + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; console.error("Erro ao criar templates:", error); - mostrarMensagem("error", "Erro ao criar templates: " + (error.message || "Erro desconhecido")); + mostrarMensagem("error", "Erro ao criar templates: " + erro); } finally { criandoTemplates = false; } @@ -351,9 +542,10 @@ } else { mostrarMensagem("error", "Erro ao criar template: " + (resultado.erro || "Erro desconhecido")); } - } catch (error: any) { + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; console.error("Erro ao criar template:", error); - mostrarMensagem("error", "Erro ao criar template: " + (error.message || "Erro desconhecido")); + mostrarMensagem("error", "Erro ao criar template: " + erro); } finally { criandoNovoTemplate = false; } @@ -436,7 +628,7 @@ adicionarLog("chat", destinatario.nome, "enviando", "Criando/buscando conversa..."); const conversaResult = await client.mutation( api.chat.criarOuBuscarConversaIndividual, - { outroUsuarioId: destinatario._id as any } + { outroUsuarioId: destinatario._id as Id<"usuarios"> } ); if (conversaResult.conversaId) { @@ -468,9 +660,10 @@ } else { adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa"); } - } catch (error: any) { + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; console.error("Erro ao enviar chat:", error); - adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); + adicionarLog("chat", destinatario.nome, "erro", `Erro: ${erro}`); } } @@ -484,7 +677,7 @@ if (template) { resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, { destinatario: destinatario.email, - destinatarioId: destinatario._id as any, + destinatarioId: destinatario._id as Id<"usuarios">, templateCodigo: template.codigo, variaveis: { nome: destinatario.nome, @@ -509,7 +702,7 @@ } else { resultadoEmail = await client.mutation(api.email.enfileirarEmail, { destinatario: destinatario.email, - destinatarioId: destinatario._id as any, + destinatarioId: destinatario._id as Id<"usuarios">, assunto: "Notificação do Sistema", corpo: mensagemPersonalizada, enviadoPorId: authStore.usuario._id as Id<"usuarios">, @@ -526,9 +719,10 @@ adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email"); } } - } catch (error: any) { + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; console.error("Erro ao enviar email:", error); - adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); + adicionarLog("email", destinatario.nome, "erro", `Erro: ${erro}`); } } else { adicionarLog("email", destinatario.nome, "erro", "Destinatário não possui email cadastrado"); @@ -577,7 +771,7 @@ adicionarLog("chat", destinatario.nome, "enviando", "Processando..."); const conversaResult = await client.mutation( api.chat.criarOuBuscarConversaIndividual, - { outroUsuarioId: destinatario._id as any } + { outroUsuarioId: destinatario._id as Id<"usuarios"> } ); if (conversaResult.conversaId) { @@ -609,9 +803,10 @@ adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa"); falhasChat++; } - } catch (error: any) { + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; console.error(`Erro ao enviar chat para ${destinatario.nome}:`, error); - adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); + adicionarLog("chat", destinatario.nome, "erro", `Erro: ${erro}`); falhasChat++; } } @@ -626,7 +821,7 @@ if (template) { const resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, { destinatario: destinatario.email, - destinatarioId: destinatario._id as any, + destinatarioId: destinatario._id as Id<"usuarios">, templateCodigo: template.codigo, variaveis: { nome: destinatario.nome, @@ -654,7 +849,7 @@ } else { const resultadoEmail = await client.mutation(api.email.enfileirarEmail, { destinatario: destinatario.email, - destinatarioId: destinatario._id as any, + destinatarioId: destinatario._id as Id<"usuarios">, assunto: "Notificação do Sistema", corpo: mensagemPersonalizada, enviadoPorId: authStore.usuario._id as Id<"usuarios">, @@ -673,9 +868,10 @@ falhasEmail++; } } - } catch (error: any) { + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; console.error(`Erro ao enviar email para ${destinatario.nome}:`, error); - adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`); + adicionarLog("email", destinatario.nome, "erro", `Erro: ${erro}`); falhasEmail++; } } else { @@ -723,10 +919,11 @@ agendarEnvio = false; dataAgendamento = ""; horaAgendamento = ""; - } catch (error: any) { + } catch (error) { + const erro = error instanceof Error ? error.message : "Erro desconhecido"; console.error("Erro ao enviar notificação:", error); - adicionarLog("email", "Sistema", "erro", `Erro geral: ${error.message || "Erro desconhecido"}`); - mostrarMensagem("error", "Erro ao enviar notificação: " + (error.message || "Erro desconhecido")); + adicionarLog("email", "Sistema", "erro", `Erro geral: ${erro}`); + mostrarMensagem("error", "Erro ao enviar notificação: " + erro); } finally { processando = false; progressoEnvio = { total: 0, enviados: 0, falhas: 0 }; @@ -1188,6 +1385,162 @@
+ +
+
+
+
+
+ + + +
+

Histórico de Agendamentos

+
+ + +
+ + + +
+
+ + {#if agendamentosFiltrados.length === 0} +
+ + + +

Nenhum agendamento encontrado

+

Os agendamentos aparecerão aqui quando você agendar envios.

+
+ {:else} + +
+ + + + + + + + + + + + + {#each agendamentosFiltrados as agendamento} + {@const status = obterStatusAgendamento(agendamento)} + {@const nomeDestinatario = obterNomeDestinatario(agendamento)} + {@const dataFormatada = formatarDataAgendamento(agendamento)} + {@const podeCancelar = status === "agendado"} + {@const templateNome = agendamento.tipo === "email" && agendamento.dados.templateInfo + ? agendamento.dados.templateInfo.nome + : agendamento.tipo === "email" && agendamento.dados.templateId + ? "Template removido" + : "-"} + + + + + + + + + {/each} + +
TipoDestinatárioData/HoraStatusTemplateAções
+
+ {#if agendamento.tipo === "email"} + + + + Email + {:else} + + + + Chat + {/if} +
+
+
{nomeDestinatario}
+ {#if agendamento.tipo === "email"} +
{agendamento.dados.destinatario}
+ {/if} +
+
{dataFormatada}
+ {#if podeCancelar} + {@const tempoRestante = agendamento.tipo === "email" + ? (agendamento.dados.agendadaPara ?? 0) - Date.now() + : (agendamento.dados.agendadaPara ?? 0) - Date.now()} + {@const horasRestantes = Math.floor(tempoRestante / (1000 * 60 * 60))} + {@const minutosRestantes = Math.floor((tempoRestante % (1000 * 60 * 60)) / (1000 * 60))} + {#if horasRestantes < 1 && minutosRestantes < 60} +
Em {minutosRestantes} min
+ {:else if horasRestantes < 24} +
Em {horasRestantes}h {minutosRestantes}min
+ {/if} + {/if} +
+ {#if status === "agendado"} + Agendado + {:else if status === "enviado"} + Enviado + {:else} + Cancelado + {/if} + + {#if agendamento.tipo === "email"} + {#if agendamento.dados.templateInfo} +
{agendamento.dados.templateInfo.nome}
+ {:else if agendamento.dados.templateId} +
Template removido
+ {:else} +
-
+ {/if} + {:else} +
-
+ {/if} +
+ {#if podeCancelar} + + {:else} + - + {/if} +
+
+ {/if} +
+
+
diff --git a/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte new file mode 100644 index 0000000..1034330 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/solicitacoes-acesso/+page.svelte @@ -0,0 +1,693 @@ + + + +
+ + {#if mensagem} +
+ {#if mensagem.tipo === "success"} + + + + {:else if mensagem.tipo === "error"} + + + + {/if} + {mensagem.texto} +
+ {/if} + + +
+
+
+ + + +
+
+

Solicitações de Acesso

+

Gerencie e analise solicitações de acesso ao sistema

+
+
+
+ + + {#if stats} +
+ + + + + + + +
+ {:else} +
+ +
+ {/if} + + +
+
+ +
+ + + + +
+ + +
+ +
+ + + + +
+
+
+
+ + + {#if carregando} +
+ +
+ {:else if solicitacoesFiltradas.length === 0} +
+
+ + + +

Nenhuma solicitação encontrada

+

+ {#if busca.trim() || filtroStatus !== "todos"} + Tente ajustar os filtros ou a busca. + {:else} + Ainda não há solicitações de acesso cadastradas. + {/if} +

+
+
+ {:else} +
+ {#each solicitacoesFiltradas as solicitacao} +
+
+
+
+
+

{solicitacao.nome}

+ + {getStatusTexto(solicitacao.status)} + +
+ +
+
+ + + + Matrícula: + {solicitacao.matricula} +
+ +
+ + + + E-mail: + {solicitacao.email} +
+ +
+ + + + Telefone: + {solicitacao.telefone} +
+
+ +
+ Solicitado em: {formatarData(solicitacao.dataSolicitacao)} ({formatarDataRelativa(solicitacao.dataSolicitacao)}) + {#if solicitacao.dataResposta} + Processado em: {formatarData(solicitacao.dataResposta)} + {/if} +
+
+ +
+ + + {#if solicitacao.status === "pendente"} + + + + {/if} +
+
+
+
+ {/each} +
+ {/if} + + + {#if modalDetalhesAberto && solicitacaoSelecionada} + + + + + {/if} + + + {#if modalAprovarAberto && solicitacaoSelecionada} + + + + + {/if} + + + {#if modalRejeitarAberto && solicitacaoSelecionada} + + + + + {/if} +
+ diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index bcfa4ee..cac7a8d 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -1,1078 +1,1149 @@ -import { v } from "convex/values"; -import { mutation, query, internalMutation } from "./_generated/server"; -import { Doc, Id } from "./_generated/dataModel"; -import type { QueryCtx, MutationCtx } from "./_generated/server"; - -// ========== HELPERS ========== - -/** - * Helper function para obter usuário autenticado (Better Auth ou Sessão) - */ -async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { - // Tentar autenticação via Better Auth primeiro - const identity = await ctx.auth.getUserIdentity(); - let usuarioAtual = null; - - if (identity && identity.email) { - usuarioAtual = await ctx.db - .query("usuarios") - .withIndex("by_email", (q) => q.eq("email", identity.email!)) - .first(); - } - - // Se não encontrou via Better Auth, tentar via sessão mais recente - if (!usuarioAtual) { - const sessaoAtiva = await ctx.db - .query("sessoes") - .filter((q) => q.eq(q.field("ativo"), true)) - .order("desc") - .first(); - - if (sessaoAtiva) { - usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); - } - } - - return usuarioAtual; -} - -// ========== MUTATIONS ========== - -/** - * Cria uma nova conversa (individual ou grupo) - */ -export const criarConversa = mutation({ - args: { - tipo: v.union(v.literal("individual"), v.literal("grupo")), - participantes: v.array(v.id("usuarios")), - nome: v.optional(v.string()), - avatar: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Validar participantes - if (!args.participantes.includes(usuarioAtual._id)) { - args.participantes.push(usuarioAtual._id); - } - - // Se for conversa individual, verificar se já existe - if (args.tipo === "individual" && args.participantes.length === 2) { - const conversaExistente = await ctx.db - .query("conversas") - .filter((q) => q.eq(q.field("tipo"), "individual")) - .collect(); - - for (const conversa of conversaExistente) { - if ( - conversa.participantes.length === 2 && - conversa.participantes.every((p) => args.participantes.includes(p)) - ) { - return conversa._id; - } - } - } - - // Criar nova conversa - const conversaId = await ctx.db.insert("conversas", { - tipo: args.tipo, - nome: args.nome, - avatar: args.avatar, - participantes: args.participantes, - criadoPor: usuarioAtual._id, - criadoEm: Date.now(), - }); - - // Criar notificações para outros participantes - if (args.tipo === "grupo") { - for (const participanteId of args.participantes) { - if (participanteId !== usuarioAtual._id) { - await ctx.db.insert("notificacoes", { - usuarioId: participanteId, - tipo: "adicionado_grupo", - conversaId, - remetenteId: usuarioAtual._id, - titulo: "Adicionado a grupo", - descricao: `Você foi adicionado ao grupo "${args.nome || "Sem nome"}" por ${usuarioAtual.nome}`, - lida: false, - criadaEm: Date.now(), - }); - } - } - } - - return conversaId; - }, -}); - -/** - * Cria ou busca uma conversa individual com outro usuário - */ -export const criarOuBuscarConversaIndividual = mutation({ - args: { - outroUsuarioId: v.id("usuarios"), - }, - returns: v.id("conversas"), - handler: async (ctx, args) => { - // TENTAR BETTER AUTH PRIMEIRO - const identity = await ctx.auth.getUserIdentity(); - - let usuarioAtual = null; - - if (identity && identity.email) { - // Buscar por email (Better Auth) - usuarioAtual = await ctx.db - .query("usuarios") - .withIndex("by_email", (q) => q.eq("email", identity.email!)) - .first(); - } - - // SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado) - if (!usuarioAtual) { - const sessaoAtiva = await ctx.db - .query("sessoes") - .filter((q) => q.eq(q.field("ativo"), true)) - .order("desc") - .first(); - - if (sessaoAtiva) { - usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); - } - } - - if (!usuarioAtual) throw new Error("Usuário não autenticado"); - - // Buscar conversa individual existente entre os dois usuários - const conversasExistentes = await ctx.db - .query("conversas") - .filter((q) => q.eq(q.field("tipo"), "individual")) - .collect(); - - for (const conversa of conversasExistentes) { - if ( - conversa.participantes.length === 2 && - conversa.participantes.includes(usuarioAtual._id) && - conversa.participantes.includes(args.outroUsuarioId) - ) { - return conversa._id; - } - } - - // Se não existe, criar nova conversa individual - const conversaId = await ctx.db.insert("conversas", { - tipo: "individual", - participantes: [usuarioAtual._id, args.outroUsuarioId], - criadoPor: usuarioAtual._id, - criadoEm: Date.now(), - }); - - return conversaId; - }, -}); - -/** - * Envia uma mensagem em uma conversa - */ -export const enviarMensagem = mutation({ - args: { - conversaId: v.id("conversas"), - conteudo: v.string(), - tipo: v.union( - v.literal("texto"), - v.literal("arquivo"), - v.literal("imagem") - ), - arquivoId: v.optional(v.id("_storage")), - arquivoNome: v.optional(v.string()), - arquivoTamanho: v.optional(v.number()), - arquivoTipo: v.optional(v.string()), - mencoes: v.optional(v.array(v.id("usuarios"))), - permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Verificar se usuário pertence à conversa - const conversa = await ctx.db.get(args.conversaId); - if (!conversa) throw new Error("Conversa não encontrada"); - if (!conversa.participantes.includes(usuarioAtual._id)) { - throw new Error("Você não pertence a esta conversa"); - } - - // Criar mensagem - const mensagemId = await ctx.db.insert("mensagens", { - conversaId: args.conversaId, - remetenteId: usuarioAtual._id, - tipo: args.tipo, - conteudo: args.conteudo, - arquivoId: args.arquivoId, - arquivoNome: args.arquivoNome, - arquivoTamanho: args.arquivoTamanho, - arquivoTipo: args.arquivoTipo, - mencoes: args.mencoes, - enviadaEm: Date.now(), - }); - - // Atualizar última mensagem da conversa - await ctx.db.patch(args.conversaId, { - ultimaMensagem: args.conteudo.substring(0, 100), - ultimaMensagemTimestamp: Date.now(), - }); - - // Criar notificações para participantes (com tratamento de erro) - try { - for (const participanteId of conversa.participantes) { - // ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa - const ehOMesmoUsuario = participanteId === usuarioAtual._id; - const deveCriarNotificacao = !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo; - - if (deveCriarNotificacao) { - const tipoNotificacao = args.mencoes?.includes(participanteId) - ? "mencao" - : "nova_mensagem"; - - await ctx.db.insert("notificacoes", { - usuarioId: participanteId, - tipo: tipoNotificacao, - conversaId: args.conversaId, - mensagemId, - remetenteId: usuarioAtual._id, - titulo: - tipoNotificacao === "mencao" - ? `${usuarioAtual.nome} mencionou você` - : `Nova mensagem de ${usuarioAtual.nome}`, - descricao: args.conteudo.substring(0, 100), - lida: false, - criadaEm: Date.now(), - }); - } - } - } catch (error) { - // Log do erro mas não falhar o envio da mensagem - console.error("Erro ao criar notificações:", error); - // A mensagem já foi criada, então retornamos o ID normalmente - } - - return mensagemId; - }, -}); - -/** - * Agenda uma mensagem para envio futuro - */ -export const agendarMensagem = mutation({ - args: { - conversaId: v.id("conversas"), - conteudo: v.string(), - agendadaPara: v.number(), // timestamp - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Validar data futura - if (args.agendadaPara <= Date.now()) { - throw new Error("Data de agendamento deve ser futura"); - } - - // Verificar se usuário pertence à conversa - const conversa = await ctx.db.get(args.conversaId); - if (!conversa) throw new Error("Conversa não encontrada"); - if (!conversa.participantes.includes(usuarioAtual._id)) { - throw new Error("Você não pertence a esta conversa"); - } - - // Criar mensagem agendada - const mensagemId = await ctx.db.insert("mensagens", { - conversaId: args.conversaId, - remetenteId: usuarioAtual._id, - tipo: "texto", - conteudo: args.conteudo, - agendadaPara: args.agendadaPara, - enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada - }); - - return mensagemId; - }, -}); - -/** - * Cancela uma mensagem agendada - */ -export const cancelarMensagemAgendada = mutation({ - args: { - mensagemId: v.id("mensagens"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const mensagem = await ctx.db.get(args.mensagemId); - if (!mensagem) throw new Error("Mensagem não encontrada"); - if (mensagem.remetenteId !== usuarioAtual._id) { - throw new Error("Você só pode cancelar suas próprias mensagens"); - } - - await ctx.db.delete(args.mensagemId); - return true; - }, -}); - -/** - * Adiciona uma reação (emoji) a uma mensagem - */ -export const reagirMensagem = mutation({ - args: { - mensagemId: v.id("mensagens"), - emoji: v.string(), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const mensagem = await ctx.db.get(args.mensagemId); - if (!mensagem) throw new Error("Mensagem não encontrada"); - - const reacoes = mensagem.reagiuPor || []; - const reacaoExistente = reacoes.find( - (r) => r.usuarioId === usuarioAtual._id && r.emoji === args.emoji - ); - - if (reacaoExistente) { - // Remover reação - await ctx.db.patch(args.mensagemId, { - reagiuPor: reacoes.filter( - (r) => !(r.usuarioId === usuarioAtual._id && r.emoji === args.emoji) - ), - }); - } else { - // Adicionar reação - await ctx.db.patch(args.mensagemId, { - reagiuPor: [ - ...reacoes, - { usuarioId: usuarioAtual._id, emoji: args.emoji }, - ], - }); - } - - return true; - }, -}); - -/** - * Marca mensagens de uma conversa como lidas - */ -export const marcarComoLida = mutation({ - args: { - conversaId: v.id("conversas"), - mensagemId: v.id("mensagens"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Buscar registro de leitura existente - const leituraExistente = await ctx.db - .query("leituras") - .withIndex("by_conversa_usuario", (q) => - q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) - ) - .first(); - - if (leituraExistente) { - await ctx.db.patch(leituraExistente._id, { - ultimaMensagemLida: args.mensagemId, - lidaEm: Date.now(), - }); - } else { - await ctx.db.insert("leituras", { - conversaId: args.conversaId, - usuarioId: usuarioAtual._id, - ultimaMensagemLida: args.mensagemId, - lidaEm: Date.now(), - }); - } - - // Marcar notificações desta conversa como lidas - const notificacoes = await ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ) - .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) - .collect(); - - for (const notificacao of notificacoes) { - await ctx.db.patch(notificacao._id, { lida: true }); - } - - return true; - }, -}); - -/** - * Atualiza o status de presença do usuário - */ -export const atualizarStatusPresenca = mutation({ - args: { - status: v.union( - v.literal("online"), - v.literal("offline"), - v.literal("ausente"), - v.literal("externo"), - v.literal("em_reuniao") - ), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - await ctx.db.patch(usuarioAtual._id, { - statusPresenca: args.status, - ultimaAtividade: Date.now(), - }); - - return true; - }, -}); - -/** - * Indica que o usuário está digitando em uma conversa - */ -export const indicarDigitacao = mutation({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Buscar indicador existente - const indicadorExistente = await ctx.db - .query("digitando") - .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)) - .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) - .first(); - - if (indicadorExistente) { - await ctx.db.patch(indicadorExistente._id, { - iniciouEm: Date.now(), - }); - } else { - await ctx.db.insert("digitando", { - conversaId: args.conversaId, - usuarioId: usuarioAtual._id, - iniciouEm: Date.now(), - }); - } - - return true; - }, -}); - -/** - * Gera URL para upload de arquivo no chat - */ -export const uploadArquivoChat = mutation({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - // Verificar se usuário pertence à conversa - const conversa = await ctx.db.get(args.conversaId); - if (!conversa) throw new Error("Conversa não encontrada"); - - if (!conversa.participantes.includes(usuarioAtual._id)) { - throw new Error("Você não pertence a esta conversa"); - } - - return await ctx.storage.generateUploadUrl(); - }, -}); - -/** - * Marca uma notificação como lida - */ -export const marcarNotificacaoLida = mutation({ - args: { - notificacaoId: v.id("notificacoes"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - await ctx.db.patch(args.notificacaoId, { lida: true }); - return true; - }, -}); - -/** - * Marca todas as notificações como lidas - */ -export const marcarTodasNotificacoesLidas = mutation({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const notificacoes = await ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ) - .collect(); - - for (const notificacao of notificacoes) { - await ctx.db.patch(notificacao._id, { lida: true }); - } - - return true; - }, -}); - -/** - * Deleta uma mensagem (soft delete) - */ -export const deletarMensagem = mutation({ - args: { - mensagemId: v.id("mensagens"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) throw new Error("Não autenticado"); - - const mensagem = await ctx.db.get(args.mensagemId); - if (!mensagem) throw new Error("Mensagem não encontrada"); - - if (mensagem.remetenteId !== usuarioAtual._id) { - throw new Error("Você só pode deletar suas próprias mensagens"); - } - - await ctx.db.patch(args.mensagemId, { - deletada: true, - conteudo: "Mensagem deletada", - }); - - return true; - }, -}); - -// ========== QUERIES ========== - -/** - * Lista todas as conversas do usuário logado - */ -export const listarConversas = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar todas as conversas do usuário - const todasConversas = await ctx.db.query("conversas").collect(); - const conversasDoUsuario = todasConversas.filter((c) => - c.participantes.includes(usuarioAtual._id) - ); - - // Ordenar por última mensagem - conversasDoUsuario.sort((a, b) => { - const timestampA = a.ultimaMensagemTimestamp || a.criadoEm; - const timestampB = b.ultimaMensagemTimestamp || b.criadoEm; - return timestampB - timestampA; - }); - - // Enriquecer com informações dos participantes - const conversasEnriquecidas = await Promise.all( - conversasDoUsuario.map(async (conversa) => { - // Buscar participantes - const participantes = await Promise.all( - conversa.participantes.map((id) => ctx.db.get(id)) - ); - - // Para conversas individuais, pegar o outro usuário - let outroUsuario = null; - if (conversa.tipo === "individual") { - const outroUsuarioRaw = participantes.find((p) => p?._id !== usuarioAtual._id); - if (outroUsuarioRaw) { - // 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot) - const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id); - - if (usuarioAtualizado) { - // Adicionar URL da foto de perfil - let fotoPerfilUrl = null; - if (usuarioAtualizado.fotoPerfil) { - fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtualizado.fotoPerfil); - } - outroUsuario = { - ...usuarioAtualizado, - fotoPerfilUrl, - }; - } - } - } - - // Contar mensagens não lidas (apenas mensagens NÃO agendadas) - const leitura = await ctx.db - .query("leituras") - .withIndex("by_conversa_usuario", (q) => - q.eq("conversaId", conversa._id).eq("usuarioId", usuarioAtual._id) - ) - .first(); - - // CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined) - const todasMensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) - .collect(); - - const mensagens = todasMensagens.filter((m) => !m.agendadaPara); - - let naoLidas = 0; - if (leitura) { - naoLidas = mensagens.filter( - (m) => - m.enviadaEm > (leitura.lidaEm || 0) && - m.remetenteId !== usuarioAtual._id - ).length; - } else { - naoLidas = mensagens.filter( - (m) => m.remetenteId !== usuarioAtual._id - ).length; - } - - return { - ...conversa, - outroUsuario, - participantesInfo: participantes.filter((p) => p !== null), - naoLidas, - }; - }) - ); - - return conversasEnriquecidas; - }, -}); - -/** - * Obtém as mensagens de uma conversa com paginação - */ -export const obterMensagens = query({ - args: { - conversaId: v.id("conversas"), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Verificar se usuário pertence à conversa - const conversa = await ctx.db.get(args.conversaId); - if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { - return []; - } - - // Buscar mensagens (excluir agendadas) - const mensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .order("desc") - .take(args.limit || 50); - - // Filtrar mensagens agendadas - const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara); - - // Enriquecer com informações do remetente - const mensagensEnriquecidas = await Promise.all( - mensagensFiltradas.map(async (mensagem) => { - const remetente = await ctx.db.get(mensagem.remetenteId); - let arquivoUrl = null; - if (mensagem.arquivoId) { - arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId); - } - return { - ...mensagem, - remetente, - arquivoUrl, - }; - }) - ); - - return mensagensEnriquecidas.reverse(); - }, -}); - -/** - * Obtém mensagens agendadas de uma conversa - */ -export const obterMensagensAgendadas = query({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar mensagens agendadas - const todasMensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .collect(); - - // Filtrar apenas as agendadas do usuário atual - const minhasMensagensAgendadas = todasMensagens.filter( - (m) => - m.remetenteId === usuarioAtual._id && - m.agendadaPara && - m.agendadaPara > Date.now() - ); - - return minhasMensagensAgendadas.sort( - (a, b) => (a.agendadaPara || 0) - (b.agendadaPara || 0) - ); - }, -}); - -/** - * Obtém as notificações do usuário - */ -export const obterNotificacoes = query({ - args: { - apenasPendentes: v.optional(v.boolean()), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - let query = ctx.db - .query("notificacoes") - .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)); - - if (args.apenasPendentes) { - query = ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ); - } - - const notificacoes = await query.order("desc").take(50); - - // Enriquecer com informações do remetente - const notificacoesEnriquecidas = await Promise.all( - notificacoes.map(async (notificacao) => { - let remetente = null; - if (notificacao.remetenteId) { - remetente = await ctx.db.get(notificacao.remetenteId); - } - return { - ...notificacao, - remetente, - }; - }) - ); - - return notificacoesEnriquecidas; - }, -}); - -/** - * Conta o número de notificações não lidas - */ -export const contarNotificacoesNaoLidas = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return 0; - - const notificacoes = await ctx.db - .query("notificacoes") - .withIndex("by_usuario_lida", (q) => - q.eq("usuarioId", usuarioAtual._id).eq("lida", false) - ) - .collect(); - - return notificacoes.length; - }, -}); - -/** - * Obtém usuários online - */ -export const obterUsuariosOnline = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_status_presenca", (q) => q.eq("statusPresenca", "online")) - .collect(); - - return usuarios.map((u) => ({ - _id: u._id, - nome: u.nome, - email: u.email, - avatar: u.avatar, - fotoPerfil: u.fotoPerfil, - statusPresenca: u.statusPresenca, - statusMensagem: u.statusMensagem, - setor: u.setor, - })); - }, -}); - -/** - * Lista todos os usuários (para criar nova conversa) - */ -export const listarTodosUsuarios = query({ - args: {}, - handler: async (ctx) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - const usuarios = await ctx.db - .query("usuarios") - .withIndex("by_ativo", (q) => q.eq("ativo", true)) - .collect(); - - // Excluir o usuário atual - return usuarios - .filter((u) => u._id !== usuarioAtual._id) - .map((u) => ({ - _id: u._id, - nome: u.nome, - email: u.email, - matricula: u.matricula, - avatar: u.avatar, - fotoPerfil: u.fotoPerfil, - statusPresenca: u.statusPresenca, - statusMensagem: u.statusMensagem, - setor: u.setor, - })); - }, -}); - -/** - * Busca mensagens em conversas - */ -export const buscarMensagens = query({ - args: { - query: v.string(), - conversaId: v.optional(v.id("conversas")), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar em todas as conversas do usuário - const todasConversas = await ctx.db.query("conversas").collect(); - const conversasDoUsuario = todasConversas.filter((c) => - c.participantes.includes(usuarioAtual._id) - ); - - let mensagens: Doc<"mensagens">[] = []; - - if (args.conversaId !== undefined) { - // Buscar em conversa específica - const mensagensConversa = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!)) - .collect(); - mensagens = mensagensConversa; - } else { - // Buscar em todas as conversas - for (const conversa of conversasDoUsuario) { - const mensagensConversa = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) - .collect(); - mensagens.push(...mensagensConversa); - } - } - - // Filtrar por query - const queryLower = args.query.toLowerCase(); - const mensagensFiltradas = mensagens.filter( - (m) => - !m.deletada && - !m.agendadaPara && - m.conteudo.toLowerCase().includes(queryLower) - ); - - // Enriquecer com informações - const mensagensEnriquecidas = await Promise.all( - mensagensFiltradas.map(async (mensagem) => { - const remetente = await ctx.db.get(mensagem.remetenteId); - const conversa = await ctx.db.get(mensagem.conversaId); - return { - ...mensagem, - remetente, - conversa, - }; - }) - ); - - return mensagensEnriquecidas - .sort((a, b) => b.enviadaEm - a.enviadaEm) - .slice(0, 50); - }, -}); - -/** - * Obtém quem está digitando em uma conversa - */ -export const obterDigitando = query({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return []; - - // Buscar indicadores de digitação (últimos 10 segundos) - const dezSegundosAtras = Date.now() - 10000; - const digitando = await ctx.db - .query("digitando") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .filter((q) => q.gte(q.field("iniciouEm"), dezSegundosAtras)) - .collect(); - - // Filtrar usuário atual e buscar informações - const digitandoFiltrado = digitando.filter( - (d) => d.usuarioId !== usuarioAtual._id - ); - - const usuarios = await Promise.all( - digitandoFiltrado.map(async (d) => { - const usuario = await ctx.db.get(d.usuarioId); - return usuario; - }) - ); - - return usuarios.filter((u) => u !== null); - }, -}); - -/** - * Conta mensagens não lidas de uma conversa - */ -export const contarNaoLidas = query({ - args: { - conversaId: v.id("conversas"), - }, - handler: async (ctx, args) => { - const usuarioAtual = await getUsuarioAutenticado(ctx); - if (!usuarioAtual) return 0; - - const leitura = await ctx.db - .query("leituras") - .withIndex("by_conversa_usuario", (q) => - q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) - ) - .first(); - - const mensagens = await ctx.db - .query("mensagens") - .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) - .filter((q) => q.eq(q.field("agendadaPara"), undefined)) - .collect(); - - if (leitura) { - return mensagens.filter( - (m) => - m.enviadaEm > (leitura.lidaEm || 0) && - m.remetenteId !== usuarioAtual._id - ).length; - } - - return mensagens.filter((m) => m.remetenteId !== usuarioAtual._id).length; - }, -}); - -// ========== INTERNAL MUTATIONS (para crons) ========== - -/** - * Envia mensagens agendadas (chamado pelo cron) - */ -export const enviarMensagensAgendadas = internalMutation({ - args: {}, - handler: async (ctx) => { - const agora = Date.now(); - - // Buscar mensagens que deveriam ser enviadas - const mensagensAgendadas = await ctx.db - .query("mensagens") - .withIndex("by_agendamento") - .filter((q) => - q.and( - q.neq(q.field("agendadaPara"), undefined), - q.lte(q.field("agendadaPara"), agora) - ) - ) - .collect(); - - for (const mensagem of mensagensAgendadas) { - // Atualizar mensagem para "enviada" - await ctx.db.patch(mensagem._id, { - agendadaPara: undefined, - enviadaEm: agora, - }); - - // Atualizar última mensagem da conversa - const conversa = await ctx.db.get(mensagem.conversaId); - if (conversa) { - await ctx.db.patch(mensagem.conversaId, { - ultimaMensagem: mensagem.conteudo.substring(0, 100), - ultimaMensagemTimestamp: agora, - }); - - // Criar notificações para outros participantes - const remetente = await ctx.db.get(mensagem.remetenteId); - for (const participanteId of conversa.participantes) { - if (participanteId !== mensagem.remetenteId) { - await ctx.db.insert("notificacoes", { - usuarioId: participanteId, - tipo: "nova_mensagem", - conversaId: mensagem.conversaId, - mensagemId: mensagem._id, - remetenteId: mensagem.remetenteId, - titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`, - descricao: mensagem.conteudo.substring(0, 100), - lida: false, - criadaEm: agora, - }); - } - } - } - } - - return mensagensAgendadas.length; - }, -}); - -/** - * Limpa indicadores de digitação antigos (chamado pelo cron) - */ -export const limparIndicadoresDigitacao = internalMutation({ - args: {}, - handler: async (ctx) => { - const dezSegundosAtras = Date.now() - 10000; - - const indicadoresAntigos = await ctx.db - .query("digitando") - .filter((q) => q.lt(q.field("iniciouEm"), dezSegundosAtras)) - .collect(); - - for (const indicador of indicadoresAntigos) { - await ctx.db.delete(indicador._id); - } - - return indicadoresAntigos.length; - }, -}); +import { v } from "convex/values"; +import { mutation, query, internalMutation } from "./_generated/server"; +import { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; + +// ========== HELPERS ========== + +/** + * Helper function para obter usuário autenticado (Better Auth ou Sessão) + */ +async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { + // Tentar autenticação via Better Auth primeiro + const identity = await ctx.auth.getUserIdentity(); + let usuarioAtual = null; + + if (identity && identity.email) { + usuarioAtual = await ctx.db + .query("usuarios") + .withIndex("by_email", (q) => q.eq("email", identity.email!)) + .first(); + } + + // Se não encontrou via Better Auth, tentar via sessão mais recente + if (!usuarioAtual) { + const sessaoAtiva = await ctx.db + .query("sessoes") + .filter((q) => q.eq(q.field("ativo"), true)) + .order("desc") + .first(); + + if (sessaoAtiva) { + usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); + } + } + + return usuarioAtual; +} + +// ========== MUTATIONS ========== + +/** + * Cria uma nova conversa (individual ou grupo) + */ +export const criarConversa = mutation({ + args: { + tipo: v.union(v.literal("individual"), v.literal("grupo")), + participantes: v.array(v.id("usuarios")), + nome: v.optional(v.string()), + avatar: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Validar participantes + if (!args.participantes.includes(usuarioAtual._id)) { + args.participantes.push(usuarioAtual._id); + } + + // Se for conversa individual, verificar se já existe + if (args.tipo === "individual" && args.participantes.length === 2) { + const conversaExistente = await ctx.db + .query("conversas") + .filter((q) => q.eq(q.field("tipo"), "individual")) + .collect(); + + for (const conversa of conversaExistente) { + if ( + conversa.participantes.length === 2 && + conversa.participantes.every((p) => args.participantes.includes(p)) + ) { + return conversa._id; + } + } + } + + // Criar nova conversa + const conversaId = await ctx.db.insert("conversas", { + tipo: args.tipo, + nome: args.nome, + avatar: args.avatar, + participantes: args.participantes, + criadoPor: usuarioAtual._id, + criadoEm: Date.now(), + }); + + // Criar notificações para outros participantes + if (args.tipo === "grupo") { + for (const participanteId of args.participantes) { + if (participanteId !== usuarioAtual._id) { + await ctx.db.insert("notificacoes", { + usuarioId: participanteId, + tipo: "adicionado_grupo", + conversaId, + remetenteId: usuarioAtual._id, + titulo: "Adicionado a grupo", + descricao: `Você foi adicionado ao grupo "${args.nome || "Sem nome"}" por ${usuarioAtual.nome}`, + lida: false, + criadaEm: Date.now(), + }); + } + } + } + + return conversaId; + }, +}); + +/** + * Cria ou busca uma conversa individual com outro usuário + */ +export const criarOuBuscarConversaIndividual = mutation({ + args: { + outroUsuarioId: v.id("usuarios"), + }, + returns: v.id("conversas"), + handler: async (ctx, args) => { + // TENTAR BETTER AUTH PRIMEIRO + const identity = await ctx.auth.getUserIdentity(); + + let usuarioAtual = null; + + if (identity && identity.email) { + // Buscar por email (Better Auth) + usuarioAtual = await ctx.db + .query("usuarios") + .withIndex("by_email", (q) => q.eq("email", identity.email!)) + .first(); + } + + // SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado) + if (!usuarioAtual) { + const sessaoAtiva = await ctx.db + .query("sessoes") + .filter((q) => q.eq(q.field("ativo"), true)) + .order("desc") + .first(); + + if (sessaoAtiva) { + usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); + } + } + + if (!usuarioAtual) throw new Error("Usuário não autenticado"); + + // Buscar conversa individual existente entre os dois usuários + const conversasExistentes = await ctx.db + .query("conversas") + .filter((q) => q.eq(q.field("tipo"), "individual")) + .collect(); + + for (const conversa of conversasExistentes) { + if ( + conversa.participantes.length === 2 && + conversa.participantes.includes(usuarioAtual._id) && + conversa.participantes.includes(args.outroUsuarioId) + ) { + return conversa._id; + } + } + + // Se não existe, criar nova conversa individual + const conversaId = await ctx.db.insert("conversas", { + tipo: "individual", + participantes: [usuarioAtual._id, args.outroUsuarioId], + criadoPor: usuarioAtual._id, + criadoEm: Date.now(), + }); + + return conversaId; + }, +}); + +/** + * Envia uma mensagem em uma conversa + */ +export const enviarMensagem = mutation({ + args: { + conversaId: v.id("conversas"), + conteudo: v.string(), + tipo: v.union( + v.literal("texto"), + v.literal("arquivo"), + v.literal("imagem") + ), + arquivoId: v.optional(v.id("_storage")), + arquivoNome: v.optional(v.string()), + arquivoTamanho: v.optional(v.number()), + arquivoTipo: v.optional(v.string()), + mencoes: v.optional(v.array(v.id("usuarios"))), + permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Verificar se usuário pertence à conversa + const conversa = await ctx.db.get(args.conversaId); + if (!conversa) throw new Error("Conversa não encontrada"); + if (!conversa.participantes.includes(usuarioAtual._id)) { + throw new Error("Você não pertence a esta conversa"); + } + + // Criar mensagem + const mensagemId = await ctx.db.insert("mensagens", { + conversaId: args.conversaId, + remetenteId: usuarioAtual._id, + tipo: args.tipo, + conteudo: args.conteudo, + arquivoId: args.arquivoId, + arquivoNome: args.arquivoNome, + arquivoTamanho: args.arquivoTamanho, + arquivoTipo: args.arquivoTipo, + mencoes: args.mencoes, + enviadaEm: Date.now(), + }); + + // Atualizar última mensagem da conversa + await ctx.db.patch(args.conversaId, { + ultimaMensagem: args.conteudo.substring(0, 100), + ultimaMensagemTimestamp: Date.now(), + }); + + // Criar notificações para participantes (com tratamento de erro) + try { + for (const participanteId of conversa.participantes) { + // ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa + const ehOMesmoUsuario = participanteId === usuarioAtual._id; + const deveCriarNotificacao = !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo; + + if (deveCriarNotificacao) { + const tipoNotificacao = args.mencoes?.includes(participanteId) + ? "mencao" + : "nova_mensagem"; + + await ctx.db.insert("notificacoes", { + usuarioId: participanteId, + tipo: tipoNotificacao, + conversaId: args.conversaId, + mensagemId, + remetenteId: usuarioAtual._id, + titulo: + tipoNotificacao === "mencao" + ? `${usuarioAtual.nome} mencionou você` + : `Nova mensagem de ${usuarioAtual.nome}`, + descricao: args.conteudo.substring(0, 100), + lida: false, + criadaEm: Date.now(), + }); + } + } + } catch (error) { + // Log do erro mas não falhar o envio da mensagem + console.error("Erro ao criar notificações:", error); + // A mensagem já foi criada, então retornamos o ID normalmente + } + + return mensagemId; + }, +}); + +/** + * Agenda uma mensagem para envio futuro + */ +export const agendarMensagem = mutation({ + args: { + conversaId: v.id("conversas"), + conteudo: v.string(), + agendadaPara: v.number(), // timestamp + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Validar data futura + if (args.agendadaPara <= Date.now()) { + throw new Error("Data de agendamento deve ser futura"); + } + + // Verificar se usuário pertence à conversa + const conversa = await ctx.db.get(args.conversaId); + if (!conversa) throw new Error("Conversa não encontrada"); + if (!conversa.participantes.includes(usuarioAtual._id)) { + throw new Error("Você não pertence a esta conversa"); + } + + // Criar mensagem agendada + const mensagemId = await ctx.db.insert("mensagens", { + conversaId: args.conversaId, + remetenteId: usuarioAtual._id, + tipo: "texto", + conteudo: args.conteudo, + agendadaPara: args.agendadaPara, + enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada + }); + + return mensagemId; + }, +}); + +/** + * Cancela uma mensagem agendada + */ +export const cancelarMensagemAgendada = mutation({ + args: { + mensagemId: v.id("mensagens"), + }, + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return { sucesso: false, erro: "Usuário não autenticado" }; + } + + const mensagem = await ctx.db.get(args.mensagemId); + if (!mensagem) { + return { sucesso: false, erro: "Mensagem não encontrada" }; + } + + if (mensagem.remetenteId !== usuarioAtual._id) { + return { sucesso: false, erro: "Você só pode cancelar suas próprias mensagens" }; + } + + if (!mensagem.agendadaPara) { + return { sucesso: false, erro: "Esta mensagem não está agendada" }; + } + + if (mensagem.agendadaPara <= Date.now()) { + return { sucesso: false, erro: "A data de agendamento já passou" }; + } + + await ctx.db.delete(args.mensagemId); + return { sucesso: true }; + }, +}); + +/** + * Adiciona uma reação (emoji) a uma mensagem + */ +export const reagirMensagem = mutation({ + args: { + mensagemId: v.id("mensagens"), + emoji: v.string(), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const mensagem = await ctx.db.get(args.mensagemId); + if (!mensagem) throw new Error("Mensagem não encontrada"); + + const reacoes = mensagem.reagiuPor || []; + const reacaoExistente = reacoes.find( + (r) => r.usuarioId === usuarioAtual._id && r.emoji === args.emoji + ); + + if (reacaoExistente) { + // Remover reação + await ctx.db.patch(args.mensagemId, { + reagiuPor: reacoes.filter( + (r) => !(r.usuarioId === usuarioAtual._id && r.emoji === args.emoji) + ), + }); + } else { + // Adicionar reação + await ctx.db.patch(args.mensagemId, { + reagiuPor: [ + ...reacoes, + { usuarioId: usuarioAtual._id, emoji: args.emoji }, + ], + }); + } + + return true; + }, +}); + +/** + * Marca mensagens de uma conversa como lidas + */ +export const marcarComoLida = mutation({ + args: { + conversaId: v.id("conversas"), + mensagemId: v.id("mensagens"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Buscar registro de leitura existente + const leituraExistente = await ctx.db + .query("leituras") + .withIndex("by_conversa_usuario", (q) => + q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) + ) + .first(); + + if (leituraExistente) { + await ctx.db.patch(leituraExistente._id, { + ultimaMensagemLida: args.mensagemId, + lidaEm: Date.now(), + }); + } else { + await ctx.db.insert("leituras", { + conversaId: args.conversaId, + usuarioId: usuarioAtual._id, + ultimaMensagemLida: args.mensagemId, + lidaEm: Date.now(), + }); + } + + // Marcar notificações desta conversa como lidas + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ) + .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) + .collect(); + + for (const notificacao of notificacoes) { + await ctx.db.patch(notificacao._id, { lida: true }); + } + + return true; + }, +}); + +/** + * Atualiza o status de presença do usuário + */ +export const atualizarStatusPresenca = mutation({ + args: { + status: v.union( + v.literal("online"), + v.literal("offline"), + v.literal("ausente"), + v.literal("externo"), + v.literal("em_reuniao") + ), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + await ctx.db.patch(usuarioAtual._id, { + statusPresenca: args.status, + ultimaAtividade: Date.now(), + }); + + return true; + }, +}); + +/** + * Indica que o usuário está digitando em uma conversa + */ +export const indicarDigitacao = mutation({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Buscar indicador existente + const indicadorExistente = await ctx.db + .query("digitando") + .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)) + .filter((q) => q.eq(q.field("conversaId"), args.conversaId)) + .first(); + + if (indicadorExistente) { + await ctx.db.patch(indicadorExistente._id, { + iniciouEm: Date.now(), + }); + } else { + await ctx.db.insert("digitando", { + conversaId: args.conversaId, + usuarioId: usuarioAtual._id, + iniciouEm: Date.now(), + }); + } + + return true; + }, +}); + +/** + * Gera URL para upload de arquivo no chat + */ +export const uploadArquivoChat = mutation({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + // Verificar se usuário pertence à conversa + const conversa = await ctx.db.get(args.conversaId); + if (!conversa) throw new Error("Conversa não encontrada"); + + if (!conversa.participantes.includes(usuarioAtual._id)) { + throw new Error("Você não pertence a esta conversa"); + } + + return await ctx.storage.generateUploadUrl(); + }, +}); + +/** + * Marca uma notificação como lida + */ +export const marcarNotificacaoLida = mutation({ + args: { + notificacaoId: v.id("notificacoes"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + await ctx.db.patch(args.notificacaoId, { lida: true }); + return true; + }, +}); + +/** + * Marca todas as notificações como lidas + */ +export const marcarTodasNotificacoesLidas = mutation({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ) + .collect(); + + for (const notificacao of notificacoes) { + await ctx.db.patch(notificacao._id, { lida: true }); + } + + return true; + }, +}); + +/** + * Deleta uma mensagem (soft delete) + */ +export const deletarMensagem = mutation({ + args: { + mensagemId: v.id("mensagens"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) throw new Error("Não autenticado"); + + const mensagem = await ctx.db.get(args.mensagemId); + if (!mensagem) throw new Error("Mensagem não encontrada"); + + if (mensagem.remetenteId !== usuarioAtual._id) { + throw new Error("Você só pode deletar suas próprias mensagens"); + } + + await ctx.db.patch(args.mensagemId, { + deletada: true, + conteudo: "Mensagem deletada", + }); + + return true; + }, +}); + +// ========== QUERIES ========== + +/** + * Lista todas as conversas do usuário logado + */ +export const listarConversas = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar todas as conversas do usuário + const todasConversas = await ctx.db.query("conversas").collect(); + const conversasDoUsuario = todasConversas.filter((c) => + c.participantes.includes(usuarioAtual._id) + ); + + // Ordenar por última mensagem + conversasDoUsuario.sort((a, b) => { + const timestampA = a.ultimaMensagemTimestamp || a.criadoEm; + const timestampB = b.ultimaMensagemTimestamp || b.criadoEm; + return timestampB - timestampA; + }); + + // Enriquecer com informações dos participantes + const conversasEnriquecidas = await Promise.all( + conversasDoUsuario.map(async (conversa) => { + // Buscar participantes + const participantes = await Promise.all( + conversa.participantes.map((id) => ctx.db.get(id)) + ); + + // Para conversas individuais, pegar o outro usuário + let outroUsuario = null; + if (conversa.tipo === "individual") { + const outroUsuarioRaw = participantes.find((p) => p?._id !== usuarioAtual._id); + if (outroUsuarioRaw) { + // 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot) + const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id); + + if (usuarioAtualizado) { + // Adicionar URL da foto de perfil + let fotoPerfilUrl = null; + if (usuarioAtualizado.fotoPerfil) { + fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtualizado.fotoPerfil); + } + outroUsuario = { + ...usuarioAtualizado, + fotoPerfilUrl, + }; + } + } + } + + // Contar mensagens não lidas (apenas mensagens NÃO agendadas) + const leitura = await ctx.db + .query("leituras") + .withIndex("by_conversa_usuario", (q) => + q.eq("conversaId", conversa._id).eq("usuarioId", usuarioAtual._id) + ) + .first(); + + // CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined) + const todasMensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) + .collect(); + + const mensagens = todasMensagens.filter((m) => !m.agendadaPara); + + let naoLidas = 0; + if (leitura) { + naoLidas = mensagens.filter( + (m) => + m.enviadaEm > (leitura.lidaEm || 0) && + m.remetenteId !== usuarioAtual._id + ).length; + } else { + naoLidas = mensagens.filter( + (m) => m.remetenteId !== usuarioAtual._id + ).length; + } + + return { + ...conversa, + outroUsuario, + participantesInfo: participantes.filter((p) => p !== null), + naoLidas, + }; + }) + ); + + return conversasEnriquecidas; + }, +}); + +/** + * Obtém as mensagens de uma conversa com paginação + */ +export const obterMensagens = query({ + args: { + conversaId: v.id("conversas"), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Verificar se usuário pertence à conversa + const conversa = await ctx.db.get(args.conversaId); + if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { + return []; + } + + // Buscar mensagens (excluir agendadas) + const mensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .order("desc") + .take(args.limit || 50); + + // Filtrar mensagens agendadas + const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara); + + // Enriquecer com informações do remetente + const mensagensEnriquecidas = await Promise.all( + mensagensFiltradas.map(async (mensagem) => { + const remetente = await ctx.db.get(mensagem.remetenteId); + let arquivoUrl = null; + if (mensagem.arquivoId) { + arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId); + } + return { + ...mensagem, + remetente, + arquivoUrl, + }; + }) + ); + + return mensagensEnriquecidas.reverse(); + }, +}); + +/** + * Obtém mensagens agendadas de uma conversa + */ +export const obterMensagensAgendadas = query({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args): Promise[]> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar mensagens agendadas + const todasMensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .collect(); + + // Filtrar apenas as agendadas do usuário atual + const minhasMensagensAgendadas = todasMensagens.filter( + (m) => + m.remetenteId === usuarioAtual._id && + m.agendadaPara !== undefined && + m.agendadaPara > Date.now() + ); + + return minhasMensagensAgendadas.sort( + (a, b) => (a.agendadaPara ?? 0) - (b.agendadaPara ?? 0) + ); + }, +}); + +/** + * Listar todas as mensagens agendadas do usuário atual (para página de notificações) + */ +export const listarAgendamentosChat = query({ + args: {}, + handler: async (ctx): Promise & { conversaInfo: Doc<"conversas"> | null; destinatarioInfo: Doc<"usuarios"> | null }>> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return []; + } + + // Buscar todas as mensagens agendadas do usuário + const todasMensagens = await ctx.db + .query("mensagens") + .withIndex("by_remetente", (q) => q.eq("remetenteId", usuarioAtual._id)) + .collect(); + + // Filtrar apenas as que têm agendamento (passadas ou futuras) + const mensagensAgendadas = todasMensagens.filter( + (m) => m.agendadaPara !== undefined + ); + + // Enriquecer com informações da conversa e destinatário + const mensagensEnriquecidas = await Promise.all( + mensagensAgendadas.map(async (mensagem) => { + let conversaInfo: Doc<"conversas"> | null = null; + let destinatarioInfo: Doc<"usuarios"> | null = null; + + conversaInfo = await ctx.db.get(mensagem.conversaId); + + // Se for conversa individual, encontrar o outro participante + if (conversaInfo && conversaInfo.tipo === "individual") { + const outroParticipanteId = conversaInfo.participantes.find( + (p) => p !== usuarioAtual._id + ); + if (outroParticipanteId) { + destinatarioInfo = await ctx.db.get(outroParticipanteId); + } + } + + return { + ...mensagem, + conversaInfo, + destinatarioInfo, + }; + }) + ); + + // Ordenar por data de agendamento (mais próximos primeiro) + return mensagensEnriquecidas.sort((a, b) => { + const dataA = a.agendadaPara ?? 0; + const dataB = b.agendadaPara ?? 0; + return dataA - dataB; + }); + }, +}); + +/** + * Obtém as notificações do usuário + */ +export const obterNotificacoes = query({ + args: { + apenasPendentes: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + let query = ctx.db + .query("notificacoes") + .withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioAtual._id)); + + if (args.apenasPendentes) { + query = ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ); + } + + const notificacoes = await query.order("desc").take(50); + + // Enriquecer com informações do remetente + const notificacoesEnriquecidas = await Promise.all( + notificacoes.map(async (notificacao) => { + let remetente = null; + if (notificacao.remetenteId) { + remetente = await ctx.db.get(notificacao.remetenteId); + } + return { + ...notificacao, + remetente, + }; + }) + ); + + return notificacoesEnriquecidas; + }, +}); + +/** + * Conta o número de notificações não lidas + */ +export const contarNotificacoesNaoLidas = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return 0; + + const notificacoes = await ctx.db + .query("notificacoes") + .withIndex("by_usuario_lida", (q) => + q.eq("usuarioId", usuarioAtual._id).eq("lida", false) + ) + .collect(); + + return notificacoes.length; + }, +}); + +/** + * Obtém usuários online + */ +export const obterUsuariosOnline = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + const usuarios = await ctx.db + .query("usuarios") + .withIndex("by_status_presenca", (q) => q.eq("statusPresenca", "online")) + .collect(); + + return usuarios.map((u) => ({ + _id: u._id, + nome: u.nome, + email: u.email, + avatar: u.avatar, + fotoPerfil: u.fotoPerfil, + statusPresenca: u.statusPresenca, + statusMensagem: u.statusMensagem, + setor: u.setor, + })); + }, +}); + +/** + * Lista todos os usuários (para criar nova conversa) + */ +export const listarTodosUsuarios = query({ + args: {}, + handler: async (ctx) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + const usuarios = await ctx.db + .query("usuarios") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .collect(); + + // Excluir o usuário atual + return usuarios + .filter((u) => u._id !== usuarioAtual._id) + .map((u) => ({ + _id: u._id, + nome: u.nome, + email: u.email, + matricula: u.matricula, + avatar: u.avatar, + fotoPerfil: u.fotoPerfil, + statusPresenca: u.statusPresenca, + statusMensagem: u.statusMensagem, + setor: u.setor, + })); + }, +}); + +/** + * Busca mensagens em conversas + */ +export const buscarMensagens = query({ + args: { + query: v.string(), + conversaId: v.optional(v.id("conversas")), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar em todas as conversas do usuário + const todasConversas = await ctx.db.query("conversas").collect(); + const conversasDoUsuario = todasConversas.filter((c) => + c.participantes.includes(usuarioAtual._id) + ); + + let mensagens: Doc<"mensagens">[] = []; + + if (args.conversaId !== undefined) { + // Buscar em conversa específica + const mensagensConversa = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!)) + .collect(); + mensagens = mensagensConversa; + } else { + // Buscar em todas as conversas + for (const conversa of conversasDoUsuario) { + const mensagensConversa = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) + .collect(); + mensagens.push(...mensagensConversa); + } + } + + // Filtrar por query + const queryLower = args.query.toLowerCase(); + const mensagensFiltradas = mensagens.filter( + (m) => + !m.deletada && + !m.agendadaPara && + m.conteudo.toLowerCase().includes(queryLower) + ); + + // Enriquecer com informações + const mensagensEnriquecidas = await Promise.all( + mensagensFiltradas.map(async (mensagem) => { + const remetente = await ctx.db.get(mensagem.remetenteId); + const conversa = await ctx.db.get(mensagem.conversaId); + return { + ...mensagem, + remetente, + conversa, + }; + }) + ); + + return mensagensEnriquecidas + .sort((a, b) => b.enviadaEm - a.enviadaEm) + .slice(0, 50); + }, +}); + +/** + * Obtém quem está digitando em uma conversa + */ +export const obterDigitando = query({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return []; + + // Buscar indicadores de digitação (últimos 10 segundos) + const dezSegundosAtras = Date.now() - 10000; + const digitando = await ctx.db + .query("digitando") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .filter((q) => q.gte(q.field("iniciouEm"), dezSegundosAtras)) + .collect(); + + // Filtrar usuário atual e buscar informações + const digitandoFiltrado = digitando.filter( + (d) => d.usuarioId !== usuarioAtual._id + ); + + const usuarios = await Promise.all( + digitandoFiltrado.map(async (d) => { + const usuario = await ctx.db.get(d.usuarioId); + return usuario; + }) + ); + + return usuarios.filter((u) => u !== null); + }, +}); + +/** + * Conta mensagens não lidas de uma conversa + */ +export const contarNaoLidas = query({ + args: { + conversaId: v.id("conversas"), + }, + handler: async (ctx, args) => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) return 0; + + const leitura = await ctx.db + .query("leituras") + .withIndex("by_conversa_usuario", (q) => + q.eq("conversaId", args.conversaId).eq("usuarioId", usuarioAtual._id) + ) + .first(); + + const mensagens = await ctx.db + .query("mensagens") + .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) + .filter((q) => q.eq(q.field("agendadaPara"), undefined)) + .collect(); + + if (leitura) { + return mensagens.filter( + (m) => + m.enviadaEm > (leitura.lidaEm || 0) && + m.remetenteId !== usuarioAtual._id + ).length; + } + + return mensagens.filter((m) => m.remetenteId !== usuarioAtual._id).length; + }, +}); + +// ========== INTERNAL MUTATIONS (para crons) ========== + +/** + * Envia mensagens agendadas (chamado pelo cron) + */ +export const enviarMensagensAgendadas = internalMutation({ + args: {}, + handler: async (ctx) => { + const agora = Date.now(); + + // Buscar mensagens que deveriam ser enviadas + const mensagensAgendadas = await ctx.db + .query("mensagens") + .withIndex("by_agendamento") + .filter((q) => + q.and( + q.neq(q.field("agendadaPara"), undefined), + q.lte(q.field("agendadaPara"), agora) + ) + ) + .collect(); + + for (const mensagem of mensagensAgendadas) { + // Atualizar mensagem para "enviada" + await ctx.db.patch(mensagem._id, { + agendadaPara: undefined, + enviadaEm: agora, + }); + + // Atualizar última mensagem da conversa + const conversa = await ctx.db.get(mensagem.conversaId); + if (conversa) { + await ctx.db.patch(mensagem.conversaId, { + ultimaMensagem: mensagem.conteudo.substring(0, 100), + ultimaMensagemTimestamp: agora, + }); + + // Criar notificações para outros participantes + const remetente = await ctx.db.get(mensagem.remetenteId); + for (const participanteId of conversa.participantes) { + if (participanteId !== mensagem.remetenteId) { + await ctx.db.insert("notificacoes", { + usuarioId: participanteId, + tipo: "nova_mensagem", + conversaId: mensagem.conversaId, + mensagemId: mensagem._id, + remetenteId: mensagem.remetenteId, + titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`, + descricao: mensagem.conteudo.substring(0, 100), + lida: false, + criadaEm: agora, + }); + } + } + } + } + + return mensagensAgendadas.length; + }, +}); + +/** + * Limpa indicadores de digitação antigos (chamado pelo cron) + */ +export const limparIndicadoresDigitacao = internalMutation({ + args: {}, + handler: async (ctx) => { + const dezSegundosAtras = Date.now() - 10000; + + const indicadoresAntigos = await ctx.db + .query("digitando") + .filter((q) => q.lt(q.field("iniciouEm"), dezSegundosAtras)) + .collect(); + + for (const indicador of indicadoresAntigos) { + await ctx.db.delete(indicador._id); + } + + return indicadoresAntigos.length; + }, +}); diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index 587ffba..d9d44c8 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -6,10 +6,44 @@ import { internalMutation, internalQuery, } from "./_generated/server"; -import { Id } from "./_generated/dataModel"; +import { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; import { renderizarTemplate } from "./templatesMensagens"; import { internal, api } from "./_generated/api"; +// ========== HELPERS ========== + +/** + * Helper function para obter usuário autenticado (Better Auth ou Sessão) + */ +async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise | null> { + // Tentar autenticação via Better Auth primeiro + const identity = await ctx.auth.getUserIdentity(); + let usuarioAtual: Doc<"usuarios"> | null = null; + + if (identity && identity.email) { + usuarioAtual = await ctx.db + .query("usuarios") + .withIndex("by_email", (q) => q.eq("email", identity.email!)) + .first(); + } + + // Se não encontrou via Better Auth, tentar via sessão mais recente + if (!usuarioAtual) { + const sessaoAtiva = await ctx.db + .query("sessoes") + .filter((q) => q.eq(q.field("ativo"), true)) + .order("desc") + .first(); + + if (sessaoAtiva) { + usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId); + } + } + + return usuarioAtual; +} + /** * Enfileirar email para envio */ @@ -187,7 +221,7 @@ export const reenviarEmail = mutation({ emailId: v.id("notificacoesEmail"), }, returns: v.object({ sucesso: v.boolean() }), - handler: async (ctx, args) => { + handler: async (ctx, args): Promise<{ sucesso: boolean }> => { const email = await ctx.db.get(args.emailId); if (!email) { return { sucesso: false }; @@ -205,6 +239,52 @@ export const reenviarEmail = mutation({ }, }); +/** + * Cancelar agendamento de email + */ +export const cancelarAgendamentoEmail = mutation({ + args: { + emailId: v.id("notificacoesEmail"), + }, + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return { sucesso: false, erro: "Usuário não autenticado" }; + } + + const email = await ctx.db.get(args.emailId); + if (!email) { + return { sucesso: false, erro: "Email não encontrado" }; + } + + // Verificar se o email pertence ao usuário atual + if (email.enviadoPor !== usuarioAtual._id) { + return { sucesso: false, erro: "Você não tem permissão para cancelar este agendamento" }; + } + + // Verificar se o email está agendado + if (!email.agendadaPara) { + return { sucesso: false, erro: "Este email não está agendado" }; + } + + // Verificar se ainda não foi enviado + if (email.status === "enviado") { + return { sucesso: false, erro: "Este email já foi enviado" }; + } + + // Verificar se já passou a data de agendamento + if (email.agendadaPara <= Date.now()) { + return { sucesso: false, erro: "A data de agendamento já passou" }; + } + + // Deletar o email agendado + await ctx.db.delete(args.emailId); + + return { sucesso: true }; + }, +}); + /** * Action para enviar email (será implementado com nodemailer) * @@ -225,8 +305,8 @@ export const buscarEmailsPorIds = query({ args: { emailIds: v.array(v.id("notificacoesEmail")), }, - handler: async (ctx, args) => { - const emails = []; + handler: async (ctx, args): Promise[]> => { + const emails: Doc<"notificacoesEmail">[] = []; for (const emailId of args.emailIds) { const email = await ctx.db.get(emailId); if (email) { @@ -237,6 +317,57 @@ export const buscarEmailsPorIds = query({ }, }); +/** + * Listar agendamentos de email do usuário atual + */ +export const listarAgendamentosEmail = query({ + args: {}, + handler: async (ctx): Promise & { destinatarioInfo: Doc<"usuarios"> | null; templateInfo: Doc<"templatesMensagens"> | null }>> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return []; + } + + // Buscar todos os emails do usuário + const todosEmails = await ctx.db + .query("notificacoesEmail") + .withIndex("by_enviado_por", (q) => q.eq("enviadoPor", usuarioAtual._id)) + .collect(); + + // Filtrar apenas os que têm agendamento (passados ou futuros) + const emailsAgendados = todosEmails.filter((email) => email.agendadaPara !== undefined); + + // Enriquecer com informações do destinatário e template + const emailsEnriquecidos = await Promise.all( + emailsAgendados.map(async (email) => { + let destinatarioInfo: Doc<"usuarios"> | null = null; + let templateInfo: Doc<"templatesMensagens"> | null = null; + + if (email.destinatarioId) { + destinatarioInfo = await ctx.db.get(email.destinatarioId); + } + + if (email.templateId) { + templateInfo = await ctx.db.get(email.templateId); + } + + return { + ...email, + destinatarioInfo, + templateInfo, + }; + }) + ); + + // Ordenar por data de agendamento (mais próximos primeiro) + return emailsEnriquecidos.sort((a, b) => { + const dataA = a.agendadaPara ?? 0; + const dataB = b.agendadaPara ?? 0; + return dataA - dataB; + }); + }, +}); + export const getActiveEmailConfig = internalQuery({ args: {}, // Tipo inferido automaticamente pelo Convex diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 676869c..fb337c9 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -501,11 +501,13 @@ export default defineSchema({ enviadoPor: v.id("usuarios"), criadoEm: v.number(), enviadoEm: v.optional(v.number()), + agendadaPara: v.optional(v.number()), // timestamp para agendamento }) .index("by_status", ["status"]) .index("by_destinatario", ["destinatarioId"]) .index("by_enviado_por", ["enviadoPor"]) - .index("by_criado_em", ["criadoEm"]), + .index("by_criado_em", ["criadoEm"]) + .index("by_agendamento", ["agendadaPara"]), configuracaoAcesso: defineTable({ chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc. -- 2.49.1 From e6105ae8eaf20b7ec4af92167bb9abb8cb720910 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 01:59:08 -0300 Subject: [PATCH 14/28] fix: update email configuration handling and improve type safety - Changed the mutation for testing SMTP connection to use an action for better handling. - Introduced an internal mutation to update the test timestamp for email configurations. - Enhanced type safety by specifying document types for user and session queries. - Improved error handling in the SMTP connection test to provide clearer feedback on failures. --- .../ti/configuracoes-email/+page.svelte | 2 +- packages/backend/convex/autenticacao.ts | 26 ++++++----- packages/backend/convex/configuracaoEmail.ts | 44 ++++++++++++------- 3 files changed, 46 insertions(+), 26 deletions(-) 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 c6e32eb..449de6f 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-email/+page.svelte @@ -137,7 +137,7 @@ testando = true; try { - const resultado = await client.mutation(api.configuracaoEmail.testarConexaoSMTP, { + const resultado = await client.action(api.configuracaoEmail.testarConexaoSMTP, { servidor: servidor.trim(), porta: portaNum, usuario: usuario.trim(), diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts index 3f7912c..dc6757d 100644 --- a/packages/backend/convex/autenticacao.ts +++ b/packages/backend/convex/autenticacao.ts @@ -8,7 +8,7 @@ import { validarSenha, } from "./auth/utils"; import { registrarLogin } from "./logsLogin"; -import { Id } from "./_generated/dataModel"; +import { Id, Doc } from "./_generated/dataModel"; import type { QueryCtx } from "./_generated/server"; /** @@ -62,6 +62,7 @@ export const login = mutation({ matricula: v.string(), nome: v.string(), email: v.string(), + funcionarioId: v.optional(v.id("funcionarios")), role: v.object({ _id: v.id("roles"), nome: v.string(), @@ -100,16 +101,19 @@ export const login = mutation({ const isEmail = args.matriculaOuEmail.includes("@"); // Buscar usuário - let usuario; + let usuario: Doc<"usuarios"> | null = null; if (isEmail) { usuario = await ctx.db .query("usuarios") .withIndex("by_email", (q) => q.eq("email", args.matriculaOuEmail)) .first(); } else { - funcionario = await ctx.db.query("funcionarios").withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail)).first(); + const funcionario: Doc<"funcionarios"> | null = await ctx.db.query("funcionarios").withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail)).first(); if (funcionario) { - usuario = await ctx.db.get(funcionario.usuarioId); + usuario = await ctx.db + .query("usuarios") + .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id)) + .first(); } } @@ -242,7 +246,7 @@ export const login = mutation({ }); // Buscar role do usuário - const role = await ctx.db.get(usuario.roleId); + const role: Doc<"roles"> | null = await ctx.db.get(usuario.roleId); if (!role) { return { sucesso: false as const, @@ -359,6 +363,7 @@ export const verificarSessao = query({ matricula: v.string(), nome: v.string(), email: v.string(), + funcionarioId: v.optional(v.id("funcionarios")), role: v.object({ _id: v.id("roles"), nome: v.string(), @@ -375,7 +380,7 @@ export const verificarSessao = query({ ), handler: async (ctx, args) => { // Buscar sessão - const sessao = await ctx.db + const sessao: Doc<"sessoes"> | null = await ctx.db .query("sessoes") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); @@ -395,7 +400,7 @@ export const verificarSessao = query({ } // Buscar usuário - const usuario = await ctx.db.get(sessao.usuarioId); + const usuario: Doc<"usuarios"> | null = await ctx.db.get(sessao.usuarioId); if (!usuario || !usuario.ativo) { return { valido: false as const, @@ -404,7 +409,7 @@ export const verificarSessao = query({ } // Buscar role - const role = await ctx.db.get(usuario.roleId); + const role: Doc<"roles"> | null = await ctx.db.get(usuario.roleId); if (!role) { return { valido: false as const, motivo: "Role não encontrada" }; } @@ -416,6 +421,7 @@ export const verificarSessao = query({ matricula: usuario.matricula, nome: usuario.nome, email: usuario.email, + funcionarioId: usuario.funcionarioId, role: { _id: role._id, nome: role.nome, @@ -478,7 +484,7 @@ export const alterarSenha = mutation({ ), handler: async (ctx, args) => { // Verificar sessão - const sessao = await ctx.db + const sessao: Doc<"sessoes"> | null = await ctx.db .query("sessoes") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); @@ -487,7 +493,7 @@ export const alterarSenha = mutation({ return { sucesso: false as const, erro: "Sessão inválida" }; } - const usuario = await ctx.db.get(sessao.usuarioId); + const usuario: Doc<"usuarios"> | null = await ctx.db.get(sessao.usuarioId); if (!usuario) { return { sucesso: false as const, erro: "Usuário não encontrado" }; } diff --git a/packages/backend/convex/configuracaoEmail.ts b/packages/backend/convex/configuracaoEmail.ts index 4988860..8453f87 100644 --- a/packages/backend/convex/configuracaoEmail.ts +++ b/packages/backend/convex/configuracaoEmail.ts @@ -1,8 +1,8 @@ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { mutation, query, action, internalMutation } from "./_generated/server"; import { encryptSMTPPassword } from "./auth/utils"; import { registrarAtividade } from "./logsAtividades"; -import { api } from "./_generated/api"; +import { api, internal } from "./_generated/api"; /** * Obter configuração de email ativa (senha mascarada) @@ -127,9 +127,25 @@ export const salvarConfigEmail = mutation({ }); /** - * Testar conexão SMTP (mutation que chama action real) + * Mutation interna para atualizar testadoEm */ -export const testarConexaoSMTP = mutation({ +export const atualizarTestadoEm = internalMutation({ + args: { + configId: v.id("configuracaoEmail"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.configId, { + testadoEm: Date.now(), + }); + return null; + }, +}); + +/** + * Testar conexão SMTP (action que chama action real) + */ +export const testarConexaoSMTP = action({ args: { servidor: v.string(), porta: v.number(), @@ -142,7 +158,7 @@ export const testarConexaoSMTP = mutation({ v.object({ sucesso: v.literal(true) }), v.object({ sucesso: v.literal(false), erro: v.string() }) ), - handler: async (ctx, args) => { + handler: async (ctx, args): Promise<{ sucesso: true } | { sucesso: false; erro: string }> => { // Validações básicas if (!args.servidor || args.servidor.trim().length === 0) { return { sucesso: false as const, erro: "Servidor SMTP não pode estar vazio" }; @@ -167,7 +183,7 @@ export const testarConexaoSMTP = mutation({ // Chamar action de teste real (que usa nodemailer) try { - const resultado = await ctx.scheduler.runAfter(0, api.actions.smtp.testarConexao, { + const resultado: { sucesso: true } | { sucesso: false; erro: string } = await ctx.runAction(api.actions.smtp.testarConexao, { servidor: args.servidor, porta: args.porta, usuario: args.usuario, @@ -178,23 +194,21 @@ export const testarConexaoSMTP = mutation({ // Se o teste foi bem-sucedido e há uma config ativa, atualizar testadoEm if (resultado.sucesso) { - const configAtiva = await ctx.db - .query("configuracaoEmail") - .withIndex("by_ativo", (q) => q.eq("ativo", true)) - .first(); - + const configAtiva = await ctx.runQuery(api.configuracaoEmail.obterConfigEmail, {}); + if (configAtiva) { - await ctx.db.patch(configAtiva._id, { - testadoEm: Date.now(), + await ctx.runMutation(internal.configuracaoEmail.atualizarTestadoEm, { + configId: configAtiva._id, }); } } return resultado; - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); return { sucesso: false as const, - erro: error.message || "Erro ao conectar com o servidor SMTP" + erro: errorMessage || "Erro ao conectar com o servidor SMTP" }; } }, -- 2.49.1 From 5d2df8077bf62e4c7ae9a74ee625700c22330328 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 02:14:07 -0300 Subject: [PATCH 15/28] feat: enhance email handling with improved error reporting and statistics - Updated the `reenviarEmail` mutation to return detailed error messages for better user feedback. - Added a new query to obtain email queue statistics, providing insights into email statuses. - Enhanced the `processarFilaEmails` mutation to track processing failures and successes more effectively. - Implemented a manual email processing mutation for immediate testing and control over email sending. - Improved email validation and error handling in the email sending action, ensuring robust delivery processes. --- packages/backend/convex/actions/email.ts | 24 ++- packages/backend/convex/actions/smtp.ts | 5 +- packages/backend/convex/email.ts | 197 +++++++++++++++++++++-- 3 files changed, 209 insertions(+), 17 deletions(-) diff --git a/packages/backend/convex/actions/email.ts b/packages/backend/convex/actions/email.ts index 8f27bfa..3ae0249 100644 --- a/packages/backend/convex/actions/email.ts +++ b/packages/backend/convex/actions/email.ts @@ -50,28 +50,48 @@ export const enviar = action({ host: config.servidor, port: config.porta, secure: config.usarSSL, + requireTLS: config.usarTLS, auth: { user: config.usuario, pass: config.senha, // Senha já descriptografada }, tls: { - // Permitir certificados autoassinados + // Permitir certificados autoassinados apenas se necessário rejectUnauthorized: false, + ciphers: "SSLv3", }, + connectionTimeout: 10000, // 10 segundos + greetingTimeout: 10000, + socketTimeout: 10000, }); + // 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}`); + } + // Enviar email const info = await transporter.sendMail({ from: `"${config.nomeRemetente}" <${config.emailRemetente}>`, to: email.destinatario, subject: email.assunto, html: email.corpo, + text: email.corpo.replace(/<[^>]*>/g, ""), // Versão texto para clientes que não suportam HTML }); + interface MessageInfo { + messageId?: string; + response?: string; + } + + const messageInfo = info as MessageInfo; + console.log("✅ Email enviado com sucesso!", { para: email.destinatario, assunto: email.assunto, - messageId: (info as { messageId?: string }).messageId, + messageId: messageInfo.messageId, + response: messageInfo.response, }); // Marcar como enviado diff --git a/packages/backend/convex/actions/smtp.ts b/packages/backend/convex/actions/smtp.ts index 5caf0ec..c68285a 100644 --- a/packages/backend/convex/actions/smtp.ts +++ b/packages/backend/convex/actions/smtp.ts @@ -42,8 +42,11 @@ export const testarConexao = action({ pass: args.senha, }, tls: { - rejectUnauthorized: !args.usarTLS ? false : false, + rejectUnauthorized: false, }, + connectionTimeout: 10000, // 10 segundos + greetingTimeout: 10000, + socketTimeout: 10000, }); // Verificar conexão diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index d9d44c8..af8208f 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -220,11 +220,21 @@ export const reenviarEmail = mutation({ args: { emailId: v.id("notificacoesEmail"), }, - returns: v.object({ sucesso: v.boolean() }), - handler: async (ctx, args): Promise<{ sucesso: boolean }> => { + returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), + handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => { const email = await ctx.db.get(args.emailId); if (!email) { - return { sucesso: false }; + return { sucesso: false, erro: "Email não encontrado" }; + } + + // Verificar se o email não foi enviado com sucesso ainda + if (email.status === "enviado") { + return { sucesso: false, erro: "Este email já foi enviado com sucesso" }; + } + + // Verificar se ainda não excedeu o limite de tentativas (max 3) + if ((email.tentativas || 0) >= 3 && email.status !== "falha") { + return { sucesso: false, erro: "Número máximo de tentativas excedido. Crie um novo email." }; } // Resetar status para pendente @@ -235,6 +245,11 @@ export const reenviarEmail = mutation({ erroDetalhes: undefined, }); + // Agendar envio imediato + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId: args.emailId, + }); + return { sucesso: true }; }, }); @@ -317,6 +332,58 @@ export const buscarEmailsPorIds = query({ }, }); +/** + * Obter estatísticas da fila de emails + */ +export const obterEstatisticasFilaEmails = query({ + args: {}, + returns: v.object({ + total: v.number(), + pendentes: v.number(), + enviando: v.number(), + enviados: v.number(), + falhas: v.number(), + comErro: v.number(), + ultimaExecucaoCron: v.optional(v.number()), + }), + handler: async (ctx) => { + const todosEmails = await ctx.db + .query("notificacoesEmail") + .collect(); + + const estatisticas = { + total: todosEmails.length, + pendentes: 0, + enviando: 0, + enviados: 0, + falhas: 0, + comErro: 0, + }; + + for (const email of todosEmails) { + switch (email.status) { + case "pendente": + estatisticas.pendentes++; + break; + case "enviando": + estatisticas.enviando++; + break; + case "enviado": + estatisticas.enviados++; + break; + case "falha": + estatisticas.falhas++; + if (email.erroDetalhes) { + estatisticas.comErro++; + } + break; + } + } + + return estatisticas; + }, +}); + /** * Listar agendamentos de email do usuário atual */ @@ -452,15 +519,23 @@ export const markEmailFalha = internalMutation({ */ export const processarFilaEmails = internalMutation({ args: {}, - returns: v.object({ processados: v.number() }), + returns: v.object({ processados: v.number(), falhas: v.number() }), handler: async (ctx) => { - // Buscar emails pendentes (max 10 por execução) + // Buscar emails pendentes que não estão agendados para o futuro (max 10 por execução) + const agora = Date.now(); const emailsPendentes = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", "pendente")) + .filter((q) => + q.or( + q.eq(q.field("agendadaPara"), undefined), + q.lte(q.field("agendadaPara"), agora) + ) + ) .take(10); let processados = 0; + let falhas = 0; for (const email of emailsPendentes) { // Verificar se não excedeu tentativas (max 3) @@ -469,21 +544,115 @@ export const processarFilaEmails = internalMutation({ status: "falha", erroDetalhes: "Número máximo de tentativas excedido", }); + falhas++; continue; } // Agendar envio via action - await ctx.scheduler.runAfter(0, api.actions.email.enviar, { - emailId: email._id, - }); - - processados++; + try { + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId: email._id, + }); + processados++; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Erro ao agendar email ${email._id}:`, errorMessage); + await ctx.db.patch(email._id, { + status: "falha", + erroDetalhes: `Erro ao agendar envio: ${errorMessage}`, + tentativas: (email.tentativas || 0) + 1, + }); + falhas++; + } } - console.log( - `📧 Fila de emails processada: ${processados} emails agendados para envio` - ); + if (processados > 0 || falhas > 0) { + console.log( + `📧 Fila de emails processada: ${processados} emails agendados, ${falhas} falhas` + ); + } - return { processados }; + return { processados, falhas }; + }, +}); + +/** + * Processar fila de emails manualmente (para testes e envio imediato) + */ +export const processarFilaEmailsManual = mutation({ + args: { + limite: v.optional(v.number()), + }, + returns: v.object({ + sucesso: v.boolean(), + processados: v.number(), + falhas: v.number(), + erro: v.optional(v.string()) + }), + handler: async (ctx, args): Promise<{ + sucesso: boolean; + processados: number; + falhas: number; + erro?: string + }> => { + const usuarioAtual = await getUsuarioAutenticado(ctx); + if (!usuarioAtual) { + return { sucesso: false, processados: 0, falhas: 0, erro: "Usuário não autenticado" }; + } + + // Verificar se usuário tem permissão (TI_MASTER ou admin) + const role = await ctx.db.get(usuarioAtual.roleId); + if (!role || (role.nivel !== 0 && role.nivel !== 1)) { + return { sucesso: false, processados: 0, falhas: 0, erro: "Permissão negada" }; + } + + const limite = args.limite || 10; + const agora = Date.now(); + + // Buscar emails pendentes que não estão agendados para o futuro + const emailsPendentes = await ctx.db + .query("notificacoesEmail") + .withIndex("by_status", (q) => q.eq("status", "pendente")) + .filter((q) => + q.or( + q.eq(q.field("agendadaPara"), undefined), + q.lte(q.field("agendadaPara"), agora) + ) + ) + .take(limite); + + let processados = 0; + let falhas = 0; + + for (const email of emailsPendentes) { + // Verificar se não excedeu tentativas (max 3) + if ((email.tentativas || 0) >= 3) { + await ctx.db.patch(email._id, { + status: "falha", + erroDetalhes: "Número máximo de tentativas excedido", + }); + falhas++; + continue; + } + + // Agendar envio via action + try { + await ctx.scheduler.runAfter(0, api.actions.email.enviar, { + emailId: email._id, + }); + processados++; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Erro ao agendar email ${email._id}:`, errorMessage); + await ctx.db.patch(email._id, { + status: "falha", + erroDetalhes: `Erro ao agendar envio: ${errorMessage}`, + tentativas: (email.tentativas || 0) + 1, + }); + falhas++; + } + } + + return { sucesso: true, processados, falhas }; }, }); -- 2.49.1 From 372b2b5bf91cd3b9a0accd137cc24b2df0dc3ab3 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 02:19:09 -0300 Subject: [PATCH 16/28] feat: add statistics and filtering options for user roles in dashboard - Introduced a new `StatsCard` component to display statistics related to user roles, including total profiles and access levels. - Implemented filtering options for user roles based on access level, enhancing the user experience in the dashboard. - Improved the layout and styling of the dashboard, including adjustments to the filters and role display cards for better usability. - Added derived state for active filters and statistics, ensuring real-time updates in the UI. --- .../routes/(dashboard)/ti/perfis/+page.svelte | 482 +++++++++++++++--- 1 file changed, 403 insertions(+), 79 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte index 91cf110..e61b8e5 100644 --- a/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte @@ -2,6 +2,7 @@ import { useQuery, useConvexClient } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; + import StatsCard from "$lib/components/ti/StatsCard.svelte"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import { format } from "date-fns"; import { ptBR } from "date-fns/locale"; @@ -22,6 +23,7 @@ let busca = $state(""); let filtroSetor = $state(""); + let filtroNivel = $state(""); let roleSelecionada = $state(null); let modalDetalhesAberto = $state(false); @@ -33,6 +35,27 @@ return Array.from(setores).sort(); }); + // Estatísticas + const stats = $derived.by(() => { + if (carregando) return null; + + const porNivel = { + 0: roles.filter(r => r.nivel === 0).length, + 1: roles.filter(r => r.nivel === 1).length, + 2: roles.filter(r => r.nivel === 2).length, + 3: roles.filter(r => r.nivel >= 3).length, + }; + + return { + total: roles.length, + nivelMaximo: porNivel[0], + nivelAlto: porNivel[1], + nivelMedio: porNivel[2], + nivelBaixo: porNivel[3], + comSetor: roles.filter(r => r.setor).length, + }; + }); + const rolesFiltradas = $derived.by(() => { let resultado = roles; @@ -51,6 +74,15 @@ resultado = resultado.filter((r) => r.setor === filtroSetor); } + // Filtro por nível + if (filtroNivel !== "") { + if (filtroNivel === 3) { + resultado = resultado.filter((r) => r.nivel >= 3); + } else { + resultado = resultado.filter((r) => r.nivel === filtroNivel); + } + } + return resultado.sort((a, b) => { // Ordenar por nível primeiro (menor nível = maior privilégio) if (a.nivel !== b.nivel) return a.nivel - b.nivel; @@ -74,6 +106,13 @@ return `Nível ${nivel}`; } + function obterCorCardNivel(nivel: number): string { + if (nivel === 0) return "border-l-4 border-error"; + if (nivel === 1) return "border-l-4 border-warning"; + if (nivel === 2) return "border-l-4 border-info"; + return "border-l-4 border-base-300"; + } + function abrirDetalhes(role: Role) { roleSelecionada = role; modalDetalhesAberto = true; @@ -95,13 +134,16 @@ function limparFiltros() { busca = ""; filtroSetor = ""; + filtroNivel = ""; } + + const temFiltrosAtivos = $derived(busca.trim() !== "" || filtroSetor !== "" || filtroNivel !== "");
-
+
+ + {#if stats} +
+ + + + + +
+ {/if} + {#if !carregando && roles.length > 0} -
+
-
-

Filtros de Busca

- +

Filtros de Busca

+
+ {#if temFiltrosAtivos} + + {/if}
-
+
- +
+ + + + +
@@ -171,17 +287,36 @@ - {#each setoresDisponiveis as setor} {/each}
+ + +
+ + +
-
- Mostrando {rolesFiltradas.length} de {roles.length} perfil(is) +
+
+ {rolesFiltradas.length} de {roles.length} perfil(is) + {#if temFiltrosAtivos} + Filtrado + {/if} +
@@ -211,37 +346,145 @@

Nenhum perfil encontrado

Não há perfis cadastrados no sistema.

+ {:else if rolesFiltradas.length === 0} +
+
+
+ + + +

Nenhum perfil encontrado

+

Nenhum perfil corresponde aos filtros aplicados.

+ {#if temFiltrosAtivos} + + {/if} +
+
+
{:else}
{#each rolesFiltradas as role} -
abrirDetalhes(role)}> +
abrirDetalhes(role)}>
-

{role.descricao}

-
{obterTextoNivel(role.nivel)}
+
+

{role.descricao}

+
{obterTextoNivel(role.nivel)}
+
+
+ + + +
-
-
+
+
+ + + Nome técnico: - {role.nome} + {role.nome}
{#if role.setor} -
+
+ + + Setor: - {role.setor} + {role.setor}
{/if} -
+
+ + + Nível: - {role.nivel} + {role.nivel}
-
-
@@ -255,58 +498,132 @@ {#if modalDetalhesAberto && roleSelecionada}
{formatarData(atestado.dataInicio)}{formatarData(atestado.dataFim)}{formatarData(atestado.dataInicio)}{formatarData(atestado.dataFim)} {atestado.dias} {formatarData(licenca.dataInicio)}{formatarData(licenca.dataFim)}{formatarData(licenca.dataInicio)}{formatarData(licenca.dataFim)} {licenca.dias}
-
{#if agendamento.tipo === "email"} - - + + Email {:else} - - + + Chat {/if} @@ -1476,21 +1987,32 @@
{nomeDestinatario}
{#if agendamento.tipo === "email"} -
{agendamento.dados.destinatario}
+
+ {agendamento.dados.destinatario} +
{/if}
{dataFormatada}
{#if podeCancelar} - {@const tempoRestante = agendamento.tipo === "email" - ? (agendamento.dados.agendadaPara ?? 0) - Date.now() - : (agendamento.dados.agendadaPara ?? 0) - Date.now()} - {@const horasRestantes = Math.floor(tempoRestante / (1000 * 60 * 60))} - {@const minutosRestantes = Math.floor((tempoRestante % (1000 * 60 * 60)) / (1000 * 60))} + {@const tempoRestante = + agendamento.tipo === "email" + ? (agendamento.dados.agendadaPara ?? 0) - Date.now() + : (agendamento.dados.agendadaPara ?? 0) - Date.now()} + {@const horasRestantes = Math.floor( + tempoRestante / (1000 * 60 * 60), + )} + {@const minutosRestantes = Math.floor( + (tempoRestante % (1000 * 60 * 60)) / (1000 * 60), + )} {#if horasRestantes < 1 && minutosRestantes < 60} -
Em {minutosRestantes} min
+
+ Em {minutosRestantes} min +
{:else if horasRestantes < 24} -
Em {horasRestantes}h {minutosRestantes}min
+
+ Em {horasRestantes}h {minutosRestantes}min +
{/if} {/if}
{#if agendamento.tipo === "email"} {#if agendamento.dados.templateInfo} -
{agendamento.dados.templateInfo.nome}
+
+ {agendamento.dados.templateInfo.nome} +
{:else if agendamento.dados.templateId} -
Template removido
+
+ Template removido +
{:else}
-
{/if} @@ -1523,8 +2049,19 @@ class="btn btn-sm btn-error btn-outline" onclick={() => cancelarAgendamento(agendamento)} > - - + + Cancelar @@ -1543,10 +2080,23 @@
- - + + - Para enviar emails, certifique-se de configurar o SMTP em Configurações de Email. + Para enviar emails, certifique-se de configurar o SMTP em Configurações + de Email.
@@ -1555,7 +2105,7 @@ @@ -1630,27 +2190,30 @@ - {/if} - diff --git a/apps/web/src/routes/(dashboard)/ti/usuarios/criar/+page.svelte b/apps/web/src/routes/(dashboard)/ti/usuarios/criar/+page.svelte index 87b8961..e36a233 100644 --- a/apps/web/src/routes/(dashboard)/ti/usuarios/criar/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/usuarios/criar/+page.svelte @@ -22,7 +22,6 @@ }); // Estados do formulário - let matricula = $state(""); let nome = $state(""); let email = $state(""); let roleId = $state(""); @@ -30,7 +29,9 @@ let senhaInicial = $state(""); let confirmarSenha = $state(""); let processando = $state(false); - let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null); + let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>( + null, + ); function mostrarMensagem(tipo: "success" | "error", texto: string) { mensagem = { tipo, texto }; @@ -43,8 +44,7 @@ e.preventDefault(); // Validações - const matriculaStr = String(matricula).trim(); - if (!matriculaStr || !nome.trim() || !email.trim() || !roleId || !senhaInicial) { + if (!nome.trim() || !email.trim() || !roleId || !senhaInicial) { mostrarMensagem("error", "Preencha todos os campos obrigatórios"); return; } @@ -63,11 +63,12 @@ try { const resultado = await client.mutation(api.usuarios.criar, { - matricula: matriculaStr, nome: nome.trim(), email: email.trim(), roleId: roleId as Id<"roles">, - funcionarioId: funcionarioId ? (funcionarioId as Id<"funcionarios">) : undefined, + funcionarioId: funcionarioId + ? (funcionarioId as Id<"funcionarios">) + : undefined, senhaInicial: senhaInicial, }); @@ -75,7 +76,7 @@ if (senhaGerada) { mostrarMensagem( "success", - `Usuário criado! SENHA TEMPORÁRIA: ${senhaGerada} - Anote esta senha, ela não será exibida novamente!` + `Usuário criado! SENHA TEMPORÁRIA: ${senhaGerada} - Anote esta senha, ela não será exibida novamente!`, ); setTimeout(() => { goto("/ti/usuarios"); @@ -102,17 +103,19 @@ // Auto-completar ao selecionar funcionário $effect(() => { if (funcionarioId && funcionarios?.data) { - const funcSelecionado = funcionarios.data.find((f: any) => f._id === funcionarioId); + const funcSelecionado = funcionarios.data.find( + (f: any) => f._id === funcionarioId, + ); if (funcSelecionado) { email = funcSelecionado.email || email; nome = funcSelecionado.nome || nome; - matricula = funcSelecionado.matricula || matricula; } } }); function gerarSenhaAleatoria() { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$!"; + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$!"; let senha = ""; for (let i = 0; i < 12; i++) { senha += chars.charAt(Math.floor(Math.random() * chars.length)); @@ -154,8 +157,12 @@
-

Criar Novo Usuário

-

Cadastre um novo usuário no sistema

+

+ Criar Novo Usuário +

+

+ Cadastre um novo usuário no sistema +

@@ -248,7 +255,9 @@
- Ao selecionar, os campos serão preenchidos automaticamente + Ao selecionar, os campos serão preenchidos automaticamente
- -
- - -
-
-
+
@@ -341,7 +340,9 @@ {#if !roles?.data || !Array.isArray(roles.data)}
- Carregando perfis disponíveis... + Carregando perfis disponíveis...
{/if}
@@ -446,7 +447,9 @@

Senha Gerada:

- + {senhaGerada}

- ⚠️ IMPORTANTE: Anote esta senha! Você precisará repassá-la - manualmente ao usuário até que o SMTP seja configurado. + ⚠️ IMPORTANTE: Anote esta senha! Você precisará + repassá-la manualmente ao usuário até que o SMTP seja configurado.

@@ -500,18 +503,27 @@

Informações Importantes

-
- +
+ Cancelar -
- diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts index 43dc379..44ae1ce 100644 --- a/packages/backend/convex/autenticacao.ts +++ b/packages/backend/convex/autenticacao.ts @@ -294,12 +294,19 @@ export const login = mutation({ timestamp: agora, }); + // Obter matrícula do funcionário se houver + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; + } + return { sucesso: true as const, token, usuario: { _id: usuario._id, - matricula: usuario.matricula, + matricula: matricula || "", nome: usuario.nome, email: usuario.email, funcionarioId: usuario.funcionarioId, @@ -568,12 +575,19 @@ export const loginComIP = internalMutation({ timestamp: agora, }); + // Obter matrícula do funcionário se houver + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; + } + return { sucesso: true as const, token, usuario: { _id: usuario._id, - matricula: usuario.matricula, + matricula: matricula || "", nome: usuario.nome, email: usuario.email, funcionarioId: usuario.funcionarioId, @@ -688,11 +702,18 @@ export const verificarSessao = query({ return { valido: false as const, motivo: "Role não encontrada" }; } + // Obter matrícula do funcionário se houver + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; + } + return { valido: true as const, usuario: { _id: usuario._id, - matricula: usuario.matricula, + matricula: matricula || "", nome: usuario.nome, email: usuario.email, funcionarioId: usuario.funcionarioId, diff --git a/packages/backend/convex/chat.ts b/packages/backend/convex/chat.ts index cac7a8d..56bb63d 100644 --- a/packages/backend/convex/chat.ts +++ b/packages/backend/convex/chat.ts @@ -94,7 +94,9 @@ export const criarConversa = mutation({ conversaId, remetenteId: usuarioAtual._id, titulo: "Adicionado a grupo", - descricao: `Você foi adicionado ao grupo "${args.nome || "Sem nome"}" por ${usuarioAtual.nome}`, + descricao: `Você foi adicionado ao grupo "${ + args.nome || "Sem nome" + }" por ${usuarioAtual.nome}`, lida: false, criadaEm: Date.now(), }); @@ -226,8 +228,9 @@ export const enviarMensagem = mutation({ for (const participanteId of conversa.participantes) { // ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa const ehOMesmoUsuario = participanteId === usuarioAtual._id; - const deveCriarNotificacao = !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo; - + const deveCriarNotificacao = + !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo; + if (deveCriarNotificacao) { const tipoNotificacao = args.mencoes?.includes(participanteId) ? "mencao" @@ -318,7 +321,10 @@ export const cancelarMensagemAgendada = mutation({ } if (mensagem.remetenteId !== usuarioAtual._id) { - return { sucesso: false, erro: "Você só pode cancelar suas próprias mensagens" }; + return { + sucesso: false, + erro: "Você só pode cancelar suas próprias mensagens", + }; } if (!mensagem.agendadaPara) { @@ -611,16 +617,20 @@ export const listarConversas = query({ // Para conversas individuais, pegar o outro usuário let outroUsuario = null; if (conversa.tipo === "individual") { - const outroUsuarioRaw = participantes.find((p) => p?._id !== usuarioAtual._id); + const outroUsuarioRaw = participantes.find( + (p) => p?._id !== usuarioAtual._id + ); if (outroUsuarioRaw) { // 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot) const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id); - + if (usuarioAtualizado) { // Adicionar URL da foto de perfil let fotoPerfilUrl = null; if (usuarioAtualizado.fotoPerfil) { - fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtualizado.fotoPerfil); + fotoPerfilUrl = await ctx.storage.getUrl( + usuarioAtualizado.fotoPerfil + ); } outroUsuario = { ...usuarioAtualizado, @@ -643,7 +653,7 @@ export const listarConversas = query({ .query("mensagens") .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) .collect(); - + const mensagens = todasMensagens.filter((m) => !m.agendadaPara); let naoLidas = 0; @@ -756,7 +766,16 @@ export const obterMensagensAgendadas = query({ */ export const listarAgendamentosChat = query({ args: {}, - handler: async (ctx): Promise & { conversaInfo: Doc<"conversas"> | null; destinatarioInfo: Doc<"usuarios"> | null }>> => { + handler: async ( + ctx + ): Promise< + Array< + Doc<"mensagens"> & { + conversaInfo: Doc<"conversas"> | null; + destinatarioInfo: Doc<"usuarios"> | null; + } + > + > => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return []; @@ -912,20 +931,31 @@ export const listarTodosUsuarios = query({ .withIndex("by_ativo", (q) => q.eq("ativo", true)) .collect(); - // Excluir o usuário atual - return usuarios - .filter((u) => u._id !== usuarioAtual._id) - .map((u) => ({ - _id: u._id, - nome: u.nome, - email: u.email, - matricula: u.matricula, - avatar: u.avatar, - fotoPerfil: u.fotoPerfil, - statusPresenca: u.statusPresenca, - statusMensagem: u.statusMensagem, - setor: u.setor, - })); + // Excluir o usuário atual e buscar matrículas + const usuariosComMatricula = await Promise.all( + usuarios + .filter((u) => u._id !== usuarioAtual._id) + .map(async (u) => { + let matricula: string | undefined = undefined; + if (u.funcionarioId) { + const funcionario = await ctx.db.get(u.funcionarioId); + matricula = funcionario?.matricula; + } + return { + _id: u._id, + nome: u.nome, + email: u.email, + matricula, + avatar: u.avatar, + fotoPerfil: u.fotoPerfil, + statusPresenca: u.statusPresenca, + statusMensagem: u.statusMensagem, + setor: u.setor, + }; + }) + ); + + return usuariosComMatricula; }, }); diff --git a/packages/backend/convex/logsAcesso.ts b/packages/backend/convex/logsAcesso.ts index 70e6421..bfa1d76 100644 --- a/packages/backend/convex/logsAcesso.ts +++ b/packages/backend/convex/logsAcesso.ts @@ -88,9 +88,14 @@ export const listar = query({ if (log.usuarioId) { const user = await ctx.db.get(log.usuarioId); if (user) { + let matricula: string | undefined = undefined; + if (user.funcionarioId) { + const funcionario = await ctx.db.get(user.funcionarioId); + matricula = funcionario?.matricula; + } usuario = { _id: user._id, - matricula: user.matricula, + matricula: matricula || "", nome: user.nome, }; } diff --git a/packages/backend/convex/logsAtividades.ts b/packages/backend/convex/logsAtividades.ts index 2a87b16..090957a 100644 --- a/packages/backend/convex/logsAtividades.ts +++ b/packages/backend/convex/logsAtividades.ts @@ -78,10 +78,15 @@ export const listarAtividades = query({ const atividadesComUsuarios = await Promise.all( atividades.map(async (atividade) => { const usuario = await ctx.db.get(atividade.usuarioId); + let matricula = "N/A"; + if (usuario?.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula || "N/A"; + } return { ...atividade, usuarioNome: usuario?.nome || "Usuário Desconhecido", - usuarioMatricula: usuario?.matricula || "N/A", + usuarioMatricula: matricula, }; }) ); @@ -157,10 +162,15 @@ export const obterHistoricoRecurso = query({ const atividadesComUsuarios = await Promise.all( atividades.map(async (atividade) => { const usuario = await ctx.db.get(atividade.usuarioId); + let matricula = "N/A"; + if (usuario?.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula || "N/A"; + } return { ...atividade, usuarioNome: usuario?.nome || "Usuário Desconhecido", - usuarioMatricula: usuario?.matricula || "N/A", + usuarioMatricula: matricula, }; }) ); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 0f07dbc..d561bbd 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -30,18 +30,19 @@ export default defineSchema({ simboloId: v.id("simbolos"), simboloTipo: simboloTipo, gestorId: v.optional(v.id("usuarios")), - statusFerias: v.optional(v.union( - v.literal("ativo"), - v.literal("em_ferias") - )), - + statusFerias: v.optional( + v.union(v.literal("ativo"), v.literal("em_ferias")) + ), + // Regime de trabalho (para cálculo correto de férias) - regimeTrabalho: v.optional(v.union( - v.literal("clt"), // CLT - Consolidação das Leis do Trabalho - v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco - v.literal("estatutario_federal"), // Servidor Público Federal - v.literal("estatutario_municipal") // Servidor Público Municipal - )), + regimeTrabalho: v.optional( + v.union( + v.literal("clt"), // CLT - Consolidação das Leis do Trabalho + v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco + v.literal("estatutario_federal"), // Servidor Público Federal + v.literal("estatutario_municipal") // Servidor Público Municipal + ) + ), // Dados Pessoais Adicionais (opcionais) nomePai: v.optional(v.string()), @@ -191,10 +192,7 @@ export default defineSchema({ licencas: defineTable({ funcionarioId: v.id("funcionarios"), - tipo: v.union( - v.literal("maternidade"), - v.literal("paternidade") - ), + tipo: v.union(v.literal("maternidade"), v.literal("paternidade")), dataInicio: v.string(), dataFim: v.string(), documentoId: v.optional(v.id("_storage")), @@ -237,11 +235,15 @@ export default defineSchema({ data: v.number(), usuarioId: v.id("usuarios"), acao: v.string(), - periodosAnteriores: v.optional(v.array(v.object({ - dataInicio: v.string(), - dataFim: v.string(), - diasCorridos: v.number(), - }))), + periodosAnteriores: v.optional( + v.array( + v.object({ + dataInicio: v.string(), + dataFim: v.string(), + diasCorridos: v.number(), + }) + ) + ), }) ) ), @@ -343,7 +345,6 @@ export default defineSchema({ // Sistema de Autenticação e Controle de Acesso usuarios: defineTable({ - matricula: v.string(), senhaHash: v.string(), // Senha criptografada com bcrypt nome: v.string(), email: v.string(), @@ -380,7 +381,6 @@ export default defineSchema({ notificacoesAtivadas: v.optional(v.boolean()), somNotificacao: v.optional(v.boolean()), }) - .index("by_matricula", ["matricula"]) .index("by_email", ["email"]) .index("by_role", ["roleId"]) .index("by_ativo", ["ativo"]) @@ -500,7 +500,7 @@ export default defineSchema({ .index("by_data_inicio", ["dataInicio"]), // Perfis Customizados - + // Templates de Mensagens templatesMensagens: defineTable({ codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc. @@ -663,8 +663,7 @@ export default defineSchema({ mensagensPorMinuto: v.optional(v.number()), tempoRespostaMedio: v.optional(v.number()), errosCount: v.optional(v.number()), - }) - .index("by_timestamp", ["timestamp"]), + }).index("by_timestamp", ["timestamp"]), alertConfigurations: defineTable({ metricName: v.string(), @@ -681,8 +680,7 @@ export default defineSchema({ notifyByChat: v.boolean(), createdBy: v.id("usuarios"), lastModified: v.number(), - }) - .index("by_enabled", ["enabled"]), + }).index("by_enabled", ["enabled"]), alertHistory: defineTable({ configId: v.id("alertConfigurations"), diff --git a/packages/backend/convex/seed.ts b/packages/backend/convex/seed.ts index 7fc21fe..d24f307 100644 --- a/packages/backend/convex/seed.ts +++ b/packages/backend/convex/seed.ts @@ -316,7 +316,6 @@ export const seedDatabase = internalMutation({ const senhaInicial = await hashPassword("Mudar@123"); await ctx.db.insert("usuarios", { - matricula: funcionario.matricula, senhaHash: senhaInicial, nome: funcionario.nome, email: funcionario.email, diff --git a/packages/backend/convex/usuarios.ts b/packages/backend/convex/usuarios.ts index 46c0d28..95946d9 100644 --- a/packages/backend/convex/usuarios.ts +++ b/packages/backend/convex/usuarios.ts @@ -4,6 +4,21 @@ import { hashPassword, generateToken } from "./auth/utils"; import { registrarAtividade } from "./logsAtividades"; import { Id, Doc } from "./_generated/dataModel"; import { api } from "./_generated/api"; +import type { QueryCtx } from "./_generated/server"; + +/** + * Helper para obter a matrícula do usuário (do funcionário se houver) + */ +async function obterMatriculaUsuario( + ctx: QueryCtx, + usuario: Doc<"usuarios"> +): Promise { + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + return funcionario?.matricula; + } + return undefined; +} /** * Associar funcionário a um usuário @@ -30,8 +45,11 @@ export const associarFuncionario = mutation({ .first(); if (usuarioExistente && usuarioExistente._id !== args.usuarioId) { + const matricula = await obterMatriculaUsuario(ctx, usuarioExistente); throw new Error( - `Este funcionário já está associado ao usuário: ${usuarioExistente.nome} (${usuarioExistente.matricula})` + `Este funcionário já está associado ao usuário: ${ + usuarioExistente.nome + }${matricula ? ` (${matricula})` : ""}` ); } @@ -66,7 +84,6 @@ export const desassociarFuncionario = mutation({ */ export const criar = mutation({ args: { - matricula: v.string(), nome: v.string(), email: v.string(), roleId: v.id("roles"), @@ -78,16 +95,6 @@ export const criar = mutation({ v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args) => { - // Verificar se matrícula já existe - const existente = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) - .first(); - - if (existente) { - return { sucesso: false as const, erro: "Matrícula já cadastrada" }; - } - // Verificar se email já existe const emailExistente = await ctx.db .query("usuarios") @@ -103,7 +110,6 @@ export const criar = mutation({ // Criar usuário const usuarioId = await ctx.db.insert("usuarios", { - matricula: args.matricula, senhaHash, nome: args.nome, email: args.email, @@ -194,9 +200,17 @@ export const listar = query({ handler: async (ctx, args) => { let usuarios = await ctx.db.query("usuarios").collect(); - // Filtrar por matrícula + // Filtrar por matrícula (buscar no funcionário) if (args.matricula) { - usuarios = usuarios.filter((u) => u.matricula.includes(args.matricula!)); + const usuariosComMatricula = await Promise.all( + usuarios.map(async (u) => { + const matricula = await obterMatriculaUsuario(ctx, u); + return { usuario: u, matricula }; + }) + ); + usuarios = usuariosComMatricula + .filter(({ matricula }) => matricula?.includes(args.matricula!)) + .map(({ usuario }) => usuario); } // Filtrar por ativo @@ -206,20 +220,25 @@ export const listar = query({ // Buscar roles e funcionários const resultado = []; - const usuariosSemRole: Array<{ nome: string; matricula: string; roleId: Id<"roles"> }> = []; + const usuariosSemRole: Array<{ + nome: string; + matricula: string; + roleId: Id<"roles">; + }> = []; for (const usuario of usuarios) { try { const role = await ctx.db.get(usuario.roleId); - + // Se a role não existe, criar uma role de erro mas ainda incluir o usuário if (!role) { + const matricula = await obterMatriculaUsuario(ctx, usuario); usuariosSemRole.push({ nome: usuario.nome, - matricula: usuario.matricula, + matricula: matricula || "N/A", roleId: usuario.roleId, }); - + // Filtrar por setor - se filtro está ativo e role não existe, pular if (args.setor) { continue; @@ -240,14 +259,19 @@ export const listar = query({ }; } } catch (error) { - console.error(`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, error); + console.error( + `Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, + error + ); } } + const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario); + // Criar role de erro (sem _creationTime pois a role não existe) resultado.push({ _id: usuario._id, - matricula: usuario.matricula, + matricula: matriculaUsuario, nome: usuario.nome, email: usuario.email, ativo: usuario.ativo, @@ -294,7 +318,10 @@ export const listar = query({ }; } } catch (error) { - console.error(`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, error); + console.error( + `Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, + error + ); } } @@ -305,14 +332,18 @@ export const listar = query({ nome: role.nome, nivel: role.nivel, ...(role.criadoPor !== undefined && { criadoPor: role.criadoPor }), - ...(role.customizado !== undefined && { customizado: role.customizado }), + ...(role.customizado !== undefined && { + customizado: role.customizado, + }), ...(role.editavel !== undefined && { editavel: role.editavel }), ...(role.setor !== undefined && { setor: role.setor }), }; + const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario); + resultado.push({ _id: usuario._id, - matricula: usuario.matricula, + matricula: matriculaUsuario, nome: usuario.nome, email: usuario.email, ativo: usuario.ativo, @@ -334,7 +365,12 @@ export const listar = query({ if (usuariosSemRole.length > 0) { console.warn( `⚠️ Encontrados ${usuariosSemRole.length} usuário(s) com perfil ausente:`, - usuariosSemRole.map((u) => `${u.nome} (${u.matricula}) - RoleID: ${u.roleId}`) + usuariosSemRole.map( + (u) => + `${u.nome}${ + u.matricula !== "N/A" ? ` (${u.matricula})` : "" + } - RoleID: ${u.roleId}` + ) ); } @@ -559,7 +595,9 @@ export const atualizarPerfil = mutation({ } // Atualizar apenas os campos fornecidos - const updates: Partial> & { atualizadoEm: number } = { atualizadoEm: Date.now() }; + const updates: Partial> & { atualizadoEm: number } = { + atualizadoEm: Date.now(), + }; if (args.avatar !== undefined) updates.avatar = args.avatar; if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil; @@ -591,7 +629,7 @@ export const obterPerfil = query({ _id: v.id("usuarios"), nome: v.string(), email: v.string(), - matricula: v.string(), + matricula: v.optional(v.string()), funcionarioId: v.optional(v.id("funcionarios")), avatar: v.optional(v.string()), fotoPerfil: v.optional(v.id("_storage")), @@ -675,11 +713,13 @@ export const obterPerfil = query({ fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtual.fotoPerfil); } + const matricula = await obterMatriculaUsuario(ctx, usuarioAtual); + return { _id: usuarioAtual._id, nome: usuarioAtual.nome, email: usuarioAtual.email, - matricula: usuarioAtual.matricula, + matricula: matricula || undefined, funcionarioId: usuarioAtual.funcionarioId, avatar: usuarioAtual.avatar, fotoPerfil: usuarioAtual.fotoPerfil, @@ -735,11 +775,13 @@ export const listarParaChat = query({ fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); } + const matricula = await obterMatriculaUsuario(ctx, usuario); + return { _id: usuario._id, nome: usuario.nome, email: usuario.email, - matricula: usuario.matricula || undefined, + matricula: matricula || undefined, avatar: usuario.avatar, fotoPerfil: usuario.fotoPerfil, fotoPerfilUrl, @@ -1035,7 +1077,6 @@ export const editarUsuario = mutation({ */ export const criarAdminMaster = mutation({ args: { - matricula: v.string(), nome: v.string(), email: v.string(), senha: v.optional(v.string()), @@ -1074,32 +1115,9 @@ export const criarAdminMaster = mutation({ }; } - // Se já existir usuário por matrícula, promove/atualiza - const existentePorMatricula = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) - .first(); - const senhaTemporaria = args.senha || gerarSenhaTemporaria(); const senhaHash = await hashPassword(senhaTemporaria); - if (existentePorMatricula) { - await ctx.db.patch(existentePorMatricula._id, { - nome: args.nome, - email: args.email, - senhaHash, - roleId: roleTIMaster._id, - ativo: true, - primeiroAcesso: true, - atualizadoEm: Date.now(), - }); - return { - sucesso: true as const, - usuarioId: existentePorMatricula._id, - senhaTemporaria, - }; - } - // Verificar se email já existe const existentePorEmail = await ctx.db .query("usuarios") @@ -1108,7 +1126,6 @@ export const criarAdminMaster = mutation({ if (existentePorEmail) { // Promove usuário existente por email await ctx.db.patch(existentePorEmail._id, { - matricula: args.matricula, nome: args.nome, senhaHash, roleId: roleTIMaster._id, @@ -1125,7 +1142,6 @@ export const criarAdminMaster = mutation({ // Criar novo usuário TI Master const usuarioId = await ctx.db.insert("usuarios", { - matricula: args.matricula, senhaHash, nome: args.nome, email: args.email, @@ -1194,7 +1210,6 @@ export const excluirUsuarioLogico = mutation({ */ export const criarUsuarioCompleto = mutation({ args: { - matricula: v.string(), nome: v.string(), email: v.string(), roleId: v.id("roles"), @@ -1212,16 +1227,6 @@ export const criarUsuarioCompleto = mutation({ v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args) => { - // Verificar se matrícula já existe - const existente = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) - .first(); - - if (existente) { - return { sucesso: false as const, erro: "Matrícula já cadastrada" }; - } - // Verificar se email já existe const emailExistente = await ctx.db .query("usuarios") @@ -1238,7 +1243,6 @@ export const criarUsuarioCompleto = mutation({ // Criar usuário const usuarioId = await ctx.db.insert("usuarios", { - matricula: args.matricula, senhaHash, nome: args.nome, email: args.email, @@ -1256,7 +1260,7 @@ export const criarUsuarioCompleto = mutation({ args.criadoPorId, "criar", "usuarios", - JSON.stringify({ usuarioId, matricula: args.matricula, nome: args.nome }), + JSON.stringify({ usuarioId, nome: args.nome }), usuarioId ); @@ -1272,7 +1276,6 @@ export const criarUsuarioCompleto = mutation({ */ export const criarAdminPadrao = mutation({ args: { - matricula: v.optional(v.string()), nome: v.optional(v.string()), email: v.optional(v.string()), senha: v.optional(v.string()), @@ -1282,7 +1285,6 @@ export const criarAdminPadrao = mutation({ usuarioId: v.optional(v.id("usuarios")), }), handler: async (ctx, args) => { - const matricula = args.matricula ?? "0000"; const nome = args.nome ?? "Administrador Geral"; const email = args.email ?? "admin@sgse.pe.gov.br"; const senha = args.senha ?? "Admin@123"; @@ -1306,12 +1308,7 @@ export const criarAdminPadrao = mutation({ if (!roleAdmin) return { sucesso: false }; - // Verificar se já existe por matrícula ou email - const existentePorMatricula = await ctx.db - .query("usuarios") - .withIndex("by_matricula", (q) => q.eq("matricula", matricula)) - .first(); - + // Verificar se já existe por email const existentePorEmail = await ctx.db .query("usuarios") .withIndex("by_email", (q) => q.eq("email", email)) @@ -1319,10 +1316,8 @@ export const criarAdminPadrao = mutation({ const senhaHash = await hashPassword(senha); - if (existentePorMatricula || existentePorEmail) { - const alvo = existentePorMatricula ?? existentePorEmail!; - await ctx.db.patch(alvo._id, { - matricula, + if (existentePorEmail) { + await ctx.db.patch(existentePorEmail._id, { nome, email, senhaHash, @@ -1331,11 +1326,10 @@ export const criarAdminPadrao = mutation({ primeiroAcesso: false, atualizadoEm: Date.now(), }); - return { sucesso: true, usuarioId: alvo._id }; + return { sucesso: true, usuarioId: existentePorEmail._id }; } const usuarioId = await ctx.db.insert("usuarios", { - matricula, senhaHash, nome, email, diff --git a/packages/backend/convex/verificarMatriculas.ts b/packages/backend/convex/verificarMatriculas.ts index 1c5c5ba..0b05979 100644 --- a/packages/backend/convex/verificarMatriculas.ts +++ b/packages/backend/convex/verificarMatriculas.ts @@ -3,7 +3,7 @@ import { v } from "convex/values"; import { Id, Doc } from "./_generated/dataModel"; /** - * Verificar duplicatas de matrícula + * Verificar duplicatas de matrícula (agora busca do funcionário associado) */ export const verificarDuplicatas = query({ args: {}, @@ -23,18 +23,27 @@ export const verificarDuplicatas = query({ handler: async (ctx) => { const usuarios = await ctx.db.query("usuarios").collect(); - // Agrupar por matrícula - const gruposPorMatricula = usuarios.reduce((acc, usuario) => { - if (!acc[usuario.matricula]) { - acc[usuario.matricula] = []; + // Agrupar por matrícula do funcionário associado + const gruposPorMatricula: Record; nome: string; email: string }>> = {}; + + for (const usuario of usuarios) { + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; } - acc[usuario.matricula].push({ - _id: usuario._id, - nome: usuario.nome, - email: usuario.email || "", - }); - return acc; - }, {} as Record; nome: string; email: string }>>); + + if (matricula) { + if (!gruposPorMatricula[matricula]) { + gruposPorMatricula[matricula] = []; + } + gruposPorMatricula[matricula].push({ + _id: usuario._id, + nome: usuario.nome, + email: usuario.email || "", + }); + } + } // Filtrar apenas duplicatas const duplicatas = Object.entries(gruposPorMatricula) @@ -50,7 +59,7 @@ export const verificarDuplicatas = query({ }); /** - * Remover duplicatas mantendo apenas o mais recente + * Remover duplicatas mantendo apenas o mais recente (agora busca do funcionário associado) */ export const removerDuplicatas = internalMutation({ args: {}, @@ -61,14 +70,23 @@ export const removerDuplicatas = internalMutation({ handler: async (ctx) => { const usuarios = await ctx.db.query("usuarios").collect(); - // Agrupar por matrícula - const gruposPorMatricula = usuarios.reduce((acc, usuario) => { - if (!acc[usuario.matricula]) { - acc[usuario.matricula] = []; + // Agrupar por matrícula do funcionário associado + const gruposPorMatricula: Record[]> = {}; + + for (const usuario of usuarios) { + let matricula: string | undefined = undefined; + if (usuario.funcionarioId) { + const funcionario = await ctx.db.get(usuario.funcionarioId); + matricula = funcionario?.matricula; } - acc[usuario.matricula].push(usuario); - return acc; - }, {} as Record[]>); + + if (matricula) { + if (!gruposPorMatricula[matricula]) { + gruposPorMatricula[matricula] = []; + } + gruposPorMatricula[matricula].push(usuario); + } + } let removidos = 0; const matriculasDuplicadas: string[] = []; -- 2.49.1