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)
-
+
+ {{ allChannels.length }} canal(is) WhatsApp configurado(s)
+
+
+ {{ Object.keys(openIncidentsByChannel).length }} incident(s) aberto(s)
+
+
+
@@ -451,7 +491,22 @@ onBeforeUnmount(() => {
-
+
+
+
+
+
+
+
+
+ {{ data.last_health_check ? formatDate(data.last_health_check) : '—' }}
+
+
+
+
+
+ {{ incidents7dByChannel[data.id] || 0 }}
+
@@ -459,11 +514,6 @@ onBeforeUnmount(() => {
-
-
- {{ formatDate(data.created_at) }}
-
-
diff --git a/supabase/functions/whatsapp-heartbeat-check/index.ts b/supabase/functions/whatsapp-heartbeat-check/index.ts
new file mode 100644
index 0000000..3bc3d5f
--- /dev/null
+++ b/supabase/functions/whatsapp-heartbeat-check/index.ts
@@ -0,0 +1,340 @@
+/*
+|--------------------------------------------------------------------------
+| Agência PSI — Edge Function: whatsapp-heartbeat-check
+|--------------------------------------------------------------------------
+| Cron a cada 2 minutos. Checa conexão Evolution de todos os tenants com
+| WhatsApp Pessoal ativo. Atualiza connection_status + last_health_check.
+|
+| Fluxo por channel:
+| 1. GET {api_url}/instance/connectionState/{instance_name}
+| 2. Se state === 'open' → marca connected, limpa first_unhealthy_at,
+| resolve incidents abertos
+| 3. Se state !== 'open' → mapeia pra connection_status, seta
+| first_unhealthy_at (se não tinha), e:
+| - Se já passou `heartbeat_threshold_minutes` (default 5) desde
+| first_unhealthy_at → abre incident (idempotente) + notifica
+| admins ativos do tenant
+| - Senão só atualiza status (ainda não alerta)
+| 4. Se erro HTTP / timeout → kind='error', segue mesma regra do caso 3
+|
+| Config por tenant (em notification_channels.metadata):
+| - heartbeat_threshold_minutes (default 5)
+| - heartbeat_alerts_enabled (default true)
+|
+| Não alerta duas vezes pelo mesmo incident (checa notified_at).
+|--------------------------------------------------------------------------
+*/
+
+import { createClient, SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
+}
+
+const DEFAULT_THRESHOLD_MINUTES = 5
+const FETCH_TIMEOUT_MS = 8000
+
+function json(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' }
+ })
+}
+
+function rewriteForContainer(apiUrl: string): string {
+ try {
+ const u = new URL(apiUrl)
+ if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
+ u.hostname = 'host.docker.internal'
+ return u.toString().replace(/\/+$/, '')
+ }
+ return apiUrl.replace(/\/+$/, '')
+ } catch {
+ return apiUrl
+ }
+}
+
+function mapStateToStatus(state: string | null): { status: string, kind: string } {
+ switch (state) {
+ case 'open':
+ return { status: 'connected', kind: 'connected' }
+ case 'connecting':
+ return { status: 'connecting', kind: 'connecting' }
+ case 'qr':
+ case 'qrcode':
+ return { status: 'qr_pending', kind: 'qr_pending' }
+ case 'close':
+ case 'closed':
+ return { status: 'disconnected', kind: 'disconnected' }
+ default:
+ return { status: 'error', kind: 'unknown' }
+ }
+}
+
+async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise {
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ return await fetch(url, { ...init, signal: controller.signal })
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+interface ChannelRow {
+ id: string
+ tenant_id: string
+ owner_id: string
+ provider: string
+ credentials: Record
+ connection_status: string | null
+ last_health_check: string | null
+ metadata: Record | null
+}
+
+async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: Date): Promise<{
+ tenant_id: string
+ channel_id: string
+ previous_status: string | null
+ new_status: string
+ action: 'ok' | 'opened' | 'resolved' | 'still_unhealthy' | 'no_change' | 'config_missing' | 'fetch_error'
+ incident_id?: string
+}> {
+ const creds = channel.credentials || {}
+ const apiUrl = String(creds.api_url || '').trim()
+ const apiKey = String(creds.api_key || '').trim()
+ const instance = String(creds.instance_name || '').trim()
+
+ if (!apiUrl || !apiKey || !instance) {
+ // Credencial incompleta — não alertamos, só marca error e segue
+ await supa.from('notification_channels')
+ .update({ connection_status: 'error', last_health_check: now.toISOString() })
+ .eq('id', channel.id)
+ return { tenant_id: channel.tenant_id, channel_id: channel.id, previous_status: channel.connection_status, new_status: 'error', action: 'config_missing' }
+ }
+
+ const base = rewriteForContainer(apiUrl)
+ const targetUrl = `${base}/instance/connectionState/${encodeURIComponent(instance)}`
+
+ let state: string | null = null
+ let rawBody: unknown = null
+ let fetchError: string | null = null
+ try {
+ const res = await fetchWithTimeout(targetUrl, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json', apikey: apiKey }
+ }, FETCH_TIMEOUT_MS)
+ if (!res.ok) {
+ fetchError = `http_${res.status}`
+ } else {
+ rawBody = await res.json().catch(() => null)
+ // Evolution retorna: { instance: { instanceName, state }} OU { state }
+ const body = rawBody as { instance?: { state?: string }, state?: string } | null
+ state = body?.instance?.state ?? body?.state ?? null
+ }
+ } catch (e) {
+ fetchError = (e as Error).message || 'fetch_failed'
+ }
+
+ const { status: newStatus, kind } = fetchError ? { status: 'error', kind: 'error' } : mapStateToStatus(state)
+ const meta = (channel.metadata || {}) as Record
+ const thresholdMinutes = Number(meta.heartbeat_threshold_minutes) || DEFAULT_THRESHOLD_MINUTES
+ const alertsEnabled = meta.heartbeat_alerts_enabled !== false
+ const firstUnhealthyAtRaw = meta.first_unhealthy_at as string | undefined
+
+ const patch: Record = {
+ connection_status: newStatus,
+ last_health_check: now.toISOString()
+ }
+ const newMeta = { ...meta }
+
+ if (newStatus === 'connected') {
+ // Recuperou
+ if (firstUnhealthyAtRaw) delete newMeta.first_unhealthy_at
+ patch.metadata = newMeta
+
+ await supa.from('notification_channels').update(patch).eq('id', channel.id)
+ const { data: resolved } = await supa.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
+ return {
+ tenant_id: channel.tenant_id,
+ channel_id: channel.id,
+ previous_status: channel.connection_status,
+ new_status: newStatus,
+ action: (resolved as unknown as number) > 0 ? 'resolved' : 'ok'
+ }
+ }
+
+ // Está unhealthy — seta first_unhealthy_at se ainda não tinha
+ const firstUnhealthyAt = firstUnhealthyAtRaw ? new Date(firstUnhealthyAtRaw) : null
+ if (!firstUnhealthyAt || isNaN(firstUnhealthyAt.getTime())) {
+ newMeta.first_unhealthy_at = now.toISOString()
+ }
+ patch.metadata = newMeta
+ await supa.from('notification_channels').update(patch).eq('id', channel.id)
+
+ const minutesUnhealthy = firstUnhealthyAt ? (now.getTime() - firstUnhealthyAt.getTime()) / 60000 : 0
+
+ if (minutesUnhealthy < thresholdMinutes) {
+ return {
+ tenant_id: channel.tenant_id,
+ channel_id: channel.id,
+ previous_status: channel.connection_status,
+ new_status: newStatus,
+ action: firstUnhealthyAt ? 'still_unhealthy' : 'no_change'
+ }
+ }
+
+ // Passou do threshold — abre incident (idempotente)
+ const { data: incidentId, error: incidentErr } = await supa.rpc('whatsapp_heartbeat_open_incident', {
+ p_channel_id: channel.id,
+ p_kind: kind,
+ p_last_state: state || fetchError,
+ p_details: rawBody || (fetchError ? { error: fetchError } : null)
+ })
+
+ if (incidentErr) {
+ return {
+ tenant_id: channel.tenant_id,
+ channel_id: channel.id,
+ previous_status: channel.connection_status,
+ new_status: newStatus,
+ action: 'fetch_error'
+ }
+ }
+
+ const newIncidentId = incidentId as unknown as string
+
+ if (alertsEnabled && newIncidentId) {
+ await notifyTenantAdmins(supa, {
+ tenant_id: channel.tenant_id,
+ incident_id: newIncidentId,
+ channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'),
+ kind,
+ minutes_unhealthy: Math.round(minutesUnhealthy)
+ })
+ }
+
+ return {
+ tenant_id: channel.tenant_id,
+ channel_id: channel.id,
+ previous_status: channel.connection_status,
+ new_status: newStatus,
+ action: 'opened',
+ incident_id: newIncidentId
+ }
+}
+
+async function notifyTenantAdmins(supa: SupabaseClient, params: {
+ tenant_id: string
+ incident_id: string
+ channel_display: string
+ kind: string
+ minutes_unhealthy: number
+}): Promise {
+ // Checa se já notificou esse incident
+ const { data: incident } = await supa
+ .from('whatsapp_connection_incidents')
+ .select('notified_at, notification_count')
+ .eq('id', params.incident_id)
+ .maybeSingle()
+
+ if (incident?.notified_at) return // anti-spam: só notifica 1x pelo mesmo incident
+
+ // Busca admins ativos do tenant
+ const { data: admins } = await supa
+ .from('tenant_members')
+ .select('user_id')
+ .eq('tenant_id', params.tenant_id)
+ .in('role', ['clinic_admin', 'tenant_admin'])
+ .eq('status', 'active')
+
+ if (!admins || admins.length === 0) return
+
+ const kindLabel: Record = {
+ disconnected: 'desconectado',
+ qr_pending: 'aguardando QR Code',
+ error: 'com erro',
+ connecting: 'tentando conectar',
+ unknown: 'em estado desconhecido'
+ }
+
+ const title = `${params.channel_display} ${kindLabel[params.kind] || 'offline'}`
+ const detail = `A conexão está fora há cerca de ${params.minutes_unhealthy} min. Envios automáticos podem estar falhando.`
+
+ const rows = admins.map((a) => ({
+ owner_id: a.user_id,
+ tenant_id: params.tenant_id,
+ type: 'system_alert',
+ ref_id: params.incident_id,
+ ref_table: 'whatsapp_connection_incidents',
+ payload: {
+ title,
+ detail,
+ severity: 'warn',
+ deeplink: '/configuracoes/whatsapp-pessoal'
+ }
+ }))
+
+ await supa.from('notifications').insert(rows)
+ await supa.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id })
+}
+
+Deno.serve(async (req) => {
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
+
+ const supa = createClient(
+ Deno.env.get('SUPABASE_URL') ?? '',
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
+ { auth: { autoRefreshToken: false, persistSession: false } }
+ )
+
+ try {
+ // Canal específico (on-demand via UI do tenant) ou varredura completa
+ const url = new URL(req.url)
+ const singleChannelId = url.searchParams.get('channel_id')
+
+ let query = supa
+ .from('notification_channels')
+ .select('id, tenant_id, owner_id, provider, credentials, connection_status, last_health_check, metadata')
+ .eq('provider', 'evolution_api')
+ .eq('channel', 'whatsapp')
+ .eq('is_active', true)
+ .is('deleted_at', null)
+
+ if (singleChannelId) query = query.eq('id', singleChannelId)
+
+ const { data: channels, error: fetchErr } = await query
+
+ if (fetchErr) return json({ error: fetchErr.message }, 500)
+ if (!channels || channels.length === 0) {
+ return json({ checked: 0, results: [] })
+ }
+
+ const now = new Date()
+ const results = await Promise.all(
+ channels.map((ch) => checkOneChannel(supa, ch as ChannelRow, now).catch((e) => ({
+ tenant_id: (ch as ChannelRow).tenant_id,
+ channel_id: (ch as ChannelRow).id,
+ previous_status: (ch as ChannelRow).connection_status,
+ new_status: 'error',
+ action: 'fetch_error' as const,
+ error: (e as Error).message
+ })))
+ )
+
+ const summary = {
+ checked: results.length,
+ opened: results.filter((r) => r.action === 'opened').length,
+ resolved: results.filter((r) => r.action === 'resolved').length,
+ still_unhealthy: results.filter((r) => r.action === 'still_unhealthy').length,
+ ok: results.filter((r) => r.action === 'ok').length,
+ errors: results.filter((r) => r.action === 'fetch_error' || r.action === 'config_missing').length
+ }
+
+ return json({ ...summary, results })
+ } catch (e) {
+ return json({ error: (e as Error).message || 'unexpected_error' }, 500)
+ }
+})