36fbc02e9f
Bug: onclick da Notification do browser (nativa do Chrome/Windows) fazia window.location.pathname = payload.deeplink direto, sem resolver alias semântico e sem abrir o drawer em alertas com thread_key. Como praticamente todos os nossos alertas do SLA vêm com deeplink '/conversas' (alias), o click na notificação do Chrome caía em NotFound. Fix: - fireBrowserNotification agora aceita um callback onClick e é exportada. - Removido o fireBrowserNotification hardcoded do subscribeRealtime do store (passa a ser responsabilidade do composable useNotifications). - useNotifications.onRealtimeNotification dispara toast + browser notif passando handleNotificationAction como handler. - handleNotificationAction: se tem thread_key → abre ConversationDrawer global direto na thread; senão resolve alias e router.push. Mesma lógica que já existe no toast e no clique do NotificationItem do sino. Agora os 3 pontos de click (toast, sininho, notificação nativa do OS) convergem pro mesmo comportamento. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
200 lines
7.5 KiB
JavaScript
200 lines
7.5 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 { 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;
|
|
}
|
|
|