Files
agenciapsilmno/supabase/functions/whatsapp-heartbeat-check/index.ts
T
Leonardo 881fa16c27 Fluxo de reativação de canal WhatsApp + alerta toast sticky + notify owner
Cadeia de fixes descoberta ao testar o heartbeat 6.1 num tenant que migrou
de Evolution → Twilio e precisava voltar pro Evolution.

1. RLS notification_channels (migration 20260423000003)
   - Policy antiga tinha `deleted_at IS NULL` como primeira condição AND,
     bloqueando leitura de soft-deleted até pro próprio owner/saas_admin.
   - Isso fazia o chooser nunca detectar "canal antigo pra reativar".
   - Relaxada: owner/membro/saas_admin leem inclusive soft-deleted.
   - Filtro de deleted_at fica no código aplicativo (todos os queries já
     filtram explicitamente quando querem apenas ativos).

2. Edge function reactivate-notification-channel (nova)
   - Espelho da deactivate existente; service_role bypass RLS.
   - Aceita {channel_id} OU {tenant_id + provider}.
   - Autoriza saas_admin OU membro ativo do tenant.
   - Garante exclusividade: soft-deleta qualquer OUTRO canal ativo do
     mesmo tenant+channel.
   - Reseta metadata.first_unhealthy_at + connection_status=disconnected
     (heartbeat começa do zero).

3. SaasWhatsappPage (/saas/whatsapp)
   - loadChannel busca soft-deleted como fallback quando não tem ativo.
   - saveCredentials detecta soft-deleted e chama reactivate edge,
     depois atualiza credentials+display_name.
   - Banner âmbar "Canal configurado anteriormente" + botão vira
     "Reativar e salvar".

4. ConfiguracoesWhatsappPage tenant (/configuracoes/whatsapp-pessoal)
   - loadCredentials busca soft-deleted como fallback.
   - Card âmbar "WhatsApp Pessoal foi usado anteriormente" com botão
     "Reativar WhatsApp Pessoal" em vez de mostrar apenas "chame o suporte".

5. ChooserPage (/configuracoes/whatsapp)
   - Fix bug lateral: comparava activeProvider === 'evolution' (template)
     com 'evolution_api' (DB) — card nunca mostrava estado ativo. Agora
     normaliza via computed activeProviderKey.
   - softDeletedByProvider map carregado no mount; cards que têm row
     soft-deleted mostram "Reativar" em vez de "Ativar".
   - handleChoose chama reactivate edge antes de goSetup se detecta
     soft-deleted do provider escolhido.

6. whatsapp-heartbeat-check: notifica owner do channel + admins
   - notifyChannelStakeholders substitui notifyTenantAdmins.
   - Set dedupa o owner_id do channel + clinic_admin + tenant_admin.
   - Em tenant solo: 1 notificação; em clínica com canal de terapeuta
     específico: terapeuta (owner) + admin recebem; em clínica com canal
     do próprio admin: 1 (owner=admin).

7. Toast frontend para system_alert
   - notificationStore.subscribeRealtime aceita callback onInsert.
   - useNotifications registra callback que dispara toast PrimeVue
     (severity error, life 24h, closable) para type='system_alert'.
   - Usuário precisa fechar manualmente — alerta crítico de infra
     não pode sumir sozinho.

