feat: enhance scheduling and management of email notifications

- Added functionality to cancel scheduled email notifications, improving user control over their email management.
- Implemented a query to list all scheduled emails for the current user, providing better visibility into upcoming notifications.
- Enhanced the email schema to support scheduling features, including a timestamp for scheduled delivery.
- Improved error handling and user feedback for email scheduling actions, ensuring a smoother user experience.
This commit is contained in:
2025-11-04 00:43:13 -03:00
parent 7fb1693717
commit 3b89c496c6
6 changed files with 2385 additions and 1109 deletions

View File

@@ -4,7 +4,44 @@
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { authStore } from "$lib/stores/auth.svelte";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel";
// Tipos para agendamentos
type TipoAgendamento = "email" | "chat";
type StatusAgendamento = "agendado" | "enviado" | "cancelado";
interface AgendamentoEmail {
_id: Id<"notificacoesEmail">;
_creationTime: number;
destinatario: string;
destinatarioId: Id<"usuarios"> | undefined;
assunto: string;
corpo: string;
templateId: Id<"templatesMensagens"> | undefined;
status: "pendente" | "enviando" | "enviado" | "falha";
agendadaPara: number | undefined;
enviadoPor: Id<"usuarios">;
criadoEm: number;
enviadoEm: number | undefined;
destinatarioInfo: Doc<"usuarios"> | null;
templateInfo: Doc<"templatesMensagens"> | null;
}
interface AgendamentoChat {
_id: Id<"mensagens">;
_creationTime: number;
conversaId: Id<"conversas">;
remetenteId: Id<"usuarios">;
conteudo: string;
agendadaPara: number | undefined;
enviadaEm: number;
conversaInfo: Doc<"conversas"> | null;
destinatarioInfo: Doc<"usuarios"> | null;
}
type Agendamento =
| { tipo: "email"; dados: AgendamentoEmail }
| { tipo: "chat"; dados: AgendamentoChat };
const client = useConvexClient();
@@ -16,12 +53,20 @@
let emailIdsRastreados = $state<Set<string>>(new Set());
// Query para buscar status dos emails
const emailIdsArray = $derived(Array.from(emailIdsRastreados));
const emailIdsArray = $derived(Array.from(emailIdsRastreados).map(id => id as Id<"notificacoesEmail">));
const emailsStatusQuery = useQuery(
api.email.buscarEmailsPorIds,
emailIdsArray.length > 0 ? { emailIds: emailIdsArray as any[] } : undefined
emailIdsArray.length > 0 ? { emailIds: emailIdsArray } : undefined
);
// Queries para agendamentos
const agendamentosEmailQuery = useQuery(api.email.listarAgendamentosEmail, {});
const agendamentosChatQuery = useQuery(api.chat.listarAgendamentosChat, {});
// Filtro de agendamentos
type FiltroAgendamento = "todos" | "agendados" | "enviados";
let filtroAgendamento = $state<FiltroAgendamento>("todos");
// Extrair dados das queries de forma robusta
const templates = $derived.by(() => {
if (templatesQuery === undefined || templatesQuery === null) {
@@ -241,6 +286,151 @@
emailIdsRastreados = new Set();
}
// Extrair e processar agendamentos
const agendamentosEmail = $derived.by(() => {
if (!agendamentosEmailQuery || agendamentosEmailQuery === undefined) return [];
const dados = Array.isArray(agendamentosEmailQuery)
? agendamentosEmailQuery
: "data" in agendamentosEmailQuery && Array.isArray(agendamentosEmailQuery.data)
? agendamentosEmailQuery.data
: [];
return dados as AgendamentoEmail[];
});
const agendamentosChat = $derived.by(() => {
if (!agendamentosChatQuery || agendamentosChatQuery === undefined) return [];
const dados = Array.isArray(agendamentosChatQuery)
? agendamentosChatQuery
: "data" in agendamentosChatQuery && Array.isArray(agendamentosChatQuery.data)
? agendamentosChatQuery.data
: [];
return dados as AgendamentoChat[];
});
// Combinar e processar agendamentos
const todosAgendamentos = $derived.by(() => {
const agendamentos: Agendamento[] = [];
for (const email of agendamentosEmail) {
if (email.agendadaPara) {
agendamentos.push({
tipo: "email",
dados: email,
});
}
}
for (const chat of agendamentosChat) {
if (chat.agendadaPara) {
agendamentos.push({
tipo: "chat",
dados: chat,
});
}
}
// Ordenar: futuros primeiro (mais próximos primeiro), depois passados (mais recentes primeiro)
return agendamentos.sort((a, b) => {
const timestampA = a.tipo === "email" ? (a.dados.agendadaPara ?? 0) : (a.dados.agendadaPara ?? 0);
const timestampB = b.tipo === "email" ? (b.dados.agendadaPara ?? 0) : (b.dados.agendadaPara ?? 0);
const agora = Date.now();
const aFuturo = timestampA > agora;
const bFuturo = timestampB > agora;
// Futuros primeiro
if (aFuturo && !bFuturo) return -1;
if (!aFuturo && bFuturo) return 1;
// Dentro do mesmo grupo, ordenar por timestamp
if (aFuturo) {
// Futuros: mais próximos primeiro
return timestampA - timestampB;
} else {
// Passados: mais recentes primeiro
return timestampB - timestampA;
}
});
});
// Filtrar agendamentos
const agendamentosFiltrados = $derived.by(() => {
if (filtroAgendamento === "todos") return todosAgendamentos;
return todosAgendamentos.filter(ag => {
const status = obterStatusAgendamento(ag);
if (filtroAgendamento === "agendados") return status === "agendado";
if (filtroAgendamento === "enviados") return status === "enviado";
return true;
});
});
// Função para obter status do agendamento
function obterStatusAgendamento(agendamento: Agendamento): StatusAgendamento {
if (agendamento.tipo === "email") {
const email = agendamento.dados;
if (email.status === "enviado") return "enviado";
if (email.agendadaPara && email.agendadaPara <= Date.now()) return "enviado";
return "agendado";
} else {
const chat = agendamento.dados;
if (chat.agendadaPara && chat.agendadaPara <= Date.now()) return "enviado";
return "agendado";
}
}
// Função para cancelar agendamento
async function cancelarAgendamento(agendamento: Agendamento) {
if (!confirm("Tem certeza que deseja cancelar este agendamento?")) {
return;
}
try {
if (agendamento.tipo === "email") {
const resultado = await client.mutation(api.email.cancelarAgendamentoEmail, {
emailId: agendamento.dados._id,
});
if (resultado.sucesso) {
mostrarMensagem("success", "Agendamento de email cancelado com sucesso!");
} else {
mostrarMensagem("error", resultado.erro || "Erro ao cancelar agendamento");
}
} else {
const resultado = await client.mutation(api.chat.cancelarMensagemAgendada, {
mensagemId: agendamento.dados._id,
});
if (resultado.sucesso) {
mostrarMensagem("success", "Agendamento de chat cancelado com sucesso!");
} else {
mostrarMensagem("error", resultado.erro || "Erro ao cancelar agendamento");
}
}
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
mostrarMensagem("error", `Erro ao cancelar agendamento: ${erro}`);
}
}
// Função para obter nome do destinatário
function obterNomeDestinatario(agendamento: Agendamento): string {
if (agendamento.tipo === "email") {
return agendamento.dados.destinatarioInfo?.nome || agendamento.dados.destinatario || "Usuário";
} else {
return agendamento.dados.destinatarioInfo?.nome || "Usuário";
}
}
// Função para formatar data/hora do agendamento
function formatarDataAgendamento(agendamento: Agendamento): string {
const timestamp = agendamento.tipo === "email"
? agendamento.dados.agendadaPara
: agendamento.dados.agendadaPara;
if (!timestamp) return "N/A";
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
}
// Função para formatar timestamp
function formatarTimestamp(timestamp: number): string {
return format(new Date(timestamp), "HH:mm:ss", { locale: ptBR });
@@ -278,9 +468,10 @@
} else {
mostrarMensagem("error", "Erro ao criar templates padrão.");
}
} catch (error: any) {
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
console.error("Erro ao criar templates:", error);
mostrarMensagem("error", "Erro ao criar templates: " + (error.message || "Erro desconhecido"));
mostrarMensagem("error", "Erro ao criar templates: " + erro);
} finally {
criandoTemplates = false;
}
@@ -351,9 +542,10 @@
} else {
mostrarMensagem("error", "Erro ao criar template: " + (resultado.erro || "Erro desconhecido"));
}
} catch (error: any) {
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
console.error("Erro ao criar template:", error);
mostrarMensagem("error", "Erro ao criar template: " + (error.message || "Erro desconhecido"));
mostrarMensagem("error", "Erro ao criar template: " + erro);
} finally {
criandoNovoTemplate = false;
}
@@ -436,7 +628,7 @@
adicionarLog("chat", destinatario.nome, "enviando", "Criando/buscando conversa...");
const conversaResult = await client.mutation(
api.chat.criarOuBuscarConversaIndividual,
{ outroUsuarioId: destinatario._id as any }
{ outroUsuarioId: destinatario._id as Id<"usuarios"> }
);
if (conversaResult.conversaId) {
@@ -468,9 +660,10 @@
} else {
adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa");
}
} catch (error: any) {
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
console.error("Erro ao enviar chat:", error);
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${erro}`);
}
}
@@ -484,7 +677,7 @@
if (template) {
resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, {
destinatario: destinatario.email,
destinatarioId: destinatario._id as any,
destinatarioId: destinatario._id as Id<"usuarios">,
templateCodigo: template.codigo,
variaveis: {
nome: destinatario.nome,
@@ -509,7 +702,7 @@
} else {
resultadoEmail = await client.mutation(api.email.enfileirarEmail, {
destinatario: destinatario.email,
destinatarioId: destinatario._id as any,
destinatarioId: destinatario._id as Id<"usuarios">,
assunto: "Notificação do Sistema",
corpo: mensagemPersonalizada,
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
@@ -526,9 +719,10 @@
adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email");
}
}
} catch (error: any) {
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
console.error("Erro ao enviar email:", error);
adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
adicionarLog("email", destinatario.nome, "erro", `Erro: ${erro}`);
}
} else {
adicionarLog("email", destinatario.nome, "erro", "Destinatário não possui email cadastrado");
@@ -577,7 +771,7 @@
adicionarLog("chat", destinatario.nome, "enviando", "Processando...");
const conversaResult = await client.mutation(
api.chat.criarOuBuscarConversaIndividual,
{ outroUsuarioId: destinatario._id as any }
{ outroUsuarioId: destinatario._id as Id<"usuarios"> }
);
if (conversaResult.conversaId) {
@@ -609,9 +803,10 @@
adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa");
falhasChat++;
}
} catch (error: any) {
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
console.error(`Erro ao enviar chat para ${destinatario.nome}:`, error);
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${erro}`);
falhasChat++;
}
}
@@ -626,7 +821,7 @@
if (template) {
const resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, {
destinatario: destinatario.email,
destinatarioId: destinatario._id as any,
destinatarioId: destinatario._id as Id<"usuarios">,
templateCodigo: template.codigo,
variaveis: {
nome: destinatario.nome,
@@ -654,7 +849,7 @@
} else {
const resultadoEmail = await client.mutation(api.email.enfileirarEmail, {
destinatario: destinatario.email,
destinatarioId: destinatario._id as any,
destinatarioId: destinatario._id as Id<"usuarios">,
assunto: "Notificação do Sistema",
corpo: mensagemPersonalizada,
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
@@ -673,9 +868,10 @@
falhasEmail++;
}
}
} catch (error: any) {
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
console.error(`Erro ao enviar email para ${destinatario.nome}:`, error);
adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
adicionarLog("email", destinatario.nome, "erro", `Erro: ${erro}`);
falhasEmail++;
}
} else {
@@ -723,10 +919,11 @@
agendarEnvio = false;
dataAgendamento = "";
horaAgendamento = "";
} catch (error: any) {
} catch (error) {
const erro = error instanceof Error ? error.message : "Erro desconhecido";
console.error("Erro ao enviar notificação:", error);
adicionarLog("email", "Sistema", "erro", `Erro geral: ${error.message || "Erro desconhecido"}`);
mostrarMensagem("error", "Erro ao enviar notificação: " + (error.message || "Erro desconhecido"));
adicionarLog("email", "Sistema", "erro", `Erro geral: ${erro}`);
mostrarMensagem("error", "Erro ao enviar notificação: " + erro);
} finally {
processando = false;
progressoEnvio = { total: 0, enviados: 0, falhas: 0 };
@@ -1188,6 +1385,162 @@
</div>
</div>
<!-- Histórico de Agendamentos -->
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-secondary/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="card-title">Histórico de Agendamentos</h2>
</div>
<!-- Filtros -->
<div class="flex gap-2">
<button
type="button"
class="btn btn-sm {filtroAgendamento === 'todos' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => filtroAgendamento = "todos"}
>
Todos
</button>
<button
type="button"
class="btn btn-sm {filtroAgendamento === 'agendados' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => filtroAgendamento = "agendados"}
>
Agendados
</button>
<button
type="button"
class="btn btn-sm {filtroAgendamento === 'enviados' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => filtroAgendamento = "enviados"}
>
Enviados
</button>
</div>
</div>
{#if agendamentosFiltrados.length === 0}
<div class="text-center py-10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="font-medium text-base-content mb-2">Nenhum agendamento encontrado</p>
<p class="text-sm text-base-content/60">Os agendamentos aparecerão aqui quando você agendar envios.</p>
</div>
{:else}
<!-- Tabela de Agendamentos -->
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Tipo</th>
<th>Destinatário</th>
<th>Data/Hora</th>
<th>Status</th>
<th>Template</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each agendamentosFiltrados as agendamento}
{@const status = obterStatusAgendamento(agendamento)}
{@const nomeDestinatario = obterNomeDestinatario(agendamento)}
{@const dataFormatada = formatarDataAgendamento(agendamento)}
{@const podeCancelar = status === "agendado"}
{@const templateNome = agendamento.tipo === "email" && agendamento.dados.templateInfo
? agendamento.dados.templateInfo.nome
: agendamento.tipo === "email" && agendamento.dados.templateId
? "Template removido"
: "-"}
<tr>
<td>
<div class="flex items-center gap-2">
{#if agendamento.tipo === "email"}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="badge badge-info badge-sm">Email</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span class="badge badge-primary badge-sm">Chat</span>
{/if}
</div>
</td>
<td>
<div class="font-medium">{nomeDestinatario}</div>
{#if agendamento.tipo === "email"}
<div class="text-xs text-base-content/60">{agendamento.dados.destinatario}</div>
{/if}
</td>
<td>
<div class="font-medium">{dataFormatada}</div>
{#if podeCancelar}
{@const tempoRestante = agendamento.tipo === "email"
? (agendamento.dados.agendadaPara ?? 0) - Date.now()
: (agendamento.dados.agendadaPara ?? 0) - Date.now()}
{@const horasRestantes = Math.floor(tempoRestante / (1000 * 60 * 60))}
{@const minutosRestantes = Math.floor((tempoRestante % (1000 * 60 * 60)) / (1000 * 60))}
{#if horasRestantes < 1 && minutosRestantes < 60}
<div class="text-xs text-warning">Em {minutosRestantes} min</div>
{:else if horasRestantes < 24}
<div class="text-xs text-info">Em {horasRestantes}h {minutosRestantes}min</div>
{/if}
{/if}
</td>
<td>
{#if status === "agendado"}
<span class="badge badge-warning badge-sm">Agendado</span>
{:else if status === "enviado"}
<span class="badge badge-success badge-sm">Enviado</span>
{:else}
<span class="badge badge-error badge-sm">Cancelado</span>
{/if}
</td>
<td>
{#if agendamento.tipo === "email"}
{#if agendamento.dados.templateInfo}
<div class="text-sm">{agendamento.dados.templateInfo.nome}</div>
{:else if agendamento.dados.templateId}
<div class="text-sm text-base-content/60 italic">Template removido</div>
{:else}
<div class="text-sm text-base-content/60">-</div>
{/if}
{:else}
<div class="text-sm text-base-content/60">-</div>
{/if}
</td>
<td>
{#if podeCancelar}
<button
type="button"
class="btn btn-sm btn-error btn-outline"
onclick={() => cancelarAgendamento(agendamento)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Cancelar
</button>
{:else}
<span class="text-sm text-base-content/60">-</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
<!-- Info -->
<div class="alert alert-warning mt-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">