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:
2025-11-05 06:14:52 -03:00
parent f6671e0f16
commit aa3e3470cd
20 changed files with 2515 additions and 1665 deletions

View File

@@ -12,51 +12,116 @@
const client = useConvexClient();
// Capturar erros de Promise não tratados relacionados a message channel
// Este erro geralmente vem de extensões do Chrome ou comunicação com Service Worker
if (typeof window !== "undefined") {
window.addEventListener(
"unhandledrejection",
(event: PromiseRejectionEvent) => {
const reason = event.reason;
const errorMessage =
reason?.message || reason?.toString() || "";
// Filtrar apenas erros relacionados a message channel fechado
if (
errorMessage.includes("message channel closed") ||
errorMessage.includes("asynchronous response") ||
(errorMessage.includes("message channel") &&
errorMessage.includes("closed"))
) {
// Prevenir que o erro apareça no console
event.preventDefault();
// Silenciar o erro - é geralmente causado por extensões do Chrome
return false;
}
},
{ capture: true }
);
}
onMount(async () => {
let checkAuth: ReturnType<typeof setInterval> | null = null;
let mounted = true;
// Aguardar usuário estar autenticado
const checkAuth = setInterval(async () => {
if (authStore.usuario) {
clearInterval(checkAuth);
await registrarPushSubscription();
checkAuth = setInterval(async () => {
if (authStore.usuario && mounted) {
clearInterval(checkAuth!);
checkAuth = null;
try {
await registrarPushSubscription();
} catch (error) {
// Silenciar erros de push subscription para evitar spam no console
if (error instanceof Error && !error.message.includes("message channel")) {
console.error("Erro ao configurar push notifications:", error);
}
}
}
}, 500);
// Limpar intervalo após 30 segundos (timeout)
setTimeout(() => {
clearInterval(checkAuth);
const timeout = setTimeout(() => {
if (checkAuth) {
clearInterval(checkAuth);
checkAuth = null;
}
}, 30000);
return () => {
clearInterval(checkAuth);
mounted = false;
if (checkAuth) {
clearInterval(checkAuth);
}
clearTimeout(timeout);
};
});
async function registrarPushSubscription() {
try {
// Solicitar subscription
const subscription = await solicitarPushSubscription();
// Verificar se Service Worker está disponível antes de tentar
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return;
}
// Solicitar subscription com timeout para evitar travamentos
const subscriptionPromise = solicitarPushSubscription();
const timeoutPromise = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 5000)
);
const subscription = await Promise.race([subscriptionPromise, timeoutPromise]);
if (!subscription) {
console.log(" Push subscription não disponível ou permissão negada");
// Não logar para evitar spam no console quando VAPID key não está configurada
return;
}
// Converter para formato serializável
const subscriptionData = subscriptionToJSON(subscription);
// Registrar no backend
const resultado = await client.mutation(api.pushNotifications.registrarPushSubscription, {
// Registrar no backend com timeout
const mutationPromise = client.mutation(api.pushNotifications.registrarPushSubscription, {
endpoint: subscriptionData.endpoint,
keys: subscriptionData.keys,
userAgent: navigator.userAgent,
});
const timeoutMutationPromise = new Promise<{ sucesso: false; erro: string }>((resolve) =>
setTimeout(() => resolve({ sucesso: false, erro: "Timeout" }), 5000)
);
const resultado = await Promise.race([mutationPromise, timeoutMutationPromise]);
if (resultado.sucesso) {
console.log("✅ Push subscription registrada com sucesso");
} else {
} else if (resultado.erro && !resultado.erro.includes("Timeout")) {
console.error("❌ Erro ao registrar push subscription:", resultado.erro);
}
} catch (error) {
// Ignorar erros relacionados a message channel fechado
if (error instanceof Error && error.message.includes("message channel")) {
return;
}
console.error("❌ Erro ao configurar push notifications:", error);
}
}

View File

@@ -11,6 +11,7 @@
import ChatWidget from "$lib/components/chat/ChatWidget.svelte";
import PresenceManager from "$lib/components/chat/PresenceManager.svelte";
import { getBrowserInfo } from "$lib/utils/browserInfo";
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
let { children }: { children: Snippet } = $props();
@@ -19,6 +20,22 @@
// Caminho atual da página
const currentPath = $derived(page.url.pathname);
// Função para obter a URL do avatar/foto do usuário
const avatarUrlDoUsuario = $derived(() => {
const usuario = authStore.usuario;
if (!usuario) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
return usuario.fotoPerfilUrl;
}
if (usuario.avatar) {
return getAvatarUrl(usuario.avatar);
}
// Fallback: gerar avatar baseado no nome
return getAvatarUrl(usuario.nome);
});
// Função para gerar classes do menu ativo
function getMenuClasses(isActive: boolean) {
const baseClasses = "group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105";
@@ -209,12 +226,14 @@
</p>
</div>
</div>
<div class="flex-none flex items-center gap-4">
<div class="flex-none flex items-center gap-4 ml-auto">
{#if authStore.autenticado}
<!-- Sino de notificações -->
<NotificationBell />
<!-- Sino de notificações no canto superior direito -->
<div class="relative">
<NotificationBell />
</div>
<div class="hidden lg:flex flex-col items-end">
<div class="hidden lg:flex flex-col items-end mr-2">
<span class="text-sm font-semibold text-primary">{authStore.usuario?.nome}</span>
<span class="text-xs text-base-content/60">{authStore.usuario?.role.nome}</span>
</div>
@@ -233,19 +252,28 @@
<!-- Anel de pulso sutil -->
<div class="absolute inset-0 rounded-2xl" style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"></div>
<!-- Ícone de usuário moderno -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-7 h-7 text-white relative z-10 group-hover:scale-110 transition-transform duration-300"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
>
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
</svg>
<!-- Avatar/Foto do usuário ou ícone padrão -->
{#if avatarUrlDoUsuario()}
<img
src={avatarUrlDoUsuario()}
alt={authStore.usuario?.nome || "Usuário"}
class="w-full h-full object-cover relative z-10"
/>
{:else}
<!-- Ícone de usuário moderno (fallback) -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-7 h-7 text-white relative z-10 group-hover:scale-110 transition-transform duration-300"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
>
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
</svg>
{/if}
<!-- Badge de status online -->
<div class="absolute top-1 right-1 w-3 h-3 bg-success rounded-full border-2 border-white shadow-lg" style="animation: pulse-dot 2s ease-in-out infinite;"></div>
<div class="absolute top-1 right-1 w-3 h-3 bg-success rounded-full border-2 border-white shadow-lg z-20" style="animation: pulse-dot 2s ease-in-out infinite;"></div>
</button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52 mt-4 border border-primary/20">
@@ -295,36 +323,36 @@
<div class="drawer lg:drawer-open" style="margin-top: 96px;">
<input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col lg:ml-72" style="height: calc(100vh - 96px);">
<div class="drawer-content flex flex-col lg:ml-72" style="min-height: calc(100vh - 96px);">
<!-- Page content -->
<div class="flex-1 overflow-y-auto">
{@render children?.()}
<!-- Footer -->
<footer class="footer footer-center bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm text-base-content p-6 border-t-2 border-primary/20 shadow-inner mt-8">
<div class="grid grid-flow-col gap-6 text-sm font-medium">
<button type="button" class="link link-hover hover:text-primary transition-colors" onclick={() => openAboutModal()}>Sobre</button>
<span class="text-base-content/30"></span>
<a href="/" class="link link-hover hover:text-primary transition-colors">Contato</a>
<span class="text-base-content/30"></span>
<a href="/" class="link link-hover hover:text-primary transition-colors">Suporte</a>
<span class="text-base-content/30"></span>
<a href="/" class="link link-hover hover:text-primary transition-colors">Privacidade</a>
</div>
<div class="flex items-center gap-3 mt-2">
<div class="avatar">
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
</div>
</div>
<div class="text-left">
<p class="text-xs font-bold text-primary">Governo do Estado de Pernambuco</p>
<p class="text-xs text-base-content/70">Secretaria de Esportes</p>
</div>
</div>
<p class="text-xs text-base-content/60 mt-2">© {new Date().getFullYear()} - Todos os direitos reservados</p>
</footer>
</div>
<!-- Footer -->
<footer class="footer footer-center bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm text-base-content p-6 border-t-2 border-primary/20 shadow-inner flex-shrink-0">
<div class="grid grid-flow-col gap-6 text-sm font-medium">
<button type="button" class="link link-hover hover:text-primary transition-colors" onclick={() => openAboutModal()}>Sobre</button>
<span class="text-base-content/30"></span>
<a href="/" class="link link-hover hover:text-primary transition-colors">Contato</a>
<span class="text-base-content/30"></span>
<a href="/" class="link link-hover hover:text-primary transition-colors">Suporte</a>
<span class="text-base-content/30"></span>
<a href="/" class="link link-hover hover:text-primary transition-colors">Privacidade</a>
</div>
<div class="flex items-center gap-3 mt-2">
<div class="avatar">
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
</div>
</div>
<div class="text-left">
<p class="text-xs font-bold text-primary">Governo do Estado de Pernambuco</p>
<p class="text-xs text-base-content/70">Secretaria de Esportes</p>
</div>
</div>
<p class="text-xs text-base-content/60 mt-2">© {new Date().getFullYear()} - Todos os direitos reservados</p>
</footer>
</div>
<div class="drawer-side z-40 fixed" style="margin-top: 96px;">
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"

View File

@@ -21,17 +21,36 @@
$effect(() => {
console.log("📊 [ChatList] Usuários carregados:", usuarios?.data?.length || 0);
console.log("👤 [ChatList] Meu perfil:", meuPerfil?.data?.nome || "Carregando...");
console.log("📋 [ChatList] Lista completa:", usuarios?.data);
console.log("🆔 [ChatList] Meu ID:", meuPerfil?.data?._id || "Não encontrado");
if (usuarios?.data) {
const meuId = meuPerfil?.data?._id;
const meusDadosNaLista = usuarios.data.find((u: any) => u._id === meuId);
if (meusDadosNaLista) {
console.warn("⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!", meusDadosNaLista.nome);
}
}
});
const usuariosFiltrados = $derived.by(() => {
if (!usuarios?.data || !Array.isArray(usuarios.data) || !meuPerfil?.data) return [];
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
if (!meuPerfil?.data) {
console.log("⏳ [ChatList] Aguardando perfil do usuário...");
return [];
}
const meuId = meuPerfil.data._id;
// Filtrar o próprio usuário da lista
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
// Log se ainda estiver na lista após filtro (não deveria acontecer)
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId);
if (aindaNaLista) {
console.error("❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!");
}
// Aplicar busca por nome/email/matrícula
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
@@ -159,6 +178,24 @@
onclick={() => handleClickUsuario(usuario)}
disabled={processando}
>
<!-- Ícone de mensagem -->
<div class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-primary"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M9 10h.01M15 10h.01"/>
</svg>
</div>
<!-- Avatar -->
<div class="relative flex-shrink-0">
<UserAvatar

View File

@@ -12,6 +12,8 @@
import { api } from "@sgse-app/backend/convex/_generated/api";
import ChatList from "./ChatList.svelte";
import ChatWindow from "./ChatWindow.svelte";
import { authStore } from "$lib/stores/auth.svelte";
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
@@ -19,6 +21,22 @@
let isMinimized = $state(false);
let activeConversation = $state<string | null>(null);
// Função para obter a URL do avatar/foto do usuário logado
const avatarUrlDoUsuario = $derived(() => {
const usuario = authStore.usuario;
if (!usuario) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
return usuario.fotoPerfilUrl;
}
if (usuario.avatar) {
return getAvatarUrl(usuario.avatar);
}
// Fallback: gerar avatar baseado no nome
return getAvatarUrl(usuario.nome);
});
// Posição do widget (arrastável)
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
@@ -259,25 +277,34 @@
<!-- Efeitos de fundo animados -->
<div class="absolute inset-0 opacity-30" style="background: radial-gradient(circle at 20% 50%, rgba(255,255,255,0.3) 0%, transparent 50%);"></div>
<div class="absolute inset-0 opacity-20" style="background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%); animation: shimmer 3s infinite;"></div>
<!-- Título com ícone moderno 3D -->
<!-- Título com avatar/foto do usuário logado -->
<h2 class="text-xl font-bold flex items-center gap-3 relative z-10">
<!-- Ícone de chat com efeito glassmorphism -->
<div class="relative flex items-center justify-center w-10 h-10 rounded-xl" style="background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(255,255,255,0.2) inset;">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="15" y2="10"/>
<line x1="9" y1="14" x2="13" y2="14"/>
</svg>
<!-- Avatar/Foto do usuário logado com efeito glassmorphism -->
<div class="relative flex items-center justify-center w-10 h-10 rounded-xl overflow-hidden" style="background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(255,255,255,0.2) inset;">
{#if avatarUrlDoUsuario()}
<img
src={avatarUrlDoUsuario()}
alt={authStore.usuario?.nome || "Usuário"}
class="w-full h-full object-cover"
/>
{:else}
<!-- Fallback: ícone de chat genérico -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="15" y2="10"/>
<line x1="9" y1="14" x2="13" y2="14"/>
</svg>
{/if}
</div>
<span class="tracking-wide font-extrabold" style="text-shadow: 0 2px 8px rgba(0,0,0,0.3); letter-spacing: 0.02em;">Mensagens</span>
</h2>

View File

@@ -77,23 +77,22 @@
<!-- Botão Voltar -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
class="btn btn-ghost btn-sm btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
onclick={voltarParaLista}
aria-label="Voltar"
title="Voltar para lista de conversas"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
stroke="currentColor"
class="w-5 h-5"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-6 h-6 text-primary"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
/>
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</button>

View File

@@ -21,8 +21,19 @@
let shouldScrollToBottom = true;
let lastMessageCount = 0;
// Obter ID do usuário atual
const usuarioAtualId = $derived(authStore.usuario?._id);
// Obter ID do usuário atual - usar $state para garantir reatividade
let usuarioAtualId = $state<string | null>(null);
// Atualizar usuarioAtualId sempre que authStore.usuario mudar
$effect(() => {
const usuario = authStore.usuario;
if (usuario?._id) {
const idStr = String(usuario._id).trim();
usuarioAtualId = idStr || null;
} else {
usuarioAtualId = null;
}
});
// Auto-scroll para a última mensagem quando novas mensagens chegam
$effect(() => {
@@ -49,8 +60,11 @@
$effect(() => {
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null);
// Só marcar como lida se não for minha mensagem
if (ultimaMensagem.remetente?._id !== usuarioAtualId) {
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId) {
client.mutation(api.chat.marcarComoLida, {
conversaId,
mensagemId: ultimaMensagem._id,
@@ -77,6 +91,7 @@
interface Mensagem {
_id: Id<"mensagens">;
remetenteId: Id<"usuarios">;
remetente?: {
_id: Id<"usuarios">;
nome: string;
@@ -226,11 +241,26 @@
<!-- Mensagens do dia -->
{#each mensagensDia as mensagem (mensagem._id)}
{@const isMinha = mensagem.remetente?._id === usuarioAtualId}
{@const remetenteIdStr = (() => {
// Priorizar remetenteId direto da mensagem
if (mensagem.remetenteId) {
return String(mensagem.remetenteId).trim();
}
// Fallback para remetente._id
if (mensagem.remetente?._id) {
return String(mensagem.remetente._id).trim();
}
return null;
})()}
{@const isMinha = usuarioAtualId && remetenteIdStr && remetenteIdStr === usuarioAtualId}
<div class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}>
<div class={`flex flex-col max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
<!-- Nome do remetente (apenas se não for minha) -->
{#if !isMinha}
<!-- Nome do remetente (sempre exibido, mas discreto para mensagens próprias) -->
{#if isMinha}
<p class="text-xs text-base-content/40 mb-1 px-3">
Você
</p>
{:else}
<p class="text-xs text-base-content/60 mb-1 px-3">
{mensagem.remetente?.nome || "Usuário"}
</p>
@@ -240,7 +270,7 @@
<div
class={`rounded-2xl px-4 py-2 ${
isMinha
? "bg-primary text-primary-content rounded-br-sm"
? "bg-blue-200 text-gray-900 rounded-br-sm"
: "bg-base-200 text-base-content rounded-bl-sm"
}`}
>

View File

@@ -74,13 +74,44 @@ export async function registrarServiceWorker(): Promise<ServiceWorkerRegistratio
}
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
// Verificar se já existe um Service Worker ativo antes de registrar
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
if (existingRegistration?.active) {
return existingRegistration;
}
// Registrar com timeout para evitar travamentos
const registerPromise = navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
console.log("Service Worker registrado:", registration);
const timeoutPromise = new Promise<ServiceWorkerRegistration | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registerPromise, timeoutPromise]);
if (registration) {
// Log apenas em desenvolvimento
if (import.meta.env.DEV) {
console.log("Service Worker registrado:", registration);
}
}
return registration;
} catch (error) {
console.error("Erro ao registrar Service Worker:", error);
// Ignorar erros silenciosamente para evitar spam no console
// especialmente erros relacionados a message channel
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
!errorMessage.includes("message channel") &&
!errorMessage.includes("registration") &&
import.meta.env.DEV
) {
console.error("Erro ao registrar Service Worker:", error);
}
}
return null;
}
}
@@ -89,52 +120,80 @@ export async function registrarServiceWorker(): Promise<ServiceWorkerRegistratio
* Solicitar subscription de push notification
*/
export async function solicitarPushSubscription(): Promise<PushSubscription | null> {
// Registrar service worker primeiro
const registration = await registrarServiceWorker();
if (!registration) {
return null;
}
// Verificar se push está disponível
if (!("PushManager" in window)) {
console.warn("Push notifications não são suportadas neste navegador");
return null;
}
// Solicitar permissão
const permission = await requestNotificationPermission();
if (permission !== "granted") {
console.warn("Permissão para notificações negada");
return null;
}
try {
// Obter subscription existente ou criar nova
let subscription = await registration.pushManager.getSubscription();
// Registrar service worker primeiro com timeout
const registrationPromise = registrarServiceWorker();
const timeoutPromise = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registrationPromise, timeoutPromise]);
if (!registration) {
return null;
}
// Verificar se push está disponível
if (!("PushManager" in window)) {
return null;
}
// Solicitar permissão com timeout
const permissionPromise = requestNotificationPermission();
const permissionTimeoutPromise = new Promise<NotificationPermission>((resolve) =>
setTimeout(() => resolve("denied"), 3000)
);
const permission = await Promise.race([permissionPromise, permissionTimeoutPromise]);
if (permission !== "granted") {
return null;
}
// Obter subscription existente ou criar nova com timeout
const getSubscriptionPromise = registration.pushManager.getSubscription();
const getSubscriptionTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
let subscription = await Promise.race([getSubscriptionPromise, getSubscriptionTimeoutPromise]);
if (!subscription) {
// VAPID public key deve vir do backend ou config
// Por enquanto, usando uma chave pública de exemplo
// Em produção, isso deve vir de uma variável de ambiente ou API
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY || "";
if (!vapidPublicKey) {
console.warn("VAPID public key não configurada");
// Não logar warning para evitar spam no console
return null;
}
// Converter chave para formato Uint8Array
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({
// Subscribe com timeout
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
const subscribeTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 5000)
);
subscription = await Promise.race([subscribePromise, subscribeTimeoutPromise]);
}
return subscription;
} catch (error) {
console.error("Erro ao obter subscription:", error);
// Ignorar erros relacionados a message channel ou service worker
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
errorMessage.includes("message channel") ||
errorMessage.includes("service worker") ||
errorMessage.includes("registration")
) {
return null;
}
}
return null;
}
}

View File

@@ -27,6 +27,7 @@
// Estados locais para atualização imediata
let fotoPerfilLocal = $state<string | null>(null);
let avatarLocal = $state<string | null>(null);
let perfilCarregado = $state(false);
// Estados para Minhas Férias
let mostrarWizard = $state(false);
@@ -43,13 +44,32 @@
// Galeria de avatares (30 avatares profissionais 3D realistas)
const avatarGallery = generateAvatarGallery(30);
// Sincronizar com authStore
// Carregar perfil ao montar a página para garantir dados atualizados (apenas uma vez)
$effect(() => {
if (authStore.usuario?.fotoPerfilUrl !== undefined) {
fotoPerfilLocal = authStore.usuario.fotoPerfilUrl;
if (authStore.autenticado && authStore.usuario && !perfilCarregado) {
perfilCarregado = true;
// Atualizar authStore com dados mais recentes do backend
authStore.refresh().catch((error) => {
console.error("Erro ao carregar perfil:", error);
perfilCarregado = false; // Permite tentar novamente em caso de erro
});
}
if (authStore.usuario?.avatar !== undefined) {
avatarLocal = authStore.usuario.avatar;
});
// Sincronizar com authStore - atualiza automaticamente quando o authStore muda
// Isso garante que a foto/avatar seja carregada imediatamente ao abrir a página
$effect(() => {
const usuario = authStore.usuario;
if (usuario) {
// Atualizar foto de perfil (pode ser null ou string)
fotoPerfilLocal = usuario.fotoPerfilUrl ?? null;
// Atualizar avatar (pode ser undefined ou string)
avatarLocal = usuario.avatar ?? null;
} else {
// Se não há usuário, limpar estados locais
fotoPerfilLocal = null;
avatarLocal = null;
perfilCarregado = false; // Reset para permitir recarregar quando houver usuário novamente
}
});
@@ -231,13 +251,21 @@
erroUpload = "";
try {
// 1. Gerar URL de upload (NOME CORRETO DA FUNÇÃO!)
// 1. Criar preview local IMEDIATAMENTE para feedback visual
const reader = new FileReader();
reader.onload = (e) => {
fotoPerfilLocal = e.target?.result as string;
avatarLocal = null;
};
reader.readAsDataURL(file);
// 2. Gerar URL de upload
const uploadUrl = await client.mutation(
api.usuarios.uploadFotoPerfil,
{}
);
// 2. Upload do arquivo
// 3. Upload do arquivo
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
@@ -250,21 +278,28 @@
const { storageId } = await response.json();
// 3. Atualizar perfil com o novo storageId
// 4. Atualizar perfil com o novo storageId
await client.mutation(api.usuarios.atualizarPerfil, {
fotoPerfil: storageId,
avatar: undefined, // Remove avatar se colocar foto
});
// 4. Atualizar authStore para obter a URL da foto
// 5. Aguardar um pouco para garantir que o backend processou
await new Promise(resolve => setTimeout(resolve, 300));
// 6. Atualizar authStore para obter a URL da foto atualizada
await authStore.refresh();
// 5. Atualizar localmente IMEDIATAMENTE com a URL do authStore
// 7. Atualizar localmente com a URL do authStore (substitui o preview temporário)
if (authStore.usuario?.fotoPerfilUrl) {
fotoPerfilLocal = authStore.usuario.fotoPerfilUrl;
avatarLocal = null;
}
// 8. Limpar o input para permitir novo upload
input.value = "";
// 9. Fechar modal após sucesso
mostrarModalFoto = false;
// Toast de sucesso
@@ -275,13 +310,16 @@
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Foto de perfil atualizada!</span>
<span>Foto de perfil atualizada com sucesso!</span>
</div>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
} catch (e: any) {
erroUpload = e.message || "Erro ao fazer upload da foto";
// Reverter mudança local se houver erro
fotoPerfilLocal = authStore.usuario?.fotoPerfilUrl || null;
avatarLocal = authStore.usuario?.avatar || null;
} finally {
uploadandoFoto = false;
}
@@ -292,7 +330,7 @@
erroUpload = "";
try {
// 1. Atualizar localmente IMEDIATAMENTE (antes mesmo da API)
// 1. Atualizar localmente IMEDIATAMENTE para feedback visual instantâneo
avatarLocal = avatarUrl;
fotoPerfilLocal = null;
@@ -302,12 +340,22 @@
fotoPerfil: undefined, // Remove foto se colocar avatar
});
// 3. Atualizar authStore em background
authStore.refresh();
// 3. Aguardar um pouco para garantir que o backend processou
await new Promise(resolve => setTimeout(resolve, 300));
// 4. Atualizar authStore e aguardar conclusão
await authStore.refresh();
// 5. Garantir que os estados locais estão sincronizados com o authStore
if (authStore.usuario?.avatar) {
avatarLocal = authStore.usuario.avatar;
fotoPerfilLocal = null;
}
// 6. Fechar modal após sucesso
mostrarModalFoto = false;
// Toast de sucesso mais discreto
// Toast de sucesso
const toast = document.createElement("div");
toast.className = "toast toast-top toast-end";
toast.innerHTML = `
@@ -315,7 +363,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Avatar atualizado!</span>
<span>Avatar atualizado com sucesso!</span>
</div>
`;
document.body.appendChild(toast);

View File

@@ -1,20 +1,35 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
type EmailDetalhes = Doc<"notificacoesEmail"> | null;
let autoRefresh = $state(true);
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let processando = $state(false);
let modalDetalhesAberto = $state(false);
let emailSelecionado = $state<EmailDetalhes>(null);
const client = useConvexClient();
const estatisticas = useQuery(api.email.obterEstatisticasFilaEmails, {});
const filaEmails = useQuery(api.email.listarFilaEmails, { limite: 50 });
// Criar uma chave reativa para forçar atualização das queries
let refreshKey = $state(0);
// Usar refreshKey nos argumentos para forçar recarregamento quando mudar
// O backend ignora esse parâmetro, mas força o Convex Svelte a reexecutar a query
const estatisticas = useQuery(api.email.obterEstatisticasFilaEmails, { _refresh: refreshKey });
const filaEmails = useQuery(api.email.listarFilaEmails, { limite: 50, _refresh: refreshKey });
// Função para forçar refresh das queries
function refreshQueries() {
refreshKey++;
}
$effect(() => {
if (autoRefresh) {
refreshInterval = setInterval(() => {
// Forçar refresh das queries invalidando o cache
// As queries do Convex Svelte atualizam automaticamente
refreshQueries();
}, 5000); // Refresh a cada 5 segundos
} else {
if (refreshInterval) {
@@ -26,6 +41,7 @@
return () => {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
};
});
@@ -39,6 +55,8 @@
if (resultado.sucesso) {
alert(`✅ Processados: ${resultado.processados}, Falhas: ${resultado.falhas}`);
// Forçar atualização após processar
refreshQueries();
} else {
alert(`❌ Erro: ${resultado.erro || "Erro desconhecido"}`);
}
@@ -84,6 +102,16 @@
return status;
}
}
function abrirModalDetalhes(email: Doc<"notificacoesEmail">) {
emailSelecionado = email;
modalDetalhesAberto = true;
}
function fecharModalDetalhes() {
modalDetalhesAberto = false;
emailSelecionado = null;
}
</script>
<div class="container mx-auto p-6">
@@ -181,6 +209,7 @@
<th>Criado em</th>
<th>Última tentativa</th>
<th>Erro</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
@@ -206,18 +235,31 @@
</td>
<td>
{#if email.erroDetalhes}
<div
class="tooltip tooltip-left"
data-tip={email.erroDetalhes}
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => abrirModalDetalhes(email)}
>
<span class="text-error text-xs cursor-help">
⚠️ Ver erro
</span>
</div>
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Ver erro
</button>
{:else}
<span class="text-base-content/50">-</span>
{/if}
</td>
<td>
<button
class="btn btn-ghost btn-xs"
onclick={() => abrirModalDetalhes(email)}
title="Ver detalhes"
>
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</td>
</tr>
{/each}
</tbody>
@@ -263,3 +305,159 @@
</div>
</div>
<!-- Modal de Detalhes do Email -->
{#if modalDetalhesAberto && emailSelecionado}
<div class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-2xl flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" 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>
Detalhes do Email
</h3>
<button class="btn btn-sm btn-circle btn-ghost" onclick={fecharModalDetalhes}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
</button>
</div>
<div class="space-y-4">
<!-- Status -->
<div class="flex items-center gap-2">
<span class="font-semibold">Status:</span>
<span class={getStatusBadgeClass(emailSelecionado.status)}>
{getStatusLabel(emailSelecionado.status)}
</span>
</div>
<!-- Informações Principais -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-semibold">Destinatário</span>
</label>
<div class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{emailSelecionado.destinatario}
</div>
</div>
<div>
<label class="label">
<span class="label-text font-semibold">Tentativas</span>
</label>
<div class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{emailSelecionado.tentativas || 0}
</div>
</div>
</div>
<!-- Assunto -->
<div>
<label class="label">
<span class="label-text font-semibold">Assunto</span>
</label>
<div class="input input-bordered">
{emailSelecionado.assunto}
</div>
</div>
<!-- Datas -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-semibold">Criado em</span>
</label>
<div class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{formatarData(emailSelecionado.criadoEm)}
</div>
</div>
{#if emailSelecionado.ultimaTentativa}
<div>
<label class="label">
<span class="label-text font-semibold">Última tentativa</span>
</label>
<div class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/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>
{formatarData(emailSelecionado.ultimaTentativa)}
</div>
</div>
{/if}
{#if emailSelecionado.enviadoEm}
<div>
<label class="label">
<span class="label-text font-semibold">Enviado em</span>
</label>
<div class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{formatarData(emailSelecionado.enviadoEm)}
</div>
</div>
{/if}
{#if emailSelecionado.agendadaPara}
<div>
<label class="label">
<span class="label-text font-semibold">Agendado para</span>
</label>
<div class="input input-bordered flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{formatarData(emailSelecionado.agendadaPara)}
</div>
</div>
{/if}
</div>
<!-- Erro Detalhado -->
{#if emailSelecionado.erroDetalhes}
<div>
<label class="label">
<span class="label-text font-semibold text-error">Detalhes do Erro</span>
</label>
<div class="bg-error/10 border border-error/20 rounded-lg p-4">
<pre class="text-sm text-error whitespace-pre-wrap break-words">{emailSelecionado.erroDetalhes}</pre>
</div>
</div>
{/if}
<!-- Corpo do Email (Preview) -->
{#if emailSelecionado.corpo}
<div>
<label class="label">
<span class="label-text font-semibold">Preview do Email</span>
</label>
<div class="border border-base-300 rounded-lg p-4 bg-base-200 max-h-64 overflow-y-auto">
{@html emailSelecionado.corpo}
</div>
</div>
{/if}
</div>
<div class="modal-action">
<button class="btn btn-primary" onclick={fecharModalDetalhes}>
Fechar
</button>
</div>
</div>
<div class="modal-backdrop" onclick={fecharModalDetalhes}></div>
</div>
{/if}

View File

@@ -814,7 +814,7 @@
if (usarTemplate && templateId) {
const template = templateSelecionado;
if (template) {
resultadoEmail = await client.mutation(
const emailId = await client.action(
api.email.enviarEmailComTemplate,
{
destinatario: destinatario.email,
@@ -824,11 +824,11 @@
nome: destinatario.nome,
matricula: destinatario.matricula,
},
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
if (emailId) {
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -840,7 +840,7 @@
destinatario.nome,
"fila",
`Email agendado para ${dataFormatada}`,
resultadoEmail.emailId,
emailId,
);
} else {
adicionarLog(
@@ -848,7 +848,7 @@
destinatario.nome,
"fila",
"Email enfileirado para envio",
resultadoEmail.emailId,
emailId,
);
}
} else {
@@ -868,18 +868,19 @@
);
}
} else {
resultadoEmail = await client.mutation(
const emailId = await client.mutation(
api.email.enfileirarEmail,
{
destinatario: destinatario.email,
destinatarioId: destinatario._id as Id<"usuarios">,
assunto: "Notificação do Sistema",
corpo: mensagemPersonalizada,
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
if (emailId) {
resultadoEmail = { sucesso: true, emailId };
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -891,7 +892,7 @@
destinatario.nome,
"fila",
`Email agendado para ${dataFormatada}`,
resultadoEmail.emailId,
emailId,
);
} else {
adicionarLog(
@@ -899,7 +900,7 @@
destinatario.nome,
"fila",
"Email enfileirado para envio",
resultadoEmail.emailId,
emailId,
);
}
} else {
@@ -1064,7 +1065,7 @@
if (usarTemplate && templateId) {
const template = templateSelecionado;
if (template) {
const resultadoEmail = await client.mutation(
const emailId = await client.action(
api.email.enviarEmailComTemplate,
{
destinatario: destinatario.email,
@@ -1074,11 +1075,11 @@
nome: destinatario.nome,
matricula: destinatario.matricula || "",
},
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
if (emailId) {
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -1090,7 +1091,7 @@
destinatario.nome,
"fila",
`Agendado para ${dataFormatada}`,
resultadoEmail.emailId,
emailId,
);
} else {
adicionarLog(
@@ -1098,7 +1099,7 @@
destinatario.nome,
"fila",
"Enfileirado para envio",
resultadoEmail.emailId,
emailId,
);
}
sucessosEmail++;
@@ -1121,18 +1122,19 @@
falhasEmail++;
}
} else {
const resultadoEmail = await client.mutation(
const emailId = await client.mutation(
api.email.enfileirarEmail,
{
destinatario: destinatario.email,
destinatarioId: destinatario._id as Id<"usuarios">,
assunto: "Notificação do Sistema",
corpo: mensagemPersonalizada,
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
enviadoPor: authStore.usuario._id as Id<"usuarios">,
agendadaPara: agendadaPara,
},
);
if (resultadoEmail?.sucesso && resultadoEmail?.emailId) {
if (emailId) {
resultadoEmail = { sucesso: true, emailId };
if (agendadaPara) {
const dataFormatada = format(
new Date(agendadaPara),
@@ -1144,7 +1146,7 @@
destinatario.nome,
"fila",
`Agendado para ${dataFormatada}`,
resultadoEmail.emailId,
emailId,
);
} else {
adicionarLog(
@@ -1152,7 +1154,7 @@
destinatario.nome,
"fila",
"Enfileirado para envio",
resultadoEmail.emailId,
emailId,
);
}
sucessosEmail++;