SLA de conversas WhatsApp (Grupo 3.4): config + detecção + alerta

Completa o Grupo 3 do CRM com alerta de conversa sem resposta além
do tempo configurado — reutiliza o pipeline system_alert (toast
vermelho sticky + sininho + drawer).

Banco (migration 20260423000005):
- conversation_sla_rules: 1 linha por tenant com threshold global
  (1-1440 min), respect_business_hours, business_hours_start/end,
  business_days (ISO 1=seg..7=dom), alert_scope (assigned_only|all),
  notify_admin_on_breach. Default: enabled=false.
- conversation_sla_breaches: incidents com UNIQUE parcial
  (tenant_id, thread_key) WHERE resolved_at IS NULL — idempotência.
- Trigger AFTER INSERT em conversation_messages resolve o breach
  automaticamente quando chega nova outbound na thread.
- RPCs service_role: sla_open_breach (idempotente), sla_mark_notified.
- RLS: membros do tenant leem; clinic_admin/tenant_admin/saas_admin
  escrevem na config; service_role escreve em breaches.

Edge function conversation-sla-check (cron 5min):
- Varre tenants com enabled=true.
- Query conversation_threads onde last_message_direction='inbound'
  (+ assigned_to NOT NULL se scope='assigned_only').
- Se respect_business_hours: calcula businessMinutesElapsed em TS
  iterando dia por dia a interseção da janela [start,end] com
  [last_inbound_at, now], só em dias marcados em business_days. TZ
  fixa em America/Sao_Paulo via Intl.DateTimeFormat.
- Se elapsed >= threshold: sla_open_breach (idempotente) + notifica
  assigned_to sempre + admins se notify_admin_on_breach (deduplicado
  via Set).
- Anti-spam: só notifica 1x por incident (checa notified_at).
- Notification leva deeplink pra /crm/conversas e payload.thread_key
  pro frontend destacar a conversa (fora de escopo deste commit).

UI em /configuracoes/conversas-sla:
- Toggle enabled + InputNumber threshold com preview "≈ Xh Ymin".
- Toggle respect_business_hours → revela start/end + seletor de dias
  úteis (pills toggleáveis Seg..Dom, ISO order).
- Select scope.
- Toggle notify_admin_on_breach.
- Card abaixo com breaches dos últimos 7 dias (status aberto/resolvido,
  thread_key, limite configurado no momento do breach, duração).
- Adicionada na ConfiguracoesPage landing + rota /configuracoes/conversas-sla.

Cron template comentado no fim da migration (mesmo padrão do heartbeat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-23 10:17:41 -03:00
parent 4441661f62
commit 771b636cee
5 changed files with 1008 additions and 0 deletions
@@ -0,0 +1,311 @@
-- ==========================================================================
-- 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');