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

@@ -3,6 +3,7 @@
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
export const enviar = action({
args: {
@@ -23,47 +24,105 @@ export const enviar = action({
return { sucesso: false, erro: "Email não encontrado" };
}
// Buscar configuração SMTP ativa com senha descriptografada
const config = await ctx.runQuery(internal.email.getActiveEmailConfigWithPassword, {});
if (!config) {
// Buscar configuração SMTP ativa
const configRaw = await ctx.runQuery(internal.email.getActiveEmailConfig, {});
if (!configRaw) {
return {
sucesso: false,
erro: "Configuração de email não encontrada ou inativa",
};
}
if (!config.testadoEm) {
// Descriptografar senha usando função compatível com Node.js
let senhaDescriptografada: string;
try {
senhaDescriptografada = await decryptSMTPPasswordNode(configRaw.senhaHash);
} catch (decryptError) {
const decryptErrorMessage = decryptError instanceof Error ? decryptError.message : String(decryptError);
console.error("Erro ao descriptografar senha SMTP:", decryptErrorMessage);
return {
sucesso: false,
erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!",
erro: `Erro ao descriptografar senha SMTP: ${decryptErrorMessage}`,
};
}
const config = {
...configRaw,
senha: senhaDescriptografada,
};
// Config já foi validado acima
// Avisar mas não bloquear se não foi testado
if (!config.testadoEm) {
console.warn("⚠️ Configuração SMTP não foi testada. Tentando enviar mesmo assim...");
}
// Marcar como enviando
await ctx.runMutation(internal.email.markEmailEnviando, {
emailId: args.emailId,
});
// Criar transporter do nodemailer
const transporter = nodemailer.createTransport({
// Criar transporter do nodemailer com configuração melhorada
const transporterOptions: {
host: string;
port: number;
secure: boolean;
requireTLS?: boolean;
auth: {
user: string;
pass: string;
};
tls?: {
rejectUnauthorized: boolean;
ciphers?: string;
};
connectionTimeout: number;
greetingTimeout: number;
socketTimeout: number;
pool?: boolean;
maxConnections?: number;
maxMessages?: number;
} = {
host: config.servidor,
port: config.porta,
secure: config.usarSSL,
requireTLS: config.usarTLS,
auth: {
user: config.usuario,
pass: config.senha, // Senha já descriptografada
},
tls: {
// Permitir certificados autoassinados apenas se necessário
connectionTimeout: 15000, // 15 segundos
greetingTimeout: 15000,
socketTimeout: 15000,
pool: true, // Usar pool de conexões
maxConnections: 5,
maxMessages: 100,
};
// Adicionar TLS apenas se necessário
if (config.usarTLS) {
transporterOptions.requireTLS = true;
transporterOptions.tls = {
rejectUnauthorized: false, // Permitir certificados autoassinados
};
} else if (config.usarSSL) {
transporterOptions.tls = {
rejectUnauthorized: false,
ciphers: "SSLv3",
},
connectionTimeout: 10000, // 10 segundos
greetingTimeout: 10000,
socketTimeout: 10000,
});
};
}
const transporter = nodemailer.createTransport(transporterOptions);
// Verificar conexão antes de enviar
try {
await transporter.verify();
console.log("✅ Conexão SMTP verificada com sucesso");
} catch (verifyError) {
const verifyErrorMessage = verifyError instanceof Error ? verifyError.message : String(verifyError);
console.warn("⚠️ Falha na verificação SMTP, mas tentando enviar mesmo assim:", verifyErrorMessage);
// Não bloquear envio por falha na verificação, apenas avisar
}
// Validar email destinatário antes de enviar
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -71,13 +130,28 @@ export const enviar = action({
throw new Error(`Email destinatário inválido: ${email.destinatario}`);
}
// Criar versão texto do HTML (remover tags e decodificar entidades básicas)
const textoPlano = email.corpo
.replace(/<[^>]*>/g, "") // Remover tags HTML
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
// Enviar email
const info = await transporter.sendMail({
from: `"${config.nomeRemetente}" <${config.emailRemetente}>`,
to: email.destinatario,
subject: email.assunto,
html: email.corpo,
text: email.corpo.replace(/<[^>]*>/g, ""), // Versão texto para clientes que não suportam HTML
text: textoPlano || email.assunto, // Versão texto para clientes que não suportam HTML
headers: {
"X-Mailer": "SGSE-Sistema",
"X-Priority": "3",
},
});
interface MessageInfo {
@@ -102,12 +176,23 @@ export const enviar = action({
return { sucesso: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("❌ Erro ao enviar email:", errorMessage);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error("❌ Erro ao enviar email:", {
emailId: args.emailId,
destinatario: email?.destinatario,
erro: errorMessage,
stack: errorStack,
});
// Marcar como falha com detalhes completos
const erroCompleto = errorStack
? `${errorMessage}\n\nStack: ${errorStack}`
: errorMessage;
// Marcar como falha
await ctx.runMutation(internal.email.markEmailFalha, {
emailId: args.emailId,
erro: errorMessage,
erro: erroCompleto.substring(0, 2000), // Limitar tamanho do erro
});
return { sucesso: false, erro: errorMessage };

View File

@@ -0,0 +1,138 @@
"use node";
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
/**
* Extrair preview de link (metadados Open Graph) - função auxiliar
*/
async function extrairPreviewLinkHelper(url: string) {
try {
// Validar URL
let urlObj: URL;
try {
urlObj = new URL(url);
} catch {
return null;
}
// Buscar HTML da página
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; SGSE-Bot/1.0)",
},
signal: AbortSignal.timeout(5000), // Timeout de 5 segundos
});
if (!response.ok) {
return null;
}
const html = await response.text();
// Extrair metadados Open Graph e Twitter Cards
const metadata: {
titulo?: string;
descricao?: string;
imagem?: string;
site?: string;
} = {};
// Título (og:title ou twitter:title ou <title>)
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i);
const twitterTitleMatch = html.match(/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i);
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
metadata.titulo = ogTitleMatch?.[1] || twitterTitleMatch?.[1] || titleMatch?.[1] || undefined;
if (metadata.titulo) {
metadata.titulo = metadata.titulo.trim().substring(0, 200);
}
// Descrição (og:description ou twitter:description ou meta description)
const ogDescMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i);
const twitterDescMatch = html.match(/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i);
const metaDescMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
metadata.descricao = ogDescMatch?.[1] || twitterDescMatch?.[1] || metaDescMatch?.[1] || undefined;
if (metadata.descricao) {
metadata.descricao = metadata.descricao.trim().substring(0, 300);
}
// Imagem (og:image ou twitter:image)
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i);
const twitterImageMatch = html.match(/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i);
const imageUrl = ogImageMatch?.[1] || twitterImageMatch?.[1];
if (imageUrl) {
// Resolver URL relativa
try {
metadata.imagem = new URL(imageUrl, url).href;
} catch {
metadata.imagem = imageUrl;
}
}
// Site (og:site_name ou domínio)
const ogSiteMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i);
metadata.site = ogSiteMatch?.[1] || urlObj.hostname.replace(/^www\./, "");
return {
url,
titulo: metadata.titulo,
descricao: metadata.descricao,
imagem: metadata.imagem,
site: metadata.site,
};
} catch (error) {
console.error("Erro ao extrair preview de link:", error);
return null;
}
}
/**
* Processar preview de link e atualizar mensagem
*/
export const processarPreviewLink = action({
args: {
mensagemId: v.id("mensagens"),
url: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Extrair preview
const preview = await extrairPreviewLinkHelper(args.url);
if (preview) {
// Atualizar mensagem com preview
await ctx.runMutation(internal.chat.atualizarLinkPreview, {
mensagemId: args.mensagemId,
linkPreview: preview,
});
}
return null;
},
});
/**
* Extrair preview de link (metadados Open Graph) - versão pública
*/
export const extrairPreviewLink = action({
args: {
url: v.string(),
},
returns: v.union(
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()),
}),
v.null()
),
handler: async (ctx, args) => {
return await extrairPreviewLinkHelper(args.url);
},
});

