1078 lines
30 KiB
TypeScript
1078 lines
30 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";
|
|
|
|
// ========== HELPERS ==========
|
|
|
|
/**
|
|
* 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
|
|
if (!usuarioAtual) {
|
|
const sessaoAtiva = await ctx.db
|
|
.query("sessoes")
|
|
.filter((q) => q.eq(q.field("ativo"), true))
|
|
.first();
|
|
|
|
if (sessaoAtiva) {
|
|
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
|
|
}
|
|
}
|
|
|
|
return usuarioAtual;
|
|
}
|
|
|
|
// ========== MUTATIONS ==========
|
|
|
|
/**
|
|
* Cria uma nova conversa (individual ou grupo)
|
|
*/
|
|
export const criarConversa = mutation({
|
|
args: {
|
|
tipo: v.union(v.literal("individual"), v.literal("grupo")),
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Criar nova conversa
|
|
const conversaId = await ctx.db.insert("conversas", {
|
|
tipo: args.tipo,
|
|
nome: args.nome,
|
|
avatar: args.avatar,
|
|
participantes: args.participantes,
|
|
criadoPor: usuarioAtual._id,
|
|
criadoEm: Date.now(),
|
|
});
|
|
|
|
// Criar notificações para outros participantes
|
|
if (args.tipo === "grupo") {
|
|
for (const participanteId of args.participantes) {
|
|
if (participanteId !== usuarioAtual._id) {
|
|
await ctx.db.insert("notificacoes", {
|
|
usuarioId: participanteId,
|
|
tipo: "adicionado_grupo",
|
|
conversaId,
|
|
remetenteId: usuarioAtual._id,
|
|
titulo: "Adicionado a grupo",
|
|
descricao: `Você foi adicionado ao grupo "${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"))),
|
|
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");
|
|
}
|
|
|
|
// Criar mensagem
|
|
const mensagemId = await ctx.db.insert("mensagens", {
|
|
conversaId: args.conversaId,
|
|
remetenteId: usuarioAtual._id,
|
|
tipo: args.tipo,
|
|
conteudo: args.conteudo,
|
|
arquivoId: args.arquivoId,
|
|
arquivoNome: args.arquivoNome,
|
|
arquivoTamanho: args.arquivoTamanho,
|
|
arquivoTipo: args.arquivoTipo,
|
|
mencoes: args.mencoes,
|
|
enviadaEm: Date.now(),
|
|
});
|
|
|
|
// Atualizar última mensagem da conversa
|
|
await ctx.db.patch(args.conversaId, {
|
|
ultimaMensagem: args.conteudo.substring(0, 100),
|
|
ultimaMensagemTimestamp: Date.now(),
|
|
});
|
|
|
|
// 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";
|
|
|
|
await ctx.db.insert("notificacoes", {
|
|
usuarioId: participanteId,
|
|
tipo: tipoNotificacao,
|
|
conversaId: args.conversaId,
|
|
mensagemId,
|
|
remetenteId: usuarioAtual._id,
|
|
titulo:
|
|
tipoNotificacao === "mencao"
|
|
? `${usuarioAtual.nome} mencionou você`
|
|
: `Nova mensagem de ${usuarioAtual.nome}`,
|
|
descricao: args.conteudo.substring(0, 100),
|
|
lida: false,
|
|
criadaEm: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
} 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");
|
|
}
|
|
|
|
// Criar mensagem agendada
|
|
const mensagemId = await ctx.db.insert("mensagens", {
|
|
conversaId: args.conversaId,
|
|
remetenteId: usuarioAtual._id,
|
|
tipo: "texto",
|
|
conteudo: args.conteudo,
|
|
agendadaPara: args.agendadaPara,
|
|
enviadaEm: args.agendadaPara, // Será usada quando a mensagem for enviada
|
|
});
|
|
|
|
return mensagemId;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Cancela uma mensagem agendada
|
|
*/
|
|
export const cancelarMensagemAgendada = 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");
|
|
if (mensagem.remetenteId !== usuarioAtual._id) {
|
|
throw new Error("Você só pode cancelar suas próprias mensagens");
|
|
}
|
|
|
|
await ctx.db.delete(args.mensagemId);
|
|
return true;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Adiciona uma reação (emoji) a uma mensagem
|
|
*/
|
|
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");
|
|
|
|
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
|
|
*/
|
|
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");
|
|
|
|
// 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(),
|
|
});
|
|
}
|
|
|
|
// 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
|
|
*/
|
|
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");
|
|
|
|
// 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
|
|
*/
|
|
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");
|
|
|
|
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)
|
|
*/
|
|
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");
|
|
|
|
if (mensagem.remetenteId !== usuarioAtual._id) {
|
|
throw new Error("Você só pode deletar suas próprias mensagens");
|
|
}
|
|
|
|
await ctx.db.patch(args.mensagemId, {
|
|
deletada: true,
|
|
conteudo: "Mensagem deletada",
|
|
});
|
|
|
|
return true;
|
|
},
|
|
});
|
|
|
|
// ========== QUERIES ==========
|
|
|
|
/**
|
|
* Lista todas as conversas do usuário logado
|
|
*/
|
|
export const listarConversas = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
|
if (!usuarioAtual) return [];
|
|
|
|
// Buscar todas as conversas do usuário
|
|
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)
|
|
const todasMensagens = await ctx.db
|
|
.query("mensagens")
|
|
.withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
|
|
.collect();
|
|
|
|
const mensagens = todasMensagens.filter((m) => !m.agendadaPara);
|
|
|
|
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;
|
|
}
|
|
|
|
return {
|
|
...conversa,
|
|
outroUsuario,
|
|
participantesInfo: participantes.filter((p) => p !== null),
|
|
naoLidas,
|
|
};
|
|
})
|
|
);
|
|
|
|
return conversasEnriquecidas;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém as mensagens de uma conversa com paginação
|
|
*/
|
|
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
|
|
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
|
|
const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara);
|
|
|
|
// Enriquecer com informações do remetente
|
|
const mensagensEnriquecidas = await Promise.all(
|
|
mensagensFiltradas.map(async (mensagem) => {
|
|
const remetente = await ctx.db.get(mensagem.remetenteId);
|
|
let arquivoUrl = null;
|
|
if (mensagem.arquivoId) {
|
|
arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId);
|
|
}
|
|
return {
|
|
...mensagem,
|
|
remetente,
|
|
arquivoUrl,
|
|
};
|
|
})
|
|
);
|
|
|
|
return mensagensEnriquecidas.reverse();
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém mensagens agendadas de uma conversa
|
|
*/
|
|
export const obterMensagensAgendadas = query({
|
|
args: {
|
|
conversaId: v.id("conversas"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
|
if (!usuarioAtual) 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
|
|
const minhasMensagensAgendadas = todasMensagens.filter(
|
|
(m) =>
|
|
m.remetenteId === usuarioAtual._id &&
|
|
m.agendadaPara &&
|
|
m.agendadaPara > Date.now()
|
|
);
|
|
|
|
return minhasMensagensAgendadas.sort(
|
|
(a, b) => (a.agendadaPara || 0) - (b.agendadaPara || 0)
|
|
);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém as notificações do usuário
|
|
*/
|
|
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
|
|
const notificacoesEnriquecidas = await Promise.all(
|
|
notificacoes.map(async (notificacao) => {
|
|
let remetente = null;
|
|
if (notificacao.remetenteId) {
|
|
remetente = await ctx.db.get(notificacao.remetenteId);
|
|
}
|
|
return {
|
|
...notificacao,
|
|
remetente,
|
|
};
|
|
})
|
|
);
|
|
|
|
return notificacoesEnriquecidas;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* 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
|
|
return usuarios
|
|
.filter((u) => u._id !== usuarioAtual._id)
|
|
.map((u) => ({
|
|
_id: u._id,
|
|
nome: u.nome,
|
|
email: u.email,
|
|
matricula: u.matricula,
|
|
avatar: u.avatar,
|
|
fotoPerfil: u.fotoPerfil,
|
|
statusPresenca: u.statusPresenca,
|
|
statusMensagem: u.statusMensagem,
|
|
setor: u.setor,
|
|
}));
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Busca mensagens em conversas
|
|
*/
|
|
export const buscarMensagens = query({
|
|
args: {
|
|
query: v.string(),
|
|
conversaId: v.optional(v.id("conversas")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
|
if (!usuarioAtual) return [];
|
|
|
|
// 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)
|
|
);
|
|
|
|
let mensagens: any[] = [];
|
|
|
|
if (args.conversaId !== undefined) {
|
|
// Buscar em conversa específica
|
|
const mensagensConversa = await ctx.db
|
|
.query("mensagens")
|
|
.withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!))
|
|
.collect();
|
|
mensagens = mensagensConversa;
|
|
} else {
|
|
// Buscar em todas as conversas
|
|
for (const conversa of conversasDoUsuario) {
|
|
const mensagensConversa = await ctx.db
|
|
.query("mensagens")
|
|
.withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
|
|
.collect();
|
|
mensagens.push(...mensagensConversa);
|
|
}
|
|
}
|
|
|
|
// Filtrar por query
|
|
const queryLower = args.query.toLowerCase();
|
|
const mensagensFiltradas = mensagens.filter(
|
|
(m) =>
|
|
!m.deletada &&
|
|
!m.agendadaPara &&
|
|
m.conteudo.toLowerCase().includes(queryLower)
|
|
);
|
|
|
|
// Enriquecer com informações
|
|
const mensagensEnriquecidas = await Promise.all(
|
|
mensagensFiltradas.map(async (mensagem) => {
|
|
const remetente = await ctx.db.get(mensagem.remetenteId);
|
|
const conversa = await ctx.db.get(mensagem.conversaId);
|
|
return {
|
|
...mensagem,
|
|
remetente,
|
|
conversa,
|
|
};
|
|
})
|
|
);
|
|
|
|
return mensagensEnriquecidas
|
|
.sort((a, b) => b.enviadaEm - a.enviadaEm)
|
|
.slice(0, 50);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém quem está digitando em uma conversa
|
|
*/
|
|
export const obterDigitando = query({
|
|
args: {
|
|
conversaId: v.id("conversas"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
|
if (!usuarioAtual) 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 buscar informações
|
|
const digitandoFiltrado = digitando.filter(
|
|
(d) => d.usuarioId !== usuarioAtual._id
|
|
);
|
|
|
|
const usuarios = await Promise.all(
|
|
digitandoFiltrado.map(async (d) => {
|
|
const usuario = await ctx.db.get(d.usuarioId);
|
|
return usuario;
|
|
})
|
|
);
|
|
|
|
return usuarios.filter((u) => u !== null);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Conta mensagens não lidas de uma conversa
|
|
*/
|
|
export const contarNaoLidas = query({
|
|
args: {
|
|
conversaId: v.id("conversas"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
|
if (!usuarioAtual) 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 mensagens = await ctx.db
|
|
.query("mensagens")
|
|
.withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
|
|
.filter((q) => q.eq(q.field("agendadaPara"), undefined))
|
|
.collect();
|
|
|
|
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
|
|
const mensagensAgendadas = await ctx.db
|
|
.query("mensagens")
|
|
.withIndex("by_agendamento")
|
|
.filter((q) =>
|
|
q.and(
|
|
q.neq(q.field("agendadaPara"), undefined),
|
|
q.lte(q.field("agendadaPara"), agora)
|
|
)
|
|
)
|
|
.collect();
|
|
|
|
for (const mensagem of mensagensAgendadas) {
|
|
// Atualizar mensagem para "enviada"
|
|
await ctx.db.patch(mensagem._id, {
|
|
agendadaPara: undefined,
|
|
enviadaEm: agora,
|
|
});
|
|
|
|
// 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,
|
|
});
|
|
|
|
// Criar notificações para outros participantes
|
|
const remetente = await ctx.db.get(mensagem.remetenteId);
|
|
for (const participanteId of conversa.participantes) {
|
|
if (participanteId !== mensagem.remetenteId) {
|
|
await ctx.db.insert("notificacoes", {
|
|
usuarioId: participanteId,
|
|
tipo: "nova_mensagem",
|
|
conversaId: mensagem.conversaId,
|
|
mensagemId: mensagem._id,
|
|
remetenteId: mensagem.remetenteId,
|
|
titulo: `Nova mensagem de ${remetente?.nome || "Usuário"}`,
|
|
descricao: mensagem.conteudo.substring(0, 100),
|
|
lida: false,
|
|
criadaEm: agora,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
},
|
|
});
|