Cron heartbeat ativado em runtime local via cron.schedule()
(não vai neste commit — é config de ambiente, não migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:21:29 -03:00

350 lines
13 KiB
TypeScript

/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: whatsapp-heartbeat-check
|--------------------------------------------------------------------------
| Cron a cada 2 minutos. Checa conexão Evolution de todos os tenants com
| WhatsApp Pessoal ativo. Atualiza connection_status + last_health_check.
|
| Fluxo por channel:
| 1. GET {api_url}/instance/connectionState/{instance_name}
| 2. Se state === 'open' → marca connected, limpa first_unhealthy_at,
| resolve incidents abertos
| 3. Se state !== 'open' → mapeia pra connection_status, seta
| first_unhealthy_at (se não tinha), e:
| - Se já passou `heartbeat_threshold_minutes` (default 5) desde
| first_unhealthy_at → abre incident (idempotente) + notifica
| admins ativos do tenant
| - Senão só atualiza status (ainda não alerta)
| 4. Se erro HTTP / timeout → kind='error', segue mesma regra do caso 3
|
| Config por tenant (em notification_channels.metadata):
| - heartbeat_threshold_minutes (default 5)
| - heartbeat_alerts_enabled (default true)
|
| Não alerta duas vezes pelo mesmo incident (checa notified_at).
|--------------------------------------------------------------------------
*/
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',
}
const DEFAULT_THRESHOLD_MINUTES = 5
const FETCH_TIMEOUT_MS = 8000
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
})
}
function rewriteForContainer(apiUrl: string): string {
try {
const u = new URL(apiUrl)
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
u.hostname = 'host.docker.internal'
return u.toString().replace(/\/+$/, '')
}
return apiUrl.replace(/\/+$/, '')
} catch {
return apiUrl
}
}
function mapStateToStatus(state: string | null): { status: string, kind: string } {
switch (state) {
case 'open':
return { status: 'connected', kind: 'connected' }
case 'connecting':
return { status: 'connecting', kind: 'connecting' }
case 'qr':
case 'qrcode':
return { status: 'qr_pending', kind: 'qr_pending' }
case 'close':
case 'closed':
return { status: 'disconnected', kind: 'disconnected' }
default:
return { status: 'error', kind: 'unknown' }
}
}
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, { ...init, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
interface ChannelRow {
id: string
tenant_id: string
owner_id: string
provider: string
credentials: Record<string, string>
connection_status: string | null
last_health_check: string | null
metadata: Record<string, unknown> | null
}
async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: Date): Promise<{
tenant_id: string
channel_id: string
previous_status: string | null
new_status: string
action: 'ok' | 'opened' | 'resolved' | 'still_unhealthy' | 'no_change' | 'config_missing' | 'fetch_error'
incident_id?: string
}> {
const creds = channel.credentials || {}
const apiUrl = String(creds.api_url || '').trim()
const apiKey = String(creds.api_key || '').trim()
const instance = String(creds.instance_name || '').trim()
if (!apiUrl || !apiKey || !instance) {
// Credencial incompleta — não alertamos, só marca error e segue
await supa.from('notification_channels')
.update({ connection_status: 'error', last_health_check: now.toISOString() })
.eq('id', channel.id)
return { tenant_id: channel.tenant_id, channel_id: channel.id, previous_status: channel.connection_status, new_status: 'error', action: 'config_missing' }
}
const base = rewriteForContainer(apiUrl)
const targetUrl = `${base}/instance/connectionState/${encodeURIComponent(instance)}`
let state: string | null = null
let rawBody: unknown = null
let fetchError: string | null = null
try {
const res = await fetchWithTimeout(targetUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json', apikey: apiKey }
}, FETCH_TIMEOUT_MS)
if (!res.ok) {
fetchError = `http_${res.status}`
} else {
rawBody = await res.json().catch(() => null)
// Evolution retorna: { instance: { instanceName, state }} OU { state }
const body = rawBody as { instance?: { state?: string }, state?: string } | null
state = body?.instance?.state ?? body?.state ?? null
}
} catch (e) {
fetchError = (e as Error).message || 'fetch_failed'
}
const { status: newStatus, kind } = fetchError ? { status: 'error', kind: 'error' } : mapStateToStatus(state)
const meta = (channel.metadata || {}) as Record<string, unknown>
const thresholdMinutes = Number(meta.heartbeat_threshold_minutes) || DEFAULT_THRESHOLD_MINUTES
const alertsEnabled = meta.heartbeat_alerts_enabled !== false
const firstUnhealthyAtRaw = meta.first_unhealthy_at as string | undefined
const patch: Record<string, unknown> = {
connection_status: newStatus,
last_health_check: now.toISOString()
}
const newMeta = { ...meta }
if (newStatus === 'connected') {
// Recuperou
if (firstUnhealthyAtRaw) delete newMeta.first_unhealthy_at
patch.metadata = newMeta
await supa.from('notification_channels').update(patch).eq('id', channel.id)
const { data: resolved } = await supa.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
return {
tenant_id: channel.tenant_id,
channel_id: channel.id,
previous_status: channel.connection_status,
new_status: newStatus,
action: (resolved as unknown as number) > 0 ? 'resolved' : 'ok'
}
}
// Está unhealthy — seta first_unhealthy_at se ainda não tinha
const firstUnhealthyAt = firstUnhealthyAtRaw ? new Date(firstUnhealthyAtRaw) : null
if (!firstUnhealthyAt || isNaN(firstUnhealthyAt.getTime())) {
newMeta.first_unhealthy_at = now.toISOString()
}
patch.metadata = newMeta
await supa.from('notification_channels').update(patch).eq('id', channel.id)
const minutesUnhealthy = firstUnhealthyAt ? (now.getTime() - firstUnhealthyAt.getTime()) / 60000 : 0
if (minutesUnhealthy < thresholdMinutes) {
return {
tenant_id: channel.tenant_id,
channel_id: channel.id,
previous_status: channel.connection_status,
new_status: newStatus,
action: firstUnhealthyAt ? 'still_unhealthy' : 'no_change'
}
}
// Passou do threshold — abre incident (idempotente)
const { data: incidentId, error: incidentErr } = await supa.rpc('whatsapp_heartbeat_open_incident', {
p_channel_id: channel.id,
p_kind: kind,
p_last_state: state || fetchError,
p_details: rawBody || (fetchError ? { error: fetchError } : null)
})
if (incidentErr) {
return {
tenant_id: channel.tenant_id,
channel_id: channel.id,
previous_status: channel.connection_status,
new_status: newStatus,
action: 'fetch_error'
}
}
const newIncidentId = incidentId as unknown as string
if (alertsEnabled && newIncidentId) {
await notifyChannelStakeholders(supa, {
tenant_id: channel.tenant_id,
channel_owner_id: channel.owner_id,
incident_id: newIncidentId,
channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'),
kind,
minutes_unhealthy: Math.round(minutesUnhealthy)
})
}
return {
tenant_id: channel.tenant_id,
channel_id: channel.id,
previous_status: channel.connection_status,
new_status: newStatus,
action: 'opened',
incident_id: newIncidentId
}
}
async function notifyChannelStakeholders(supa: SupabaseClient, params: {
tenant_id: string
channel_owner_id: string
incident_id: string
channel_display: string
kind: string
minutes_unhealthy: number
}): Promise<void> {
// Checa se já notificou esse incident
const { data: incident } = await supa
.from('whatsapp_connection_incidents')
.select('notified_at, notification_count')
.eq('id', params.incident_id)
.maybeSingle()
if (incident?.notified_at) return // anti-spam: só notifica 1x pelo mesmo incident
// Stakeholders = owner do canal + admins ativos do tenant (deduplicado).
// owner geralmente é o dono do celular (WhatsApp Pessoal) ou admin da clínica;
// admins garantem que alguém com permissão de infra seja alertado.
const userIds = new Set<string>()
if (params.channel_owner_id) userIds.add(params.channel_owner_id)
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
const kindLabel: Record<string, string> = {
disconnected: 'desconectado',
qr_pending: 'aguardando QR Code',
error: 'com erro',
connecting: 'tentando conectar',
unknown: 'em estado desconhecido'
}
const title = `${params.channel_display} ${kindLabel[params.kind] || 'offline'}`
const detail = `A conexão está fora há cerca de ${params.minutes_unhealthy} min. Envios automáticos podem estar falhando.`
const rows = Array.from(userIds).map((uid) => ({
owner_id: uid,
tenant_id: params.tenant_id,
type: 'system_alert',
ref_id: params.incident_id,
ref_table: 'whatsapp_connection_incidents',
payload: {
title,
detail,
severity: 'warn',
deeplink: '/configuracoes/whatsapp-pessoal'
}
}))
await supa.from('notifications').insert(rows)
await supa.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id })
}
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 {
// Canal específico (on-demand via UI do tenant) ou varredura completa
const url = new URL(req.url)
const singleChannelId = url.searchParams.get('channel_id')
let query = supa
.from('notification_channels')
.select('id, tenant_id, owner_id, provider, credentials, connection_status, last_health_check, metadata')
.eq('provider', 'evolution_api')
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
if (singleChannelId) query = query.eq('id', singleChannelId)
const { data: channels, error: fetchErr } = await query
if (fetchErr) return json({ error: fetchErr.message }, 500)
if (!channels || channels.length === 0) {
return json({ checked: 0, results: [] })
}
const now = new Date()
const results = await Promise.all(
channels.map((ch) => checkOneChannel(supa, ch as ChannelRow, now).catch((e) => ({
tenant_id: (ch as ChannelRow).tenant_id,
channel_id: (ch as ChannelRow).id,
previous_status: (ch as ChannelRow).connection_status,
new_status: 'error',
action: 'fetch_error' as const,
error: (e as Error).message
})))
)
const summary = {
checked: results.length,
opened: results.filter((r) => r.action === 'opened').length,
resolved: results.filter((r) => r.action === 'resolved').length,
still_unhealthy: results.filter((r) => r.action === 'still_unhealthy').length,
ok: results.filter((r) => r.action === 'ok').length,
errors: results.filter((r) => r.action === 'fetch_error' || r.action === 'config_missing').length
}
return json({ ...summary, results })
} catch (e) {
return json({ error: (e as Error).message || 'unexpected_error' }, 500)
}
})