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:
@@ -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');
|
||||
Reference in New Issue
Block a user