feat: enhance push notification management and error handling

- Implemented error handling for unhandled promise rejections related to message channels, improving stability during push notification operations.
- Updated the PushNotificationManager component to manage push subscription registration with timeouts, preventing application hangs.
- Enhanced the sidebar and chat components to display user avatars, improving user experience and visual consistency.
- Refactored email processing logic to support scheduled email sending, integrating new backend functionalities for better email management.
- Improved overall error handling and logging across components to reduce console spam and enhance debugging capabilities.
This commit is contained in:
2025-11-05 06:14:52 -03:00
parent f6671e0f16
commit aa3e3470cd
20 changed files with 2515 additions and 1665 deletions

View File

@@ -14,9 +14,10 @@ export const enviar = action({
"use node";
const nodemailer = await import("nodemailer");
let email;
try {
// Buscar email da fila
const email = await ctx.runQuery(internal.email.getEmailById, {
email = await ctx.runQuery(internal.email.getEmailById, {
emailId: args.emailId,
});

View File

@@ -36,11 +36,21 @@ export const enviarPush = action({
// Por enquanto, vamos usar uma implementação básica
// Em produção, você precisará configurar VAPID keys
const webpush = await import("web-push");
const webpushModule = await import("web-push");
// web-push pode exportar como default ou named exports
// Usar a declaração de tipo do módulo web-push
interface WebPushType {
setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
sendNotification: (
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string | Buffer
) => Promise<void>;
}
const webpush: WebPushType = (webpushModule.default || webpushModule) as WebPushType;
// VAPID keys devem vir de variáveis de ambiente
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const publicKey: string | undefined = process.env.VAPID_PUBLIC_KEY;
const privateKey: string | undefined = process.env.VAPID_PRIVATE_KEY;
if (!publicKey || !privateKey) {
console.warn("⚠️ VAPID keys não configuradas. Push notifications não funcionarão.");
@@ -75,7 +85,7 @@ export const enviarPush = action({
console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
return { sucesso: true };
} catch (error) {
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("❌ Erro ao enviar push notification:", errorMessage);

View File

@@ -3,6 +3,9 @@
import { action } from "../_generated/server";
import { v } from "convex/values";
// Importar nodemailer de forma estática para evitar problemas com caminhos no Windows
import nodemailer from "nodemailer";
export const testarConexao = action({
args: {
servidor: v.string(),
@@ -17,8 +20,6 @@ export const testarConexao = action({
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
"use node";
const nodemailer = await import("nodemailer");
try {
// Validações básicas

View File

@@ -1,4 +1,5 @@
"use node";
/**
* Utilitários de criptografia compatíveis com Node.js
* Para uso em actions que rodam em ambiente Node.js

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,13 @@ crons.interval(
internal.chat.enviarMensagensAgendadas
);
// Processar fila de emails (incluindo agendados) a cada minuto
crons.interval(
"processar-fila-emails",
{ minutes: 1 },
internal.email.processarFilaEmails
);
// Limpar indicadores de digitação antigos (>10s) a cada minuto
crons.interval(
"limpar-indicadores-digitacao",
@@ -33,13 +40,5 @@ crons.interval(
{}
);
// Processar fila de emails pendentes a cada 2 minutos
crons.interval(
"processar-fila-emails",
{ minutes: 2 },
internal.email.processarFilaEmails,
{}
);
export default crons;

View File

@@ -2,6 +2,7 @@ import { v } from "convex/values";
import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server";
import { internal, api } from "./_generated/api";
import { renderizarTemplate } from "./templatesMensagens";
import type { Doc, Id } from "./_generated/dataModel";
// ========== INTERNAL QUERIES ==========
@@ -116,8 +117,14 @@ export const enfileirarEmail = mutation({
corpo: v.string(),
templateId: v.optional(v.id("templatesMensagens")),
enviadoPor: v.id("usuarios"), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento
},
handler: async (ctx, args) => {
// Validar agendamento se fornecido
if (args.agendadaPara !== undefined && args.agendadaPara <= Date.now()) {
throw new Error("Data de agendamento deve ser futura");
}
const emailId = await ctx.db.insert("notificacoesEmail", {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
@@ -128,8 +135,13 @@ export const enfileirarEmail = mutation({
tentativas: 0,
criadoEm: Date.now(),
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,
});
// O cron job processará emails automaticamente:
// - Emails sem agendamento serão processados imediatamente (próxima execução do cron)
// - Emails agendados serão processados quando a hora chegar
return emailId;
},
});
@@ -144,12 +156,16 @@ export const enviarEmailComTemplate = action({
templateCodigo: v.string(),
variaveis: v.optional(v.record(v.string(), v.string())),
enviadoPor: v.id("usuarios"), // Obrigatório conforme schema
agendadaPara: v.optional(v.number()), // timestamp opcional para agendamento
},
handler: async (ctx, args) => {
handler: async (ctx, args): Promise<Id<"notificacoesEmail">> => {
// Buscar template
const template = await ctx.runQuery(api.templatesMensagens.obterTemplatePorCodigo, {
codigo: args.templateCodigo,
});
const template: Doc<"templatesMensagens"> | null = await ctx.runQuery(
api.templatesMensagens.obterTemplatePorCodigo,
{
codigo: args.templateCodigo,
}
);
if (!template) {
throw new Error(`Template não encontrado: ${args.templateCodigo}`);
@@ -160,16 +176,21 @@ export const enviarEmailComTemplate = action({
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate);
// Enfileirar email
const emailId = await ctx.runMutation(api.email.enfileirarEmail, {
// Enfileirar email via mutation
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: tituloRenderizado,
corpo: corpoRenderizado,
templateId: template._id,
templateId: template._id, // template._id sempre existe se template não é null
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,
});
if (!emailId) {
throw new Error("Erro ao enfileirar email: ID não retornado");
}
return emailId;
},
});
@@ -182,29 +203,45 @@ export const enviarEmailComTemplate = action({
export const processarFilaEmails = internalMutation({
args: {},
handler: async (ctx) => {
// Buscar emails pendentes (limitado a 10 por vez para não sobrecarregar)
const emailsPendentes = await ctx.db
const agora = Date.now();
// Buscar emails pendentes que devem ser processados agora
// (sem agendamento OU com agendamento que já passou)
const emailsParaProcessar = await ctx.db
.query("notificacoesEmail")
.filter((q) => q.eq(q.field("status"), "pendente"))
.filter((q) => {
const statusPendente = q.eq(q.field("status"), "pendente");
const semAgendamento = q.eq(q.field("agendadaPara"), undefined);
const agendamentoJaPassou = q.and(
q.neq(q.field("agendadaPara"), undefined),
q.lte(q.field("agendadaPara"), agora)
);
return q.and(
statusPendente,
q.or(semAgendamento, agendamentoJaPassou)
);
})
.order("asc") // Mais antigos primeiro
.take(10);
if (emailsPendentes.length === 0) {
if (emailsParaProcessar.length === 0) {
return { processados: 0 };
}
// Agendar envio de cada email via action
for (const email of emailsPendentes) {
for (const email of emailsParaProcessar) {
// Agendar envio assíncrono (não bloqueia o cron)
ctx.scheduler.runAfter(0, api.actions.email.enviar, {
emailId: email._id,
}).catch((error) => {
console.error(`Erro ao agendar envio de email ${email._id}:`, error);
}).catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
});
}
return { processados: emailsPendentes.length };
},
return { processados: emailsParaProcessar.length };
}
});
// ========== QUERIES ==========
@@ -221,6 +258,7 @@ export const listarFilaEmails = query({
v.literal("enviado"),
v.literal("falha")
)),
_refresh: v.optional(v.number()), // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
handler: async (ctx, args) => {
let emails;
@@ -247,7 +285,9 @@ export const listarFilaEmails = query({
* Obter estatísticas da fila de emails (para debug e monitoramento)
*/
export const obterEstatisticasFilaEmails = query({
args: {},
args: {
_refresh: v.optional(v.number()), // Parâmetro ignorado, usado apenas para forçar refresh no frontend
},
returns: v.object({
pendentes: v.number(),
enviando: v.number(),
@@ -324,14 +364,15 @@ export const processarFilaEmailsManual = action({
emailId: email._id,
});
processados++;
} catch (error) {
console.error(`Erro ao agendar envio de email ${email._id}:`, error);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage);
falhas++;
}
}
return { sucesso: true, processados, falhas };
} catch (error) {
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
sucesso: false,

View File

@@ -152,11 +152,12 @@ export const enviarPushNotification = internalMutation({
}
// Se há conversaId, verificar preferências específicas da conversa
if (args.data?.conversaId) {
const conversaId = args.data?.conversaId;
if (conversaId) {
const preferencias = await ctx.db
.query("preferenciasNotificacaoConversa")
.withIndex("by_usuario_conversa", (q) =>
q.eq("usuarioId", args.usuarioId).eq("conversaId", args.data.conversaId)
q.eq("usuarioId", args.usuarioId).eq("conversaId", conversaId)
)
.first();
@@ -167,7 +168,7 @@ export const enviarPushNotification = internalMutation({
}
// Se apenas menções e não é menção, não enviar
if (preferencias.apenasMencoes && args.data.tipo !== "mencao") {
if (preferencias.apenasMencoes && args.data?.tipo !== "mencao") {
return { enviados: 0, falhas: 0 };
}
}
@@ -177,17 +178,28 @@ export const enviarPushNotification = internalMutation({
let enviados = 0;
let falhas = 0;
// Converter IDs para strings ao passar para a action
// A action espera strings, mas recebemos Ids do Convex
const dataParaAction = args.data
? {
conversaId: args.data.conversaId ? String(args.data.conversaId) : undefined,
mensagemId: args.data.mensagemId ? String(args.data.mensagemId) : undefined,
tipo: args.data.tipo,
}
: undefined;
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,
data: dataParaAction,
});
enviados++;
} catch (error) {
console.error(`Erro ao agendar push para subscription ${subscription._id}:`, error);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Erro ao agendar push para subscription ${subscription._id}:`, errorMessage);
falhas++;
}
}

View File

@@ -0,0 +1,46 @@
declare module "web-push" {
export interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface SendOptions {
TTL?: number;
headers?: Record<string, string>;
vapidDetails?: {
subject: string;
publicKey: string;
privateKey: string;
};
}
export function setVapidDetails(
subject: string,
publicKey: string,
privateKey: string
): void;
export function sendNotification(
subscription: PushSubscription,
payload: string | Buffer,
options?: SendOptions
): Promise<void>;
export function generateVAPIDKeys(): {
publicKey: string;
privateKey: string;
};
interface WebPush {
setVapidDetails: typeof setVapidDetails;
sendNotification: typeof sendNotification;
generateVAPIDKeys: typeof generateVAPIDKeys;
}
const webpush: WebPush;
export default webpush;
}

View File

@@ -4,7 +4,7 @@ import { hashPassword, generateToken } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades";
import { Id, Doc } from "./_generated/dataModel";
import { api } from "./_generated/api";
import type { QueryCtx } from "./_generated/server";
import type { QueryCtx, MutationCtx } from "./_generated/server";
/**
* Helper para obter a matrícula do usuário (do funcionário se houver)
@@ -20,6 +20,38 @@ async function obterMatriculaUsuario(
return undefined;
}
/**
* Helper para obter usuário autenticado (Better Auth ou Sessão)
* Usa a mesma lógica do obterPerfil para garantir consistência
*/
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
// Tentar autenticação via Better Auth primeiro
const identity = await ctx.auth.getUserIdentity();
let usuarioAtual = null;
if (identity && identity.email) {
usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
}
// Se não encontrou via Better Auth, tentar via sessão mais recente
if (!usuarioAtual) {
const sessaoAtiva = await ctx.db
.query("sessoes")
.filter((q) => q.eq(q.field("ativo"), true))
.order("desc")
.first();
if (sessaoAtiva) {
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
}
}
return usuarioAtual;
}
/**
* Associar funcionário a um usuário
*/
@@ -761,15 +793,25 @@ export const listarParaChat = query({
})
),
handler: async (ctx) => {
// Obter usuário autenticado usando função helper compartilhada
const usuarioAtual = await getUsuarioAutenticado(ctx);
// Buscar todos os usuários ativos
const usuarios = await ctx.db
.query("usuarios")
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
// Filtrar o usuário atual da lista apenas se conseguimos identificá-lo com certeza
// Se não conseguimos identificar (usuarioAtual é null), retornar todos
// O frontend fará um filtro adicional usando obterPerfil como camada de segurança
const usuariosFiltrados = usuarioAtual
? usuarios.filter((u) => u._id !== usuarioAtual._id)
: usuarios;
// Buscar foto de perfil URL para cada usuário
const usuariosComFoto = await Promise.all(
usuarios.map(async (usuario) => {
usuariosFiltrados.map(async (usuario) => {
let fotoPerfilUrl = null;
if (usuario.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);