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,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