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');
|
||||||
@@ -158,6 +158,13 @@ const grupos = [
|
|||||||
icon: 'pi pi-ban',
|
icon: 'pi pi-ban',
|
||||||
to: '/configuracoes/conversas-optouts'
|
to: '/configuracoes/conversas-optouts'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'conversas-sla',
|
||||||
|
label: 'SLA de resposta',
|
||||||
|
desc: 'Tempo máximo pra responder mensagens de pacientes. Alerta quando estourar.',
|
||||||
|
icon: 'pi pi-stopwatch',
|
||||||
|
to: '/configuracoes/conversas-sla'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'lembretes-sessao',
|
key: 'lembretes-sessao',
|
||||||
label: 'Lembretes de Sessão',
|
label: 'Lembretes de Sessão',
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<!--
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — SLA de conversas (CRM Grupo 3.4)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Configura tempo máximo de resposta a mensagens de pacientes. Quando
|
||||||
|
| uma conversa fica sem resposta além do threshold, o terapeuta atribuído
|
||||||
|
| recebe alerta (toast vermelho + sininho) e opcionalmente o admin da clínica.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
-->
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const tenantStore = useTenantStore();
|
||||||
|
|
||||||
|
const DEFAULT_RULE = {
|
||||||
|
enabled: false,
|
||||||
|
threshold_minutes: 60,
|
||||||
|
respect_business_hours: true,
|
||||||
|
business_hours_start: '08:00',
|
||||||
|
business_hours_end: '18:00',
|
||||||
|
business_days: [1, 2, 3, 4, 5],
|
||||||
|
alert_scope: 'assigned_only',
|
||||||
|
notify_admin_on_breach: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIAS_ISO = [
|
||||||
|
{ iso: 1, label: 'Seg' },
|
||||||
|
{ iso: 2, label: 'Ter' },
|
||||||
|
{ iso: 3, label: 'Qua' },
|
||||||
|
{ iso: 4, label: 'Qui' },
|
||||||
|
{ iso: 5, label: 'Sex' },
|
||||||
|
{ iso: 6, label: 'Sáb' },
|
||||||
|
{ iso: 7, label: 'Dom' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOPE_OPTIONS = [
|
||||||
|
{ value: 'assigned_only', label: 'Apenas conversas atribuídas' },
|
||||||
|
{ value: 'all', label: 'Todas as conversas (incluindo não atribuídas)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const rule = ref({ ...DEFAULT_RULE });
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const breaches = ref([]);
|
||||||
|
const breachesLoading = ref(false);
|
||||||
|
|
||||||
|
const tenantId = computed(() => tenantStore.activeTenantId);
|
||||||
|
|
||||||
|
// Converte TIME do DB ('08:00:00') → string 'HH:MM' pra time picker
|
||||||
|
function timeFromDb(t) {
|
||||||
|
return String(t || '').slice(0, 5);
|
||||||
|
}
|
||||||
|
// 'HH:MM' → 'HH:MM:00' pra DB
|
||||||
|
function timeToDb(t) {
|
||||||
|
const clean = String(t || '').trim();
|
||||||
|
if (/^\d{2}:\d{2}$/.test(clean)) return `${clean}:00`;
|
||||||
|
if (/^\d{2}:\d{2}:\d{2}$/.test(clean)) return clean;
|
||||||
|
return '08:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRule() {
|
||||||
|
if (!tenantId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversation_sla_rules')
|
||||||
|
.select('*')
|
||||||
|
.eq('tenant_id', tenantId.value)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
if (data) {
|
||||||
|
rule.value = {
|
||||||
|
enabled: !!data.enabled,
|
||||||
|
threshold_minutes: Number(data.threshold_minutes) || 60,
|
||||||
|
respect_business_hours: !!data.respect_business_hours,
|
||||||
|
business_hours_start: timeFromDb(data.business_hours_start),
|
||||||
|
business_hours_end: timeFromDb(data.business_hours_end),
|
||||||
|
business_days: Array.isArray(data.business_days) ? [...data.business_days] : [1, 2, 3, 4, 5],
|
||||||
|
alert_scope: data.alert_scope || 'assigned_only',
|
||||||
|
notify_admin_on_breach: !!data.notify_admin_on_breach
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
rule.value = { ...DEFAULT_RULE, business_days: [...DEFAULT_RULE.business_days] };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Erro ao carregar regra', detail: e.message, life: 4000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDay(iso) {
|
||||||
|
const arr = rule.value.business_days || [];
|
||||||
|
const idx = arr.indexOf(iso);
|
||||||
|
if (idx >= 0) arr.splice(idx, 1);
|
||||||
|
else arr.push(iso);
|
||||||
|
arr.sort((a, b) => a - b);
|
||||||
|
rule.value.business_days = [...arr];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const r = rule.value;
|
||||||
|
if (!r.threshold_minutes || r.threshold_minutes < 1 || r.threshold_minutes > 1440) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Threshold inválido', detail: 'Use entre 1 e 1440 minutos (24h).', life: 3500 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (r.respect_business_hours) {
|
||||||
|
if (!r.business_days || r.business_days.length === 0) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Selecione ao menos um dia útil', life: 3500 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!/^\d{2}:\d{2}$/.test(r.business_hours_start) || !/^\d{2}:\d{2}$/.test(r.business_hours_end)) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Horário inválido', detail: 'Formato: HH:MM', life: 3500 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRule() {
|
||||||
|
if (!tenantId.value) return;
|
||||||
|
if (!validate()) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
tenant_id: tenantId.value,
|
||||||
|
enabled: !!rule.value.enabled,
|
||||||
|
threshold_minutes: Math.round(Number(rule.value.threshold_minutes) || 60),
|
||||||
|
respect_business_hours: !!rule.value.respect_business_hours,
|
||||||
|
business_hours_start: timeToDb(rule.value.business_hours_start),
|
||||||
|
business_hours_end: timeToDb(rule.value.business_hours_end),
|
||||||
|
business_days: [...(rule.value.business_days || [])].sort((a, b) => a - b),
|
||||||
|
alert_scope: rule.value.alert_scope,
|
||||||
|
notify_admin_on_breach: !!rule.value.notify_admin_on_breach
|
||||||
|
};
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('conversation_sla_rules')
|
||||||
|
.upsert(payload, { onConflict: 'tenant_id' });
|
||||||
|
if (error) throw error;
|
||||||
|
toast.add({ severity: 'success', summary: 'Configuração salva', life: 2500 });
|
||||||
|
loadBreaches();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBreaches() {
|
||||||
|
if (!tenantId.value) return;
|
||||||
|
breachesLoading.value = true;
|
||||||
|
try {
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversation_sla_breaches')
|
||||||
|
.select('id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach, breached_at, resolved_at, notification_count')
|
||||||
|
.eq('tenant_id', tenantId.value)
|
||||||
|
.gte('breached_at', sevenDaysAgo)
|
||||||
|
.order('breached_at', { ascending: false })
|
||||||
|
.limit(30);
|
||||||
|
if (error) throw error;
|
||||||
|
breaches.value = data || [];
|
||||||
|
} catch (e) {
|
||||||
|
// falha silenciosa no histórico — não é bloqueante
|
||||||
|
console.warn('[SLA] loadBreaches:', e?.message);
|
||||||
|
} finally {
|
||||||
|
breachesLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function breachDurationMin(b) {
|
||||||
|
const start = new Date(b.last_inbound_at).getTime();
|
||||||
|
const end = new Date(b.resolved_at || Date.now()).getTime();
|
||||||
|
return Math.max(0, Math.round((end - start) / 60000));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadRule();
|
||||||
|
await loadBreaches();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => tenantStore.activeTenantId, async (id) => {
|
||||||
|
if (id) {
|
||||||
|
await loadRule();
|
||||||
|
await loadBreaches();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="cfg-subheader">
|
||||||
|
<div class="cfg-subheader__icon"><i class="pi pi-stopwatch" /></div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="cfg-subheader__title">SLA de resposta</div>
|
||||||
|
<div class="cfg-subheader__sub">Alerta quando conversa fica sem resposta além do tempo configurado.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-10">
|
||||||
|
<ProgressSpinner style="width: 40px; height: 40px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Card principal -->
|
||||||
|
<div class="border border-[var(--surface-border)] rounded-lg bg-[var(--surface-card)] p-4 flex flex-col gap-4">
|
||||||
|
<!-- Toggle habilitar -->
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ToggleSwitch v-model="rule.enabled" inputId="sla-enabled" />
|
||||||
|
<label for="sla-enabled" class="text-sm font-semibold cursor-pointer">Ativar alertas de SLA</label>
|
||||||
|
</div>
|
||||||
|
<Tag :value="rule.enabled ? 'Ativo' : 'Desativado'" :severity="rule.enabled ? 'success' : 'secondary'" class="text-[0.7rem]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Threshold -->
|
||||||
|
<div class="flex flex-col gap-1 pt-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">
|
||||||
|
Tempo máximo pra resposta (minutos)
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<InputNumber v-model="rule.threshold_minutes" :min="1" :max="1440" showButtons buttonLayout="horizontal" :inputStyle="{ width: '5rem', textAlign: 'center' }" incrementButtonIcon="pi pi-plus" decrementButtonIcon="pi pi-minus" />
|
||||||
|
<span class="text-sm text-[var(--text-color-secondary)]">
|
||||||
|
min
|
||||||
|
<span v-if="rule.threshold_minutes >= 60" class="opacity-70">
|
||||||
|
(≈ {{ Math.floor(rule.threshold_minutes / 60) }}h{{ rule.threshold_minutes % 60 ? ` ${rule.threshold_minutes % 60}min` : '' }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-[var(--text-color-secondary)]">Entre 1 min e 1440 min (24h).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Business hours -->
|
||||||
|
<div class="border-t border-[var(--surface-border)] pt-3 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ToggleSwitch v-model="rule.respect_business_hours" inputId="sla-bh" />
|
||||||
|
<label for="sla-bh" class="text-sm font-semibold cursor-pointer">Pausar fora do horário comercial</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-[var(--text-color-secondary)] -mt-2 pl-[3.25rem]">
|
||||||
|
Mensagens fora do horário só começam a contar no próximo dia útil.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rule.respect_business_hours" class="pl-4 border-l-4 border-amber-400/50 flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Início</label>
|
||||||
|
<InputText v-model="rule.business_hours_start" placeholder="08:00" maxlength="5" class="!w-24 text-center font-mono" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Fim</label>
|
||||||
|
<InputText v-model="rule.business_hours_end" placeholder="18:00" maxlength="5" class="!w-24 text-center font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Dias úteis</label>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="d in DIAS_ISO"
|
||||||
|
:key="d.iso"
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-xs font-semibold rounded-full border transition-all"
|
||||||
|
:class="rule.business_days?.includes(d.iso)
|
||||||
|
? 'bg-amber-500 text-white border-amber-500'
|
||||||
|
: 'bg-transparent text-[var(--text-color-secondary)] border-[var(--surface-border)] hover:border-amber-400/60'"
|
||||||
|
@click="toggleDay(d.iso)"
|
||||||
|
>
|
||||||
|
{{ d.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Escopo -->
|
||||||
|
<div class="border-t border-[var(--surface-border)] pt-3 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">
|
||||||
|
Aplicar a quais conversas
|
||||||
|
</label>
|
||||||
|
<Select v-model="rule.alert_scope" :options="SCOPE_OPTIONS" optionLabel="label" optionValue="value" class="w-full" />
|
||||||
|
<small class="text-[var(--text-color-secondary)]">
|
||||||
|
<strong>Apenas atribuídas:</strong> o alerta vai pro terapeuta responsável.
|
||||||
|
<strong>Todas:</strong> inclui conversas sem responsável — o alerta vai só pros admins da clínica (se CC ligado).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notify admin -->
|
||||||
|
<div class="flex items-center gap-3 border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<ToggleSwitch v-model="rule.notify_admin_on_breach" inputId="sla-admin" />
|
||||||
|
<label for="sla-admin" class="text-sm cursor-pointer">
|
||||||
|
<span class="font-semibold">Notificar também admins da clínica</span>
|
||||||
|
<span class="block text-xs text-[var(--text-color-secondary)]">clinic_admin e tenant_admin recebem cópia dos alertas</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<div class="flex justify-end border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<Button label="Salvar configuração" icon="pi pi-save" :loading="saving" @click="saveRule" class="rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Histórico de breaches -->
|
||||||
|
<div class="border border-[var(--surface-border)] rounded-lg bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-history text-[var(--primary-color)]" />
|
||||||
|
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Estouros nos últimos 7 dias</h3>
|
||||||
|
<Tag :value="breaches.length" severity="secondary" class="text-[0.65rem]" />
|
||||||
|
</div>
|
||||||
|
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="breachesLoading" v-tooltip.bottom="'Recarregar'" @click="loadBreaches" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="breachesLoading" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
|
||||||
|
Carregando…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!breaches.length" class="text-xs text-[var(--text-color-secondary)] italic py-4 text-center">
|
||||||
|
Nenhum estouro registrado — todas as respostas dentro do prazo 👏
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-1 max-h-[320px] overflow-y-auto text-xs">
|
||||||
|
<div v-for="b in breaches" :key="b.id"
|
||||||
|
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-3 px-2 py-1.5 rounded hover:bg-[var(--surface-hover)]">
|
||||||
|
<Tag :value="b.resolved_at ? 'Resolvido' : 'Aberto'" :severity="b.resolved_at ? 'success' : 'danger'" class="text-[0.62rem]" />
|
||||||
|
<span class="truncate">
|
||||||
|
<span class="font-mono text-[0.65rem] text-[var(--text-color-secondary)]">{{ b.thread_key }}</span>
|
||||||
|
<span class="ml-2 text-[var(--text-color-secondary)]">limite {{ b.threshold_minutes_at_breach }}min</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)] font-mono whitespace-nowrap">{{ formatDate(b.breached_at) }}</span>
|
||||||
|
<span class="font-mono" :class="b.resolved_at ? 'text-green-600' : 'text-orange-600'">
|
||||||
|
{{ breachDurationMin(b) }}min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -148,6 +148,11 @@ export default {
|
|||||||
name: 'ConfiguracoesConversasOptouts',
|
name: 'ConfiguracoesConversasOptouts',
|
||||||
component: () => import('@/layout/configuracoes/ConfiguracoesConversasOptoutsPage.vue')
|
component: () => import('@/layout/configuracoes/ConfiguracoesConversasOptoutsPage.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'conversas-sla',
|
||||||
|
name: 'ConfiguracoesConversasSla',
|
||||||
|
component: () => import('@/layout/configuracoes/ConfiguracoesConversasSlaPage.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'lembretes-sessao',
|
path: 'lembretes-sessao',
|
||||||
name: 'ConfiguracoesLembretesSessao',
|
name: 'ConfiguracoesLembretesSessao',
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — Edge Function: conversation-sla-check
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cron a cada 5 minutos. Detecta conversas com mensagem INBOUND mais
|
||||||
|
| antiga que o threshold de SLA do tenant sem resposta OUTBOUND depois,
|
||||||
|
| abre breach (idempotente), e notifica:
|
||||||
|
| - Terapeuta atribuído (assigned_to) sempre
|
||||||
|
| - clinic_admin/tenant_admin se rule.notify_admin_on_breach = true
|
||||||
|
|
|
||||||
|
| Se rule.respect_business_hours = true, o tempo decorrido conta apenas
|
||||||
|
| minutos DENTRO da janela comercial (pausa fora). Isso evita falsos
|
||||||
|
| positivos quando o paciente manda mensagem às 23:00 — o SLA só começa
|
||||||
|
| a contar a partir do próximo horário comercial.
|
||||||
|
|
|
||||||
|
| Escopo (rule.alert_scope):
|
||||||
|
| - 'assigned_only' (default): só conversas com assigned_to preenchido
|
||||||
|
| - 'all': todas, inclusive não-atribuídas (aí o alerta vai só pros admins)
|
||||||
|
|
|
||||||
|
| Resolução: trigger trg_sla_resolve_on_outbound marca breach como
|
||||||
|
| resolved_at quando chega nova mensagem outbound na thread.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient, SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule = {
|
||||||
|
tenant_id: string
|
||||||
|
enabled: boolean
|
||||||
|
threshold_minutes: number
|
||||||
|
respect_business_hours: boolean
|
||||||
|
business_hours_start: string // 'HH:MM:SS'
|
||||||
|
business_hours_end: string
|
||||||
|
business_days: number[] // 1=seg ... 7=dom
|
||||||
|
alert_scope: 'assigned_only' | 'all'
|
||||||
|
notify_admin_on_breach: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadCandidate = {
|
||||||
|
tenant_id: string
|
||||||
|
thread_key: string
|
||||||
|
patient_id: string | null
|
||||||
|
patient_name: string | null
|
||||||
|
contact_number: string | null
|
||||||
|
assigned_to: string | null
|
||||||
|
last_inbound_at: string // ISO
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Business-hours math
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseHHMMSS(s: string): { h: number; m: number } {
|
||||||
|
const [h, m] = s.split(':').map((x) => parseInt(x, 10))
|
||||||
|
return { h: h || 0, m: m || 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cria um Date em UTC que representa "YYYY-MM-DD HH:MM em horário de São Paulo".
|
||||||
|
// Usa Intl.DateTimeFormat pra descobrir o offset do timezone naquela data.
|
||||||
|
function saoPauloDate(year: number, month: number, day: number, hour: number, minute: number): Date {
|
||||||
|
// Cria uma instância UTC aproximada e ajusta pelo offset de SP na data
|
||||||
|
const approxUtc = Date.UTC(year, month - 1, day, hour, minute)
|
||||||
|
// Descobre o offset atual de SP
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Sao_Paulo',
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false
|
||||||
|
})
|
||||||
|
const parts = fmt.formatToParts(new Date(approxUtc))
|
||||||
|
const getP = (t: string) => parseInt(parts.find((p) => p.type === t)?.value || '0', 10)
|
||||||
|
const spRenderedUtcMs = Date.UTC(getP('year'), getP('month') - 1, getP('day'), getP('hour'), getP('minute'))
|
||||||
|
const offsetMs = approxUtc - spRenderedUtcMs
|
||||||
|
return new Date(approxUtc + offsetMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO day of week in São Paulo (1=Monday .. 7=Sunday)
|
||||||
|
function isoWeekdaySp(d: Date): number {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Sao_Paulo', weekday: 'short' })
|
||||||
|
const map: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }
|
||||||
|
return map[fmt.format(d)] || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retorna {year, month, day} em timezone de São Paulo
|
||||||
|
function ymdSp(d: Date): { year: number, month: number, day: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Sao_Paulo',
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
|
})
|
||||||
|
const parts = fmt.formatToParts(d)
|
||||||
|
return {
|
||||||
|
year: parseInt(parts.find((p) => p.type === 'year')?.value || '0', 10),
|
||||||
|
month: parseInt(parts.find((p) => p.type === 'month')?.value || '0', 10),
|
||||||
|
day: parseInt(parts.find((p) => p.type === 'day')?.value || '0', 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna quantos minutos transcorreram DENTRO da janela comercial entre from e to.
|
||||||
|
* Business days são ISO 1-7 (1=seg). business_hours_start/end são 'HH:MM:SS' em SP.
|
||||||
|
*/
|
||||||
|
function businessMinutesElapsed(
|
||||||
|
fromISO: string,
|
||||||
|
toDate: Date,
|
||||||
|
rule: Rule
|
||||||
|
): number {
|
||||||
|
const from = new Date(fromISO)
|
||||||
|
if (toDate <= from) return 0
|
||||||
|
|
||||||
|
const { h: startH, m: startM } = parseHHMMSS(rule.business_hours_start)
|
||||||
|
const { h: endH, m: endM } = parseHHMMSS(rule.business_hours_end)
|
||||||
|
const daysSet = new Set(rule.business_days)
|
||||||
|
|
||||||
|
let total = 0
|
||||||
|
// Itera dia a dia, de from.date até to.date (inclusive)
|
||||||
|
const fromYmd = ymdSp(from)
|
||||||
|
const toYmd = ymdSp(toDate)
|
||||||
|
|
||||||
|
let cursor = new Date(from)
|
||||||
|
let cursorYmd = fromYmd
|
||||||
|
let safety = 0 // evita loop infinito em caso de bug
|
||||||
|
while (safety < 400) {
|
||||||
|
safety++
|
||||||
|
// dia atual em SP
|
||||||
|
const dayStartSp = saoPauloDate(cursorYmd.year, cursorYmd.month, cursorYmd.day, startH, startM)
|
||||||
|
const dayEndSp = saoPauloDate(cursorYmd.year, cursorYmd.month, cursorYmd.day, endH, endM)
|
||||||
|
|
||||||
|
// Se é dia de trabalho, soma interseção
|
||||||
|
if (daysSet.has(isoWeekdaySp(dayStartSp))) {
|
||||||
|
const intervalStart = Math.max(from.getTime(), dayStartSp.getTime())
|
||||||
|
const intervalEnd = Math.min(toDate.getTime(), dayEndSp.getTime())
|
||||||
|
if (intervalEnd > intervalStart) {
|
||||||
|
total += Math.floor((intervalEnd - intervalStart) / 60000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avança pro próximo dia
|
||||||
|
if (cursorYmd.year === toYmd.year && cursorYmd.month === toYmd.month && cursorYmd.day === toYmd.day) break
|
||||||
|
// Adiciona 1 dia em UTC (suficiente mesmo com DST pq estamos só iterando data local)
|
||||||
|
cursor = new Date(cursor.getTime() + 24 * 3600 * 1000)
|
||||||
|
cursorYmd = ymdSp(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Processamento por tenant
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise<{
|
||||||
|
tenant_id: string
|
||||||
|
candidates: number
|
||||||
|
opened: number
|
||||||
|
still_pending: number
|
||||||
|
notified: number
|
||||||
|
}> {
|
||||||
|
// Query candidatas: threads onde:
|
||||||
|
// - última mensagem é INBOUND
|
||||||
|
// - (se assigned_only) assigned_to IS NOT NULL
|
||||||
|
// Vou usar a view conversation_threads + filtro direction='inbound'.
|
||||||
|
let query = supa
|
||||||
|
.from('conversation_threads')
|
||||||
|
.select('tenant_id, thread_key, patient_id, patient_name, contact_number, assigned_to, last_message_at, last_message_direction')
|
||||||
|
.eq('tenant_id', rule.tenant_id)
|
||||||
|
.eq('last_message_direction', 'inbound')
|
||||||
|
|
||||||
|
if (rule.alert_scope === 'assigned_only') {
|
||||||
|
query = query.not('assigned_to', 'is', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: candidates, error } = await query
|
||||||
|
if (error) {
|
||||||
|
return { tenant_id: rule.tenant_id, candidates: 0, opened: 0, still_pending: 0, notified: 0, /* @ts-ignore */ error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
let opened = 0
|
||||||
|
let stillPending = 0
|
||||||
|
let notified = 0
|
||||||
|
|
||||||
|
for (const row of candidates || []) {
|
||||||
|
const last = row.last_message_at as string | null
|
||||||
|
if (!last) continue
|
||||||
|
|
||||||
|
const elapsed = rule.respect_business_hours
|
||||||
|
? businessMinutesElapsed(last, now, rule)
|
||||||
|
: Math.floor((now.getTime() - new Date(last).getTime()) / 60000)
|
||||||
|
|
||||||
|
if (elapsed < rule.threshold_minutes) {
|
||||||
|
stillPending++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abre breach (idempotente)
|
||||||
|
const { data: breachId, error: openErr } = await supa.rpc('sla_open_breach', {
|
||||||
|
p_tenant_id: rule.tenant_id,
|
||||||
|
p_thread_key: row.thread_key,
|
||||||
|
p_assigned_to: row.assigned_to,
|
||||||
|
p_last_inbound_at: last,
|
||||||
|
p_threshold_minutes: rule.threshold_minutes
|
||||||
|
})
|
||||||
|
if (openErr || !breachId) continue
|
||||||
|
|
||||||
|
opened++
|
||||||
|
|
||||||
|
// Notificação (só se ainda não notificou esse breach)
|
||||||
|
const didNotify = await notifyBreach(supa, {
|
||||||
|
breach_id: breachId as unknown as string,
|
||||||
|
tenant_id: rule.tenant_id,
|
||||||
|
thread_key: row.thread_key,
|
||||||
|
patient_name: row.patient_name || row.contact_number || 'Paciente desconhecido',
|
||||||
|
assigned_to: row.assigned_to as string | null,
|
||||||
|
notify_admin: rule.notify_admin_on_breach,
|
||||||
|
elapsed_minutes: elapsed,
|
||||||
|
threshold_minutes: rule.threshold_minutes
|
||||||
|
})
|
||||||
|
if (didNotify) notified++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tenant_id: rule.tenant_id, candidates: (candidates || []).length, opened, still_pending: stillPending, notified }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyBreach(supa: SupabaseClient, params: {
|
||||||
|
breach_id: string
|
||||||
|
tenant_id: string
|
||||||
|
thread_key: string
|
||||||
|
patient_name: string
|
||||||
|
assigned_to: string | null
|
||||||
|
notify_admin: boolean
|
||||||
|
elapsed_minutes: number
|
||||||
|
threshold_minutes: number
|
||||||
|
}): Promise<boolean> {
|
||||||
|
// Anti-spam: não renotifica se já notificou
|
||||||
|
const { data: breach } = await supa
|
||||||
|
.from('conversation_sla_breaches')
|
||||||
|
.select('notified_at')
|
||||||
|
.eq('id', params.breach_id)
|
||||||
|
.maybeSingle()
|
||||||
|
if (breach?.notified_at) return false
|
||||||
|
|
||||||
|
// Monta set de user_ids (assigned_to + admins, se configurado)
|
||||||
|
const userIds = new Set<string>()
|
||||||
|
if (params.assigned_to) userIds.add(params.assigned_to)
|
||||||
|
|
||||||
|
if (params.notify_admin) {
|
||||||
|
const { data: admins } = await supa
|
||||||
|
.from('tenant_members')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('tenant_id', params.tenant_id)
|
||||||
|
.in('role', ['clinic_admin', 'tenant_admin'])
|
||||||
|
.eq('status', 'active')
|
||||||
|
for (const a of admins || []) userIds.add(a.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds.size === 0) return false
|
||||||
|
|
||||||
|
const title = `SLA estourado: ${params.patient_name}`
|
||||||
|
const detail = `Conversa sem resposta há ${params.elapsed_minutes} min (limite: ${params.threshold_minutes}). Responda o quanto antes.`
|
||||||
|
|
||||||
|
const rows = Array.from(userIds).map((uid) => ({
|
||||||
|
owner_id: uid,
|
||||||
|
tenant_id: params.tenant_id,
|
||||||
|
type: 'system_alert',
|
||||||
|
ref_id: params.breach_id,
|
||||||
|
ref_table: 'conversation_sla_breaches',
|
||||||
|
payload: {
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
severity: 'error',
|
||||||
|
deeplink: '/crm/conversas',
|
||||||
|
actionLabel: 'Abrir CRM',
|
||||||
|
thread_key: params.thread_key
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { error: insertErr } = await supa.from('notifications').insert(rows)
|
||||||
|
if (insertErr) return false
|
||||||
|
|
||||||
|
await supa.rpc('sla_mark_notified', { p_breach_id: params.breach_id })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Handler
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||||
|
|
||||||
|
const supa = createClient(
|
||||||
|
Deno.env.get('SUPABASE_URL') ?? '',
|
||||||
|
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
|
||||||
|
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// Regras habilitadas
|
||||||
|
const { data: rules, error: rulesErr } = await supa
|
||||||
|
.from('conversation_sla_rules')
|
||||||
|
.select('tenant_id, enabled, threshold_minutes, respect_business_hours, business_hours_start, business_hours_end, business_days, alert_scope, notify_admin_on_breach')
|
||||||
|
.eq('enabled', true)
|
||||||
|
|
||||||
|
if (rulesErr) return json({ error: rulesErr.message }, 500)
|
||||||
|
if (!rules || rules.length === 0) return json({ checked: 0, results: [] })
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
rules.map((r) => processRule(supa, r as Rule, now).catch((e) => ({
|
||||||
|
tenant_id: (r as Rule).tenant_id,
|
||||||
|
candidates: 0, opened: 0, still_pending: 0, notified: 0,
|
||||||
|
error: (e as Error).message
|
||||||
|
})))
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
checked: results.length,
|
||||||
|
opened: results.reduce((s, r) => s + r.opened, 0),
|
||||||
|
notified: results.reduce((s, r) => s + r.notified, 0),
|
||||||
|
still_pending: results.reduce((s, r) => s + r.still_pending, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ...summary, results })
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: (e as Error).message || 'unexpected_error' }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user