771b636cee
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>
312 lines
11 KiB
PL/PgSQL
312 lines
11 KiB
PL/PgSQL
-- ==========================================================================
|
|
-- 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');
|