2483 lines
75 KiB
TypeScript
2483 lines
75 KiB
TypeScript
import { v } from 'convex/values';
|
|
import { api, internal } from './_generated/api';
|
|
import type { Doc, Id } from './_generated/dataModel';
|
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
|
import { internalMutation, mutation, query } from './_generated/server';
|
|
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<boolean> {
|
|
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<Doc<'conversas'>, '_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<Doc<'conversas'>, '_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
|
|
},
|
|
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
|
|
});
|
|
}
|
|
|
|
// 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)
|
|
const conteudoBusca = 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
|
|
});
|
|
|
|
// 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
|
|
await ctx.db.patch(args.conversaId, {
|
|
ultimaMensagem: args.conteudo.substring(0, 100),
|
|
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);
|
|
if (!notificacao) throw new Error('Notificação não encontrada');
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
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()
|
|
},
|
|
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
|
|
const conteudoBusca = normalizarTextoParaBusca(args.novoConteudo);
|
|
|
|
// Atualizar mensagem
|
|
await ctx.db.patch(args.mensagemId, {
|
|
conteudo: args.novoConteudo.trim(),
|
|
conteudoBusca,
|
|
editadaEm: Date.now()
|
|
});
|
|
|
|
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
|
|
});
|
|
|
|
// Criar notificação para o novo participante
|
|
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}`,
|
|
lida: false,
|
|
criadaEm: Date.now()
|
|
});
|
|
|
|
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())
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
|
if (!usuarioAtual) return [];
|
|
|
|
// 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 [];
|
|
}
|
|
|
|
// Buscar mensagens (excluir agendadas)
|
|
const mensagens = await ctx.db
|
|
.query('mensagens')
|
|
.withIndex('by_conversa', (q) => q.eq('conversaId', args.conversaId))
|
|
.order('desc')
|
|
.take(args.limit || 50);
|
|
|
|
// Filtrar mensagens agendadas e garantir que são da conversa correta
|
|
// SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas
|
|
const mensagensFiltradas = mensagens.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)
|
|
return mensagensEnriquecidas.filter((m) => m !== null).reverse();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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<Doc<'mensagens'>[]> => {
|
|
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<typeof m> => 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<typeof n> => 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
|
|
}));
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
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
|
|
};
|
|
})
|
|
);
|
|
|
|
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<typeof m> => 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;
|
|
}
|
|
});
|