View File

@@ -0,0 +1,93 @@
"use node";
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
/**
* Enviar push notification usando Web Push API
*/
export const enviarPush = action({
args: {
subscriptionId: v.id("pushSubscriptions"),
titulo: v.string(),
corpo: v.string(),
data: v.optional(
v.object({
conversaId: v.optional(v.string()),
mensagemId: v.optional(v.string()),
tipo: v.optional(v.string()),
})
),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
try {
// Buscar subscription
const subscription = await ctx.runQuery(internal.pushNotifications.getSubscriptionById, {
subscriptionId: args.subscriptionId,
});
if (!subscription || !subscription.ativo) {
return { sucesso: false, erro: "Subscription não encontrada ou inativa" };
}
// Web Push requer VAPID keys (deve estar em variáveis de ambiente)
// Por enquanto, vamos usar uma implementação básica
// Em produção, você precisará configurar VAPID keys
const webpush = await import("web-push");
// VAPID keys devem vir de variáveis de ambiente
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
if (!publicKey || !privateKey) {
console.warn("⚠️ VAPID keys não configuradas. Push notifications não funcionarão.");
// Em desenvolvimento, podemos retornar sucesso sem enviar
return { sucesso: true };
}
webpush.setVapidDetails("mailto:suporte@sgse.app", publicKey, privateKey);
// Preparar payload da notificação
const payload = JSON.stringify({
title: args.titulo,
body: args.corpo,
icon: "/favicon.png",
badge: "/favicon.png",
data: args.data || {},
tag: args.data?.conversaId || "default",
requireInteraction: args.data?.tipo === "mencao", // Menções requerem interação
});
// Enviar push notification
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
},
payload
);
console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
return { sucesso: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("❌ Erro ao enviar push notification:", errorMessage);
// Se subscription inválida, marcar como inativa
if (errorMessage.includes("410") || errorMessage.includes("expired")) {
await ctx.runMutation(internal.pushNotifications.marcarSubscriptionInativa, {
subscriptionId: args.subscriptionId,
});
}
return { sucesso: false, erro: errorMessage };
}
},
});

View File

