Files
sgse-app/packages/backend/convex/chat.ts
deyvisonwanderley 6166043735 feat: enhance ErrorModal and chat components with new features and improvements
- Refactored ErrorModal to utilize a dialog element for better accessibility and user experience, including a close button with an icon.
- Updated chat components to improve participant display and message read status, enhancing user engagement and clarity.
- Introduced loading indicators for user and conversation data in SalaReuniaoManager to improve responsiveness during data fetching.
- Enhanced message handling in MessageList to indicate whether messages have been read, providing users with better feedback on message status.
- Improved overall structure and styling across various components for consistency and maintainability.
2025-11-05 14:05:52 -03:00

2399 lines
79 KiB
TypeScript

import { v } from "convex/values";
import { mutation, query, internalMutation } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx, MutationCtx } from "./_generated/server";
import { internal, api } from "./_generated/api";
// ========== 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 (Better Auth ou Sessão)
*/
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
// Tentar autenticação via Better Auth primeiro
const identity = await ctx.auth.getUserIdentity();
let usuarioAtual = null;
if (identity && identity.email) {
usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
}
// Se não encontrou via Better Auth, tentar via sessão mais recente
if (!usuarioAtual) {
const sessaoAtiva = await ctx.db
.query("sessoes")
.filter((q) => q.eq(q.field("ativo"), true))
.order("desc")
.first();
if (sessaoAtiva) {
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
}
}
return usuarioAtual;
}
/**
* 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()),
avatar: 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: any = {
tipo: args.tipo,
nome: args.nome,
avatar: args.avatar,
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")),
avatar: v.optional(v.string()),
},
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: any = {
tipo: "sala_reuniao" as const,
nome: args.nome.trim(),
avatar: args.avatar,
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) => {
// TENTAR BETTER AUTH PRIMEIRO
const identity = await ctx.auth.getUserIdentity();
let usuarioAtual = null;
if (identity && identity.email) {
// Buscar por email (Better Auth)
usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
}
// SE NÃO ENCONTROU, BUSCAR POR SESSÃO ATIVA (Sistema customizado)
if (!usuarioAtual) {
const sessaoAtiva = await ctx.db
.query("sessoes")
.filter((q) => q.eq(q.field("ativo"), true))
.order("desc")
.first();
if (sessaoAtiva) {
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
}
}
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) 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");
}
// 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)
const urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
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 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,
avatar: u.avatar,
fotoPerfil: u.fotoPerfil,
statusPresenca: u.statusPresenca,
statusMensagem: u.statusMensagem,
setor: u.setor,
}));
},
});
/**
* 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 = 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,
avatar: u.avatar,
fotoPerfil: u.fotoPerfil,
fotoPerfilUrl,
statusPresenca: u.statusPresenca,
statusMensagem: u.statusMensagem,
setor: u.setor,
};
})
);
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;
},
});