import { v } from "convex/values"; import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server"; import { internal, api } from "./_generated/api"; import { renderizarTemplateEmailFromDoc, type VariaveisTemplate, } from "./templatesMensagens"; import type { Doc, Id } from "./_generated/dataModel"; // ========== INTERNAL QUERIES ========== /** * Obter email por ID (internal query) */ export const getEmailById = internalQuery({ args: { emailId: v.id("notificacoesEmail"), }, handler: async (ctx, args) => { return await ctx.db.get(args.emailId); }, }); /** * Obter configuração SMTP ativa (internal query) */ export const getActiveEmailConfig = internalQuery({ args: {}, handler: async (ctx) => { const config = await ctx.db .query("configuracaoEmail") .withIndex("by_ativo", (q) => q.eq("ativo", true)) .first(); return config; }, }); /** * Listar emails pendentes (internal query) */ export const listarEmailsPendentes = internalQuery({ args: { limite: v.optional(v.number()), }, handler: async (ctx, args) => { const emails = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", "pendente")) .order("asc") // Mais antigos primeiro .take(args.limite || 10); return emails; }, }); // ========== INTERNAL MUTATIONS ========== /** * Marcar email como enviando (internal mutation) */ export const markEmailEnviando = internalMutation({ args: { emailId: v.id("notificacoesEmail"), }, handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); if (!email) return; await ctx.db.patch(args.emailId, { status: "enviando", ultimaTentativa: Date.now(), tentativas: email.tentativas + 1, }); }, }); /** * Marcar email como enviado (internal mutation) */ export const markEmailEnviado = internalMutation({ args: { emailId: v.id("notificacoesEmail"), }, handler: async (ctx, args) => { await ctx.db.patch(args.emailId, { status: "enviado", enviadoEm: Date.now(), }); }, }); /** * Marcar email como falha (internal mutation) */ export const markEmailFalha = internalMutation({ args: { emailId: v.id("notificacoesEmail"), erro: v.string(), }, handler: async (ctx, args) => { await ctx.db.patch(args.emailId, { status: "falha", erroDetalhes: args.erro, ultimaTentativa: Date.now(), }); }, }); // ========== PUBLIC MUTATIONS ========== /** * Enfileirar email para envio assíncrono */ export const enfileirarEmail = mutation({ args: { destinatario: v.string(), destinatarioId: v.optional(v.id("usuarios")), assunto: v.string(), 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, assunto: args.assunto, corpo: args.corpo, templateId: args.templateId, status: "pendente", tentativas: 0, criadoEm: Date.now(), enviadoPor: args.enviadoPor, agendadaPara: args.agendadaPara, }); // Processar imediatamente se não houver agendamento ou se o agendamento já passou const agora = Date.now(); const deveProcessarAgora = args.agendadaPara === undefined || args.agendadaPara <= agora; if (deveProcessarAgora) { // Agendar envio imediato via action (não bloqueia a mutation) ctx.scheduler .runAfter(0, api.actions.email.enviar, { emailId: emailId, }) .catch((error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Erro ao agendar envio imediato de email ${emailId}:`, errorMessage); // Não falha a mutation se houver erro ao agendar - o cron pode processar depois }); } // Emails agendados para o futuro serão processados pelo cron quando a hora chegar return emailId; }, }); /** * Cancelar agendamento de email */ export const cancelarAgendamentoEmail = mutation({ args: { emailId: v.id("notificacoesEmail"), }, handler: async (ctx, args) => { const email = await ctx.db.get(args.emailId); if (!email) { return { sucesso: false, erro: "Email não encontrado" }; } if (email.status !== "pendente") { return { sucesso: false, erro: "Apenas emails pendentes podem ser cancelados" }; } // Remove o email da fila await ctx.db.delete(args.emailId); return { sucesso: true }; }, }); /** * Enviar email usando template */ export const enviarEmailComTemplate = action({ args: { destinatario: v.string(), destinatarioId: v.optional(v.id("usuarios")), 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): Promise> => { // Buscar template 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}`); } // Renderizar template com variáveis const variaveisTemplate: VariaveisTemplate = args.variaveis ?? {}; // Garantir que urlSistema sempre tenha protocolo se presente if ( typeof variaveisTemplate.urlSistema === "string" && !variaveisTemplate.urlSistema.match(/^https?:\/\//i) ) { variaveisTemplate.urlSistema = `http://${variaveisTemplate.urlSistema}`; } const emailRenderizado = renderizarTemplateEmailFromDoc(template, variaveisTemplate); // Enfileirar email via mutation const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, { destinatario: args.destinatario, destinatarioId: args.destinatarioId, assunto: emailRenderizado.titulo, corpo: emailRenderizado.html, // HTML completo com wrapper 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; }, }); // ========== INTERNAL MUTATION (CRON) ========== /** * Processar fila de emails pendentes (chamado pelo cron) */ export const processarFilaEmails = internalMutation({ args: {}, handler: async (ctx) => { 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) => { 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 (emailsParaProcessar.length === 0) { return { processados: 0 }; } // Agendar envio de cada email via action 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: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Erro ao agendar envio de email ${email._id}:`, errorMessage); }); } return { processados: emailsParaProcessar.length }; } }); // ========== QUERIES ========== /** * Listar emails da fila (para monitoramento) */ export const listarFilaEmails = query({ args: { limite: v.optional(v.number()), status: v.optional(v.union( v.literal("pendente"), v.literal("enviando"), 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; // Filtrar por status se fornecido if (args.status) { emails = await ctx.db .query("notificacoesEmail") .withIndex("by_status", (q) => q.eq("status", args.status!)) .order("desc") .take(args.limite || 50); } else { // Sem filtro, buscar todos e ordenar por data de criação const todosEmails = await ctx.db.query("notificacoesEmail").collect(); todosEmails.sort((a, b) => b.criadoEm - a.criadoEm); emails = todosEmails.slice(0, args.limite || 50); } return emails; }, }); /** * Obter estatísticas da fila de emails (para debug e monitoramento) */ export const obterEstatisticasFilaEmails = query({ 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(), enviados: v.number(), falhas: v.number(), total: v.number(), }), handler: async (ctx) => { const todosEmails = await ctx.db.query("notificacoesEmail").collect(); const estatisticas = { pendentes: 0, enviando: 0, enviados: 0, falhas: 0, total: todosEmails.length, }; for (const email of todosEmails) { switch (email.status) { case "pendente": estatisticas.pendentes++; break; case "enviando": estatisticas.enviando++; break; case "enviado": estatisticas.enviados++; break; case "falha": estatisticas.falhas++; break; } } return estatisticas; }, }); /** * Buscar emails por IDs (para monitoramento de status) */ export const buscarEmailsPorIds = query({ args: { emailIds: v.array(v.id("notificacoesEmail")), }, handler: async (ctx, args) => { const emails = []; for (const emailId of args.emailIds) { const email = await ctx.db.get(emailId); if (email) { emails.push(email); } } return emails; }, }); /** * Listar agendamentos de email (emails com agendadaPara definido) */ export const listarAgendamentosEmail = query({ args: {}, handler: async (ctx) => { // Buscar todos os emails agendados (pendentes ou enviando) const emailsAgendados = await ctx.db .query("notificacoesEmail") .filter((q) => { const temAgendamento = q.neq(q.field("agendadaPara"), undefined); const statusValido = q.or( q.eq(q.field("status"), "pendente"), q.eq(q.field("status"), "enviando") ); return q.and(temAgendamento, statusValido); }) .order("asc") .collect(); // Enriquecer com informações de destinatário e template const emailsEnriquecidos = await Promise.all( emailsAgendados.map(async (email) => { const destinatarioInfo = email.destinatarioId ? await ctx.db.get(email.destinatarioId) : null; const templateInfo = email.templateId ? await ctx.db.get(email.templateId) : null; return { ...email, destinatarioInfo, templateInfo, }; }) ); return emailsEnriquecidos; }, }); // ========== PUBLIC MUTATIONS (MANUAL) ========== /** * Processar fila de emails manualmente (para uso em interface) */ export const processarFilaEmailsManual = action({ args: { limite: v.optional(v.number()), }, returns: v.object({ sucesso: v.boolean(), processados: v.number(), falhas: v.number(), erro: v.optional(v.string()), }), handler: async (ctx, args) => { try { // Buscar emails pendentes const emailsPendentes = await ctx.runQuery(internal.email.listarEmailsPendentes, { limite: args.limite || 10, }); if (emailsPendentes.length === 0) { return { sucesso: true, processados: 0, falhas: 0 }; } let processados = 0; let falhas = 0; // Processar cada email for (const email of emailsPendentes) { try { // Agendar envio via action await ctx.scheduler.runAfter(0, api.actions.email.enviar, { emailId: email._id, }); processados++; } 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: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); return { sucesso: false, processados: 0, falhas: 0, erro: errorMessage, }; } }, });