From 3b89c496c6dfc2c6c5a35d70f4944ff38c5bec0b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 00:43:13 -0300 Subject: [PATCH] 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.