-- ========================================================================== -- Agencia PSI — Migracao: SLA de conversas WhatsApp (Grupo 3.4) -- ========================================================================== -- Criado por: Leonardo Nohama -- Data: 2026-04-23 · Sao Carlos/SP — Brasil -- -- Modelo: -- - conversation_sla_rules → config (1 linha por tenant) -- - conversation_sla_breaches → incidents (1 aberto por thread — UNIQUE) -- - Trigger AFTER INSERT outbound → resolve breach automatico -- - RPCs pra edge cron: sla_open_breach, sla_mark_notified -- -- Regras (combinado com o user): -- 1. Threshold GLOBAL por tenant (1 valor unico) -- 2. Respeita horario comercial (pausa cronometro fora) — configuravel -- 3. Escopo configuravel: 'assigned_only' (default) ou 'all' -- 4. Notifica terapeuta atribuido + CC admin opcional -- -- Anti-spam: notification_count + notified_at na tabela breach, -- idempotencia via UNIQUE parcial (so 1 breach aberto por tenant+thread). -- ========================================================================== -- --------------------------------------------------------------------------- -- Tabela: conversation_sla_rules (config) -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.conversation_sla_rules ( tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE, enabled BOOLEAN NOT NULL DEFAULT false, threshold_minutes INT NOT NULL DEFAULT 60 CHECK (threshold_minutes >= 1 AND threshold_minutes <= 1440), -- 1 min a 24h respect_business_hours BOOLEAN NOT NULL DEFAULT true, business_hours_start TIME NOT NULL DEFAULT '08:00', business_hours_end TIME NOT NULL DEFAULT '18:00', -- ISO: 1=seg ... 7=dom. Default: seg a sex business_days SMALLINT[] NOT NULL DEFAULT ARRAY[1,2,3,4,5]::SMALLINT[] CHECK (array_length(business_days, 1) BETWEEN 1 AND 7), alert_scope TEXT NOT NULL DEFAULT 'assigned_only' CHECK (alert_scope IN ('assigned_only', 'all')), notify_admin_on_breach BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_sla_rules_updated_at ON public.conversation_sla_rules; CREATE TRIGGER trg_sla_rules_updated_at BEFORE UPDATE ON public.conversation_sla_rules FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); COMMENT ON TABLE public.conversation_sla_rules IS 'Configuracao de SLA por tenant. 1 linha por tenant. Threshold global.'; -- --------------------------------------------------------------------------- -- Tabela: conversation_sla_breaches (incidents) -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.conversation_sla_breaches ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, thread_key TEXT NOT NULL, -- Snapshots pra auditoria e pra notificacao assigned_to UUID, last_inbound_at TIMESTAMPTZ NOT NULL, threshold_minutes_at_breach INT NOT NULL, breached_at TIMESTAMPTZ NOT NULL DEFAULT now(), resolved_at TIMESTAMPTZ, resolved_by_message_id BIGINT, -- Controle de notificacao (anti-spam) notified_at TIMESTAMPTZ, notification_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_sla_breaches_updated_at ON public.conversation_sla_breaches; CREATE TRIGGER trg_sla_breaches_updated_at BEFORE UPDATE ON public.conversation_sla_breaches FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); -- Apenas 1 breach aberto por (tenant, thread) — idempotencia do open CREATE UNIQUE INDEX IF NOT EXISTS uq_sla_breaches_open_per_thread ON public.conversation_sla_breaches (tenant_id, thread_key) WHERE resolved_at IS NULL; CREATE INDEX IF NOT EXISTS idx_sla_breaches_tenant_breached ON public.conversation_sla_breaches (tenant_id, breached_at DESC); CREATE INDEX IF NOT EXISTS idx_sla_breaches_open ON public.conversation_sla_breaches (resolved_at) WHERE resolved_at IS NULL; COMMENT ON TABLE public.conversation_sla_breaches IS 'Estouros de SLA detectados pelo cron. Max 1 aberto por thread (UNIQUE parcial). Resolve automatico via trigger outbound.'; -- --------------------------------------------------------------------------- -- Trigger: resolve breach automatico quando nova outbound responde a thread -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_thread_key TEXT; BEGIN -- So processa outbound IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF; -- Calcula thread_key no mesmo padrao da view conversation_threads v_thread_key := COALESCE( NEW.patient_id::text, 'anon:' || COALESCE(NEW.to_number, 'unknown') ); UPDATE public.conversation_sla_breaches SET resolved_at = now(), resolved_by_message_id = NEW.id WHERE tenant_id = NEW.tenant_id AND thread_key = v_thread_key AND resolved_at IS NULL; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_sla_resolve_on_outbound ON public.conversation_messages; CREATE TRIGGER trg_sla_resolve_on_outbound AFTER INSERT ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound(); -- --------------------------------------------------------------------------- -- RPC: sla_open_breach (idempotente pra cron) -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.sla_open_breach( p_tenant_id UUID, p_thread_key TEXT, p_assigned_to UUID, p_last_inbound_at TIMESTAMPTZ, p_threshold_minutes INT ) RETURNS UUID LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_existing_id UUID; v_new_id UUID; BEGIN IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN RAISE EXCEPTION 'tenant_and_thread_required'; END IF; -- Ja tem aberto? Retorna o mesmo id (idempotente) SELECT id INTO v_existing_id FROM public.conversation_sla_breaches WHERE tenant_id = p_tenant_id AND thread_key = p_thread_key AND resolved_at IS NULL; IF FOUND THEN UPDATE public.conversation_sla_breaches SET assigned_to = COALESCE(p_assigned_to, assigned_to), last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at) WHERE id = v_existing_id; RETURN v_existing_id; END IF; INSERT INTO public.conversation_sla_breaches (tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach) VALUES (p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes) RETURNING id INTO v_new_id; RETURN v_new_id; END; $$; REVOKE ALL ON FUNCTION public.sla_open_breach(UUID, TEXT, UUID, TIMESTAMPTZ, INT) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.sla_open_breach(UUID, TEXT, UUID, TIMESTAMPTZ, INT) TO service_role; -- --------------------------------------------------------------------------- -- RPC: sla_mark_notified (anti-spam) -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.sla_mark_notified(p_breach_id UUID) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN UPDATE public.conversation_sla_breaches SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_breach_id; END; $$; REVOKE ALL ON FUNCTION public.sla_mark_notified(UUID) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.sla_mark_notified(UUID) TO service_role; -- --------------------------------------------------------------------------- -- RLS: conversation_sla_rules -- --------------------------------------------------------------------------- ALTER TABLE public.conversation_sla_rules ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "sla_rules: select membros/admin" ON public.conversation_sla_rules; CREATE POLICY "sla_rules: select membros/admin" ON public.conversation_sla_rules FOR SELECT TO authenticated USING ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.tenant_id = conversation_sla_rules.tenant_id AND tm.user_id = auth.uid() AND tm.status = 'active' ) ); DROP POLICY IF EXISTS "sla_rules: write admins" ON public.conversation_sla_rules; CREATE POLICY "sla_rules: write admins" ON public.conversation_sla_rules FOR ALL TO authenticated USING ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.tenant_id = conversation_sla_rules.tenant_id AND tm.user_id = auth.uid() AND tm.role IN ('clinic_admin', 'tenant_admin') AND tm.status = 'active' ) ) WITH CHECK ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.tenant_id = conversation_sla_rules.tenant_id AND tm.user_id = auth.uid() AND tm.role IN ('clinic_admin', 'tenant_admin') AND tm.status = 'active' ) ); -- --------------------------------------------------------------------------- -- RLS: conversation_sla_breaches -- --------------------------------------------------------------------------- ALTER TABLE public.conversation_sla_breaches ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "sla_breaches: select membros/admin" ON public.conversation_sla_breaches; CREATE POLICY "sla_breaches: select membros/admin" ON public.conversation_sla_breaches FOR SELECT TO authenticated USING ( public.is_saas_admin() OR EXISTS ( SELECT 1 FROM public.tenant_members tm WHERE tm.tenant_id = conversation_sla_breaches.tenant_id AND tm.user_id = auth.uid() AND tm.status = 'active' ) ); DROP POLICY IF EXISTS "sla_breaches: write service_role" ON public.conversation_sla_breaches; CREATE POLICY "sla_breaches: write service_role" ON public.conversation_sla_breaches FOR ALL TO service_role USING (true) WITH CHECK (true); -- --------------------------------------------------------------------------- -- Cron job (TEMPLATE — descomentar pra ativar) -- --------------------------------------------------------------------------- -- Checa SLA de todos os tenants com enabled=true a cada 5 minutos. -- -- SELECT cron.schedule( -- 'conversation-sla-check-every-5min', -- '*/5 * * * *', -- $$ -- SELECT net.http_post( -- url := current_setting('app.settings.supabase_url') || '/functions/v1/conversation-sla-check', -- headers := jsonb_build_object( -- 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'), -- 'Content-Type', 'application/json' -- ), -- body := '{}'::jsonb -- ); -- $$ -- ); -- -- Desativar: SELECT cron.unschedule('conversation-sla-check-every-5min');