-- ========================================================================== -- 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');