feat: implement audio/video call functionality in chat
- Added a new schema for managing audio/video calls, including fields for call type, room name, and participant management. - Enhanced ChatWindow component to support initiating audio and video calls with dynamic loading of the CallWindow component. - Updated package dependencies to include 'lib-jitsi-meet' for call handling. - Refactored existing code to accommodate new call features and improve user experience.
This commit is contained in:
578
packages/backend/convex/chamadas.ts
Normal file
578
packages/backend/convex/chamadas.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
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
|
||||
*/
|
||||
async function verificarAnfitriao(
|
||||
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
|
||||
const chamadasAtivas = await ctx.db
|
||||
.query('chamadas')
|
||||
.withIndex('by_conversa_ativa', (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 (chamadasAtivas.length > 0) {
|
||||
// Retornar chamada ativa existente
|
||||
return chamadasAtivas[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 verificarAnfitriao(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 verificarAnfitriao(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 verificarAnfitriao(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 verificarAnfitriao(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 verificarAnfitriao(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
|
||||
const chamadasAtivas = await ctx.db
|
||||
.query('chamadas')
|
||||
.withIndex('by_conversa_ativa', (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 (chamadasAtivas.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Retornar a chamada mais recente
|
||||
return chamadasAtivas.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 verificarAnfitriao(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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user