refactor: enhance chat components with type safety and response functionality

- Updated type definitions in ChatWindow and MessageList components for better type safety.
- Improved MessageInput to handle message responses, including a preview feature for replying to messages.
- Enhanced the chat message handling logic to support message references and improve user interaction.
- Refactored notification utility functions to support push notifications and rate limiting for email sending.
- Updated backend schema to accommodate new features related to message responses and notifications.
This commit is contained in:
2025-11-04 20:36:01 -03:00
parent 15374276d5
commit 12db52a8a7
23 changed files with 3195 additions and 503 deletions

View File

@@ -2,9 +2,21 @@ 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)
*/
@@ -190,6 +202,7 @@ export const enviarMensagem = mutation({
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) => {
@@ -203,20 +216,78 @@ export const enviarMensagem = mutation({
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");
}
if (mensagemOriginal.deletada) {
throw new Error("Não é possível responder a uma mensagem deletada");
}
}
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto)
let linkPreview = undefined;
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 extração de preview (assíncrono, não bloqueia envio)
ctx.scheduler.runAfter(1000, api.actions.linkPreview.extrairPreviewLink, {
url: primeiraUrl,
}).then((preview) => {
if (preview) {
// Atualizar mensagem com preview via mutation interna
return ctx.runMutation(internal.chat.atualizarLinkPreview, {
mensagemId,
linkPreview: preview,
});
}
}).catch((error) => {
console.error("Erro ao agendar/processar preview de link:", error);
});
}
}
// 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(),
});
// 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),
@@ -236,20 +307,79 @@ export const enviarMensagem = mutation({
? "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:
tipoNotificacao === "mencao"
? `${usuarioAtual.nome} mencionou você`
: `Nova mensagem de ${usuarioAtual.nome}`,
descricao: args.conteudo.substring(0, 100),
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,
urlSistema,
},
enviadoPorId: usuarioAtual._id,
}).catch((error) => {
console.error(`Erro ao agendar email para usuário ${participanteId}:`, error);
});
}
}
}
}
}
} catch (error) {
@@ -558,6 +688,83 @@ export const marcarTodasNotificacoesLidas = mutation({
/**
* 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" };
}
// 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"),
@@ -710,7 +917,7 @@ export const obterMensagens = query({
// Filtrar mensagens agendadas
const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara);
// Enriquecer com informações do remetente
// 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);
@@ -718,10 +925,30 @@ export const obterMensagens = query({
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) {
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,
};
})
);
@@ -960,17 +1187,25 @@ export const listarTodosUsuarios = query({
});
/**
* Busca mensagens em conversas
* Busca mensagens em conversas com filtros avançados
*/
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) =>
@@ -980,6 +1215,12 @@ export const buscarMensagens = query({
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")
@@ -987,7 +1228,7 @@ export const buscarMensagens = query({
.collect();
mensagens = mensagensConversa;
} else {
// Buscar em todas as conversas
// Buscar em todas as conversas do usuário
for (const conversa of conversasDoUsuario) {
const mensagensConversa = await ctx.db
.query("mensagens")
@@ -997,14 +1238,49 @@ export const buscarMensagens = query({
}
}
// Filtrar por query
const queryLower = args.query.toLowerCase();
const mensagensFiltradas = mensagens.filter(
(m) =>
!m.deletada &&
!m.agendadaPara &&
m.conteudo.toLowerCase().includes(queryLower)
);
// Aplicar filtros
let mensagensFiltradas = mensagens.filter((m) => {
// Excluir deletadas e agendadas
if (m.deletada || m.agendadaPara) {
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
if (args.remetenteId && m.remetenteId !== 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
const mensagensEnriquecidas = await Promise.all(