Feat ausencia #7
@@ -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);
|
||||
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(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (checkAuth) {
|
||||
clearInterval(checkAuth);
|
||||
checkAuth = null;
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
<!-- 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,7 +252,15 @@
|
||||
<!-- 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 -->
|
||||
<!-- 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"
|
||||
@@ -243,9 +270,10 @@
|
||||
>
|
||||
<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,13 +323,14 @@
|
||||
|
||||
<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?.()}
|
||||
</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 mt-8">
|
||||
<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>
|
||||
@@ -325,7 +354,6 @@
|
||||
<p class="text-xs text-base-content/60 mt-2">© {new Date().getFullYear()} - Todos os direitos reservados</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-side z-40 fixed" style="margin-top: 96px;">
|
||||
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"
|
||||
></label>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +277,18 @@
|
||||
<!-- 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;">
|
||||
<!-- 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"
|
||||
@@ -278,6 +304,7 @@
|
||||
<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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<path
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
|
||||
/>
|
||||
class="w-6 h-6 text-primary"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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: "/",
|
||||
});
|
||||
|
||||
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) {
|
||||
// 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();
|
||||
try {
|
||||
// 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)) {
|
||||
console.warn("Push notifications não são suportadas neste navegador");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Solicitar permissão
|
||||
const permission = await requestNotificationPermission();
|
||||
// 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") {
|
||||
console.warn("Permissão para notificações negada");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Obter subscription existente ou criar nova
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -14,9 +14,10 @@ export const enviar = action({
|
||||
"use node";
|
||||
const nodemailer = await import("nodemailer");
|
||||
|
||||
let email;
|
||||
try {
|
||||
// Buscar email da fila
|
||||
const email = await ctx.runQuery(internal.email.getEmailById, {
|
||||
email = await ctx.runQuery(internal.email.getEmailById, {
|
||||
emailId: args.emailId,
|
||||
});
|
||||
|
||||
|
||||
@@ -36,11 +36,21 @@ export const enviarPush = action({
|
||||
// Por enquanto, vamos usar uma implementação básica
|
||||
// Em produção, você precisará configurar VAPID keys
|
||||
|
||||
const webpush = await import("web-push");
|
||||
const webpushModule = await import("web-push");
|
||||
// web-push pode exportar como default ou named exports
|
||||
// Usar a declaração de tipo do módulo web-push
|
||||
interface WebPushType {
|
||||
setVapidDetails: (subject: string, publicKey: string, privateKey: string) => void;
|
||||
sendNotification: (
|
||||
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
||||
payload: string | Buffer
|
||||
) => Promise<void>;
|
||||
}
|
||||
const webpush: WebPushType = (webpushModule.default || webpushModule) as WebPushType;
|
||||
|
||||
// VAPID keys devem vir de variáveis de ambiente
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
||||
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
||||
const publicKey: string | undefined = process.env.VAPID_PUBLIC_KEY;
|
||||
const privateKey: string | undefined = process.env.VAPID_PRIVATE_KEY;
|
||||
|
||||
if (!publicKey || !privateKey) {
|
||||
console.warn("⚠️ VAPID keys não configuradas. Push notifications não funcionarão.");
|
||||
@@ -75,7 +85,7 @@ export const enviarPush = action({
|
||||
|
||||
console.log(`✅ Push notification enviada para ${subscription.endpoint}`);
|
||||
return { sucesso: true };
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("❌ Erro ao enviar push notification:", errorMessage);
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { action } from "../_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
// Importar nodemailer de forma estática para evitar problemas com caminhos no Windows
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
export const testarConexao = action({
|
||||
args: {
|
||||
servidor: v.string(),
|
||||
@@ -17,8 +20,6 @@ export const testarConexao = action({
|
||||
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
"use node";
|
||||
const nodemailer = await import("nodemailer");
|
||||
|
||||
try {
|
||||
// Validações básicas
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use node";
|
||||
|
||||
/**
|
||||
* Utilitários de criptografia compatíveis com Node.js
|
||||
* Para uso em actions que rodam em ambiente Node.js
|
||||
|
||||
@@ -225,36 +225,15 @@ export const enviarMensagem = mutation({
|
||||
if (!mensagemOriginal || mensagemOriginal.conversaId !== args.conversaId) {
|
||||
throw new Error("Mensagem original não encontrada ou não pertence à mesma conversa");
|
||||
}
|
||||
// SEGURANÇA: Verificar se o remetente da mensagem original é participante da conversa
|
||||
if (!conversa.participantes.includes(mensagemOriginal.remetenteId)) {
|
||||
throw new Error("Mensagem original inválida");
|
||||
}
|
||||
if (mensagemOriginal.deletada) {
|
||||
throw new Error("Não é possível responder a uma mensagem deletada");
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar URLs no conteúdo e extrair preview (apenas para mensagens de texto)
|
||||
let linkPreview = undefined;
|
||||
if (args.tipo === "texto") {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const urls = args.conteudo.match(urlRegex);
|
||||
if (urls && urls.length > 0) {
|
||||
// Pegar primeira URL encontrada
|
||||
const primeiraUrl = urls[0];
|
||||
// Agendar extração de preview (assíncrono, não bloqueia envio)
|
||||
ctx.scheduler.runAfter(1000, api.actions.linkPreview.extrairPreviewLink, {
|
||||
url: primeiraUrl,
|
||||
}).then((preview) => {
|
||||
if (preview) {
|
||||
// Atualizar mensagem com preview via mutation interna
|
||||
return ctx.runMutation(internal.chat.atualizarLinkPreview, {
|
||||
mensagemId,
|
||||
linkPreview: preview,
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("Erro ao agendar/processar preview de link:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Criar mensagem
|
||||
const mensagemId = await ctx.db.insert("mensagens", {
|
||||
conversaId: args.conversaId,
|
||||
@@ -450,6 +429,17 @@ export const cancelarMensagemAgendada = mutation({
|
||||
return { sucesso: false, erro: "Mensagem não encontrada" };
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante
|
||||
const conversa = await ctx.db.get(mensagem.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return { sucesso: false, erro: "Você não tem acesso a esta mensagem" };
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa
|
||||
if (!conversa.participantes.includes(mensagem.remetenteId)) {
|
||||
return { sucesso: false, erro: "Mensagem inválida" };
|
||||
}
|
||||
|
||||
if (mensagem.remetenteId !== usuarioAtual._id) {
|
||||
return {
|
||||
sucesso: false,
|
||||
@@ -472,6 +462,7 @@ export const cancelarMensagemAgendada = mutation({
|
||||
|
||||
/**
|
||||
* Adiciona uma reação (emoji) a uma mensagem
|
||||
* SEGURANÇA: Usuário só pode reagir a mensagens de conversas onde é participante
|
||||
*/
|
||||
export const reagirMensagem = mutation({
|
||||
args: {
|
||||
@@ -485,6 +476,17 @@ export const reagirMensagem = mutation({
|
||||
const mensagem = await ctx.db.get(args.mensagemId);
|
||||
if (!mensagem) throw new Error("Mensagem não encontrada");
|
||||
|
||||
// SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante
|
||||
const conversa = await ctx.db.get(mensagem.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
throw new Error("Você não pode reagir a mensagens de conversas onde não participa");
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa
|
||||
if (!conversa.participantes.includes(mensagem.remetenteId)) {
|
||||
throw new Error("Mensagem inválida");
|
||||
}
|
||||
|
||||
const reacoes = mensagem.reagiuPor || [];
|
||||
const reacaoExistente = reacoes.find(
|
||||
(r) => r.usuarioId === usuarioAtual._id && r.emoji === args.emoji
|
||||
@@ -513,6 +515,7 @@ export const reagirMensagem = mutation({
|
||||
|
||||
/**
|
||||
* Marca mensagens de uma conversa como lidas
|
||||
* SEGURANÇA: Usuário só pode marcar como lida mensagens de conversas onde é participante
|
||||
*/
|
||||
export const marcarComoLida = mutation({
|
||||
args: {
|
||||
@@ -523,6 +526,21 @@ export const marcarComoLida = mutation({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) throw new Error("Não autenticado");
|
||||
|
||||
// SEGURANÇA: Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
throw new Error("Você não pertence a esta conversa");
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se a mensagem pertence à conversa e se o remetente é participante
|
||||
const mensagem = await ctx.db.get(args.mensagemId);
|
||||
if (!mensagem || mensagem.conversaId !== args.conversaId) {
|
||||
throw new Error("Mensagem não encontrada nesta conversa");
|
||||
}
|
||||
if (!conversa.participantes.includes(mensagem.remetenteId)) {
|
||||
throw new Error("Mensagem inválida");
|
||||
}
|
||||
|
||||
// Buscar registro de leitura existente
|
||||
const leituraExistente = await ctx.db
|
||||
.query("leituras")
|
||||
@@ -590,6 +608,7 @@ export const atualizarStatusPresenca = mutation({
|
||||
|
||||
/**
|
||||
* Indica que o usuário está digitando em uma conversa
|
||||
* SEGURANÇA: Usuário só pode indicar digitação em conversas onde é participante
|
||||
*/
|
||||
export const indicarDigitacao = mutation({
|
||||
args: {
|
||||
@@ -599,6 +618,12 @@ export const indicarDigitacao = mutation({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) throw new Error("Não autenticado");
|
||||
|
||||
// SEGURANÇA: Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
throw new Error("Você não pertence a esta conversa");
|
||||
}
|
||||
|
||||
// Buscar indicador existente
|
||||
const indicadorExistente = await ctx.db
|
||||
.query("digitando")
|
||||
@@ -647,6 +672,7 @@ export const uploadArquivoChat = mutation({
|
||||
|
||||
/**
|
||||
* Marca uma notificação como lida
|
||||
* SEGURANÇA: Usuário só pode marcar como lida suas próprias notificações
|
||||
*/
|
||||
export const marcarNotificacaoLida = mutation({
|
||||
args: {
|
||||
@@ -656,6 +682,22 @@ export const marcarNotificacaoLida = mutation({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) throw new Error("Não autenticado");
|
||||
|
||||
const notificacao = await ctx.db.get(args.notificacaoId);
|
||||
if (!notificacao) throw new Error("Notificação não encontrada");
|
||||
|
||||
// SEGURANÇA: Verificar se a notificação pertence ao usuário atual
|
||||
if (notificacao.usuarioId !== usuarioAtual._id) {
|
||||
throw new Error("Você não tem permissão para marcar esta notificação como lida");
|
||||
}
|
||||
|
||||
// SEGURANÇA: Se a notificação tem conversaId, verificar se usuário ainda é participante
|
||||
if (notificacao.conversaId) {
|
||||
const conversa = await ctx.db.get(notificacao.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
throw new Error("Você não tem acesso a esta notificação");
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.notificacaoId, { lida: true });
|
||||
return true;
|
||||
},
|
||||
@@ -708,6 +750,17 @@ export const editarMensagem = mutation({
|
||||
return { sucesso: false, erro: "Mensagem não encontrada" };
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante
|
||||
const conversa = await ctx.db.get(mensagem.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return { sucesso: false, erro: "Você não tem acesso a esta mensagem" };
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa
|
||||
if (!conversa.participantes.includes(mensagem.remetenteId)) {
|
||||
return { sucesso: false, erro: "Mensagem inválida" };
|
||||
}
|
||||
|
||||
// Verificar se usuário é o remetente
|
||||
if (mensagem.remetenteId !== usuarioAtual._id) {
|
||||
return { sucesso: false, erro: "Você só pode editar suas próprias mensagens" };
|
||||
@@ -776,6 +829,17 @@ export const deletarMensagem = mutation({
|
||||
const mensagem = await ctx.db.get(args.mensagemId);
|
||||
if (!mensagem) throw new Error("Mensagem não encontrada");
|
||||
|
||||
// SEGURANÇA: Verificar se a mensagem pertence a uma conversa onde o usuário é participante
|
||||
const conversa = await ctx.db.get(mensagem.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
throw new Error("Você não tem acesso a esta mensagem");
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o remetente da mensagem é participante da conversa
|
||||
if (!conversa.participantes.includes(mensagem.remetenteId)) {
|
||||
throw new Error("Mensagem inválida");
|
||||
}
|
||||
|
||||
if (mensagem.remetenteId !== usuarioAtual._id) {
|
||||
throw new Error("Você só pode deletar suas próprias mensagens");
|
||||
}
|
||||
@@ -793,6 +857,7 @@ export const deletarMensagem = mutation({
|
||||
|
||||
/**
|
||||
* Lista todas as conversas do usuário logado
|
||||
* SEGURANÇA: Usuário só vê conversas onde é participante
|
||||
*/
|
||||
export const listarConversas = query({
|
||||
args: {},
|
||||
@@ -800,7 +865,7 @@ export const listarConversas = query({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return [];
|
||||
|
||||
// Buscar todas as conversas do usuário
|
||||
// Buscar todas as conversas do usuário (SEGURANÇA: filtrar por participante)
|
||||
const todasConversas = await ctx.db.query("conversas").collect();
|
||||
const conversasDoUsuario = todasConversas.filter((c) =>
|
||||
c.participantes.includes(usuarioAtual._id)
|
||||
@@ -856,12 +921,18 @@ export const listarConversas = query({
|
||||
.first();
|
||||
|
||||
// CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined)
|
||||
// SEGURANÇA: Filtrar apenas mensagens de participantes da conversa
|
||||
const todasMensagens = await ctx.db
|
||||
.query("mensagens")
|
||||
.withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
|
||||
.collect();
|
||||
|
||||
const mensagens = todasMensagens.filter((m) => !m.agendadaPara);
|
||||
// Filtrar mensagens agendadas e garantir que remetente é participante
|
||||
const mensagens = todasMensagens.filter((m) => {
|
||||
if (m.agendadaPara) return false;
|
||||
// Garantir que o remetente é participante da conversa
|
||||
return conversa.participantes.includes(m.remetenteId);
|
||||
});
|
||||
|
||||
let naoLidas = 0;
|
||||
if (leitura) {
|
||||
@@ -891,6 +962,7 @@ export const listarConversas = query({
|
||||
|
||||
/**
|
||||
* Obtém as mensagens de uma conversa com paginação
|
||||
* SEGURANÇA: Usuário só vê mensagens de conversas onde é participante
|
||||
*/
|
||||
export const obterMensagens = query({
|
||||
args: {
|
||||
@@ -901,7 +973,7 @@ export const obterMensagens = query({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return [];
|
||||
|
||||
// Verificar se usuário pertence à conversa
|
||||
// Verificar se usuário pertence à conversa (SEGURANÇA CRÍTICA)
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return [];
|
||||
@@ -914,13 +986,30 @@ export const obterMensagens = query({
|
||||
.order("desc")
|
||||
.take(args.limit || 50);
|
||||
|
||||
// Filtrar mensagens agendadas
|
||||
const mensagensFiltradas = mensagens.filter((m) => !m.agendadaPara);
|
||||
// Filtrar mensagens agendadas e garantir que são da conversa correta
|
||||
// SEGURANÇA: Apenas mensagens de participantes da conversa são retornadas
|
||||
const mensagensFiltradas = mensagens.filter((m) => {
|
||||
// Excluir agendadas
|
||||
if (m.agendadaPara) return false;
|
||||
|
||||
// Garantir que a mensagem pertence à conversa correta (segurança adicional)
|
||||
if (m.conversaId !== args.conversaId) return false;
|
||||
|
||||
// SEGURANÇA CRÍTICA: Garantir que o remetente é participante da conversa
|
||||
// Isso garante que usuários só veem mensagens de conversas onde participam
|
||||
return conversa.participantes.includes(m.remetenteId);
|
||||
});
|
||||
|
||||
// Enriquecer com informações do remetente e mensagem respondida
|
||||
const mensagensEnriquecidas = await Promise.all(
|
||||
mensagensFiltradas.map(async (mensagem) => {
|
||||
const remetente = await ctx.db.get(mensagem.remetenteId);
|
||||
|
||||
// SEGURANÇA: Não retornar informações de remetente se não for participante
|
||||
if (!remetente || !conversa.participantes.includes(remetente._id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let arquivoUrl = null;
|
||||
if (mensagem.arquivoId) {
|
||||
arquivoUrl = await ctx.storage.getUrl(mensagem.arquivoId);
|
||||
@@ -930,7 +1019,7 @@ export const obterMensagens = query({
|
||||
let mensagemOriginal = null;
|
||||
if (mensagem.respostaPara) {
|
||||
const original = await ctx.db.get(mensagem.respostaPara);
|
||||
if (original) {
|
||||
if (original && conversa.participantes.includes(original.remetenteId)) {
|
||||
const remetenteOriginal = await ctx.db.get(original.remetenteId);
|
||||
mensagemOriginal = {
|
||||
_id: original._id,
|
||||
@@ -953,12 +1042,14 @@ export const obterMensagens = query({
|
||||
})
|
||||
);
|
||||
|
||||
return mensagensEnriquecidas.reverse();
|
||||
// Filtrar nulls (caso alguma mensagem tenha sido rejeitada por segurança)
|
||||
return mensagensEnriquecidas.filter((m) => m !== null).reverse();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém mensagens agendadas de uma conversa
|
||||
* SEGURANÇA: Usuário só vê suas próprias mensagens agendadas de conversas onde é participante
|
||||
*/
|
||||
export const obterMensagensAgendadas = query({
|
||||
args: {
|
||||
@@ -968,18 +1059,25 @@ export const obterMensagensAgendadas = query({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return [];
|
||||
|
||||
// SEGURANÇA: Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Buscar mensagens agendadas
|
||||
const todasMensagens = await ctx.db
|
||||
.query("mensagens")
|
||||
.withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
|
||||
.collect();
|
||||
|
||||
// Filtrar apenas as agendadas do usuário atual
|
||||
// Filtrar apenas as agendadas do usuário atual (SEGURANÇA: só suas próprias mensagens)
|
||||
const minhasMensagensAgendadas = todasMensagens.filter(
|
||||
(m) =>
|
||||
m.remetenteId === usuarioAtual._id &&
|
||||
m.agendadaPara !== undefined &&
|
||||
m.agendadaPara > Date.now()
|
||||
m.agendadaPara > Date.now() &&
|
||||
m.conversaId === args.conversaId // Garantir que pertence à conversa correta
|
||||
);
|
||||
|
||||
return minhasMensagensAgendadas.sort(
|
||||
@@ -990,6 +1088,7 @@ export const obterMensagensAgendadas = query({
|
||||
|
||||
/**
|
||||
* Listar todas as mensagens agendadas do usuário atual (para página de notificações)
|
||||
* SEGURANÇA: Usuário só vê suas próprias mensagens agendadas de conversas onde ainda é participante
|
||||
*/
|
||||
export const listarAgendamentosChat = query({
|
||||
args: {},
|
||||
@@ -1022,13 +1121,22 @@ export const listarAgendamentosChat = query({
|
||||
// Enriquecer com informações da conversa e destinatário
|
||||
const mensagensEnriquecidas = await Promise.all(
|
||||
mensagensAgendadas.map(async (mensagem) => {
|
||||
let conversaInfo: Doc<"conversas"> | null = null;
|
||||
const conversaInfo = await ctx.db.get(mensagem.conversaId);
|
||||
|
||||
// SEGURANÇA: Verificar se usuário ainda é participante da conversa
|
||||
if (!conversaInfo || !conversaInfo.participantes.includes(usuarioAtual._id)) {
|
||||
return null; // Usuário não é mais participante, não mostrar mensagem
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o remetente (que deve ser o usuário atual) é participante
|
||||
if (!conversaInfo.participantes.includes(mensagem.remetenteId)) {
|
||||
return null; // Remetente não é participante, mensagem inválida
|
||||
}
|
||||
|
||||
let destinatarioInfo: Doc<"usuarios"> | null = null;
|
||||
|
||||
conversaInfo = await ctx.db.get(mensagem.conversaId);
|
||||
|
||||
// Se for conversa individual, encontrar o outro participante
|
||||
if (conversaInfo && conversaInfo.tipo === "individual") {
|
||||
if (conversaInfo.tipo === "individual") {
|
||||
const outroParticipanteId = conversaInfo.participantes.find(
|
||||
(p) => p !== usuarioAtual._id
|
||||
);
|
||||
@@ -1045,8 +1153,10 @@ export const listarAgendamentosChat = query({
|
||||
})
|
||||
);
|
||||
|
||||
// Ordenar por data de agendamento (mais próximos primeiro)
|
||||
return mensagensEnriquecidas.sort((a, b) => {
|
||||
// Filtrar nulls e ordenar por data de agendamento (mais próximos primeiro)
|
||||
return mensagensEnriquecidas
|
||||
.filter((m): m is NonNullable<typeof m> => m !== null)
|
||||
.sort((a, b) => {
|
||||
const dataA = a.agendadaPara ?? 0;
|
||||
const dataB = b.agendadaPara ?? 0;
|
||||
return dataA - dataB;
|
||||
@@ -1056,6 +1166,7 @@ export const listarAgendamentosChat = query({
|
||||
|
||||
/**
|
||||
* Obtém as notificações do usuário
|
||||
* SEGURANÇA: Usuário só vê notificações de conversas onde ainda é participante
|
||||
*/
|
||||
export const obterNotificacoes = query({
|
||||
args: {
|
||||
@@ -1079,9 +1190,22 @@ export const obterNotificacoes = query({
|
||||
|
||||
const notificacoes = await query.order("desc").take(50);
|
||||
|
||||
// Enriquecer com informações do remetente
|
||||
// Enriquecer com informações do remetente e validar acesso
|
||||
const notificacoesEnriquecidas = await Promise.all(
|
||||
notificacoes.map(async (notificacao) => {
|
||||
// SEGURANÇA: Se a notificação tem conversaId, verificar se usuário ainda é participante
|
||||
if (notificacao.conversaId) {
|
||||
const conversa = await ctx.db.get(notificacao.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return null; // Usuário não é mais participante, não mostrar notificação
|
||||
}
|
||||
|
||||
// SEGURANÇA: Se tem remetenteId, verificar se é participante da conversa
|
||||
if (notificacao.remetenteId && !conversa.participantes.includes(notificacao.remetenteId)) {
|
||||
return null; // Remetente não é participante, notificação inválida
|
||||
}
|
||||
}
|
||||
|
||||
let remetente = null;
|
||||
if (notificacao.remetenteId) {
|
||||
remetente = await ctx.db.get(notificacao.remetenteId);
|
||||
@@ -1093,7 +1217,8 @@ export const obterNotificacoes = query({
|
||||
})
|
||||
);
|
||||
|
||||
return notificacoesEnriquecidas;
|
||||
// Filtrar nulls antes de retornar
|
||||
return notificacoesEnriquecidas.filter((n): n is NonNullable<typeof n> => n !== null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1188,6 +1313,7 @@ export const listarTodosUsuarios = query({
|
||||
|
||||
/**
|
||||
* Busca mensagens em conversas com filtros avançados
|
||||
* SEGURANÇA: Usuário só vê mensagens de conversas onde é participante e onde o remetente também é participante
|
||||
*/
|
||||
export const buscarMensagens = query({
|
||||
args: {
|
||||
@@ -1212,6 +1338,16 @@ export const buscarMensagens = query({
|
||||
c.participantes.includes(usuarioAtual._id)
|
||||
);
|
||||
|
||||
// SEGURANÇA: Se filtrar por remetente, verificar se ele é participante de alguma conversa do usuário
|
||||
if (args.remetenteId) {
|
||||
const remetenteEParticipante = conversasDoUsuario.some(c =>
|
||||
c.participantes.includes(args.remetenteId!)
|
||||
);
|
||||
if (!remetenteEParticipante) {
|
||||
return []; // Remetente não é participante de nenhuma conversa do usuário
|
||||
}
|
||||
}
|
||||
|
||||
let mensagens: Doc<"mensagens">[] = [];
|
||||
|
||||
if (args.conversaId !== undefined) {
|
||||
@@ -1226,7 +1362,11 @@ export const buscarMensagens = query({
|
||||
.query("mensagens")
|
||||
.withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId!))
|
||||
.collect();
|
||||
mensagens = mensagensConversa;
|
||||
|
||||
// SEGURANÇA: Filtrar apenas mensagens cujo remetente é participante desta conversa específica
|
||||
mensagens = mensagensConversa.filter(m =>
|
||||
conversa.participantes.includes(m.remetenteId)
|
||||
);
|
||||
} else {
|
||||
// Buscar em todas as conversas do usuário
|
||||
for (const conversa of conversasDoUsuario) {
|
||||
@@ -1234,7 +1374,12 @@ export const buscarMensagens = query({
|
||||
.query("mensagens")
|
||||
.withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
|
||||
.collect();
|
||||
mensagens.push(...mensagensConversa);
|
||||
|
||||
// SEGURANÇA: Filtrar apenas mensagens cujo remetente é participante desta conversa específica
|
||||
const mensagensValidas = mensagensConversa.filter(m =>
|
||||
conversa.participantes.includes(m.remetenteId)
|
||||
);
|
||||
mensagens.push(...mensagensValidas);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,6 +1390,18 @@ export const buscarMensagens = query({
|
||||
return false;
|
||||
}
|
||||
|
||||
// SEGURANÇA CRÍTICA: Garantir que a mensagem pertence a uma conversa do usuário
|
||||
// e que o remetente é participante dessa conversa específica
|
||||
const conversaDaMensagem = conversasDoUsuario.find(c => c._id === m.conversaId);
|
||||
if (!conversaDaMensagem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// SEGURANÇA CRÍTICA: Garantir que o remetente é participante da conversa específica da mensagem
|
||||
if (!conversaDaMensagem.participantes.includes(m.remetenteId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtrar por query (busca no conteúdo normalizado)
|
||||
if (queryNormalizada && queryNormalizada.length > 0) {
|
||||
const conteudoBusca = m.conteudoBusca || normalizarTextoParaBusca(m.conteudo);
|
||||
@@ -1253,10 +1410,16 @@ export const buscarMensagens = query({
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrar por remetente
|
||||
if (args.remetenteId && m.remetenteId !== args.remetenteId) {
|
||||
// Filtrar por remetente (já verificado acima, mas garantir novamente)
|
||||
if (args.remetenteId) {
|
||||
if (m.remetenteId !== args.remetenteId) {
|
||||
return false;
|
||||
}
|
||||
// Verificar novamente se o remetente é participante da conversa específica desta mensagem
|
||||
if (!conversaDaMensagem.participantes.includes(args.remetenteId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrar por tipo
|
||||
if (args.tipo && m.tipo !== args.tipo) {
|
||||
@@ -1282,20 +1445,34 @@ export const buscarMensagens = query({
|
||||
mensagensFiltradas = mensagensFiltradas.slice(0, args.limite);
|
||||
}
|
||||
|
||||
// Enriquecer com informações
|
||||
// Enriquecer com informações (apenas para mensagens válidas)
|
||||
const mensagensEnriquecidas = await Promise.all(
|
||||
mensagensFiltradas.map(async (mensagem) => {
|
||||
const conversaDaMensagem = conversasDoUsuario.find(c => c._id === mensagem.conversaId);
|
||||
|
||||
// SEGURANÇA: Validar novamente antes de enriquecer
|
||||
if (!conversaDaMensagem || !conversaDaMensagem.participantes.includes(mensagem.remetenteId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remetente = await ctx.db.get(mensagem.remetenteId);
|
||||
const conversa = await ctx.db.get(mensagem.conversaId);
|
||||
|
||||
// SEGURANÇA: Só retornar se remetente for participante
|
||||
if (!remetente || !conversaDaMensagem.participantes.includes(remetente._id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...mensagem,
|
||||
remetente,
|
||||
conversa,
|
||||
conversa: conversaDaMensagem,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Filtrar nulls antes de retornar
|
||||
return mensagensEnriquecidas
|
||||
.filter((m): m is NonNullable<typeof m> => m !== null)
|
||||
.sort((a, b) => b.enviadaEm - a.enviadaEm)
|
||||
.slice(0, 50);
|
||||
},
|
||||
@@ -1303,6 +1480,7 @@ export const buscarMensagens = query({
|
||||
|
||||
/**
|
||||
* Obtém quem está digitando em uma conversa
|
||||
* SEGURANÇA: Usuário só vê digitação de conversas onde é participante
|
||||
*/
|
||||
export const obterDigitando = query({
|
||||
args: {
|
||||
@@ -1312,6 +1490,12 @@ export const obterDigitando = query({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return [];
|
||||
|
||||
// SEGURANÇA: Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Buscar indicadores de digitação (últimos 10 segundos)
|
||||
const dezSegundosAtras = Date.now() - 10000;
|
||||
const digitando = await ctx.db
|
||||
@@ -1320,14 +1504,22 @@ export const obterDigitando = query({
|
||||
.filter((q) => q.gte(q.field("iniciouEm"), dezSegundosAtras))
|
||||
.collect();
|
||||
|
||||
// Filtrar usuário atual e buscar informações
|
||||
// Filtrar usuário atual e garantir que são participantes da conversa
|
||||
const digitandoFiltrado = digitando.filter(
|
||||
(d) => d.usuarioId !== usuarioAtual._id
|
||||
(d) => {
|
||||
if (d.usuarioId === usuarioAtual._id) return false;
|
||||
// Garantir que o usuário digitando é participante da conversa
|
||||
return conversa.participantes.includes(d.usuarioId);
|
||||
}
|
||||
);
|
||||
|
||||
const usuarios = await Promise.all(
|
||||
digitandoFiltrado.map(async (d) => {
|
||||
const usuario = await ctx.db.get(d.usuarioId);
|
||||
// SEGURANÇA: Só retornar se for participante
|
||||
if (!usuario || !conversa.participantes.includes(usuario._id)) {
|
||||
return null;
|
||||
}
|
||||
return usuario;
|
||||
})
|
||||
);
|
||||
@@ -1338,6 +1530,7 @@ export const obterDigitando = query({
|
||||
|
||||
/**
|
||||
* Conta mensagens não lidas de uma conversa
|
||||
* SEGURANÇA: Usuário só conta mensagens de conversas onde é participante
|
||||
*/
|
||||
export const contarNaoLidas = query({
|
||||
args: {
|
||||
@@ -1347,6 +1540,12 @@ export const contarNaoLidas = query({
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
if (!usuarioAtual) return 0;
|
||||
|
||||
// SEGURANÇA: Verificar se usuário pertence à conversa
|
||||
const conversa = await ctx.db.get(args.conversaId);
|
||||
if (!conversa || !conversa.participantes.includes(usuarioAtual._id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const leitura = await ctx.db
|
||||
.query("leituras")
|
||||
.withIndex("by_conversa_usuario", (q) =>
|
||||
@@ -1354,12 +1553,17 @@ export const contarNaoLidas = query({
|
||||
)
|
||||
.first();
|
||||
|
||||
const mensagens = await ctx.db
|
||||
const todasMensagens = await ctx.db
|
||||
.query("mensagens")
|
||||
.withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
|
||||
.filter((q) => q.eq(q.field("agendadaPara"), undefined))
|
||||
.collect();
|
||||
|
||||
// SEGURANÇA: Filtrar apenas mensagens de participantes da conversa
|
||||
const mensagens = todasMensagens.filter((m) =>
|
||||
conversa.participantes.includes(m.remetenteId)
|
||||
);
|
||||
|
||||
if (leitura) {
|
||||
return mensagens.filter(
|
||||
(m) =>
|
||||
|
||||
@@ -10,6 +10,13 @@ crons.interval(
|
||||
internal.chat.enviarMensagensAgendadas
|
||||
);
|
||||
|
||||
// Processar fila de emails (incluindo agendados) a cada minuto
|
||||
crons.interval(
|
||||
"processar-fila-emails",
|
||||
{ minutes: 1 },
|
||||
internal.email.processarFilaEmails
|
||||
);
|
||||
|
||||
// Limpar indicadores de digitação antigos (>10s) a cada minuto
|
||||
crons.interval(
|
||||
"limpar-indicadores-digitacao",
|
||||
@@ -33,13 +40,5 @@ crons.interval(
|
||||
{}
|
||||
);
|
||||
|
||||
// Processar fila de emails pendentes a cada 2 minutos
|
||||
crons.interval(
|
||||
"processar-fila-emails",
|
||||
{ minutes: 2 },
|
||||
internal.email.processarFilaEmails,
|
||||
{}
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 type { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
// ========== INTERNAL QUERIES ==========
|
||||
|
||||
@@ -116,8 +117,14 @@ export const enfileirarEmail = mutation({
|
||||
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,
|
||||
@@ -128,8 +135,13 @@ export const enfileirarEmail = mutation({
|
||||
tentativas: 0,
|
||||
criadoEm: Date.now(),
|
||||
enviadoPor: args.enviadoPor,
|
||||
agendadaPara: args.agendadaPara,
|
||||
});
|
||||
|
||||
// O cron job processará emails automaticamente:
|
||||
// - Emails sem agendamento serão processados imediatamente (próxima execução do cron)
|
||||
// - Emails agendados serão processados quando a hora chegar
|
||||
|
||||
return emailId;
|
||||
},
|
||||
});
|
||||
@@ -144,12 +156,16 @@ export const enviarEmailComTemplate = action({
|
||||
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) => {
|
||||
handler: async (ctx, args): Promise<Id<"notificacoesEmail">> => {
|
||||
// Buscar template
|
||||
const template = await ctx.runQuery(api.templatesMensagens.obterTemplatePorCodigo, {
|
||||
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}`);
|
||||
@@ -160,16 +176,21 @@ export const enviarEmailComTemplate = action({
|
||||
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
|
||||
const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate);
|
||||
|
||||
// Enfileirar email
|
||||
const emailId = await ctx.runMutation(api.email.enfileirarEmail, {
|
||||
// Enfileirar email via mutation
|
||||
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
|
||||
destinatario: args.destinatario,
|
||||
destinatarioId: args.destinatarioId,
|
||||
assunto: tituloRenderizado,
|
||||
corpo: corpoRenderizado,
|
||||
templateId: template._id,
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -182,29 +203,45 @@ export const enviarEmailComTemplate = action({
|
||||
export const processarFilaEmails = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Buscar emails pendentes (limitado a 10 por vez para não sobrecarregar)
|
||||
const emailsPendentes = await ctx.db
|
||||
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) => q.eq(q.field("status"), "pendente"))
|
||||
.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 (emailsPendentes.length === 0) {
|
||||
if (emailsParaProcessar.length === 0) {
|
||||
return { processados: 0 };
|
||||
}
|
||||
|
||||
// Agendar envio de cada email via action
|
||||
for (const email of emailsPendentes) {
|
||||
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) => {
|
||||
console.error(`Erro ao agendar envio de email ${email._id}:`, error);
|
||||
}).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: emailsPendentes.length };
|
||||
},
|
||||
return { processados: emailsParaProcessar.length };
|
||||
}
|
||||
});
|
||||
|
||||
// ========== QUERIES ==========
|
||||
@@ -221,6 +258,7 @@ export const listarFilaEmails = query({
|
||||
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;
|
||||
@@ -247,7 +285,9 @@ export const listarFilaEmails = query({
|
||||
* Obter estatísticas da fila de emails (para debug e monitoramento)
|
||||
*/
|
||||
export const obterEstatisticasFilaEmails = query({
|
||||
args: {},
|
||||
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(),
|
||||
@@ -324,14 +364,15 @@ export const processarFilaEmailsManual = action({
|
||||
emailId: email._id,
|
||||
});
|
||||
processados++;
|
||||
} catch (error) {
|
||||
console.error(`Erro ao agendar envio de email ${email._id}:`, error);
|
||||
} 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) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
sucesso: false,
|
||||
|
||||
@@ -152,11 +152,12 @@ export const enviarPushNotification = internalMutation({
|
||||
}
|
||||
|
||||
// Se há conversaId, verificar preferências específicas da conversa
|
||||
if (args.data?.conversaId) {
|
||||
const conversaId = args.data?.conversaId;
|
||||
if (conversaId) {
|
||||
const preferencias = await ctx.db
|
||||
.query("preferenciasNotificacaoConversa")
|
||||
.withIndex("by_usuario_conversa", (q) =>
|
||||
q.eq("usuarioId", args.usuarioId).eq("conversaId", args.data.conversaId)
|
||||
q.eq("usuarioId", args.usuarioId).eq("conversaId", conversaId)
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -167,7 +168,7 @@ export const enviarPushNotification = internalMutation({
|
||||
}
|
||||
|
||||
// Se apenas menções e não é menção, não enviar
|
||||
if (preferencias.apenasMencoes && args.data.tipo !== "mencao") {
|
||||
if (preferencias.apenasMencoes && args.data?.tipo !== "mencao") {
|
||||
return { enviados: 0, falhas: 0 };
|
||||
}
|
||||
}
|
||||
@@ -177,17 +178,28 @@ export const enviarPushNotification = internalMutation({
|
||||
let enviados = 0;
|
||||
let falhas = 0;
|
||||
|
||||
// Converter IDs para strings ao passar para a action
|
||||
// A action espera strings, mas recebemos Ids do Convex
|
||||
const dataParaAction = args.data
|
||||
? {
|
||||
conversaId: args.data.conversaId ? String(args.data.conversaId) : undefined,
|
||||
mensagemId: args.data.mensagemId ? String(args.data.mensagemId) : undefined,
|
||||
tipo: args.data.tipo,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
await ctx.scheduler.runAfter(0, api.actions.pushNotifications.enviarPush, {
|
||||
subscriptionId: subscription._id,
|
||||
titulo: args.titulo,
|
||||
corpo: args.corpo,
|
||||
data: args.data,
|
||||
data: dataParaAction,
|
||||
});
|
||||
enviados++;
|
||||
} catch (error) {
|
||||
console.error(`Erro ao agendar push para subscription ${subscription._id}:`, error);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Erro ao agendar push para subscription ${subscription._id}:`, errorMessage);
|
||||
falhas++;
|
||||
}
|
||||
}
|
||||
|
||||
46
packages/backend/convex/types/web-push.d.ts
vendored
Normal file
46
packages/backend/convex/types/web-push.d.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
declare module "web-push" {
|
||||
export interface PushSubscription {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
TTL?: number;
|
||||
headers?: Record<string, string>;
|
||||
vapidDetails?: {
|
||||
subject: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function setVapidDetails(
|
||||
subject: string,
|
||||
publicKey: string,
|
||||
privateKey: string
|
||||
): void;
|
||||
|
||||
export function sendNotification(
|
||||
subscription: PushSubscription,
|
||||
payload: string | Buffer,
|
||||
options?: SendOptions
|
||||
): Promise<void>;
|
||||
|
||||
export function generateVAPIDKeys(): {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
interface WebPush {
|
||||
setVapidDetails: typeof setVapidDetails;
|
||||
sendNotification: typeof sendNotification;
|
||||
generateVAPIDKeys: typeof generateVAPIDKeys;
|
||||
}
|
||||
|
||||
const webpush: WebPush;
|
||||
export default webpush;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { hashPassword, generateToken } from "./auth/utils";
|
||||
import { registrarAtividade } from "./logsAtividades";
|
||||
import { Id, Doc } from "./_generated/dataModel";
|
||||
import { api } from "./_generated/api";
|
||||
import type { QueryCtx } from "./_generated/server";
|
||||
import type { QueryCtx, MutationCtx } from "./_generated/server";
|
||||
|
||||
/**
|
||||
* Helper para obter a matrícula do usuário (do funcionário se houver)
|
||||
@@ -20,6 +20,38 @@ async function obterMatriculaUsuario(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para obter usuário autenticado (Better Auth ou Sessão)
|
||||
* Usa a mesma lógica do obterPerfil para garantir consistência
|
||||
*/
|
||||
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
||||
// Tentar autenticação via Better Auth primeiro
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
let usuarioAtual = null;
|
||||
|
||||
if (identity && identity.email) {
|
||||
usuarioAtual = await ctx.db
|
||||
.query("usuarios")
|
||||
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
||||
.first();
|
||||
}
|
||||
|
||||
// Se não encontrou via Better Auth, tentar via sessão mais recente
|
||||
if (!usuarioAtual) {
|
||||
const sessaoAtiva = await ctx.db
|
||||
.query("sessoes")
|
||||
.filter((q) => q.eq(q.field("ativo"), true))
|
||||
.order("desc")
|
||||
.first();
|
||||
|
||||
if (sessaoAtiva) {
|
||||
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
|
||||
}
|
||||
}
|
||||
|
||||
return usuarioAtual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Associar funcionário a um usuário
|
||||
*/
|
||||
@@ -761,15 +793,25 @@ export const listarParaChat = query({
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
// Obter usuário autenticado usando função helper compartilhada
|
||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||
|
||||
// Buscar todos os usuários ativos
|
||||
const usuarios = await ctx.db
|
||||
.query("usuarios")
|
||||
.filter((q) => q.eq(q.field("ativo"), true))
|
||||
.collect();
|
||||
|
||||
// Filtrar o usuário atual da lista apenas se conseguimos identificá-lo com certeza
|
||||
// Se não conseguimos identificar (usuarioAtual é null), retornar todos
|
||||
// O frontend fará um filtro adicional usando obterPerfil como camada de segurança
|
||||
const usuariosFiltrados = usuarioAtual
|
||||
? usuarios.filter((u) => u._id !== usuarioAtual._id)
|
||||
: usuarios;
|
||||
|
||||
// Buscar foto de perfil URL para cada usuário
|
||||
const usuariosComFoto = await Promise.all(
|
||||
usuarios.map(async (usuario) => {
|
||||
usuariosFiltrados.map(async (usuario) => {
|
||||
let fotoPerfilUrl = null;
|
||||
if (usuario.fotoPerfil) {
|
||||
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
|
||||
|
||||
Reference in New Issue
Block a user