import { v } from 'convex/values'; import { api, internal } from './_generated/api'; import type { Doc, Id } from './_generated/dataModel'; import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'; import { renderizarTemplateEmailFromDoc, type VariaveisTemplate } from './templatesMensagens'; // ========== 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, enviando ou já enviados que tinham agendamento) const emailsAgendados = await ctx.db .query('notificacoesEmail') .filter((q) => { // Apenas emails que têm agendadaPara definido return q.neq(q.field('agendadaPara'), undefined); }) .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 }; } } });