From 5c50db670483e4806cab537bcffa8de8ac20467e Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 23 Apr 2026 09:54:48 -0300 Subject: [PATCH] Notifications: fallback de polling + catch-up ao focar a aba MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realtime em ambiente self-hosted às vezes perde eventos (WebSocket desconecta silenciosamente, JWT expira, sleep do SO, etc). Sem fallback, system_alert chega no DB mas toast nunca dispara — usuário só vê ao relogar ou recarregar. Três caminhos complementares agora: 1. Realtime (instantâneo, quando funciona) 2. visibilitychange — ao voltar pro foco da aba, recarrega notificações e dispara toast pras system_alert não-lidas ainda não exibidas 3. Polling a cada 60s como redundância Set alertedIds (in-memory por sessão) evita toast duplicado quando dois caminhos entregam a mesma notif. Seed inicial marca notifs já lidas/ arquivadas no mount pra não disparar retroativamente. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/composables/useNotifications.js | 51 ++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/src/composables/useNotifications.js b/src/composables/useNotifications.js index 06cf300..e49c8a9 100644 --- a/src/composables/useNotifications.js +++ b/src/composables/useNotifications.js @@ -23,14 +23,20 @@ import { useNotificationStore } from '@/stores/notificationStore'; // 24h em ms — na prática "sticky" (PrimeVue Toast não aceita Infinity). const STICKY_TOAST_LIFE_MS = 24 * 60 * 60 * 1000; +// Fallback polling — garante catch-up mesmo se Realtime perder eventos. +const POLLING_INTERVAL_MS = 60_000; + export function useNotifications() { const store = useNotificationStore(); const toast = useToast(); + const alertedIds = new Set(); // ids que já dispararam toast nesta sessão + let ownerId = null; + let pollTimer = null; - function onRealtimeNotification(item) { - // Toast aparece pra alertas de sistema (heartbeat, infra, etc). - // Inbound/agendas têm seus próprios notifiers visuais. + function showToastFor(item) { if (item?.type !== 'system_alert') return; + if (alertedIds.has(item.id)) return; + alertedIds.add(item.id); const payload = item.payload || {}; toast.add({ severity: 'error', @@ -41,16 +47,53 @@ export function useNotifications() { }); } + function onRealtimeNotification(item) { + showToastFor(item); + } + + // Re-carrega notifs do DB e dispara toast pras system_alert não-lidas + // que ainda não foram vistas nesta sessão. Usado no mount, no visibilitychange + // e no polling de fallback pro caso de Realtime perder eventos. + async function refreshAndMaybeAlert() { + if (!ownerId) return; + await store.load(ownerId); + for (const item of store.items || []) { + if (item.type !== 'system_alert') continue; + if (item.read_at || item.archived) continue; + showToastFor(item); + } + } + + function onVisibilityChange() { + if (document.visibilityState === 'visible') { + refreshAndMaybeAlert(); + } + } + onMounted(async () => { const { data, error } = await supabase.auth.getUser(); if (error || !data?.user?.id) return; - const ownerId = data.user.id; + ownerId = data.user.id; await store.load(ownerId); + + // Seed do set: notifs system_alert já lidas/vistas não redisparam toast + for (const item of store.items || []) { + if (item.type === 'system_alert' && (item.read_at || item.archived)) { + alertedIds.add(item.id); + } + } + store.subscribeRealtime(ownerId, onRealtimeNotification); + + document.addEventListener('visibilitychange', onVisibilityChange); + pollTimer = setInterval(refreshAndMaybeAlert, POLLING_INTERVAL_MS); }); onUnmounted(() => { + document.removeEventListener('visibilitychange', onVisibilityChange); + if (pollTimer) clearInterval(pollTimer); + pollTimer = null; store.unsubscribe(); });