- Added error handling logic to manage Jitsi connection and track creation errors, providing user-friendly feedback through the new ErrorModal. - Introduced functionality to dynamically create and manage local audio and video tracks during calls. - Updated Jitsi configuration to separate host and port for improved connection handling. - Refactored call initiation logic to ensure robust error reporting and user guidance during connection issues.
580 lines
16 KiB
TypeScript
580 lines
16 KiB
TypeScript
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
|
|
*/
|
|
function gerarRoomName(conversaId: Id<'conversas'>, tipo: 'audio' | 'video'): string {
|
|
const timestamp = Date.now();
|
|
const random = Math.random().toString(36).substring(2, 9);
|
|
return `sgse-${tipo}-${conversaId.replace('conversas|', '')}-${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<boolean> {
|
|
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<boolean> {
|
|
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 = gerarRoomName(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;
|
|
}
|
|
});
|
|
|
|
|