/* |-------------------------------------------------------------------------- | 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 { useRouter } from 'vue-router'; import { useToast } from 'primevue/usetoast'; import { supabase } from '@/lib/supabase/client'; import { useNotificationStore, fireBrowserNotification } from '@/stores/notificationStore'; import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'; import { useTenantStore } from '@/stores/tenantStore'; // 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; // Aliases semânticos do deeplink → rota real por role. Mesmo map do AppLayout. const DEEPLINK_ALIASES = { '/crm/conversas': { therapist: '/therapist/conversas', clinic_admin: '/admin/conversas' }, '/conversas': { therapist: '/therapist/conversas', clinic_admin: '/admin/conversas' } }; function resolveDeeplink(link, role) { if (!link || typeof link !== 'string') return link; const alias = DEEPLINK_ALIASES[link]; if (!alias) return link; return alias[role] || alias.therapist; } export function useNotifications() { const store = useNotificationStore(); const toast = useToast(); const router = useRouter(); const conversationDrawer = useConversationDrawerStore(); const tenantStore = useTenantStore(); 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), thread_key: payload.thread_key || null } }); } // Ação principal ao clicar numa notificação (seja do toast, sino ou OS): // 1. Se tem thread_key, abre o drawer global na thread // 2. Senão, resolve alias e navega via router async function handleNotificationAction(item) { if (!item) return; const payload = item.payload || {}; if (payload.thread_key) { try { const tenantId = tenantStore.activeTenantId; const { data } = await supabase .from('conversation_threads') .select('*') .eq('tenant_id', tenantId) .eq('thread_key', payload.thread_key) .maybeSingle(); if (data) { await conversationDrawer.openForThread(data); return; } } catch { // cai no fallback abaixo } } const role = tenantStore?.activeRole || 'therapist'; const link = resolveDeeplink(payload.deeplink, role); if (link) { if (typeof link === 'string' && link.startsWith('/')) router.push(link); else window.location.href = link; } } function onRealtimeNotification(item) { showToastFor(item); // Dispara a notificação nativa do browser com handler de click que // aplica a mesma lógica (drawer / alias) do toast e do sininho. fireBrowserNotification(item, handleNotificationAction); } // 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: marca TODAS as system_alert existentes como "já vistas" pra // não redisparar toast em F5/reload. Alertas antigos aparecem no // sino/drawer normalmente; toast só dispara pra alertas que chegarem // depois do mount (via Realtime ou catch-up detectando ids novos). for (const item of store.items || []) { if (item.type === 'system_alert') { 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; }