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

1068 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"))),
},
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 outros participantes (com tratamento de erro)
try {
for (const participanteId of conversa.participantes) {
if (participanteId !== usuarioAtual._id) {
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) {
// Adicionar URL da foto de perfil
let fotoPerfilUrl = null;
if (outroUsuarioRaw.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(outroUsuarioRaw.fotoPerfil);
}
outroUsuario = {
...outroUsuarioRaw,
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;
},
});