import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { Id } from './_generated/dataModel'; import type { QueryCtx, MutationCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; // ========== HELPERS ========== /** * Helper function para obter usuário autenticado */ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { const usuarioAtual = await getCurrentUserFunction(ctx); if (!usuarioAtual) { console.warn('⚠️ [chamadas] Usuário não autenticado'); } return usuarioAtual || null; } /** * Gerar nome único para a sala Jitsi * Usa configuração do backend se disponível, senão usa padrão 'sgse' */ async function gerarRoomName( ctx: QueryCtx | MutationCtx, conversaId: Id<'conversas'>, tipo: 'audio' | 'video' ): Promise { // Buscar configuração Jitsi ativa const configJitsi = await ctx.db .query('configuracaoJitsi') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); const roomPrefix = configJitsi?.roomPrefix || 'sgse'; const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 9); const conversaHash = conversaId.replace('conversas|', '').replace(/[^a-zA-Z0-9]/g, '').substring(0, 10); return `${roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`; } /** * Verificar se usuário é anfitrião da chamada (helper interno) */ async function verificarSeEhAnfitriao( ctx: QueryCtx | MutationCtx, chamadaId: Id<'chamadas'>, usuarioId: Id<'usuarios'> ): Promise { const chamada = await ctx.db.get(chamadaId); if (!chamada) return false; return chamada.criadoPor === usuarioId; } /** * Verificar se usuário participa da conversa */ async function verificarParticipanteConversa( ctx: QueryCtx | MutationCtx, conversaId: Id<'conversas'>, usuarioId: Id<'usuarios'> ): Promise { const conversa = await ctx.db.get(conversaId); if (!conversa) return false; return conversa.participantes.includes(usuarioId); } // ========== MUTATIONS ========== /** * Criar nova chamada de áudio ou vídeo */ export const criarChamada = mutation({ args: { conversaId: v.id('conversas'), tipo: v.union(v.literal('audio'), v.literal('video')), audioHabilitado: v.optional(v.boolean()), videoHabilitado: v.optional(v.boolean()) }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); // Verificar se usuário participa da conversa const participa = await verificarParticipanteConversa(ctx, args.conversaId, usuarioAtual._id); if (!participa) { throw new Error('Você não participa desta conversa'); } // Verificar se já existe chamada ativa (aguardando ou em_andamento) const todasAtivas = await ctx.db .query('chamadas') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId)) .filter((q) => q.or( q.eq(q.field('status'), 'aguardando'), q.eq(q.field('status'), 'em_andamento') ) ) .collect(); if (todasAtivas.length > 0) { // Retornar chamada ativa existente return todasAtivas[0]._id; } // Obter participantes da conversa const conversa = await ctx.db.get(args.conversaId); if (!conversa) throw new Error('Conversa não encontrada'); // Gerar nome único da sala const roomName = await gerarRoomName(ctx, args.conversaId, args.tipo); // Criar chamada const chamadaId = await ctx.db.insert('chamadas', { conversaId: args.conversaId, tipo: args.tipo, roomName, criadoPor: usuarioAtual._id, participantes: conversa.participantes, status: 'aguardando', gravando: false, configuracoes: { audioHabilitado: args.audioHabilitado ?? true, videoHabilitado: args.videoHabilitado ?? (args.tipo === 'video'), participantesConfig: conversa.participantes.map((participanteId) => ({ usuarioId: participanteId, audioHabilitado: participanteId === usuarioAtual._id ? (args.audioHabilitado ?? true) : true, videoHabilitado: participanteId === usuarioAtual._id ? (args.videoHabilitado ?? (args.tipo === 'video')) : (args.tipo === 'video'), forcadoPeloAnfitriao: false })) }, criadoEm: Date.now() }); return chamadaId; } }); /** * Iniciar chamada (marcar como em andamento) */ export const iniciarChamada = mutation({ args: { chamadaId: v.id('chamadas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Verificar se usuário participa da chamada if (!chamada.participantes.includes(usuarioAtual._id)) { throw new Error('Você não participa desta chamada'); } // Se já estiver em andamento, retornar if (chamada.status === 'em_andamento') { return null; } // Atualizar status await ctx.db.patch(args.chamadaId, { status: 'em_andamento', iniciadaEm: Date.now() }); return null; } }); /** * Finalizar chamada e calcular duração */ export const finalizarChamada = mutation({ args: { chamadaId: v.id('chamadas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Verificar se usuário participa da chamada if (!chamada.participantes.includes(usuarioAtual._id)) { throw new Error('Você não participa desta chamada'); } // Se já estiver finalizada, retornar if (chamada.status === 'finalizada' || chamada.status === 'cancelada') { return null; } // Calcular duração const finalizadaEm = Date.now(); const iniciadaEm = chamada.iniciadaEm || chamada.criadoEm; const duracaoSegundos = Math.floor((finalizadaEm - iniciadaEm) / 1000); // Se estiver gravando, parar gravação const gravando = chamada.gravando; // Atualizar status await ctx.db.patch(args.chamadaId, { status: 'finalizada', finalizadaEm, duracaoSegundos, gravando: false, gravacaoFinalizadaEm: gravando ? finalizadaEm : chamada.gravacaoFinalizadaEm }); return null; } }); /** * Cancelar chamada (antes de iniciar) */ export const cancelarChamada = mutation({ args: { chamadaId: v.id('chamadas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Apenas anfitrião pode cancelar const ehAnfitriao = await verificarSeEhAnfitriao(ctx, args.chamadaId, usuarioAtual._id); if (!ehAnfitriao) { throw new Error('Apenas o anfitrião pode cancelar a chamada'); } // Se já estiver finalizada, retornar if (chamada.status === 'finalizada' || chamada.status === 'cancelada') { return null; } // Atualizar status await ctx.db.patch(args.chamadaId, { status: 'cancelada', finalizadaEm: Date.now() }); return null; } }); /** * Adicionar participante à chamada */ export const adicionarParticipante = mutation({ args: { chamadaId: v.id('chamadas'), usuarioId: v.id('usuarios') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Verificar se usuário já participa if (chamada.participantes.includes(args.usuarioId)) { return null; } // Verificar se usuário participa da conversa const participa = await verificarParticipanteConversa(ctx, chamada.conversaId, args.usuarioId); if (!participa) { throw new Error('Usuário não participa desta conversa'); } // Atualizar participantes const novosParticipantes = [...chamada.participantes, args.usuarioId]; // Atualizar configurações const configParticipantes = chamada.configuracoes?.participantesConfig || []; const novaConfig = [...configParticipantes, { usuarioId: args.usuarioId, audioHabilitado: chamada.configuracoes?.audioHabilitado ?? true, videoHabilitado: chamada.configuracoes?.videoHabilitado ?? (chamada.tipo === 'video'), forcadoPeloAnfitriao: false }]; await ctx.db.patch(args.chamadaId, { participantes: novosParticipantes, configuracoes: { ...(chamada.configuracoes || { audioHabilitado: true, videoHabilitado: chamada.tipo === 'video' }), participantesConfig: novaConfig } }); return null; } }); /** * Remover participante da chamada (apenas anfitrião) */ export const removerParticipante = mutation({ args: { chamadaId: v.id('chamadas'), usuarioId: v.id('usuarios') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Apenas anfitrião pode remover participantes const ehAnfitriao = await verificarSeEhAnfitriao(ctx, args.chamadaId, usuarioAtual._id); if (!ehAnfitriao) { throw new Error('Apenas o anfitrião pode remover participantes'); } // Não pode remover o anfitrião if (args.usuarioId === chamada.criadoPor) { throw new Error('Não é possível remover o anfitrião'); } // Atualizar participantes const novosParticipantes = chamada.participantes.filter((id) => id !== args.usuarioId); // Atualizar configurações const configParticipantes = chamada.configuracoes?.participantesConfig || []; const novaConfig = configParticipantes.filter((config) => config.usuarioId !== args.usuarioId); await ctx.db.patch(args.chamadaId, { participantes: novosParticipantes, configuracoes: { ...(chamada.configuracoes || { audioHabilitado: true, videoHabilitado: chamada.tipo === 'video' }), participantesConfig: novaConfig } }); return null; } }); /** * Toggle áudio/vídeo de participante (anfitrião controla) */ export const toggleAudioVideoParticipante = mutation({ args: { chamadaId: v.id('chamadas'), participanteId: v.id('usuarios'), tipo: v.union(v.literal('audio'), v.literal('video')), habilitado: v.boolean() }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Apenas anfitrião pode controlar outros participantes const ehAnfitriao = await verificarSeEhAnfitriao(ctx, args.chamadaId, usuarioAtual._id); const ehOProprioParticipante = args.participanteId === usuarioAtual._id; if (!ehAnfitriao && !ehOProprioParticipante) { throw new Error('Apenas o anfitrião pode controlar outros participantes'); } // Atualizar configurações do participante const configParticipantes = chamada.configuracoes?.participantesConfig || []; const participanteIndex = configParticipantes.findIndex( (config) => config.usuarioId === args.participanteId ); if (participanteIndex === -1) { // Adicionar configuração se não existir configParticipantes.push({ usuarioId: args.participanteId, audioHabilitado: args.tipo === 'audio' ? args.habilitado : (chamada.configuracoes?.audioHabilitado ?? true), videoHabilitado: args.tipo === 'video' ? args.habilitado : (chamada.configuracoes?.videoHabilitado ?? (chamada.tipo === 'video')), forcadoPeloAnfitriao: !ehOProprioParticipante && args.habilitado === false }); } else { // Atualizar configuração existente configParticipantes[participanteIndex] = { ...configParticipantes[participanteIndex], audioHabilitado: args.tipo === 'audio' ? args.habilitado : configParticipantes[participanteIndex].audioHabilitado, videoHabilitado: args.tipo === 'video' ? args.habilitado : configParticipantes[participanteIndex].videoHabilitado, forcadoPeloAnfitriao: !ehOProprioParticipante && !args.habilitado ? true : configParticipantes[participanteIndex].forcadoPeloAnfitriao }; } await ctx.db.patch(args.chamadaId, { configuracoes: { ...(chamada.configuracoes || { audioHabilitado: true, videoHabilitado: chamada.tipo === 'video' }), participantesConfig: configParticipantes } }); return null; } }); /** * Iniciar gravação (apenas anfitrião) */ export const iniciarGravacao = mutation({ args: { chamadaId: v.id('chamadas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Apenas anfitrião pode iniciar gravação const ehAnfitriao = await verificarSeEhAnfitriao(ctx, args.chamadaId, usuarioAtual._id); if (!ehAnfitriao) { throw new Error('Apenas o anfitrião pode iniciar a gravação'); } // Se já estiver gravando, retornar if (chamada.gravando) { return null; } // Atualizar status de gravação await ctx.db.patch(args.chamadaId, { gravando: true, gravacaoIniciadaPor: usuarioAtual._id, gravacaoIniciadaEm: Date.now() }); return null; } }); /** * Finalizar gravação (apenas anfitrião) */ export const finalizarGravacao = mutation({ args: { chamadaId: v.id('chamadas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) throw new Error('Não autenticado'); const chamada = await ctx.db.get(args.chamadaId); if (!chamada) throw new Error('Chamada não encontrada'); // Apenas anfitrião pode finalizar gravação const ehAnfitriao = await verificarSeEhAnfitriao(ctx, args.chamadaId, usuarioAtual._id); if (!ehAnfitriao) { throw new Error('Apenas o anfitrião pode finalizar a gravação'); } // Se não estiver gravando, retornar if (!chamada.gravando) { return null; } // Atualizar status de gravação await ctx.db.patch(args.chamadaId, { gravando: false, gravacaoFinalizadaEm: Date.now() }); return null; } }); // ========== QUERIES ========== /** * Obter chamada ativa de uma conversa */ export const obterChamadaAtiva = query({ args: { conversaId: v.id('conversas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return null; // Verificar se usuário participa da conversa const participa = await verificarParticipanteConversa(ctx, args.conversaId, usuarioAtual._id); if (!participa) return null; // Buscar chamada ativa (aguardando ou em_andamento) const todasAtivas = await ctx.db .query('chamadas') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId)) .filter((q) => q.or( q.eq(q.field('status'), 'aguardando'), q.eq(q.field('status'), 'em_andamento') ) ) .collect(); if (todasAtivas.length === 0) { return null; } // Retornar a chamada mais recente return todasAtivas.sort((a, b) => (b.criadoEm || 0) - (a.criadoEm || 0))[0]; } }); /** * Listar histórico de chamadas de uma conversa */ export const listarChamadas = 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 participa da conversa const participa = await verificarParticipanteConversa(ctx, args.conversaId, usuarioAtual._id); if (!participa) return []; // Buscar chamadas const chamadas = await ctx.db .query('chamadas') .withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId)) .order('desc') .take(args.limit || 50); return chamadas; } }); /** * Verificar se usuário é anfitrião da chamada */ export const verificarAnfitriao = query({ args: { chamadaId: v.id('chamadas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return false; return await verificarSeEhAnfitriao(ctx, args.chamadaId, usuarioAtual._id); } }); /** * Obter informações detalhadas da chamada */ export const obterChamada = query({ args: { chamadaId: v.id('chamadas') }, handler: async (ctx, args) => { const usuarioAtual = await getUsuarioAutenticado(ctx); if (!usuarioAtual) return null; const chamada = await ctx.db.get(args.chamadaId); if (!chamada) return null; // Verificar se usuário participa da chamada if (!chamada.participantes.includes(usuarioAtual._id)) { return null; } return chamada; } });