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'; import { getCurrentUserFunction } from './auth'; // ========== 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 * * FASE 1 IMPLEMENTADA: Usa Custom Auth Provider configurado no convex.config.ts * * O provider tenta: * 1. Buscar sessão customizada por token (sistema atual) ✅ FUNCIONANDO * 2. Validar via Better Auth (quando configurado) ⏳ PRÓXIMA FASE * * ⚠️ CORREÇÃO DE SEGURANÇA: Busca sessão por token específico (não mais recente) */ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { const usuarioAtual = await getCurrentUserFunction(ctx); if (!usuarioAtual) { console.warn('⚠️ [getUsuarioAutenticado] Usuário não autenticado - token inválido ou expirado'); } return usuarioAtual || null; } /** * 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()) }, 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: Omit, '_id' | '_creationTime'> = { tipo: args.tipo, nome: args.nome, 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')) }, 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: Omit, '_id' | '_creationTime'> = { tipo: 'sala_reuniao' as const, nome: args.nome.trim(), 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) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); 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 // Campos para criptografia E2E criptografado: v.optional(v.boolean()), // Indica se a mensagem está criptografada iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia keyId: v.optional(v.string()) // Identificador da chave usada para criptografar }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { console.error( '❌ [enviarMensagem] Usuário não autenticado - Better Auth não conseguiu identificar' ); throw new Error('Não autenticado'); } // Log para debug (apenas em desenvolvimento) if (process.env.NODE_ENV === 'development') { console.log('✅ [enviarMensagem] Usuário identificado:', { id: usuarioAtual._id, nome: usuarioAtual.nome, email: usuarioAtual.email }); } // Validar tamanho da mensagem const MAX_MENSAGEM_LENGTH = 5000; if (args.conteudo.length > MAX_MENSAGEM_LENGTH) { throw new Error(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`); } // Validação de tipo de arquivo (se houver arquivo) if (args.arquivoTipo) { const TIPOS_PERMITIDOS = [ // Imagens 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', // Documentos 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', // Arquivos 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/x-tar', 'application/gzip' ]; if (!TIPOS_PERMITIDOS.includes(args.arquivoTipo)) { throw new Error('Tipo de arquivo não permitido'); } // Validar tamanho de arquivo (10MB) const MAX_FILE_SIZE = 10 * 1024 * 1024; if (args.arquivoTamanho && args.arquivoTamanho > MAX_FILE_SIZE) { throw new Error(`Arquivo muito grande. O tamanho máximo é ${MAX_FILE_SIZE / 1024 / 1024}MB.`); } // Validar nome do arquivo (sanitizar) if (args.arquivoNome) { const nomeSanitizado = args.arquivoNome.replace(/[^a-zA-Z0-9._-]/g, '_'); if (nomeSanitizado !== args.arquivoNome) { // Se o nome foi alterado, usar o sanitizado args.arquivoNome = nomeSanitizado; } } } // 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) // Se a mensagem estiver criptografada, não criar índice de busca (conteudoBusca será undefined) const conteudoBusca = args.criptografado ? undefined : 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(), lidaPor: [], // Inicializar como array vazio // Campos de criptografia E2E criptografado: args.criptografado ?? false, iv: args.iv, keyId: args.keyId }); // 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 // Se a mensagem estiver criptografada, usar placeholder const ultimaMensagemTexto = args.criptografado ? '🔒 Mensagem criptografada' : args.conteudo.substring(0, 100); await ctx.db.patch(args.conversaId, { ultimaMensagem: ultimaMensagemTexto, ultimaMensagemTimestamp: Date.now(), ultimaMensagemRemetenteId: usuarioAtual._id // Guardar ID do remetente da última mensagem }); // 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) let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173'; // Garantir que a URL sempre tenha protocolo if (!urlSistema.match(/^https?:\/\//i)) { urlSistema = `http://${urlSistema}`; } 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'); } // Normalizar conteúdo para busca const conteudoBusca = normalizarTextoParaBusca(args.conteudo); // Criar mensagem agendada const mensagemId = await ctx.db.insert('mensagens', { conversaId: args.conversaId, remetenteId: usuarioAtual._id, tipo: 'texto', conteudo: args.conteudo, conteudoBusca, agendadaPara: args.agendadaPara, enviadaEm: args.agendadaPara, // Será atualizado quando a mensagem for enviada lidaPor: [] // Inicializar como array vazio }); 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() }); } // Atualizar status de leitura nas mensagens // Buscar todas as mensagens até a mensagem atual (incluindo ela) na conversa const todasMensagens = await ctx.db .query('mensagens') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId)) .filter((q) => q.and( q.lte(q.field('enviadaEm'), mensagem.enviadaEm), q.neq(q.field('remetenteId'), usuarioAtual._id) // Apenas mensagens de outros usuários ) ) .collect(); // Atualizar cada mensagem para incluir o usuário atual no array lidaPor (se ainda não estiver) for (const msg of todasMensagens) { const lidaPor = msg.lidaPor || []; if (!lidaPor.includes(usuarioAtual._id)) { await ctx.db.patch(msg._id, { lidaPor: [...lidaPor, usuarioAtual._id] }); } } // 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); // Se a notificação não existe (já foi deletada), retornar sucesso silenciosamente // Isso evita erros quando múltiplas tentativas são feitas ou quando a notificação já foi removida if (!notificacao) { return true; } // 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'); } } // Se já está marcada como lida, retornar sucesso sem fazer nada if (notificacao.lida) { return true; } 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 todas as notificações do usuário * SEGURANÇA: Usuário só pode deletar suas próprias notificações */ export const limparTodasNotificacoes = 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', (q) => q.eq('usuarioId', usuarioAtual._id)) .collect(); for (const notificacao of notificacoes) { await ctx.db.delete(notificacao._id); } return { excluidas: notificacoes.length }; } }); /** * Deleta apenas as notificações não lidas do usuário * SEGURANÇA: Usuário só pode deletar suas próprias notificações */ export const limparNotificacoesNaoLidas = 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.delete(notificacao._id); } return { excluidas: notificacoes.length }; } }); /** * Deleta uma mensagem (soft delete) */ /** * Editar mensagem enviada */ export const editarMensagem = mutation({ args: { mensagemId: v.id('mensagens'), novoConteudo: v.string(), // Campos para criptografia E2E (opcionais, apenas se a mensagem for criptografada) iv: v.optional(v.string()), // Initialization Vector (base64) para descriptografia keyId: v.optional(v.string()) // Identificador da chave usada para criptografar }, 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 (se não estiver criptografado) // Se a mensagem original estava criptografada, manter criptografado const estaCriptografada = mensagem.criptografado ?? false; const conteudoBusca = estaCriptografada ? undefined : normalizarTextoParaBusca(args.novoConteudo); // Atualizar mensagem // Se a mensagem estava criptografada e novos campos de criptografia foram fornecidos, atualizar const updateData: { conteudo: string; conteudoBusca?: string; editadaEm: number; iv?: string; keyId?: string; } = { conteudo: args.novoConteudo.trim(), conteudoBusca, editadaEm: Date.now() }; // Se a mensagem estava criptografada e novos campos foram fornecidos, atualizar if (estaCriptografada && (args.iv || args.keyId)) { if (args.iv) updateData.iv = args.iv; if (args.keyId) updateData.keyId = args.keyId; } await ctx.db.patch(args.mensagemId, updateData); 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 }); // Verificar se a conversa tem criptografia E2E ativa const chaveE2E = await ctx.db .query('chavesCriptografia') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true)) .first(); // Criar notificação para o novo participante const agora = Date.now(); 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}${chaveE2E ? '. Esta conversa usa criptografia E2E - você receberá a chave automaticamente.' : ''}`, lida: false, criadaEm: agora }); // Se a conversa tem E2E ativo, notificar sobre a chave if (chaveE2E) { await ctx.db.insert('notificacoes', { usuarioId: args.participanteId, tipo: 'alerta_seguranca', conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: 'Chave de criptografia E2E disponível', descricao: `Esta conversa usa criptografia end-to-end. A chave foi compartilhada com você automaticamente.`, lida: false, criadaEm: agora }); } 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) { const tituloNotificacao = args.titulo || 'Notificação da sala de reunião'; const descricaoNotificacao = args.mensagem.substring(0, 100); // Limitar descrição para push // Criar notificação no banco await ctx.db.insert('notificacoes', { usuarioId: participanteId, tipo: 'nova_mensagem', conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: tituloNotificacao, descricao: args.mensagem, lida: false, criadaEm: Date.now() }); // Enviar push notification (assíncrono, não bloqueia) ctx.scheduler .runAfter(0, internal.pushNotifications.enviarPushNotification, { usuarioId: participanteId, titulo: tituloNotificacao, corpo: descricaoNotificacao, data: { conversaId: args.conversaId, tipo: 'notificacao_reuniao' } }) .catch((error) => { console.error(`Erro ao agendar push para usuário ${participanteId}:`, error); }); } 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()), cursor: v.optional(v.id('mensagens')) // ID da última mensagem carregada (para paginação) }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return { mensagens: [], hasMore: false }; // 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 { mensagens: [], hasMore: false }; } const limit = args.limit || 50; let query = ctx.db .query('mensagens') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId)) .order('desc'); // Se há cursor, buscar mensagens anteriores a ele if (args.cursor) { const cursorMsg = await ctx.db.get(args.cursor); if (cursorMsg && cursorMsg.conversaId === args.conversaId) { // Buscar mensagens anteriores à mensagem do cursor query = query.filter((q) => q.lt(q.field('_creationTime'), cursorMsg._creationTime)); } } // Buscar uma mensagem a mais para verificar se há mais mensagens const mensagens = await query.take(limit + 1); const hasMore = mensagens.length > limit; const mensagensParaRetornar = hasMore ? mensagens.slice(0, limit) : mensagens; // Filtrar mensagens agendadas e garantir que são da conversa correta // SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas const mensagensFiltradas = mensagensParaRetornar.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) const mensagensFinais = mensagensEnriquecidas.filter((m) => m !== null).reverse(); return { mensagens: mensagensFinais, hasMore, nextCursor: hasMore && mensagensFinais.length > 0 ? mensagensFinais[mensagensFinais.length - 1]._id : null }; } }); /** * 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, 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; } // Buscar URL da foto de perfil se existir let fotoPerfilUrl: string | null = null; if (u.fotoPerfil) { fotoPerfilUrl = await ctx.storage.getUrl(u.fotoPerfil); } return { _id: u._id, nome: u.nome, email: u.email, matricula, fotoPerfil: u.fotoPerfil, fotoPerfilUrl, 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 // Como o índice by_agendamento indexa por agendadaPara, podemos usar range query // Buscar mensagens com agendadaPara entre 0 e agora (mensagens agendadas que já devem ser enviadas) // Valores undefined não aparecem no índice, então só buscamos mensagens realmente agendadas const mensagensAgendadas = await ctx.db .query('mensagens') .withIndex('by_agendamento', (q) => q.gte('agendadaPara', 0).lte('agendadaPara', agora)) .collect(); for (const mensagem of mensagensAgendadas) { // Normalizar conteúdo para busca (se ainda não foi feito) const conteudoBusca = mensagem.conteudoBusca || normalizarTextoParaBusca(mensagem.conteudo); // Atualizar mensagem para "enviada" await ctx.db.patch(mensagem._id, { agendadaPara: undefined, enviadaEm: agora, conteudoBusca: conteudoBusca // Garantir que tem conteúdo de busca }); // 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, ultimaMensagemRemetenteId: mensagem.remetenteId // Guardar ID do remetente }); // Criar notificações para outros participantes const remetente = await ctx.db.get(mensagem.remetenteId); if (remetente) { // Determinar tipo de notificação (se há menções) const tipoNotificacao = mensagem.mencoes && mensagem.mencoes.length > 0 ? 'mencao' : 'nova_mensagem'; const titulo = tipoNotificacao === 'mencao' ? `${remetente.nome} mencionou você` : `Nova mensagem de ${remetente.nome}`; const descricao = mensagem.conteudo.substring(0, 100); for (const participanteId of conversa.participantes) { if (participanteId !== mensagem.remetenteId) { // Criar notificação no banco await ctx.db.insert('notificacoes', { usuarioId: participanteId, tipo: tipoNotificacao, conversaId: mensagem.conversaId, mensagemId: mensagem._id, remetenteId: mensagem.remetenteId, titulo, descricao, lida: false, criadaEm: agora }); // Enviar push notification (assíncrono, não bloqueia) ctx.scheduler .runAfter(0, internal.pushNotifications.enviarPushNotification, { usuarioId: participanteId, titulo, corpo: descricao, data: { conversaId: mensagem.conversaId, mensagemId: mensagem._id, tipo: tipoNotificacao } }) .catch((error) => { console.error(`Erro ao agendar push para usuário ${participanteId}:`, error); }); } } } } } 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; } }); // ========== CRIPTOGRAFIA E2E ========== /** * Compartilha uma chave de criptografia E2E para uma conversa * A chave deve ser criptografada antes de ser enviada (usando chave pública do servidor ou outro método) */ export const compartilharChaveCriptografia = mutation({ args: { conversaId: v.id('conversas'), chaveCompartilhada: v.string(), // Chave criptografada (base64) keyId: v.string() // Identificador único da chave }, 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'); } // Desativar chaves antigas da conversa const chavesAntigas = await ctx.db .query('chavesCriptografia') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true)) .collect(); for (const chaveAntiga of chavesAntigas) { await ctx.db.patch(chaveAntiga._id, { ativo: false }); } // Criar nova chave await ctx.db.insert('chavesCriptografia', { conversaId: args.conversaId, chaveCompartilhada: args.chaveCompartilhada, keyId: args.keyId, criadoPor: usuarioAtual._id, criadoEm: Date.now(), ativo: true }); // Criar notificações para outros participantes sobre a ativação/regeneração da chave const agora = Date.now(); for (const participanteId of conversa.participantes) { if (participanteId !== usuarioAtual._id) { await ctx.db.insert('notificacoes', { usuarioId: participanteId, tipo: 'alerta_seguranca', conversaId: args.conversaId, remetenteId: usuarioAtual._id, titulo: 'Criptografia E2E atualizada', descricao: `${usuarioAtual.nome} ${chavesAntigas.length > 0 ? 'regenerou' : 'ativou'} a criptografia end-to-end nesta conversa. Suas mensagens futuras serão criptografadas.`, lida: false, criadaEm: agora }); } } return { sucesso: true }; } }); /** * Obtém a chave de criptografia ativa para uma conversa */ export const obterChaveCriptografia = query({ args: { conversaId: v.id('conversas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return null; } // Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return null; } if (!conversa.participantes.includes(usuarioAtual._id)) { return null; } // Buscar chave ativa const chave = await ctx.db .query('chavesCriptografia') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true)) .first(); if (!chave) { return null; } return { chaveCompartilhada: chave.chaveCompartilhada, keyId: chave.keyId, criadoPor: chave.criadoPor, criadoEm: chave.criadoEm }; } }); /** * Verifica se uma conversa tem criptografia E2E habilitada */ export const verificarCriptografiaE2E = query({ args: { conversaId: v.id('conversas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) { return false; } // Verificar se usuário pertence à conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa) { return false; } if (!conversa.participantes.includes(usuarioAtual._id)) { return false; } // Verificar se existe chave ativa const chave = await ctx.db .query('chavesCriptografia') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId).eq('ativo', true)) .first(); return chave !== null; } });