From 771b636cee5ec73f3c27d30bb69839f2362d5f1d Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 23 Apr 2026 10:17:41 -0300 Subject: [PATCH] =?UTF-8?q?SLA=20de=20conversas=20WhatsApp=20(Grupo=203.4)?= =?UTF-8?q?:=20config=20+=20detec=C3=A7=C3=A3o=20+=20alerta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../20260423000005_conversation_sla.sql | 311 ++++++++++++++++ src/layout/ConfiguracoesPage.vue | 7 + .../ConfiguracoesConversasSlaPage.vue | 346 ++++++++++++++++++ src/router/routes.configs.js | 5 + .../functions/conversation-sla-check/index.ts | 339 +++++++++++++++++ 5 files changed, 1008 insertions(+) create mode 100644 database-novo/migrations/20260423000005_conversation_sla.sql create mode 100644 src/layout/configuracoes/ConfiguracoesConversasSlaPage.vue create mode 100644 supabase/functions/conversation-sla-check/index.ts diff --git a/database-novo/migrations/20260423000005_conversation_sla.sql b/database-novo/migrations/20260423000005_conversation_sla.sql new file mode 100644 index 0000000..75f2eae --- /dev/null +++ b/database-novo/migrations/20260423000005_conversation_sla.sql @@ -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'); diff --git a/src/layout/ConfiguracoesPage.vue b/src/layout/ConfiguracoesPage.vue index f1e022d..edd758f 100644 --- a/src/layout/ConfiguracoesPage.vue +++ b/src/layout/ConfiguracoesPage.vue @@ -158,6 +158,13 @@ const grupos = [ icon: 'pi pi-ban', 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', label: 'Lembretes de Sessão', diff --git a/src/layout/configuracoes/ConfiguracoesConversasSlaPage.vue b/src/layout/configuracoes/ConfiguracoesConversasSlaPage.vue new file mode 100644 index 0000000..c09c1c8 --- /dev/null +++ b/src/layout/configuracoes/ConfiguracoesConversasSlaPage.vue @@ -0,0 +1,346 @@ + + + + diff --git a/src/router/routes.configs.js b/src/router/routes.configs.js index ae355bd..6c7bc6a 100644 --- a/src/router/routes.configs.js +++ b/src/router/routes.configs.js @@ -148,6 +148,11 @@ export default { name: 'ConfiguracoesConversasOptouts', component: () => import('@/layout/configuracoes/ConfiguracoesConversasOptoutsPage.vue') }, + { + path: 'conversas-sla', + name: 'ConfiguracoesConversasSla', + component: () => import('@/layout/configuracoes/ConfiguracoesConversasSlaPage.vue') + }, { path: 'lembretes-sessao', name: 'ConfiguracoesLembretesSessao', diff --git a/supabase/functions/conversation-sla-check/index.ts b/supabase/functions/conversation-sla-check/index.ts new file mode 100644 index 0000000..3c96381 --- /dev/null +++ b/supabase/functions/conversation-sla-check/index.ts @@ -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 = { 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 { + // 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() + 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) + } +})