Files
sgse-app/packages/backend/convex/email.ts

509 lines
14 KiB
TypeScript

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 { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper";
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<Id<"notificacoesEmail">> => {
// 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 = args.variaveis || {};
// Garantir que urlSistema sempre tenha protocolo se presente
if (variaveisTemplate.urlSistema && !variaveisTemplate.urlSistema.match(/^https?:\/\//i)) {
variaveisTemplate.urlSistema = `http://${variaveisTemplate.urlSistema}`;
}
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate);
// Usar htmlCorpo se disponível, senão gerar do corpo
let corpoHTML = template.htmlCorpo;
if (corpoHTML) {
// Renderizar variáveis no HTML
corpoHTML = renderizarTemplate(corpoHTML, variaveisTemplate);
} else {
// Gerar HTML do corpo renderizado
if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) {
corpoHTML = wrapEmailHTML(corpoRenderizado, tituloRenderizado);
} else {
const corpoHTMLFormatado = textToHTML(corpoRenderizado);
corpoHTML = wrapEmailHTML(corpoHTMLFormatado, tituloRenderizado);
}
}
// Enfileirar email via mutation
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: tituloRenderizado,
corpo: corpoHTML, // Usar HTML completo
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,
};
}
},
});