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"; import { internal, api } from "./_generated/api"; // ========== HELPERS ========== /** * Normaliza texto para busca (remove acentos, converte para lowercase) */ function normalizarTextoParaBusca(texto: string): string { return texto .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") // Remove diacríticos .trim(); } /** * 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; } /** * Helper function para verificar se usuário é administrador de uma sala de reunião */ async function verificarPermissaoAdmin( ctx: QueryCtx | MutationCtx, conversaId: Id<"conversas">, usuarioId: Id<"usuarios"> ): Promise { const conversa = await ctx.db.get(conversaId); if (!conversa) return false; // Verificar se é sala de reunião if (conversa.tipo !== "sala_reuniao") return false; // Verificar se tem array de administradores if (!conversa.administradores || conversa.administradores.length === 0) { // Se não tem administradores definidos, o criador é admin por padrão return conversa.criadoPor === usuarioId; } // Verificar se está na lista de administradores return conversa.administradores.includes(usuarioId); } // ========== MUTATIONS ========== /** * Cria uma nova conversa (individual ou grupo) */ export const criarConversa = mutation({ args: { tipo: v.union(v.literal("individual"), v.literal("grupo"), v.literal("sala_reuniao")), 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; } } } // Preparar dados da conversa const dadosConversa: any = { tipo: args.tipo, nome: args.nome, avatar: args.avatar, participantes: args.participantes, criadoPor: usuarioAtual._id, criadoEm: Date.now(), }; // Se for sala de reunião, adicionar administradores (criador sempre é admin) if (args.tipo === "sala_reuniao") { dadosConversa.administradores = [usuarioAtual._id]; } // Criar nova conversa const conversaId = await ctx.db.insert("conversas", dadosConversa); // Criar notificações para outros participantes if (args.tipo === "grupo" || args.tipo === "sala_reuniao") { const tipoNotificacao = args.tipo === "sala_reuniao" ? "adicionado_grupo" : "adicionado_grupo"; const tipoTexto = args.tipo === "sala_reuniao" ? "sala de reunião" : "grupo"; for (const participanteId of args.participantes) { if (participanteId !== usuarioAtual._id) { await ctx.db.insert("notificacoes", { usuarioId: participanteId, tipo: tipoNotificacao, conversaId, remetenteId: usuarioAtual._id, titulo: args.tipo === "sala_reuniao" ? "Adicionado a sala de reunião" : "Adicionado a grupo", descricao: `Você foi adicionado à ${tipoTexto} "${ args.nome || "Sem nome" }" por ${usuarioAtual.nome}`, lida: false, criadaEm: Date.now(), }); } } } return conversaId; }, }); /** * Cria uma nova sala de reunião (wrapper específico para facilitar uso) */ export const criarSalaReuniao = mutation({ args: { nome: v.string(), participantes: v.array(v.id("usuarios")), avatar: v.optional(v.string()), }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error("Não autenticado"); // Validar nome if (!args.nome || args.nome.trim().length === 0) { throw new Error("O nome da sala de reunião é obrigatório"); } // Validar participantes const participantesUnicos = [...new Set(args.participantes)]; if (!participantesUnicos.includes(usuarioAtual._id)) { participantesUnicos.push(usuarioAtual._id); } // Preparar dados da conversa const dadosConversa: any = { tipo: "sala_reuniao" as const, nome: args.nome.trim(), avatar: args.avatar, participantes: participantesUnicos, criadoPor: usuarioAtual._id, criadoEm: Date.now(), administradores: [usuarioAtual._id], // Criador sempre é admin }; // Criar nova conversa const conversaId = await ctx.db.insert("conversas", dadosConversa); // Criar notificações para outros participantes const tipoNotificacao = "adicionado_grupo"; const tipoTexto = "sala de reunião"; for (const participanteId of participantesUnicos) { if (participanteId !== usuarioAtual._id) { await ctx.db.insert("notificacoes", { usuarioId: participanteId, tipo: tipoNotificacao, conversaId, remetenteId: usuarioAtual._id, titulo: "Adicionado a sala de reunião", descricao: `Você foi adicionado à ${tipoTexto} "${ 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"))), respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo 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"); } // Normalizar conteúdo para busca (remover acentos, lowercase) const conteudoBusca = normalizarTextoParaBusca(args.conteudo); // Verificar se é resposta a outra mensagem if (args.respostaPara) { const mensagemOriginal = await ctx.db.get(args.respostaPara); if (!mensagemOriginal || mensagemOriginal.conversaId !== args.conversaId) { throw new Error("Mensagem original não encontrada ou não pertence à mesma conversa"); } // SEGURANÇA: Verificar se o remetente da mensagem original é participante da conversa if (!conversa.participantes.includes(mensagemOriginal.remetenteId)) { throw new Error("Mensagem original inválida"); } if (mensagemOriginal.deletada) { throw new Error("Não é possível responder a uma mensagem deletada"); } } // Criar mensagem const mensagemId = await ctx.db.insert("mensagens", { conversaId: args.conversaId, remetenteId: usuarioAtual._id, tipo: args.tipo, conteudo: args.conteudo, conteudoBusca, arquivoId: args.arquivoId, arquivoNome: args.arquivoNome, arquivoTamanho: args.arquivoTamanho, arquivoTipo: args.arquivoTipo, mencoes: args.mencoes, respostaPara: args.respostaPara, enviadaEm: Date.now(), }); // Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto, assíncrono) if (args.tipo === "texto") { const urlRegex = /(https?:\/\/[^\s]+)/g; const urls = args.conteudo.match(urlRegex); if (urls && urls.length > 0) { // Pegar primeira URL encontrada const primeiraUrl = urls[0]; // Agendar processamento de preview via action wrapper ctx.scheduler.runAfter(1000, api.actions.linkPreview.processarPreviewLink, { mensagemId, url: primeiraUrl, }).catch((error) => { console.error("Erro ao agendar processamento de preview de link:", error); }); } } // 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"; const titulo = tipoNotificacao === "mencao" ? `${usuarioAtual.nome} mencionou você` : `Nova mensagem de ${usuarioAtual.nome}`; const descricao = args.conteudo.substring(0, 100); // Criar notificação no banco await ctx.db.insert("notificacoes", { usuarioId: participanteId, tipo: tipoNotificacao, conversaId: args.conversaId, mensagemId, remetenteId: usuarioAtual._id, titulo, descricao, lida: false, criadaEm: Date.now(), }); // Enviar push notification (assíncrono, não bloqueia) ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, { usuarioId: participanteId, titulo, corpo: descricao, data: { conversaId: args.conversaId, mensagemId, tipo: tipoNotificacao, }, }).catch((error) => { console.error(`Erro ao agendar push para usuário ${participanteId}:`, error); }); // Se usuário offline, enviar email (assíncrono) const usuarioOnline = await ctx.runQuery(internal.pushNotifications.verificarUsuarioOnline, { usuarioId: participanteId, }); if (!usuarioOnline) { // Verificar preferências de email para esta conversa const preferencias = await ctx.db .query("preferenciasNotificacaoConversa") .withIndex("by_usuario_conversa", (q) => q.eq("usuarioId", participanteId).eq("conversaId", args.conversaId) ) .first(); const deveEnviarEmail = !preferencias || preferencias.emailAtivado !== false; if (deveEnviarEmail) { // Buscar email do usuário const usuarioParticipante = await ctx.db.get(participanteId); if (usuarioParticipante?.email) { // Obter URL do sistema (padrão: localhost para dev) const urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; ctx.scheduler.runAfter(1000, api.email.enviarEmailComTemplate, { destinatario: usuarioParticipante.email, destinatarioId: participanteId, templateCodigo: tipoNotificacao === "mencao" ? "chat_mencao" : "chat_mensagem", variaveis: { remetente: usuarioAtual.nome, mensagem: descricao, conversaId: args.conversaId.toString(), urlSistema, }, enviadoPor: usuarioAtual._id, }).catch((error) => { console.error(`Erro ao agendar email para usuário ${participanteId}:`, error); }); } } } } } } 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" }; } // SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante const conversa = await ctx.db.get(mensagem.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { return { sucesso: false, erro: "Você não tem acesso a esta mensagem" }; } // SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa if (!conversa.participantes.includes(mensagem.remetenteId)) { return { sucesso: false, erro: "Mensagem inválida" }; } 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 * SEGURANÇA: Usuário só pode reagir a mensagens de conversas onde é participante */ 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"); // SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante const conversa = await ctx.db.get(mensagem.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { throw new Error("Você não pode reagir a mensagens de conversas onde não participa"); } // SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa if (!conversa.participantes.includes(mensagem.remetenteId)) { throw new Error("Mensagem inválida"); } 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 * SEGURANÇA: Usuário só pode marcar como lida mensagens de conversas onde é participante */ 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"); // SEGURANÇA: Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { throw new Error("Você não pertence a esta conversa"); } // SEGURANÇA: Verificar se a mensagem pertence à conversa e se o remetente é participante const mensagem = await ctx.db.get(args.mensagemId); if (!mensagem || mensagem.conversaId !== args.conversaId) { throw new Error("Mensagem não encontrada nesta conversa"); } if (!conversa.participantes.includes(mensagem.remetenteId)) { throw new Error("Mensagem inválida"); } // 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 * SEGURANÇA: Usuário só pode indicar digitação em conversas onde é participante */ 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"); // SEGURANÇA: Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { throw new Error("Você não pertence a esta conversa"); } // 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 * SEGURANÇA: Usuário só pode marcar como lida suas próprias notificações */ 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"); const notificacao = await ctx.db.get(args.notificacaoId); if (!notificacao) throw new Error("Notificação não encontrada"); // SEGURANÇA: Verificar se a notificação pertence ao usuário atual if (notificacao.usuarioId !== usuarioAtual._id) { throw new Error("Você não tem permissão para marcar esta notificação como lida"); } // SEGURANÇA: Se a notificação tem conversaId, verificar se usuário ainda é participante if (notificacao.conversaId) { const conversa = await ctx.db.get(notificacao.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { throw new Error("Você não tem acesso a esta notificação"); } } 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) */ /** * Editar mensagem enviada */ export const editarMensagem = mutation({ args: { mensagemId: v.id("mensagens"), novoConteudo: v.string(), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const mensagem = await ctx.db.get(args.mensagemId); if (!mensagem) { return { sucesso: false, erro: "Mensagem não encontrada" }; } // SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante const conversa = await ctx.db.get(mensagem.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { return { sucesso: false, erro: "Você não tem acesso a esta mensagem" }; } // SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa if (!conversa.participantes.includes(mensagem.remetenteId)) { return { sucesso: false, erro: "Mensagem inválida" }; } // Verificar se usuário é o remetente if (mensagem.remetenteId !== usuarioAtual._id) { return { sucesso: false, erro: "Você só pode editar suas próprias mensagens" }; } // Verificar se mensagem não foi deletada if (mensagem.deletada) { return { sucesso: false, erro: "Não é possível editar uma mensagem deletada" }; } // Verificar se não é mensagem agendada if (mensagem.agendadaPara) { return { sucesso: false, erro: "Não é possível editar mensagens agendadas" }; } // Validar novo conteúdo if (!args.novoConteudo || args.novoConteudo.trim().length === 0) { return { sucesso: false, erro: "O conteúdo da mensagem não pode estar vazio" }; } // Normalizar conteúdo para busca const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo); // Atualizar mensagem await ctx.db.patch(args.mensagemId, { conteudo: args.novoConteudo.trim(), conteudoBusca, editadaEm: Date.now(), }); return { sucesso: true }; }, }); /** * Mutation interna para atualizar link preview */ export const atualizarLinkPreview = internalMutation({ args: { mensagemId: v.id("mensagens"), linkPreview: v.object({ url: v.string(), titulo: v.optional(v.string()), descricao: v.optional(v.string()), imagem: v.optional(v.string()), site: v.optional(v.string()), }), }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.mensagemId, { linkPreview: args.linkPreview, }); return null; }, }); 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"); // SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante const conversa = await ctx.db.get(mensagem.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { throw new Error("Você não tem acesso a esta mensagem"); } // SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa if (!conversa.participantes.includes(mensagem.remetenteId)) { throw new Error("Mensagem inválida"); } // Verificar se é admin de sala de reunião ou se é o próprio remetente const isAdmin = await verificarPermissaoAdmin(ctx, mensagem.conversaId, usuarioAtual._id); if (mensagem.remetenteId !== usuarioAtual._id && !isAdmin) { throw new Error("Você só pode deletar suas próprias mensagens"); } await ctx.db.patch(args.mensagemId, { deletada: true, conteudo: "Mensagem deletada", }); return true; }, }); /** * Deleta uma mensagem como administrador (com notificação ao remetente) */ export const deletarMensagemComoAdmin = mutation({ args: { mensagemId: v.id("mensagens"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const mensagem = await ctx.db.get(args.mensagemId); if (!mensagem) { return { sucesso: false, erro: "Mensagem não encontrada" }; } // SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante const conversa = await ctx.db.get(mensagem.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { return { sucesso: false, erro: "Você não tem acesso a esta mensagem" }; } // SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa if (!conversa.participantes.includes(mensagem.remetenteId)) { return { sucesso: false, erro: "Mensagem inválida" }; } // Verificar se usuário é administrador da sala const isAdmin = await verificarPermissaoAdmin(ctx, mensagem.conversaId, usuarioAtual._id); if (!isAdmin) { return { sucesso: false, erro: "Apenas administradores podem deletar mensagens de outros usuários" }; } // Não permitir deletar mensagem já deletada if (mensagem.deletada) { return { sucesso: false, erro: "Mensagem já foi deletada" }; } // Deletar mensagem await ctx.db.patch(args.mensagemId, { deletada: true, conteudo: "Mensagem deletada por administrador", }); // Criar notificação para o remetente original (se não for o próprio admin) if (mensagem.remetenteId !== usuarioAtual._id) { const remetente = await ctx.db.get(mensagem.remetenteId); if (remetente) { await ctx.db.insert("notificacoes", { usuarioId: mensagem.remetenteId, tipo: "nova_mensagem", conversaId: mensagem.conversaId, mensagemId: args.mensagemId, remetenteId: usuarioAtual._id, titulo: "Mensagem deletada", descricao: `Sua mensagem foi deletada por um administrador da sala "${conversa.nome || "Sem nome"}"`, lida: false, criadaEm: Date.now(), }); } } return { sucesso: true }; }, }); /** * Adiciona um participante à sala de reunião (apenas administradores) */ export const adicionarParticipanteSala = mutation({ args: { conversaId: v.id("conversas"), participanteId: v.id("usuarios"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return { sucesso: false, erro: "Sala de reunião não encontrada" }; } // Verificar se é sala de reunião if (conversa.tipo !== "sala_reuniao") { return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" }; } // Verificar se usuário é administrador const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id); if (!isAdmin) { return { sucesso: false, erro: "Apenas administradores podem adicionar participantes" }; } // Verificar se participante já está na sala if (conversa.participantes.includes(args.participanteId)) { return { sucesso: false, erro: "Usuário já é participante desta sala" }; } // Verificar se participante existe const participante = await ctx.db.get(args.participanteId); if (!participante) { return { sucesso: false, erro: "Usuário não encontrado" }; } // Adicionar participante const novosParticipantes = [...conversa.participantes, args.participanteId]; await ctx.db.patch(args.conversaId, { participantes: novosParticipantes, }); // Criar notificação para o novo participante await ctx.db.insert("notificacoes", { usuarioId: args.participanteId, tipo: "adicionado_grupo", conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: "Adicionado a sala de reunião", descricao: `Você foi adicionado à sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`, lida: false, criadaEm: Date.now(), }); return { sucesso: true }; }, }); /** * Remove um participante da sala de reunião (apenas administradores, não pode remover outros admins) */ export const removerParticipanteSala = mutation({ args: { conversaId: v.id("conversas"), participanteId: v.id("usuarios"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return { sucesso: false, erro: "Sala de reunião não encontrada" }; } // Verificar se é sala de reunião if (conversa.tipo !== "sala_reuniao") { return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" }; } // Verificar se usuário é administrador const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id); if (!isAdmin) { return { sucesso: false, erro: "Apenas administradores podem remover participantes" }; } // Verificar se participante está na sala if (!conversa.participantes.includes(args.participanteId)) { return { sucesso: false, erro: "Usuário não é participante desta sala" }; } // Verificar se está tentando remover outro administrador const isParticipanteAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId); if (isParticipanteAdmin) { return { sucesso: false, erro: "Não é possível remover outros administradores" }; } // Remover participante const novosParticipantes = conversa.participantes.filter((p) => p !== args.participanteId); await ctx.db.patch(args.conversaId, { participantes: novosParticipantes, }); // Criar notificação para o participante removido const participanteRemovido = await ctx.db.get(args.participanteId); if (participanteRemovido) { await ctx.db.insert("notificacoes", { usuarioId: args.participanteId, tipo: "nova_mensagem", conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: "Removido da sala de reunião", descricao: `Você foi removido da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`, lida: false, criadaEm: Date.now(), }); } return { sucesso: true }; }, }); /** * Promove um participante a administrador (apenas administradores) */ export const promoverAdministrador = mutation({ args: { conversaId: v.id("conversas"), participanteId: v.id("usuarios"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return { sucesso: false, erro: "Sala de reunião não encontrada" }; } // Verificar se é sala de reunião if (conversa.tipo !== "sala_reuniao") { return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" }; } // Verificar se usuário é administrador const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id); if (!isAdmin) { return { sucesso: false, erro: "Apenas administradores podem promover outros administradores" }; } // Verificar se participante está na sala if (!conversa.participantes.includes(args.participanteId)) { return { sucesso: false, erro: "Usuário não é participante desta sala" }; } // Verificar se já é administrador const jaEhAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId); if (jaEhAdmin) { return { sucesso: false, erro: "Usuário já é administrador desta sala" }; } // Obter lista atual de administradores ou criar nova const administradoresAtuais = conversa.administradores || []; // Se não está na lista, adicionar if (!administradoresAtuais.includes(args.participanteId)) { const novosAdministradores = [...administradoresAtuais, args.participanteId]; await ctx.db.patch(args.conversaId, { administradores: novosAdministradores, }); // Criar notificação para o novo administrador const novoAdmin = await ctx.db.get(args.participanteId); if (novoAdmin) { await ctx.db.insert("notificacoes", { usuarioId: args.participanteId, tipo: "nova_mensagem", conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: "Promovido a administrador", descricao: `Você foi promovido a administrador da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`, lida: false, criadaEm: Date.now(), }); } } return { sucesso: true }; }, }); /** * Rebaixa um administrador a participante (apenas administradores, não pode rebaixar a si mesmo) */ export const rebaixarAdministrador = mutation({ args: { conversaId: v.id("conversas"), participanteId: v.id("usuarios"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return { sucesso: false, erro: "Sala de reunião não encontrada" }; } // Verificar se é sala de reunião if (conversa.tipo !== "sala_reuniao") { return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" }; } // Verificar se usuário é administrador const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id); if (!isAdmin) { return { sucesso: false, erro: "Apenas administradores podem rebaixar outros administradores" }; } // Não permitir rebaixar a si mesmo if (args.participanteId === usuarioAtual._id) { return { sucesso: false, erro: "Você não pode rebaixar a si mesmo" }; } // Verificar se é administrador const isParticipanteAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, args.participanteId); if (!isParticipanteAdmin) { return { sucesso: false, erro: "Usuário não é administrador desta sala" }; } // Não permitir rebaixar o criador da sala if (conversa.criadoPor === args.participanteId) { return { sucesso: false, erro: "Não é possível rebaixar o criador da sala" }; } // Remover da lista de administradores const administradoresAtuais = conversa.administradores || []; const novosAdministradores = administradoresAtuais.filter((adminId) => adminId !== args.participanteId); await ctx.db.patch(args.conversaId, { administradores: novosAdministradores.length > 0 ? novosAdministradores : undefined, }); // Criar notificação para o administrador rebaixado const adminRebaixado = await ctx.db.get(args.participanteId); if (adminRebaixado) { await ctx.db.insert("notificacoes", { usuarioId: args.participanteId, tipo: "nova_mensagem", conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: "Rebaixado de administrador", descricao: `Você foi rebaixado de administrador da sala de reunião "${conversa.nome || "Sem nome"}" por ${usuarioAtual.nome}`, lida: false, criadaEm: Date.now(), }); } return { sucesso: true }; }, }); /** * Permite que um usuário saia de um grupo ou sala de reunião */ export const sairGrupoOuSala = mutation({ args: { conversaId: v.id("conversas"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return { sucesso: false, erro: "Conversa não encontrada" }; } // Verificar se é grupo ou sala de reunião if (conversa.tipo !== "grupo" && conversa.tipo !== "sala_reuniao") { return { sucesso: false, erro: "Esta funcionalidade é apenas para grupos e salas de reunião" }; } // Verificar se usuário é participante if (!conversa.participantes.includes(usuarioAtual._id)) { return { sucesso: false, erro: "Você não é participante desta conversa" }; } // Remover usuário dos participantes const novosParticipantes = conversa.participantes.filter((p) => p !== usuarioAtual._id); // Se for sala de reunião e o usuário for administrador, removê-lo também dos administradores let novosAdministradores = conversa.administradores; if (conversa.tipo === "sala_reuniao" && conversa.administradores) { novosAdministradores = conversa.administradores.filter((adminId) => adminId !== usuarioAtual._id); } await ctx.db.patch(args.conversaId, { participantes: novosParticipantes, administradores: novosAdministradores && novosAdministradores.length > 0 ? novosAdministradores : undefined, }); // Criar notificação para outros participantes informando que o usuário saiu const tipoTexto = conversa.tipo === "sala_reuniao" ? "sala de reunião" : "grupo"; for (const participanteId of novosParticipantes) { await ctx.db.insert("notificacoes", { usuarioId: participanteId, tipo: "nova_mensagem", conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: "Participante saiu", descricao: `${usuarioAtual.nome} saiu da ${tipoTexto} "${conversa.nome || "Sem nome"}"`, lida: false, criadaEm: Date.now(), }); } return { sucesso: true }; }, }); /** * Encerra uma sala de reunião (apenas administradores) * Remove todos os participantes e marca a sala como encerrada */ export const encerrarReuniao = mutation({ args: { conversaId: v.id("conversas"), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return { sucesso: false, erro: "Sala de reunião não encontrada" }; } // Verificar se é sala de reunião if (conversa.tipo !== "sala_reuniao") { return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" }; } // Verificar se usuário é administrador const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id); if (!isAdmin) { return { sucesso: false, erro: "Apenas administradores podem encerrar a reunião" }; } // Criar notificação para todos os participantes informando que a reunião foi encerrada for (const participanteId of conversa.participantes) { if (participanteId !== usuarioAtual._id) { await ctx.db.insert("notificacoes", { usuarioId: participanteId, tipo: "nova_mensagem", conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: "Reunião encerrada", descricao: `A sala de reunião "${conversa.nome || "Sem nome"}" foi encerrada por ${usuarioAtual.nome}`, lida: false, criadaEm: Date.now(), }); } } // Remover todos os participantes (exceto o criador, se necessário manter histórico) // Por enquanto, vamos apenas limpar a lista de participantes await ctx.db.patch(args.conversaId, { participantes: [], administradores: undefined, }); return { sucesso: true }; }, }); /** * Envia uma notificação para todos os participantes de uma sala de reunião (apenas administradores) */ export const enviarNotificacaoReuniao = mutation({ args: { conversaId: v.id("conversas"), titulo: v.string(), mensagem: v.string(), }, returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return { sucesso: false, erro: "Não autenticado" }; } const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return { sucesso: false, erro: "Sala de reunião não encontrada" }; } // Verificar se é sala de reunião if (conversa.tipo !== "sala_reuniao") { return { sucesso: false, erro: "Esta funcionalidade é apenas para salas de reunião" }; } // Verificar se usuário é administrador const isAdmin = await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id); if (!isAdmin) { return { sucesso: false, erro: "Apenas administradores podem enviar notificações" }; } // Criar notificação para todos os participantes for (const participanteId of conversa.participantes) { await ctx.db.insert("notificacoes", { usuarioId: participanteId, tipo: "nova_mensagem", conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: args.titulo || "Notificação da sala de reunião", descricao: args.mensagem, lida: false, criadaEm: Date.now(), }); } return { sucesso: true }; }, }); // ========== QUERIES ========== /** * Verifica se o usuário atual é administrador de uma sala de reunião */ export const verificarSeEhAdmin = query({ args: { conversaId: v.id("conversas"), }, returns: v.boolean(), handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return false; return await verificarPermissaoAdmin(ctx, args.conversaId, usuarioAtual._id); }, }); /** * Lista todas as conversas do usuário logado * SEGURANÇA: Usuário só vê conversas onde é participante */ export const listarConversas = query({ args: {}, handler: async (ctx) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return []; // Buscar todas as conversas do usuário (SEGURANÇA: filtrar por participante) 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) // SEGURANÇA: Filtrar apenas mensagens de participantes da conversa const todasMensagens = await ctx.db .query("mensagens") .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) .collect(); // Filtrar mensagens agendadas e garantir que remetente é participante const mensagens = todasMensagens.filter((m) => { if (m.agendadaPara) return false; // Garantir que o remetente é participante da conversa return conversa.participantes.includes(m.remetenteId); }); 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; } // Verificar se usuário é administrador (apenas para salas de reunião) const isAdmin = conversa.tipo === "sala_reuniao" ? await verificarPermissaoAdmin(ctx, conversa._id, usuarioAtual._id) : false; // Enriquecer participantes com fotoPerfilUrl (para grupos e salas) const participantesInfo = await Promise.all( participantes .filter((p) => p !== null) .map(async (participante) => { if (!participante) return null; let fotoPerfilUrl = null; if (participante.fotoPerfil) { fotoPerfilUrl = await ctx.storage.getUrl(participante.fotoPerfil); } return { ...participante, fotoPerfilUrl, }; }) ); return { ...conversa, outroUsuario, participantesInfo: participantesInfo.filter((p) => p !== null), naoLidas, isAdmin, // Adicionar flag de admin }; }) ); return conversasEnriquecidas; }, }); /** * Obtém as mensagens de uma conversa com paginação * SEGURANÇA: Usuário só vê mensagens de conversas onde é participante */ 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 (SEGURANÇA CRÍTICA) 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 e garantir que são da conversa correta // SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas const mensagensFiltradas = mensagens.filter((m) => { // Excluir agendadas if (m.agendadaPara) return false; // Garantir que a mensagem pertence à conversa correta (segurança adicional) if (m.conversaId !== args.conversaId) return false; // SEGURANÇA CRÍTICA: Garantir que o remetente é participante da conversa // Isso garante que usuários só veem mensagens de conversas onde participam return conversa.participantes.includes(m.remetenteId); }); // Enriquecer com informações do remetente e mensagem respondida const mensagensEnriquecidas = await Promise.all( mensagensFiltradas.map(async (mensagem) => { const remetente = await ctx.db.get(mensagem.remetenteId); // SEGURANÇA: Não retornar informações de remetente se não for participante if (!remetente || !conversa.participantes.includes(remetente._id)) { return null; } let arquivoUrl = null; if (mensagem.arquivoId) { arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId); } // Buscar mensagem original se for resposta let mensagemOriginal = null; if (mensagem.respostaPara) { const original = await ctx.db.get(mensagem.respostaPara); if (original && conversa.participantes.includes(original.remetenteId)) { const remetenteOriginal = await ctx.db.get(original.remetenteId); mensagemOriginal = { _id: original._id, conteudo: original.conteudo.substring(0, 100), // Limitar tamanho remetente: remetenteOriginal ? { _id: remetenteOriginal._id, nome: remetenteOriginal.nome, } : null, deletada: original.deletada || false, }; } } return { ...mensagem, remetente, arquivoUrl, mensagemOriginal, }; }) ); // Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança) return mensagensEnriquecidas.filter((m) => m !== null).reverse(); }, }); /** * Obtém mensagens agendadas de uma conversa * SEGURANÇA: Usuário só vê suas próprias mensagens agendadas de conversas onde é participante */ export const obterMensagensAgendadas = query({ args: { conversaId: v.id("conversas"), }, handler: async (ctx, args): Promise[]> => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return []; // SEGURANÇA: Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { 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 (SEGURANÇA: só suas próprias mensagens) const minhasMensagensAgendadas = todasMensagens.filter( (m) => m.remetenteId === usuarioAtual._id && m.agendadaPara !== undefined && m.agendadaPara > Date.now() && m.conversaId === args.conversaId // Garantir que pertence à conversa correta ); 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) * SEGURANÇA: Usuário só vê suas próprias mensagens agendadas de conversas onde ainda é participante */ export const listarAgendamentosChat = query({ args: {}, handler: async ( ctx ): Promise< Array< Doc<"mensagens"> & { 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) => { const conversaInfo = await ctx.db.get(mensagem.conversaId); // SEGURANÇA: Verificar se usuário ainda é participante da conversa if (!conversaInfo || !conversaInfo.participantes.includes(usuarioAtual._id)) { return null; // Usuário não é mais participante, não mostrar mensagem } // SEGURANÇA: Verificar se o remetente (que deve ser o usuário atual) é participante if (!conversaInfo.participantes.includes(mensagem.remetenteId)) { return null; // Remetente não é participante, mensagem inválida } let destinatarioInfo: Doc<"usuarios"> | null = null; // Se for conversa individual, encontrar o outro participante if (conversaInfo.tipo === "individual") { const outroParticipanteId = conversaInfo.participantes.find( (p) => p !== usuarioAtual._id ); if (outroParticipanteId) { destinatarioInfo = await ctx.db.get(outroParticipanteId); } } return { ...mensagem, conversaInfo, destinatarioInfo, }; }) ); // Filtrar nulls e ordenar por data de agendamento (mais próximos primeiro) return mensagensEnriquecidas .filter((m): m is NonNullable => m !== null) .sort((a, b) => { const dataA = a.agendadaPara ?? 0; const dataB = b.agendadaPara ?? 0; return dataA - dataB; }); }, }); /** * Obtém as notificações do usuário * SEGURANÇA: Usuário só vê notificações de conversas onde ainda é participante */ 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 e validar acesso const notificacoesEnriquecidas = await Promise.all( notificacoes.map(async (notificacao) => { // SEGURANÇA: Se a notificação tem conversaId, verificar se usuário ainda é participante if (notificacao.conversaId) { const conversa = await ctx.db.get(notificacao.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { return null; // Usuário não é mais participante, não mostrar notificação } // SEGURANÇA: Se tem remetenteId, verificar se é participante da conversa if (notificacao.remetenteId && !conversa.participantes.includes(notificacao.remetenteId)) { return null; // Remetente não é participante, notificação inválida } } let remetente = null; if (notificacao.remetenteId) { remetente = await ctx.db.get(notificacao.remetenteId); } return { ...notificacao, remetente, }; }) ); // Filtrar nulls antes de retornar return notificacoesEnriquecidas.filter((n): n is NonNullable => n !== null); }, }); /** * 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 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; }, }); /** * Busca mensagens em conversas com filtros avançados * SEGURANÇA: Usuário só vê mensagens de conversas onde é participante e onde o remetente também é participante */ export const buscarMensagens = query({ args: { query: v.string(), conversaId: v.optional(v.id("conversas")), remetenteId: v.optional(v.id("usuarios")), tipo: v.optional(v.union(v.literal("texto"), v.literal("arquivo"), v.literal("imagem"))), dataInicio: v.optional(v.number()), dataFim: v.optional(v.number()), limite: v.optional(v.number()), }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return []; // Normalizar query para busca const queryNormalizada = normalizarTextoParaBusca(args.query); // 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) ); // SEGURANÇA: Se filtrar por remetente, verificar se ele é participante de alguma conversa do usuário if (args.remetenteId) { const remetenteEParticipante = conversasDoUsuario.some(c => c.participantes.includes(args.remetenteId!) ); if (!remetenteEParticipante) { return []; // Remetente não é participante de nenhuma conversa do usuário } } let mensagens: Doc<"mensagens">[] = []; if (args.conversaId !== undefined) { // Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { return []; } // Buscar em conversa específica const mensagensConversa = await ctx.db .query("mensagens") .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!)) .collect(); // SEGURANÇA: Filtrar apenas mensagens cujo remetente é participante desta conversa específica mensagens = mensagensConversa.filter(m => conversa.participantes.includes(m.remetenteId) ); } else { // Buscar em todas as conversas do usuário for (const conversa of conversasDoUsuario) { const mensagensConversa = await ctx.db .query("mensagens") .withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id)) .collect(); // SEGURANÇA: Filtrar apenas mensagens cujo remetente é participante desta conversa específica const mensagensValidas = mensagensConversa.filter(m => conversa.participantes.includes(m.remetenteId) ); mensagens.push(...mensagensValidas); } } // Aplicar filtros let mensagensFiltradas = mensagens.filter((m) => { // Excluir deletadas e agendadas if (m.deletada || m.agendadaPara) { return false; } // SEGURANÇA CRÍTICA: Garantir que a mensagem pertence a uma conversa do usuário // e que o remetente é participante dessa conversa específica const conversaDaMensagem = conversasDoUsuario.find(c => c._id === m.conversaId); if (!conversaDaMensagem) { return false; } // SEGURANÇA CRÍTICA: Garantir que o remetente é participante da conversa específica da mensagem if (!conversaDaMensagem.participantes.includes(m.remetenteId)) { return false; } // Filtrar por query (busca no conteúdo normalizado) if (queryNormalizada && queryNormalizada.length > 0) { const conteudoBusca = m.conteudoBusca || normalizarTextoParaBusca(m.conteudo); if (!conteudoBusca.includes(queryNormalizada)) { return false; } } // Filtrar por remetente (já verificado acima, mas garantir novamente) if (args.remetenteId) { if (m.remetenteId !== args.remetenteId) { return false; } // Verificar novamente se o remetente é participante da conversa específica desta mensagem if (!conversaDaMensagem.participantes.includes(args.remetenteId)) { return false; } } // Filtrar por tipo if (args.tipo && m.tipo !== args.tipo) { return false; } // Filtrar por data if (args.dataInicio && m.enviadaEm < args.dataInicio) { return false; } if (args.dataFim && m.enviadaEm > args.dataFim) { return false; } return true; }); // Ordenar por data (mais recentes primeiro) mensagensFiltradas.sort((a, b) => b.enviadaEm - a.enviadaEm); // Limitar resultados if (args.limite) { mensagensFiltradas = mensagensFiltradas.slice(0, args.limite); } // Enriquecer com informações (apenas para mensagens válidas) const mensagensEnriquecidas = await Promise.all( mensagensFiltradas.map(async (mensagem) => { const conversaDaMensagem = conversasDoUsuario.find(c => c._id === mensagem.conversaId); // SEGURANÇA: Validar novamente antes de enriquecer if (!conversaDaMensagem || !conversaDaMensagem.participantes.includes(mensagem.remetenteId)) { return null; } const remetente = await ctx.db.get(mensagem.remetenteId); // SEGURANÇA: Só retornar se remetente for participante if (!remetente || !conversaDaMensagem.participantes.includes(remetente._id)) { return null; } return { ...mensagem, remetente, conversa: conversaDaMensagem, }; }) ); // Filtrar nulls antes de retornar return mensagensEnriquecidas .filter((m): m is NonNullable => m !== null) .sort((a, b) => b.enviadaEm - a.enviadaEm) .slice(0, 50); }, }); /** * Obtém quem está digitando em uma conversa * SEGURANÇA: Usuário só vê digitação de conversas onde é participante */ export const obterDigitando = query({ args: { conversaId: v.id("conversas"), }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return []; // SEGURANÇA: Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { 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 garantir que são participantes da conversa const digitandoFiltrado = digitando.filter( (d) => { if (d.usuarioId === usuarioAtual._id) return false; // Garantir que o usuário digitando é participante da conversa return conversa.participantes.includes(d.usuarioId); } ); const usuarios = await Promise.all( digitandoFiltrado.map(async (d) => { const usuario = await ctx.db.get(d.usuarioId); // SEGURANÇA: Só retornar se for participante if (!usuario || !conversa.participantes.includes(usuario._id)) { return null; } return usuario; }) ); return usuarios.filter((u) => u !== null); }, }); /** * Conta mensagens não lidas de uma conversa * SEGURANÇA: Usuário só conta mensagens de conversas onde é participante */ export const contarNaoLidas = query({ args: { conversaId: v.id("conversas"), }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return 0; // SEGURANÇA: Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) { 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 todasMensagens = await ctx.db .query("mensagens") .withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId)) .filter((q) => q.eq(q.field("agendadaPara"), undefined)) .collect(); // SEGURANÇA: Filtrar apenas mensagens de participantes da conversa const mensagens = todasMensagens.filter((m) => conversa.participantes.includes(m.remetenteId) ); 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; }, });