/* |-------------------------------------------------------------------------- | 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) } })