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