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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user