Notifications: fallback de polling + catch-up ao focar a aba

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) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-23 09:54:48 -03:00
parent e409ba64ef
commit 5c50db6704
+47 -4
View File
@@ -23,14 +23,20 @@ import { useNotificationStore } from '@/stores/notificationStore';
// 24h em ms — na prática "sticky" (PrimeVue Toast não aceita Infinity). // 24h em ms — na prática "sticky" (PrimeVue Toast não aceita Infinity).
const STICKY_TOAST_LIFE_MS = 24 * 60 * 60 * 1000; 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() { export function useNotifications() {
const store = useNotificationStore(); const store = useNotificationStore();
const toast = useToast(); const toast = useToast();
const alertedIds = new Set(); // ids que já dispararam toast nesta sessão
let ownerId = null;
let pollTimer = null;
function onRealtimeNotification(item) { function showToastFor(item) {
// Toast aparece pra alertas de sistema (heartbeat, infra, etc).
// Inbound/agendas têm seus próprios notifiers visuais.
if (item?.type !== 'system_alert') return; if (item?.type !== 'system_alert') return;
if (alertedIds.has(item.id)) return;
alertedIds.add(item.id);
const payload = item.payload || {}; const payload = item.payload || {};
toast.add({ toast.add({
severity: 'error', 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 () => { onMounted(async () => {
const { data, error } = await supabase.auth.getUser(); const { data, error } = await supabase.auth.getUser();
if (error || !data?.user?.id) return; if (error || !data?.user?.id) return;
const ownerId = data.user.id; ownerId = data.user.id;
await store.load(ownerId); 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); store.subscribeRealtime(ownerId, onRealtimeNotification);
document.addEventListener('visibilitychange', onVisibilityChange);
pollTimer = setInterval(refreshAndMaybeAlert, POLLING_INTERVAL_MS);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('visibilitychange', onVisibilityChange);
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
store.unsubscribe(); store.unsubscribe();
}); });