From e1f756ea8263eb93b86f1141d8690192d4a9e3bd Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 23 Apr 2026 07:49:09 -0300 Subject: [PATCH] =?UTF-8?q?Heartbeat=20WhatsApp=20Evolution=20(Grupo=206.1?= =?UTF-8?q?):=20detec=C3=A7=C3=A3o=20+=20incident=20+=20alerta=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detecta celular desconectado antes de falhar envios silenciosamente. Banco (migration 20260423000002): - Tabela whatsapp_connection_incidents (tenant_id, channel_id, kind, started_at, resolved_at, duration_seconds, notified_at, details). UNIQUE parcial garante no máximo 1 incident aberto por channel. - RPCs whatsapp_heartbeat_open_incident (idempotente), _resolve_open_incidents e _mark_notified. Service_role only. - RLS: membros do tenant leem, saas_admin tudo. - ALTER notifications.type pra aceitar 'system_alert' (usado pelo alerta). Edge function whatsapp-heartbeat-check: - Varre notification_channels provider=evolution_api e ativos. - GET {api_url}/instance/connectionState/{instance} (timeout 8s, rewrite localhost → host.docker.internal pra containers). - Mapeia state pra connection_status (open/connecting/qr_pending/ disconnected/error), persiste + last_health_check. - Lógica de threshold: marca first_unhealthy_at em metadata na primeira falha; só abre incident após heartbeat_threshold_minutes (default 5). - Notifica admins ativos (clinic_admin/tenant_admin) do tenant via insert em notifications. Anti-spam: só notifica 1x por incident. - Aceita ?channel_id=X pra check on-demand de um tenant específico. UI tenant (/configuracoes/whatsapp-pessoal): - Novo card "Monitoramento de conexão" com toggle alerts_enabled + InputNumber threshold (1-60 min). Persiste em notification_channels.metadata. - Histórico últimos 7 dias: kind (tag colorida), aberto/resolvido, início → fim, duração formatada (Ns/Xmin Ys/Nh Xmin). UI SaaS (/saas/whatsapp): - Badge "N incidents abertos" no header quando há algum. - Botão "Verificar tudo agora" invoca a edge function e atualiza a lista. - Tabela enriquecida: coluna Status ganha pill "Incident aberto", colunas novas Última check e Incidents 7d (em laranja se > 0). Cron template no final da migration (comentado — descomentar cron.schedule pra ativar 2min periódico). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...23000002_whatsapp_connection_heartbeat.sql | 272 ++++++++++++++ .../ConfiguracoesWhatsappPage.vue | 159 +++++++- src/views/pages/saas/SaasWhatsappPage.vue | 74 +++- .../whatsapp-heartbeat-check/index.ts | 340 ++++++++++++++++++ 4 files changed, 832 insertions(+), 13 deletions(-) create mode 100644 database-novo/migrations/20260423000002_whatsapp_connection_heartbeat.sql create mode 100644 supabase/functions/whatsapp-heartbeat-check/index.ts diff --git a/database-novo/migrations/20260423000002_whatsapp_connection_heartbeat.sql b/database-novo/migrations/20260423000002_whatsapp_connection_heartbeat.sql new file mode 100644 index 0000000..20a60f6 --- /dev/null +++ b/database-novo/migrations/20260423000002_whatsapp_connection_heartbeat.sql @@ -0,0 +1,272 @@ +-- ========================================================================== +-- Agencia PSI — Migracao: Heartbeat de conexao WhatsApp (Grupo 6.1) +-- ========================================================================== +-- Criado por: Leonardo Nohama +-- Data: 2026-04-23 · Sao Carlos/SP — Brasil +-- +-- Contexto: notification_channels ja tem connection_status e last_health_check, +-- mas nao ha tracking de incidents (quando conexao caiu, quanto tempo ficou +-- fora, se ja alertou o admin). Criamos tabela de incidents + helpers RPC. +-- +-- Modelo: +-- - notification_channels.connection_status (ja existe) = estado atual +-- - notification_channels.last_health_check (ja existe) = ultima check +-- - notification_channels.metadata (ja existe) = config (threshold, toggles) +-- +-- Novo: +-- - whatsapp_connection_incidents = eventos de degradacao (open/close) +-- - RPC open_incident (idempotente — ignora se ja tem aberto) +-- - RPC resolve_open_incidents (fecha todos abertos pro channel) +-- +-- Fluxo esperado (edge function whatsapp-heartbeat-check): +-- 1. Lista channels evolution_api ativos +-- 2. Pra cada: GET /instance/connectionState/{instance} +-- 3. Atualiza connection_status + last_health_check +-- 4. Se state != 'open' por > threshold minutos → open_incident (se nao tem) +-- e dispara notificacao pros admins do tenant +-- 5. Se state == 'open' → resolve_open_incidents (se tiver aberto) +-- ========================================================================== + +-- --------------------------------------------------------------------------- +-- Tabela: whatsapp_connection_incidents +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.whatsapp_connection_incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES public.notification_channels(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('evolution_api', 'twilio')), + + -- Tipo do incident (snapshot do connection_status que abriu) + kind TEXT NOT NULL CHECK (kind IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown')), + + -- Snapshots pra auditoria + last_state TEXT, -- estado quando abriu + details JSONB, -- payload bruto do provider + + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, -- NULL enquanto aberto + + -- Tempo caido (preenchido ao resolver) + duration_seconds INT, + + -- Controle de notificacao (anti-spam) + notified_at TIMESTAMPTZ, -- quando enviou notificacao pros admins + notification_count INT NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Trigger updated_at +DROP TRIGGER IF EXISTS trg_wa_incidents_updated_at ON public.whatsapp_connection_incidents; +CREATE TRIGGER trg_wa_incidents_updated_at + BEFORE UPDATE ON public.whatsapp_connection_incidents + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Apenas 1 incident aberto por channel (constraint de negocio) +CREATE UNIQUE INDEX IF NOT EXISTS uq_wa_incidents_open_per_channel + ON public.whatsapp_connection_incidents (channel_id) + WHERE resolved_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_wa_incidents_tenant_started + ON public.whatsapp_connection_incidents (tenant_id, started_at DESC); + +CREATE INDEX IF NOT EXISTS idx_wa_incidents_open + ON public.whatsapp_connection_incidents (resolved_at) + WHERE resolved_at IS NULL; + +COMMENT ON TABLE public.whatsapp_connection_incidents IS + 'Eventos de degradacao de conexao WhatsApp (evolution/twilio). Max 1 aberto por channel (UNIQUE parcial).'; + + +-- --------------------------------------------------------------------------- +-- RPC: whatsapp_heartbeat_open_incident +-- Idempotente — se ja tem incident aberto no channel, retorna o existente. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.whatsapp_heartbeat_open_incident( + p_channel_id UUID, + p_kind TEXT, + p_last_state TEXT DEFAULT NULL, + p_details JSONB DEFAULT NULL +) +RETURNS UUID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant_id UUID; + v_provider TEXT; + v_existing_id UUID; + v_new_id UUID; +BEGIN + -- Busca tenant/provider do channel + SELECT tenant_id, provider INTO v_tenant_id, v_provider + FROM public.notification_channels + WHERE id = p_channel_id + AND deleted_at IS NULL; + + IF NOT FOUND THEN + RAISE EXCEPTION 'channel_not_found'; + END IF; + + IF p_kind NOT IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown') THEN + RAISE EXCEPTION 'invalid_kind: %', p_kind; + END IF; + + -- Ja tem aberto? Retorna o mesmo id (idempotente) + SELECT id INTO v_existing_id + FROM public.whatsapp_connection_incidents + WHERE channel_id = p_channel_id + AND resolved_at IS NULL; + + IF FOUND THEN + -- Atualiza o incident existente com detalhes frescos + UPDATE public.whatsapp_connection_incidents + SET last_state = COALESCE(p_last_state, last_state), + details = COALESCE(p_details, details), + kind = p_kind -- pode mudar de qr_pending → disconnected, por ex + WHERE id = v_existing_id; + RETURN v_existing_id; + END IF; + + -- Abre novo + INSERT INTO public.whatsapp_connection_incidents + (tenant_id, channel_id, provider, kind, last_state, details) + VALUES + (v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details) + RETURNING id INTO v_new_id; + + RETURN v_new_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.whatsapp_heartbeat_open_incident(UUID, TEXT, TEXT, JSONB) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.whatsapp_heartbeat_open_incident(UUID, TEXT, TEXT, JSONB) TO service_role; + + +-- --------------------------------------------------------------------------- +-- RPC: whatsapp_heartbeat_resolve_open_incidents +-- Fecha todos os incidents abertos de um channel. Retorna quantos fechou. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents( + p_channel_id UUID +) +RETURNS INT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_count INT := 0; +BEGIN + UPDATE public.whatsapp_connection_incidents + SET resolved_at = now(), + duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::INT + WHERE channel_id = p_channel_id + AND resolved_at IS NULL; + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; + +REVOKE ALL ON FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(UUID) TO service_role; + + +-- --------------------------------------------------------------------------- +-- RPC: whatsapp_heartbeat_mark_notified +-- Marca incident como notificado (anti-spam de alertas). +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.whatsapp_heartbeat_mark_notified( + p_incident_id UUID +) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + UPDATE public.whatsapp_connection_incidents + SET notified_at = now(), + notification_count = notification_count + 1 + WHERE id = p_incident_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.whatsapp_heartbeat_mark_notified(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.whatsapp_heartbeat_mark_notified(UUID) TO service_role; + + +-- --------------------------------------------------------------------------- +-- RLS +-- --------------------------------------------------------------------------- +ALTER TABLE public.whatsapp_connection_incidents ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "wa_incidents: select membros/admin" ON public.whatsapp_connection_incidents; +CREATE POLICY "wa_incidents: select membros/admin" + ON public.whatsapp_connection_incidents + FOR SELECT + TO authenticated + USING ( + public.is_saas_admin() + OR EXISTS ( + SELECT 1 FROM public.tenant_members tm + WHERE tm.tenant_id = whatsapp_connection_incidents.tenant_id + AND tm.user_id = auth.uid() + AND tm.status = 'active' + ) + ); + +-- Write apenas via service_role (edge function cron) +DROP POLICY IF EXISTS "wa_incidents: write service_role" ON public.whatsapp_connection_incidents; +CREATE POLICY "wa_incidents: write service_role" + ON public.whatsapp_connection_incidents + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + + +-- --------------------------------------------------------------------------- +-- Expandir notifications.type pra aceitar 'system_alert' (usado por heartbeat) +-- --------------------------------------------------------------------------- +ALTER TABLE public.notifications + DROP CONSTRAINT IF EXISTS notifications_type_check; + +ALTER TABLE public.notifications + ADD CONSTRAINT notifications_type_check + CHECK (type = ANY (ARRAY[ + 'new_scheduling'::text, + 'new_patient'::text, + 'recurrence_alert'::text, + 'session_status'::text, + 'inbound_message'::text, + 'system_alert'::text + ])); + + +-- --------------------------------------------------------------------------- +-- Cron job (TEMPLATE — descomentar pra ativar) +-- --------------------------------------------------------------------------- +-- Checa heartbeat de todos os tenants Evolution ativos a cada 2 minutos. +-- Threshold de minutos fora do ar antes de abrir incident fica em +-- notification_channels.metadata.heartbeat_threshold_minutes (default 5). +-- +-- SELECT cron.schedule( +-- 'whatsapp-heartbeat-every-2min', +-- '*/2 * * * *', +-- $$ +-- SELECT net.http_post( +-- url := current_setting('app.settings.supabase_url') || '/functions/v1/whatsapp-heartbeat-check', +-- headers := jsonb_build_object( +-- 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'), +-- 'Content-Type', 'application/json' +-- ), +-- body := '{}'::jsonb +-- ); +-- $$ +-- ); +-- +-- Desativar: SELECT cron.unschedule('whatsapp-heartbeat-every-2min'); diff --git a/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue b/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue index 4af3bcb..674ca6f 100644 --- a/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue +++ b/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue @@ -152,6 +152,99 @@ async function checkConnectionStatus() { } } +// ────────────────────────────────────────────────────────────── +// Monitoramento de conexão (Heartbeat — Grupo 6.1) +// ────────────────────────────────────────────────────────────── +const heartbeatConfig = ref({ threshold_minutes: 5, alerts_enabled: true }); +const heartbeatConfigSaving = ref(false); +const incidents = ref([]); +const incidentsLoading = ref(false); + +async function loadHeartbeatConfig() { + if (!channelRecord.value) return; + const meta = channelRecord.value.metadata || {}; + heartbeatConfig.value = { + threshold_minutes: Number(meta.heartbeat_threshold_minutes) || 5, + alerts_enabled: meta.heartbeat_alerts_enabled !== false + }; +} + +async function saveHeartbeatConfig() { + if (!channelRecord.value?.id) return; + heartbeatConfigSaving.value = true; + try { + const threshold = Math.max(1, Math.min(60, Math.round(Number(heartbeatConfig.value.threshold_minutes) || 5))); + const newMeta = { + ...(channelRecord.value.metadata || {}), + heartbeat_threshold_minutes: threshold, + heartbeat_alerts_enabled: !!heartbeatConfig.value.alerts_enabled + }; + const { error } = await supabase + .from('notification_channels') + .update({ metadata: newMeta }) + .eq('id', channelRecord.value.id); + if (error) throw error; + channelRecord.value.metadata = newMeta; + heartbeatConfig.value.threshold_minutes = threshold; + toast.add({ severity: 'success', summary: 'Configuração salva', life: 2000 }); + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 }); + } finally { + heartbeatConfigSaving.value = false; + } +} + +async function loadIncidents() { + if (!tenantId.value || !channelRecord.value?.id) return; + incidentsLoading.value = true; + try { + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString(); + const { data, error } = await supabase + .from('whatsapp_connection_incidents') + .select('id, kind, last_state, started_at, resolved_at, duration_seconds, notified_at') + .eq('channel_id', channelRecord.value.id) + .gte('started_at', sevenDaysAgo) + .order('started_at', { ascending: false }) + .limit(30); + if (error) throw error; + incidents.value = data || []; + } catch (e) { + toast.add({ severity: 'warn', summary: 'Histórico indisponível', detail: e.message, life: 3000 }); + } finally { + incidentsLoading.value = false; + } +} + +function formatDuration(seconds) { + if (!seconds && seconds !== 0) return '—'; + const s = Number(seconds); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}min ${s % 60}s`; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return `${h}h ${m}min`; +} + +function incidentKindLabel(kind) { + return { + disconnected: 'Desconectado', + error: 'Erro', + qr_pending: 'Aguardando QR', + connecting: 'Conectando', + unknown: 'Desconhecido' + }[kind] || kind; +} + +function incidentKindSeverity(kind) { + return { + disconnected: 'danger', + error: 'danger', + qr_pending: 'warn', + connecting: 'info', + unknown: 'secondary' + }[kind] || 'secondary'; +} + // Buscar QR Code para conectar async function fetchQrCode() { if (!isMounted) return; @@ -593,7 +686,11 @@ function onPageChange(event) { onMounted(async () => { await loadUser(); await Promise.all([loadCredentials(), loadTemplates(), loadLogs()]); - if (hasCredentials.value) await checkConnectionStatus(); + if (hasCredentials.value) { + await checkConnectionStatus(); + await loadHeartbeatConfig(); + await loadIncidents(); + } }); onBeforeUnmount(() => { @@ -664,6 +761,66 @@ onBeforeUnmount(() => { + +
+
+
+
+ +
+
+
Monitoramento de conexão
+
+ Recebe alerta quando o celular fica offline por mais tempo que o configurado. +
+
+
+
+ +
+
+ + +
+
+ + + min sem conexão +
+
+
+
+ + +
+
+ Últimos 7 dias ({{ incidents.length }} {{ incidents.length === 1 ? 'evento' : 'eventos' }}) +
+
+ Carregando… +
+
+ Nenhum evento registrado nos últimos 7 dias — conexão estável. +
+
+
+ + + {{ inc.resolved_at ? 'Resolvido' : 'Aberto' }} + + + {{ new Date(inc.started_at).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) }} + → {{ new Date(inc.resolved_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }} + + {{ formatDuration(inc.duration_seconds) }} +
+
+
+
+
diff --git a/src/views/pages/saas/SaasWhatsappPage.vue b/src/views/pages/saas/SaasWhatsappPage.vue index 58a302c..5018c75 100644 --- a/src/views/pages/saas/SaasWhatsappPage.vue +++ b/src/views/pages/saas/SaasWhatsappPage.vue @@ -352,13 +352,29 @@ function closeQrDialog() { // ── Visão geral: todos os canais WhatsApp ───────────────────── const allChannels = ref([]); const loadingAll = ref(false); +const openIncidentsByChannel = ref({}); // { channel_id: incident_row } +const incidents7dByChannel = ref({}); // { channel_id: count } +const heartbeatRunning = ref(false); async function loadAllChannels() { loadingAll.value = true; try { - const { data, error } = await supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false }); - if (error) throw error; - allChannels.value = data || []; + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString(); + const [channelsRes, openRes, countRes] = await Promise.all([ + supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false }), + supabase.from('whatsapp_connection_incidents').select('id, channel_id, kind, started_at, last_state').is('resolved_at', null), + supabase.from('whatsapp_connection_incidents').select('channel_id').gte('started_at', sevenDaysAgo) + ]); + if (channelsRes.error) throw channelsRes.error; + allChannels.value = channelsRes.data || []; + + const openMap = {}; + for (const inc of openRes.data || []) openMap[inc.channel_id] = inc; + openIncidentsByChannel.value = openMap; + + const countMap = {}; + for (const row of countRes.data || []) countMap[row.channel_id] = (countMap[row.channel_id] || 0) + 1; + incidents7dByChannel.value = countMap; } catch (e) { toast.add({ severity: 'error', summary: 'Erro ao carregar canais', detail: e.message, life: 4000 }); } finally { @@ -366,6 +382,25 @@ async function loadAllChannels() { } } +async function runHeartbeatNow() { + heartbeatRunning.value = true; + try { + const { data, error } = await supabase.functions.invoke('whatsapp-heartbeat-check', { body: {} }); + if (error) throw error; + toast.add({ + severity: 'success', + summary: 'Heartbeat executado', + detail: `${data?.checked || 0} canais: ${data?.ok || 0} ok · ${data?.opened || 0} novos incidents · ${data?.resolved || 0} resolvidos`, + life: 4500 + }); + await loadAllChannels(); + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro', detail: e.message || 'Falha ao invocar heartbeat', life: 5000 }); + } finally { + heartbeatRunning.value = false; + } +} + function channelStatusTag(ch) { if (!ch.is_active) return { label: 'Desativado', severity: 'secondary' }; switch (ch.connection_status) { @@ -430,9 +465,14 @@ onBeforeUnmount(() => {
-
- {{ allChannels.length }} canal(is) WhatsApp configurado(s) -
@@ -451,7 +491,22 @@ onBeforeUnmount(() => { + + + + + + @@ -459,11 +514,6 @@ onBeforeUnmount(() => { - - -