Files
agenciapsilmno/src/composables/useNotifications.js
T
Leonardo 4441661f62 Toast system_alert: agregar no catch-up pra não empilhar enxurrada
Bug: acumulando N system_alert não-lidas, o refreshAndMaybeAlert
(mount / visibilitychange / polling 60s) disparava N toasts de uma vez.
Comum após recarregar a página com alertas pendentes do último teste.

Fix: no catch-up, mostra só a notif mais recente, com sufixo "+N
outros alertas no sino" no detail se houver múltiplas. As demais são
marcadas no alertedIds pra não redisparar — continuam visíveis no
sininho/drawer com badge.

Eventos novos via Realtime seguem aparecendo individualmente (fluxo
normal — o usuário está online vendo chegar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:03:24 -03:00

141 lines
5.0 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useNotifications.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { onMounted, onUnmounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useNotificationStore } from '@/stores/notificationStore';
// Alertas de sistema ficam fixos em vermelho até o usuário fechar manualmente.
// 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;
// Label curto do botão do toast baseado no deeplink
function defaultActionLabel(deeplink) {
if (!deeplink) return null;
if (deeplink.includes('creditos-whatsapp')) return 'Ir pra loja';
if (deeplink.includes('whatsapp-pessoal')) return 'Ver conexão';
if (deeplink.includes('whatsapp-oficial')) return 'Ver canal oficial';
return 'Abrir';
}
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',
summary: payload.title || 'Alerta do sistema',
detail: payload.detail || '',
life: STICKY_TOAST_LIFE_MS,
closable: true,
group: 'system-alerts',
data: {
deeplink: payload.deeplink,
actionLabel: payload.actionLabel || defaultActionLabel(payload.deeplink)
}
});
}
function onRealtimeNotification(item) {
showToastFor(item);
}
// Re-carrega notifs do DB e dispara toast AGREGADO pras system_alert
// não-lidas ainda não vistas nesta sessão. Se tiver várias, mostra só
// a mais recente com sufixo "(+N outros alertas)" no detail pra evitar
// enxurrada de toasts ao voltar pra aba / recarregar. Eventos novos via
// Realtime continuam aparecendo individualmente (showToastFor direto).
async function refreshAndMaybeAlert() {
if (!ownerId) return;
await store.load(ownerId);
const pending = (store.items || [])
.filter((i) => i.type === 'system_alert' && !i.read_at && !i.archived && !alertedIds.has(i.id));
if (pending.length === 0) return;
// Marca os demais como já "alertados" nesta sessão pra não redisparar
// nos próximos ticks de polling/visibility — eles continuam no sininho.
const newest = pending[0]; // store.items já vem ordenado por created_at DESC
for (let i = 1; i < pending.length; i++) {
alertedIds.add(pending[i].id);
}
if (pending.length > 1) {
const extra = pending.length - 1;
const suffix = ` • +${extra} outro${extra === 1 ? '' : 's'} alerta${extra === 1 ? '' : 's'} no sino`;
const patched = {
...newest,
payload: {
...(newest.payload || {}),
detail: `${newest.payload?.detail || ''}${suffix}`
}
};
showToastFor(patched);
} else {
showToastFor(newest);
}
}
function onVisibilityChange() {
if (document.visibilityState === 'visible') {
refreshAndMaybeAlert();
}
}
onMounted(async () => {
const { data, error } = await supabase.auth.getUser();
if (error || !data?.user?.id) return;
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();
});
return store;
}