@@ -0,0 +1,72 @@
/**
* Utilitários de criptografia compatíveis com Node.js
* Para uso em actions que rodam em ambiente Node.js
*/
/**
* Descriptografa senha SMTP usando Web Crypto API compatível com Node.js
* Esta versão funciona em ambiente Node.js (actions)
*/
export async function decryptSMTPPasswordNode(encryptedPassword: string): Promise<string> {
try {
// Em Node.js, crypto.subtle está disponível globalmente
const crypto = globalThis.crypto;
if (!crypto || !crypto.subtle) {
throw new Error("Web Crypto API não disponível");
}
// Chave base - mesma usada em auth/utils.ts
const keyMaterial = new TextEncoder().encode("SGSE-EMAIL-ENCRYPTION-KEY-2024");
// Importar chave material
const keyMaterialKey = await crypto.subtle.importKey(
"raw",
keyMaterial,
{ name: "PBKDF2" },
false,
["deriveBits", "deriveKey"]
);
// Derivar chave de 256 bits usando PBKDF2
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode("SGSE-SALT"),
iterations: 100000,
hash: "SHA-256",
},
keyMaterialKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
// Decodificar base64 manualmente (compatível com Node.js e browser)
const binaryString = atob(encryptedPassword);
const combined = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
// Extrair IV e dados criptografados
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
// Descriptografar
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encrypted
);
// Converter para string
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Erro ao descriptografar senha SMTP (Node.js):", errorMessage);
throw new Error(`Falha ao descriptografar senha SMTP: ${errorMessage}`);
}
}

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(

View File

@@ -44,6 +44,139 @@ async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise<Doc<"
return usuarioAtual;
}
/**
* Configurações padrão de rate limiting
*/
const RATE_LIMIT_CONFIG = {
emailsPorMinuto: 10,
emailsPorHora: 100,
} as const;
/**
* Verifica rate limiting para um remetente
* Retorna true se pode enviar, false se excedeu limite
*/
async function verificarRateLimit(
ctx: MutationCtx,
remetenteId: Id<"usuarios">
): Promise<{ permitido: boolean; motivo?: string }> {
const agora = Date.now();
const umMinutoAtras = agora - 60 * 1000;
const umaHoraAtras = agora - 60 * 60 * 1000;
// Verificar limite por minuto
const emailsUltimoMinuto = await ctx.db
.query("rateLimitEmails")
.withIndex("by_remetente_periodo", (q) =>
q.eq("remetenteId", remetenteId).eq("periodo", "minuto")
)
.filter((q) => q.gte(q.field("timestamp"), umMinutoAtras))
.collect();
const totalUltimoMinuto = emailsUltimoMinuto.reduce(
(sum, rl) => sum + rl.contador,
0
);
if (totalUltimoMinuto >= RATE_LIMIT_CONFIG.emailsPorMinuto) {
return {
permitido: false,
motivo: `Limite de ${RATE_LIMIT_CONFIG.emailsPorMinuto} emails por minuto excedido. Tente novamente em alguns instantes.`,
};
}
// Verificar limite por hora
const emailsUltimaHora = await ctx.db
.query("rateLimitEmails")
.withIndex("by_remetente_periodo", (q) =>
q.eq("remetenteId", remetenteId).eq("periodo", "hora")
)
.filter((q) => q.gte(q.field("timestamp"), umaHoraAtras))
.collect();
const totalUltimaHora = emailsUltimaHora.reduce(
(sum, rl) => sum + rl.contador,
0
);
if (totalUltimaHora >= RATE_LIMIT_CONFIG.emailsPorHora) {
return {
permitido: false,
motivo: `Limite de ${RATE_LIMIT_CONFIG.emailsPorHora} emails por hora excedido. Tente novamente mais tarde.`,
};
}
return { permitido: true };
}
/**
* Registra envio de email para rate limiting
*/
async function registrarEnvioRateLimit(
ctx: MutationCtx,
remetenteId: Id<"usuarios">
): Promise<void> {
const agora = Date.now();
// Limpar registros antigos (mais de 1 hora)
const umaHoraAtras = agora - 60 * 60 * 1000;
const registrosAntigos = await ctx.db
.query("rateLimitEmails")
.withIndex("by_timestamp")
.filter((q) => q.lt(q.field("timestamp"), umaHoraAtras))
.collect();
for (const registro of registrosAntigos) {
await ctx.db.delete(registro._id);
}
// Criar ou atualizar registro do minuto atual
const minutoAtual = Math.floor(agora / 60000) * 60000; // Arredondar para o minuto
const registroMinuto = await ctx.db
.query("rateLimitEmails")
.withIndex("by_remetente_periodo", (q) =>
q.eq("remetenteId", remetenteId).eq("periodo", "minuto")
)
.filter((q) => q.eq(q.field("timestamp"), minutoAtual))
.first();
if (registroMinuto) {
await ctx.db.patch(registroMinuto._id, {
contador: registroMinuto.contador + 1,
});
} else {
await ctx.db.insert("rateLimitEmails", {
remetenteId,
timestamp: minutoAtual,
contador: 1,
periodo: "minuto",
});
}
// Criar ou atualizar registro da hora atual
const horaAtual = Math.floor(agora / 3600000) * 3600000; // Arredondar para a hora
const registroHora = await ctx.db
.query("rateLimitEmails")
.withIndex("by_remetente_periodo", (q) =>
q.eq("remetenteId", remetenteId).eq("periodo", "hora")
)
.filter((q) => q.eq(q.field("timestamp"), horaAtual))
.first();
if (registroHora) {
await ctx.db.patch(registroHora._id, {
contador: registroHora.contador + 1,
});
} else {
await ctx.db.insert("rateLimitEmails", {
remetenteId,
timestamp: horaAtual,
contador: 1,
periodo: "hora",
});
}
}
/**
* Enfileirar email para envio
*/
@@ -60,18 +193,27 @@ export const enfileirarEmail = mutation({
returns: v.object({
sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")),
erro: v.optional(v.string()),
}),
handler: async (ctx, args) => {
// Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.destinatario)) {
return { sucesso: false };
return { sucesso: false, erro: "Email destinatário inválido" };
}
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) {
return { sucesso: false };
return { sucesso: false, erro: "Data de agendamento deve ser futura" };
}
}
// Verificar rate limiting (apenas para envios imediatos, não agendados)
if (args.agendadaPara === undefined) {
const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId);
if (!rateLimitCheck.permitido) {
return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" };
}
}
@@ -89,6 +231,11 @@ export const enfileirarEmail = mutation({
agendadaPara: args.agendadaPara,
});
// Registrar rate limit apenas para envios imediatos
if (args.agendadaPara === undefined) {
await registrarEnvioRateLimit(ctx, args.enviadoPorId);
}
// Agendar envio
if (args.agendadaPara !== undefined) {
// Agendar para o momento especificado
@@ -122,6 +269,7 @@ export const enviarEmailComTemplate = mutation({
returns: v.object({
sucesso: v.boolean(),
emailId: v.optional(v.id("notificacoesEmail")),
erro: v.optional(v.string()),
}),
handler: async (ctx, args) => {
// Buscar template
@@ -132,13 +280,21 @@ export const enviarEmailComTemplate = mutation({
if (!template) {
console.error("Template não encontrado:", args.templateCodigo);
return { sucesso: false };
return { sucesso: false, erro: `Template "${args.templateCodigo}" não encontrado` };
}
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined) {
if (args.agendadaPara <= Date.now()) {
return { sucesso: false };
return { sucesso: false, erro: "Data de agendamento deve ser futura" };
}
}
// Verificar rate limiting (apenas para envios imediatos, não agendados)
if (args.agendadaPara === undefined) {
const rateLimitCheck = await verificarRateLimit(ctx, args.enviadoPorId);
if (!rateLimitCheck.permitido) {
return { sucesso: false, erro: rateLimitCheck.motivo || "Limite de envio excedido" };
}
}
@@ -160,6 +316,11 @@ export const enviarEmailComTemplate = mutation({
agendadaPara: args.agendadaPara,
});
// Registrar rate limit apenas para envios imediatos
if (args.agendadaPara === undefined) {
await registrarEnvioRateLimit(ctx, args.enviadoPorId);
}
// Agendar envio
if (args.agendadaPara !== undefined) {
// Agendar para o momento especificado
@@ -384,6 +545,64 @@ export const obterEstatisticasFilaEmails = query({
},
});
/**
* Obter estatísticas de rate limiting para um usuário
*/
export const obterEstatisticasRateLimit = query({
args: {
remetenteId: v.id("usuarios"),
},
returns: v.object({
emailsUltimoMinuto: v.number(),
emailsUltimaHora: v.number(),
limiteMinuto: v.number(),
limiteHora: v.number(),
podeEnviar: v.boolean(),
}),
handler: async (ctx, args) => {
const agora = Date.now();
const umMinutoAtras = agora - 60 * 1000;
const umaHoraAtras = agora - 60 * 60 * 1000;
// Contar emails do último minuto
const emailsUltimoMinuto = await ctx.db
.query("rateLimitEmails")
.withIndex("by_remetente_periodo", (q) =>
q.eq("remetenteId", args.remetenteId).eq("periodo", "minuto")
)
.filter((q) => q.gte(q.field("timestamp"), umMinutoAtras))
.collect();
const totalUltimoMinuto = emailsUltimoMinuto.reduce(
(sum, rl) => sum + rl.contador,
0
);
// Contar emails da última hora
const emailsUltimaHora = await ctx.db
.query("rateLimitEmails")
.withIndex("by_remetente_periodo", (q) =>
q.eq("remetenteId", args.remetenteId).eq("periodo", "hora")
)
.filter((q) => q.gte(q.field("timestamp"), umaHoraAtras))
.collect();
const totalUltimaHora = emailsUltimaHora.reduce(
(sum, rl) => sum + rl.contador,
0
);
return {
emailsUltimoMinuto: totalUltimoMinuto,
emailsUltimaHora: totalUltimaHora,
limiteMinuto: RATE_LIMIT_CONFIG.emailsPorMinuto,
limiteHora: RATE_LIMIT_CONFIG.emailsPorHora,
podeEnviar: totalUltimoMinuto < RATE_LIMIT_CONFIG.emailsPorMinuto &&
totalUltimaHora < RATE_LIMIT_CONFIG.emailsPorHora,
};
},
});
/**
* Listar agendamentos de email do usuário atual
*/
@@ -516,6 +735,7 @@ export const markEmailFalha = internalMutation({
/**
* Processar fila de emails (cron job - processa emails pendentes)
* Implementa delay exponencial entre envios para evitar bloqueio SMTP
*/
export const processarFilaEmails = internalMutation({
args: {},
@@ -537,32 +757,62 @@ export const processarFilaEmails = internalMutation({
let processados = 0;
let falhas = 0;
// Agrupar emails por remetente para aplicar rate limiting e delay
const emailsPorRemetente = new Map<Id<"usuarios">, Array<Doc<"notificacoesEmail">>>();
for (const email of emailsPendentes) {
// Verificar se não excedeu tentativas (max 3)
if ((email.tentativas || 0) >= 3) {
await ctx.db.patch(email._id, {
status: "falha",
erroDetalhes: "Número máximo de tentativas excedido",
});
falhas++;
continue;
if (!emailsPorRemetente.has(email.enviadoPor)) {
emailsPorRemetente.set(email.enviadoPor, []);
}
emailsPorRemetente.get(email.enviadoPor)!.push(email);
}
// Agendar envio via action
try {
await ctx.scheduler.runAfter(0, api.actions.email.enviar, {
emailId: email._id,
});
processados++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
await ctx.db.patch(email._id, {
status: "falha",
erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
tentativas: (email.tentativas || 0) + 1,
});
falhas++;
for (const [remetenteId, emails] of emailsPorRemetente.entries()) {
// Verificar rate limit do remetente
const rateLimitCheck = await verificarRateLimit(ctx, remetenteId);
for (let i = 0; i < emails.length; i++) {
const email = emails[i];
// Verificar se não excedeu tentativas (max 3)
if ((email.tentativas || 0) >= 3) {
await ctx.db.patch(email._id, {
status: "falha",
erroDetalhes: "Número máximo de tentativas excedido",
});
falhas++;
continue;
}
// Se rate limit excedido, pular este lote
if (!rateLimitCheck.permitido && i === 0) {
console.log(`⏸️ Rate limit excedido para remetente ${remetenteId}, aguardando...`);
break;
}
// Delay exponencial baseado na tentativa (primeira: 0ms, segunda: 2s, terceira: 4s)
const delayExponencial = email.tentativas
? Math.min(2000 * Math.pow(2, email.tentativas - 1), 10000) // Máximo 10s
: 0;
// Delay adicional entre emails do mesmo remetente (1 segundo)
const delayEntreEmails = i * 1000;
// Agendar envio via action com delay
try {
await ctx.scheduler.runAfter(delayExponencial + delayEntreEmails, api.actions.email.enviar, {
emailId: email._id,
});
processados++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar email ${email._id}:`, errorMessage);
await ctx.db.patch(email._id, {
status: "falha",
erroDetalhes: `Erro ao agendar envio: ${errorMessage}`,
tentativas: (email.tentativas || 0) + 1,
});
falhas++;
}
}
}

View File

@@ -0,0 +1,136 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
/**
* Obter preferências de notificação para uma conversa
*/
export const obterPreferenciasConversa = query({
args: {
conversaId: v.id("conversas"),
},
returns: v.union(
v.object({
pushAtivado: v.boolean(),
emailAtivado: v.boolean(),
somAtivado: v.boolean(),
silenciado: v.boolean(),
apenasMencoes: v.boolean(),
}),
v.null()
),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity?.email) {
return null;
}
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuario) {
return null;
}
const preferencias = await ctx.db
.query("preferenciasNotificacaoConversa")
.withIndex("by_usuario_conversa", (q) =>
q.eq("usuarioId", usuario._id).eq("conversaId", args.conversaId)
)
.first();
if (!preferencias) {
// Retornar valores padrão
return {
pushAtivado: true,
emailAtivado: true,
somAtivado: true,
silenciado: false,
apenasMencoes: false,
};
}
return {
pushAtivado: preferencias.pushAtivado,
emailAtivado: preferencias.emailAtivado,
somAtivado: preferencias.somAtivado,
silenciado: preferencias.silenciado,
apenasMencoes: preferencias.apenasMencoes,
};
},
});
/**
* Atualizar preferências de notificação para uma conversa
*/
export const atualizarPreferenciasConversa = mutation({
args: {
conversaId: v.id("conversas"),
pushAtivado: v.optional(v.boolean()),
emailAtivado: v.optional(v.boolean()),
somAtivado: v.optional(v.boolean()),
silenciado: v.optional(v.boolean()),
apenasMencoes: v.optional(v.boolean()),
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity?.email) {
return { sucesso: false };
}
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuario) {
return { sucesso: false };
}
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa || !conversa.participantes.includes(usuario._id)) {
return { sucesso: false };
}
const preferenciasExistentes = await ctx.db
.query("preferenciasNotificacaoConversa")
.withIndex("by_usuario_conversa", (q) =>
q.eq("usuarioId", usuario._id).eq("conversaId", args.conversaId)
)
.first();
const agora = Date.now();
if (preferenciasExistentes) {
// Atualizar preferências existentes
await ctx.db.patch(preferenciasExistentes._id, {
pushAtivado: args.pushAtivado ?? preferenciasExistentes.pushAtivado,
emailAtivado: args.emailAtivado ?? preferenciasExistentes.emailAtivado,
somAtivado: args.somAtivado ?? preferenciasExistentes.somAtivado,
silenciado: args.silenciado ?? preferenciasExistentes.silenciado,
apenasMencoes: args.apenasMencoes ?? preferenciasExistentes.apenasMencoes,
atualizadoEm: agora,
});
} else {
// Criar novas preferências com valores padrão
await ctx.db.insert("preferenciasNotificacaoConversa", {
usuarioId: usuario._id,
conversaId: args.conversaId,
pushAtivado: args.pushAtivado ?? true,
emailAtivado: args.emailAtivado ?? true,
somAtivado: args.somAtivado ?? true,
silenciado: args.silenciado ?? false,
apenasMencoes: args.apenasMencoes ?? false,
criadoEm: agora,
atualizadoEm: agora,
});
}
return { sucesso: true };
},
});

View File

@@ -0,0 +1,266 @@
import { v } from "convex/values";
import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
import { Id } from "./_generated/dataModel";
import { internal, api } from "./_generated/api";
/**
* Registrar subscription de push notification
*/
export const registrarPushSubscription = mutation({
args: {
endpoint: v.string(),
keys: v.object({
p256dh: v.string(),
auth: v.string(),
}),
userAgent: v.optional(v.string()),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
// Obter usuário autenticado
const identity = await ctx.auth.getUserIdentity();
if (!identity?.email) {
return { sucesso: false, erro: "Usuário não autenticado" };
}
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuario) {
return { sucesso: false, erro: "Usuário não encontrado" };
}
// Verificar se já existe subscription com este endpoint
const existente = await ctx.db
.query("pushSubscriptions")
.withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint))
.first();
if (existente) {
// Atualizar subscription existente
await ctx.db.patch(existente._id, {
usuarioId: usuario._id,
keys: args.keys,
userAgent: args.userAgent,
ultimaAtividade: Date.now(),
ativo: true,
});
} else {
// Criar nova subscription
await ctx.db.insert("pushSubscriptions", {
usuarioId: usuario._id,
endpoint: args.endpoint,
keys: args.keys,
userAgent: args.userAgent,
criadoEm: Date.now(),
ultimaAtividade: Date.now(),
ativo: true,
});
}
return { sucesso: true };
},
});
/**
* Remover subscription de push notification
*/
export const removerPushSubscription = mutation({
args: {
endpoint: v.string(),
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
const subscription = await ctx.db
.query("pushSubscriptions")
.withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint))
.first();
if (subscription) {
await ctx.db.patch(subscription._id, { ativo: false });
}
return { sucesso: true };
},
});
/**
* Obter subscriptions ativas de um usuário
*/
export const obterPushSubscriptions = internalQuery({
args: {
usuarioId: v.id("usuarios"),
},
returns: v.array(
v.object({
_id: v.id("pushSubscriptions"),
endpoint: v.string(),
keys: v.object({
p256dh: v.string(),
auth: v.string(),
}),
})
),
handler: async (ctx, args) => {
const subscriptions = await ctx.db
.query("pushSubscriptions")
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId).eq("ativo", true))
.collect();
return subscriptions.map((sub) => ({
_id: sub._id,
endpoint: sub.endpoint,
keys: sub.keys,
}));
},
});
/**
* Enviar push notification para um usuário
* Esta função será chamada quando uma nova mensagem chegar
*/
export const enviarPushNotification = internalMutation({
args: {
usuarioId: v.id("usuarios"),
titulo: v.string(),
corpo: v.string(),
data: v.optional(
v.object({
conversaId: v.optional(v.id("conversas")),
mensagemId: v.optional(v.id("mensagens")),
tipo: v.optional(v.string()),
})
),
},
returns: v.object({ enviados: v.number(), falhas: v.number() }),
handler: async (ctx, args) => {
// Buscar subscriptions ativas do usuário
const subscriptions = await ctx.runQuery(internal.pushNotifications.obterPushSubscriptions, {
usuarioId: args.usuarioId,
});
if (subscriptions.length === 0) {
return { enviados: 0, falhas: 0 };
}
// Verificar preferências do usuário
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario || usuario.notificacoesAtivadas === false) {
return { enviados: 0, falhas: 0 };
}
// Se há conversaId, verificar preferências específicas da conversa
if (args.data?.conversaId) {
const preferencias = await ctx.db
.query("preferenciasNotificacaoConversa")
.withIndex("by_usuario_conversa", (q) =>
q.eq("usuarioId", args.usuarioId).eq("conversaId", args.data.conversaId)
)
.first();
if (preferencias) {
// Se silenciado ou push desativado, não enviar
if (preferencias.silenciado || !preferencias.pushAtivado) {
return { enviados: 0, falhas: 0 };
}
// Se apenas menções e não é menção, não enviar
if (preferencias.apenasMencoes && args.data.tipo !== "mencao") {
return { enviados: 0, falhas: 0 };
}
}
}
// Agendar envio de push via action (que roda em Node.js)
let enviados = 0;
let falhas = 0;
for (const subscription of subscriptions) {
try {
await ctx.scheduler.runAfter(0, api.actions.pushNotifications.enviarPush, {
subscriptionId: subscription._id,
titulo: args.titulo,
corpo: args.corpo,
data: args.data,
});
enviados++;
} catch (error) {
console.error(`Erro ao agendar push para subscription ${subscription._id}:`, error);
falhas++;
}
}
return { enviados, falhas };
},
});
/**
* Obter subscription por ID (para actions)
*/
export const getSubscriptionById = internalQuery({
args: {
subscriptionId: v.id("pushSubscriptions"),
},
returns: v.union(
v.object({
_id: v.id("pushSubscriptions"),
endpoint: v.string(),
keys: v.object({
p256dh: v.string(),
auth: v.string(),
}),
ativo: v.boolean(),
}),
v.null()
),
handler: async (ctx, args) => {
const subscription = await ctx.db.get(args.subscriptionId);
if (!subscription) {
return null;
}
return {
_id: subscription._id,
endpoint: subscription.endpoint,
keys: subscription.keys,
ativo: subscription.ativo,
};
},
});
/**
* Marcar subscription como inativa
*/
export const marcarSubscriptionInativa = internalMutation({
args: {
subscriptionId: v.id("pushSubscriptions"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.subscriptionId, { ativo: false });
return null;
},
});
/**
* Verificar se usuário está online (última atividade recente)
*/
export const verificarUsuarioOnline = internalQuery({
args: {
usuarioId: v.id("usuarios"),
},
returns: v.boolean(),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario || !usuario.ultimaAtividade) {
return false;
}
// Considerar online se última atividade foi há menos de 5 minutos
const cincoMinutosAtras = Date.now() - 5 * 60 * 1000;
return usuario.ultimaAtividade >= cincoMinutosAtras;
},
});

View File

@@ -604,6 +604,19 @@ export default defineSchema({
descricao: v.string(),
}).index("by_chave", ["chave"]),
// Rate Limiting de Emails
rateLimitEmails: defineTable({
remetenteId: v.id("usuarios"),
timestamp: v.number(),
contador: v.number(), // quantidade de emails enviados neste período
periodo: v.union(
v.literal("minuto"), // último minuto
v.literal("hora") // última hora
),
})
.index("by_remetente_periodo", ["remetenteId", "periodo", "timestamp"])
.index("by_timestamp", ["timestamp"]),
// Sistema de Chat
conversas: defineTable({
tipo: v.union(v.literal("individual"), v.literal("grupo")),
@@ -628,10 +641,20 @@ export default defineSchema({
v.literal("imagem")
),
conteudo: v.string(), // texto ou nome do arquivo
conteudoBusca: v.optional(v.string()), // versão normalizada para busca
arquivoId: v.optional(v.id("_storage")),
arquivoNome: v.optional(v.string()),
arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()),
linkPreview: v.optional(
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()),
})
),
reagiuPor: v.optional(
v.array(
v.object({
@@ -641,6 +664,7 @@ export default defineSchema({
)
),
mencoes: v.optional(v.array(v.id("usuarios"))),
respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo
agendadaPara: v.optional(v.number()), // timestamp
enviadaEm: v.number(),
editadaEm: v.optional(v.number()),
@@ -648,7 +672,8 @@ export default defineSchema({
})
.index("by_conversa", ["conversaId", "enviadaEm"])
.index("by_remetente", ["remetenteId"])
.index("by_agendamento", ["agendadaPara"]),
.index("by_agendamento", ["agendadaPara"])
.index("by_resposta", ["respostaPara"]),
leituras: defineTable({
conversaId: v.id("conversas"),
@@ -686,6 +711,37 @@ export default defineSchema({
.index("by_conversa", ["conversaId", "iniciouEm"])
.index("by_usuario", ["usuarioId"]),
// Push Notifications
pushSubscriptions: defineTable({
usuarioId: v.id("usuarios"),
endpoint: v.string(), // URL do serviço de push
keys: v.object({
p256dh: v.string(), // Chave pública
auth: v.string(), // Chave de autenticação
}),
userAgent: v.optional(v.string()),
criadoEm: v.number(),
ultimaAtividade: v.number(),
ativo: v.boolean(),
})
.index("by_usuario", ["usuarioId", "ativo"])
.index("by_endpoint", ["endpoint"]),
// Preferências de Notificação por Conversa
preferenciasNotificacaoConversa: defineTable({
usuarioId: v.id("usuarios"),
conversaId: v.id("conversas"),
pushAtivado: v.boolean(), // Receber push notifications
emailAtivado: v.boolean(), // Receber emails quando offline
somAtivado: v.boolean(), // Tocar som
silenciado: v.boolean(), // Silenciar completamente
apenasMencoes: v.boolean(), // Notificar apenas quando mencionado
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_usuario_conversa", ["usuarioId", "conversaId"])
.index("by_conversa", ["conversaId"]),
// Tabelas de Monitoramento do Sistema
systemMetrics: defineTable({
timestamp: v.number(),

View File

@@ -1,262 +1,312 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
import { Doc } from "./_generated/dataModel";
/**
* Listar todos os templates
*/
export const listarTemplates = query({
args: {},
handler: async (ctx) => {
const templates = await ctx.db.query("templatesMensagens").collect();
return templates;
},
});
/**
* Obter template por código
*/
export const obterTemplatePorCodigo = query({
args: {
codigo: v.string(),
},
handler: async (ctx, args) => {
const template = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
.first();
return template;
},
});
/**
* Criar template customizado (apenas TI_MASTER)
*/
export const criarTemplate = mutation({
args: {
codigo: v.string(),
nome: v.string(),
titulo: v.string(),
corpo: v.string(),
variaveis: v.optional(v.array(v.string())),
criadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Verificar se código já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
.first();
if (existente) {
return { sucesso: false as const, erro: "Código de template já existe" };
}
// Criar template
const templateId = await ctx.db.insert("templatesMensagens", {
codigo: args.codigo,
nome: args.nome,
tipo: "customizado",
titulo: args.titulo,
corpo: args.corpo,
variaveis: args.variaveis,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
});
// Log de atividade
await registrarAtividade(
ctx,
args.criadoPorId,
"criar",
"templates",
JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
templateId
);
return { sucesso: true as const, templateId };
},
});
/**
* Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
*/
export const editarTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
nome: v.optional(v.string()),
titulo: v.optional(v.string()),
corpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Não permite editar templates do sistema
if (template.tipo === "sistema") {
return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
}
// Atualizar template
const updates: Partial<Doc<"templatesMensagens">> = {};
if (args.nome !== undefined) updates.nome = args.nome;
if (args.titulo !== undefined) updates.titulo = args.titulo;
if (args.corpo !== undefined) updates.corpo = args.corpo;
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
await ctx.db.patch(args.templateId, updates);
// Log de atividade
await registrarAtividade(
ctx,
args.editadoPorId,
"editar",
"templates",
JSON.stringify(updates),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
*/
export const excluirTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
excluidoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Não permite excluir templates do sistema
if (template.tipo === "sistema") {
return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
}
// Excluir template
await ctx.db.delete(args.templateId);
// Log de atividade
await registrarAtividade(
ctx,
args.excluidoPorId,
"excluir",
"templates",
JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Renderizar template com variáveis
*/
export function renderizarTemplate(template: string, variaveis: Record<string, string>): string {
let resultado = template;
for (const [chave, valor] of Object.entries(variaveis)) {
const placeholder = `{{${chave}}}`;
resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
}
return resultado;
}
/**
* Criar templates padrão do sistema (chamado no seed)
*/
export const criarTemplatesPadrao = mutation({
args: {},
handler: async (ctx) => {
const templatesPadrao = [
{
codigo: "USUARIO_BLOQUEADO",
nome: "Usuário Bloqueado",
titulo: "Sua conta foi bloqueada",
corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
variaveis: ["motivo"],
},
{
codigo: "USUARIO_DESBLOQUEADO",
nome: "Usuário Desbloqueado",
titulo: "Sua conta foi desbloqueada",
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
variaveis: [],
},
{
codigo: "SENHA_RESETADA",
nome: "Senha Resetada",
titulo: "Sua senha foi resetada",
corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
variaveis: ["senha"],
},
{
codigo: "PERMISSAO_ALTERADA",
nome: "Permissão Alterada",
titulo: "Suas permissões foram atualizadas",
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
variaveis: [],
},
{
codigo: "AVISO_GERAL",
nome: "Aviso Geral",
titulo: "{{titulo}}",
corpo: "{{mensagem}}",
variaveis: ["titulo", "mensagem"],
},
{
codigo: "BEM_VINDO",
nome: "Boas-vindas",
titulo: "Bem-vindo ao SGSE",
corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
variaveis: ["nome", "matricula", "senha"],
},
];
for (const template of templatesPadrao) {
// Verificar se já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
.first();
if (!existente) {
await ctx.db.insert("templatesMensagens", {
...template,
tipo: "sistema",
criadoEm: Date.now(),
});
}
}
return { sucesso: true };
},
});
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
import { Doc } from "./_generated/dataModel";
/**
* Listar todos os templates
*/
export const listarTemplates = query({
args: {},
handler: async (ctx) => {
const templates = await ctx.db.query("templatesMensagens").collect();
return templates;
},
});
/**
* Obter template por código
*/
export const obterTemplatePorCodigo = query({
args: {
codigo: v.string(),
},
handler: async (ctx, args) => {
const template = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
.first();
return template;
},
});
/**
* Criar template customizado (apenas TI_MASTER)
*/
export const criarTemplate = mutation({
args: {
codigo: v.string(),
nome: v.string(),
titulo: v.string(),
corpo: v.string(),
variaveis: v.optional(v.array(v.string())),
criadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Verificar se código já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
.first();
if (existente) {
return { sucesso: false as const, erro: "Código de template já existe" };
}
// Criar template
const templateId = await ctx.db.insert("templatesMensagens", {
codigo: args.codigo,
nome: args.nome,
tipo: "customizado",
titulo: args.titulo,
corpo: args.corpo,
variaveis: args.variaveis,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
});
// Log de atividade
await registrarAtividade(
ctx,
args.criadoPorId,
"criar",
"templates",
JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
templateId
);
return { sucesso: true as const, templateId };
},
});
/**
* Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
*/
export const editarTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
nome: v.optional(v.string()),
titulo: v.optional(v.string()),
corpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Não permite editar templates do sistema
if (template.tipo === "sistema") {
return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
}
// Atualizar template
const updates: Partial<Doc<"templatesMensagens">> = {};
if (args.nome !== undefined) updates.nome = args.nome;
if (args.titulo !== undefined) updates.titulo = args.titulo;
if (args.corpo !== undefined) updates.corpo = args.corpo;
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
await ctx.db.patch(args.templateId, updates);
// Log de atividade
await registrarAtividade(
ctx,
args.editadoPorId,
"editar",
"templates",
JSON.stringify(updates),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
*/
export const excluirTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
excluidoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Não permite excluir templates do sistema
if (template.tipo === "sistema") {
return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
}
// Excluir template
await ctx.db.delete(args.templateId);
// Log de atividade
await registrarAtividade(
ctx,
args.excluidoPorId,
"excluir",
"templates",
JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Renderizar template com variáveis
*/
export function renderizarTemplate(template: string, variaveis: Record<string, string>): string {
let resultado = template;
for (const [chave, valor] of Object.entries(variaveis)) {
const placeholder = `{{${chave}}}`;
resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
}
return resultado;
}
/**
* Criar templates padrão do sistema (chamado no seed)
*/
export const criarTemplatesPadrao = mutation({
args: {},
handler: async (ctx) => {
const templatesPadrao = [
{
codigo: "USUARIO_BLOQUEADO",
nome: "Usuário Bloqueado",
titulo: "Sua conta foi bloqueada",
corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
variaveis: ["motivo"],
},
{
codigo: "USUARIO_DESBLOQUEADO",
nome: "Usuário Desbloqueado",
titulo: "Sua conta foi desbloqueada",
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
variaveis: [],
},
{
codigo: "SENHA_RESETADA",
nome: "Senha Resetada",
titulo: "Sua senha foi resetada",
corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
variaveis: ["senha"],
},
{
codigo: "PERMISSAO_ALTERADA",
nome: "Permissão Alterada",
titulo: "Suas permissões foram atualizadas",
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
variaveis: [],
},
{
codigo: "AVISO_GERAL",
nome: "Aviso Geral",
titulo: "{{titulo}}",
corpo: "{{mensagem}}",
variaveis: ["titulo", "mensagem"],
},
{
codigo: "BEM_VINDO",
nome: "Boas-vindas",
titulo: "Bem-vindo ao SGSE",
corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
variaveis: ["nome", "matricula", "senha"],
},
{
codigo: "chat_mensagem",
nome: "Nova Mensagem no Chat",
titulo: "Nova mensagem de {{remetente}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #4F46E5;'>Nova mensagem no chat</h2>"
+ "<p><strong>{{remetente}}</strong> enviou uma nova mensagem:</p>"
+ "<div style='background-color: #F3F4F6; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'>{{mensagem}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/chat?conversa={{conversaId}}' "
+ "style='background-color: #4F46E5; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver conversa"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Você está recebendo este email porque não estava online quando a mensagem foi enviada. "
+ "Você pode desativar essas notificações nas configurações da conversa."
+ "</p>"
+ "</div></body></html>",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
{
codigo: "chat_mencao",
nome: "Menção no Chat",
titulo: "{{remetente}} mencionou você",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #DC2626;'>Você foi mencionado!</h2>"
+ "<p><strong>{{remetente}}</strong> mencionou você em uma mensagem:</p>"
+ "<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'>{{mensagem}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/chat?conversa={{conversaId}}' "
+ "style='background-color: #DC2626; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver mensagem"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Você está recebendo este email porque foi mencionado em uma conversa. "
+ "Você pode desativar essas notificações nas configurações da conversa."
+ "</p>"
+ "</div></body></html>",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
];
for (const template of templatesPadrao) {
// Verificar se já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
.first();
if (!existente) {
await ctx.db.insert("templatesMensagens", {
...template,
tipo: "sistema",
criadoEm: Date.now(),
});
}
}
return { sucesso: true };
},
});