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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
46
packages/backend/convex/types/web-push.d.ts
vendored
Normal file
46
packages/backend/convex/types/web-push.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user