Files
sgse-app/packages/backend/convex/chamadas.ts

611 lines
17 KiB
TypeScript

import { v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { mutation, query } 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<string> {
// 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<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 = 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;
}
});