Heartbeat WhatsApp Evolution (Grupo 6.1): detecção + incident + alerta admin
Detecta celular desconectado antes de falhar envios silenciosamente.
Banco (migration 20260423000002):
- Tabela whatsapp_connection_incidents (tenant_id, channel_id, kind,
started_at, resolved_at, duration_seconds, notified_at, details).
UNIQUE parcial garante no máximo 1 incident aberto por channel.
- RPCs whatsapp_heartbeat_open_incident (idempotente), _resolve_open_incidents
e _mark_notified. Service_role only.
- RLS: membros do tenant leem, saas_admin tudo.
- ALTER notifications.type pra aceitar 'system_alert' (usado pelo alerta).
Edge function whatsapp-heartbeat-check:
- Varre notification_channels provider=evolution_api e ativos.
- GET {api_url}/instance/connectionState/{instance} (timeout 8s, rewrite
localhost → host.docker.internal pra containers).
- Mapeia state pra connection_status (open/connecting/qr_pending/
disconnected/error), persiste + last_health_check.
- Lógica de threshold: marca first_unhealthy_at em metadata na primeira
falha; só abre incident após heartbeat_threshold_minutes (default 5).
- Notifica admins ativos (clinic_admin/tenant_admin) do tenant via
insert em notifications. Anti-spam: só notifica 1x por incident.
- Aceita ?channel_id=X pra check on-demand de um tenant específico.
UI tenant (/configuracoes/whatsapp-pessoal):
- Novo card "Monitoramento de conexão" com toggle alerts_enabled +
InputNumber threshold (1-60 min). Persiste em
notification_channels.metadata.
- Histórico últimos 7 dias: kind (tag colorida), aberto/resolvido,
início → fim, duração formatada (Ns/Xmin Ys/Nh Xmin).
UI SaaS (/saas/whatsapp):
- Badge "N incidents abertos" no header quando há algum.
- Botão "Verificar tudo agora" invoca a edge function e atualiza a lista.
- Tabela enriquecida: coluna Status ganha pill "Incident aberto",
colunas novas Última check e Incidents 7d (em laranja se > 0).
Cron template no final da migration (comentado — descomentar
cron.schedule pra ativar 2min periódico).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Migracao: Heartbeat de conexao WhatsApp (Grupo 6.1)
|
||||||
|
-- ==========================================================================
|
||||||
|
-- Criado por: Leonardo Nohama
|
||||||
|
-- Data: 2026-04-23 · Sao Carlos/SP — Brasil
|
||||||
|
--
|
||||||
|
-- Contexto: notification_channels ja tem connection_status e last_health_check,
|
||||||
|
-- mas nao ha tracking de incidents (quando conexao caiu, quanto tempo ficou
|
||||||
|
-- fora, se ja alertou o admin). Criamos tabela de incidents + helpers RPC.
|
||||||
|
--
|
||||||
|
-- Modelo:
|
||||||
|
-- - notification_channels.connection_status (ja existe) = estado atual
|
||||||
|
-- - notification_channels.last_health_check (ja existe) = ultima check
|
||||||
|
-- - notification_channels.metadata (ja existe) = config (threshold, toggles)
|
||||||
|
--
|
||||||
|
-- Novo:
|
||||||
|
-- - whatsapp_connection_incidents = eventos de degradacao (open/close)
|
||||||
|
-- - RPC open_incident (idempotente — ignora se ja tem aberto)
|
||||||
|
-- - RPC resolve_open_incidents (fecha todos abertos pro channel)
|
||||||
|
--
|
||||||
|
-- Fluxo esperado (edge function whatsapp-heartbeat-check):
|
||||||
|
-- 1. Lista channels evolution_api ativos
|
||||||
|
-- 2. Pra cada: GET /instance/connectionState/{instance}
|
||||||
|
-- 3. Atualiza connection_status + last_health_check
|
||||||
|
-- 4. Se state != 'open' por > threshold minutos → open_incident (se nao tem)
|
||||||
|
-- e dispara notificacao pros admins do tenant
|
||||||
|
-- 5. Se state == 'open' → resolve_open_incidents (se tiver aberto)
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Tabela: whatsapp_connection_incidents
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.whatsapp_connection_incidents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||||
|
channel_id UUID NOT NULL REFERENCES public.notification_channels(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('evolution_api', 'twilio')),
|
||||||
|
|
||||||
|
-- Tipo do incident (snapshot do connection_status que abriu)
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown')),
|
||||||
|
|
||||||
|
-- Snapshots pra auditoria
|
||||||
|
last_state TEXT, -- estado quando abriu
|
||||||
|
details JSONB, -- payload bruto do provider
|
||||||
|
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
resolved_at TIMESTAMPTZ, -- NULL enquanto aberto
|
||||||
|
|
||||||
|
-- Tempo caido (preenchido ao resolver)
|
||||||
|
duration_seconds INT,
|
||||||
|
|
||||||
|
-- Controle de notificacao (anti-spam)
|
||||||
|
notified_at TIMESTAMPTZ, -- quando enviou notificacao pros admins
|
||||||
|
notification_count INT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trigger updated_at
|
||||||
|
DROP TRIGGER IF EXISTS trg_wa_incidents_updated_at ON public.whatsapp_connection_incidents;
|
||||||
|
CREATE TRIGGER trg_wa_incidents_updated_at
|
||||||
|
BEFORE UPDATE ON public.whatsapp_connection_incidents
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
-- Apenas 1 incident aberto por channel (constraint de negocio)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_wa_incidents_open_per_channel
|
||||||
|
ON public.whatsapp_connection_incidents (channel_id)
|
||||||
|
WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_incidents_tenant_started
|
||||||
|
ON public.whatsapp_connection_incidents (tenant_id, started_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wa_incidents_open
|
||||||
|
ON public.whatsapp_connection_incidents (resolved_at)
|
||||||
|
WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.whatsapp_connection_incidents IS
|
||||||
|
'Eventos de degradacao de conexao WhatsApp (evolution/twilio). Max 1 aberto por channel (UNIQUE parcial).';
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- RPC: whatsapp_heartbeat_open_incident
|
||||||
|
-- Idempotente — se ja tem incident aberto no channel, retorna o existente.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.whatsapp_heartbeat_open_incident(
|
||||||
|
p_channel_id UUID,
|
||||||
|
p_kind TEXT,
|
||||||
|
p_last_state TEXT DEFAULT NULL,
|
||||||
|
p_details JSONB DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id UUID;
|
||||||
|
v_provider TEXT;
|
||||||
|
v_existing_id UUID;
|
||||||
|
v_new_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Busca tenant/provider do channel
|
||||||
|
SELECT tenant_id, provider INTO v_tenant_id, v_provider
|
||||||
|
FROM public.notification_channels
|
||||||
|
WHERE id = p_channel_id
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'channel_not_found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_kind NOT IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown') THEN
|
||||||
|
RAISE EXCEPTION 'invalid_kind: %', p_kind;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||||
|
SELECT id INTO v_existing_id
|
||||||
|
FROM public.whatsapp_connection_incidents
|
||||||
|
WHERE channel_id = p_channel_id
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
-- Atualiza o incident existente com detalhes frescos
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET last_state = COALESCE(p_last_state, last_state),
|
||||||
|
details = COALESCE(p_details, details),
|
||||||
|
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
|
||||||
|
WHERE id = v_existing_id;
|
||||||
|
RETURN v_existing_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Abre novo
|
||||||
|
INSERT INTO public.whatsapp_connection_incidents
|
||||||
|
(tenant_id, channel_id, provider, kind, last_state, details)
|
||||||
|
VALUES
|
||||||
|
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
RETURN v_new_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.whatsapp_heartbeat_open_incident(UUID, TEXT, TEXT, JSONB) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.whatsapp_heartbeat_open_incident(UUID, TEXT, TEXT, JSONB) TO service_role;
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- RPC: whatsapp_heartbeat_resolve_open_incidents
|
||||||
|
-- Fecha todos os incidents abertos de um channel. Retorna quantos fechou.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(
|
||||||
|
p_channel_id UUID
|
||||||
|
)
|
||||||
|
RETURNS INT
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_count INT := 0;
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET resolved_at = now(),
|
||||||
|
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::INT
|
||||||
|
WHERE channel_id = p_channel_id
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(UUID) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(UUID) TO service_role;
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- RPC: whatsapp_heartbeat_mark_notified
|
||||||
|
-- Marca incident como notificado (anti-spam de alertas).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.whatsapp_heartbeat_mark_notified(
|
||||||
|
p_incident_id UUID
|
||||||
|
)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET notified_at = now(),
|
||||||
|
notification_count = notification_count + 1
|
||||||
|
WHERE id = p_incident_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.whatsapp_heartbeat_mark_notified(UUID) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.whatsapp_heartbeat_mark_notified(UUID) TO service_role;
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- RLS
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
ALTER TABLE public.whatsapp_connection_incidents ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "wa_incidents: select membros/admin" ON public.whatsapp_connection_incidents;
|
||||||
|
CREATE POLICY "wa_incidents: select membros/admin"
|
||||||
|
ON public.whatsapp_connection_incidents
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
public.is_saas_admin()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.tenant_members tm
|
||||||
|
WHERE tm.tenant_id = whatsapp_connection_incidents.tenant_id
|
||||||
|
AND tm.user_id = auth.uid()
|
||||||
|
AND tm.status = 'active'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Write apenas via service_role (edge function cron)
|
||||||
|
DROP POLICY IF EXISTS "wa_incidents: write service_role" ON public.whatsapp_connection_incidents;
|
||||||
|
CREATE POLICY "wa_incidents: write service_role"
|
||||||
|
ON public.whatsapp_connection_incidents
|
||||||
|
FOR ALL
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Expandir notifications.type pra aceitar 'system_alert' (usado por heartbeat)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
ALTER TABLE public.notifications
|
||||||
|
DROP CONSTRAINT IF EXISTS notifications_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.notifications
|
||||||
|
ADD CONSTRAINT notifications_type_check
|
||||||
|
CHECK (type = ANY (ARRAY[
|
||||||
|
'new_scheduling'::text,
|
||||||
|
'new_patient'::text,
|
||||||
|
'recurrence_alert'::text,
|
||||||
|
'session_status'::text,
|
||||||
|
'inbound_message'::text,
|
||||||
|
'system_alert'::text
|
||||||
|
]));
|
||||||
|
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Cron job (TEMPLATE — descomentar pra ativar)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Checa heartbeat de todos os tenants Evolution ativos a cada 2 minutos.
|
||||||
|
-- Threshold de minutos fora do ar antes de abrir incident fica em
|
||||||
|
-- notification_channels.metadata.heartbeat_threshold_minutes (default 5).
|
||||||
|
--
|
||||||
|
-- SELECT cron.schedule(
|
||||||
|
-- 'whatsapp-heartbeat-every-2min',
|
||||||
|
-- '*/2 * * * *',
|
||||||
|
-- $$
|
||||||
|
-- SELECT net.http_post(
|
||||||
|
-- url := current_setting('app.settings.supabase_url') || '/functions/v1/whatsapp-heartbeat-check',
|
||||||
|
-- headers := jsonb_build_object(
|
||||||
|
-- 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'),
|
||||||
|
-- 'Content-Type', 'application/json'
|
||||||
|
-- ),
|
||||||
|
-- body := '{}'::jsonb
|
||||||
|
-- );
|
||||||
|
-- $$
|
||||||
|
-- );
|
||||||
|
--
|
||||||
|
-- Desativar: SELECT cron.unschedule('whatsapp-heartbeat-every-2min');
|
||||||
@@ -152,6 +152,99 @@ async function checkConnectionStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Monitoramento de conexão (Heartbeat — Grupo 6.1)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
const heartbeatConfig = ref({ threshold_minutes: 5, alerts_enabled: true });
|
||||||
|
const heartbeatConfigSaving = ref(false);
|
||||||
|
const incidents = ref([]);
|
||||||
|
const incidentsLoading = ref(false);
|
||||||
|
|
||||||
|
async function loadHeartbeatConfig() {
|
||||||
|
if (!channelRecord.value) return;
|
||||||
|
const meta = channelRecord.value.metadata || {};
|
||||||
|
heartbeatConfig.value = {
|
||||||
|
threshold_minutes: Number(meta.heartbeat_threshold_minutes) || 5,
|
||||||
|
alerts_enabled: meta.heartbeat_alerts_enabled !== false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHeartbeatConfig() {
|
||||||
|
if (!channelRecord.value?.id) return;
|
||||||
|
heartbeatConfigSaving.value = true;
|
||||||
|
try {
|
||||||
|
const threshold = Math.max(1, Math.min(60, Math.round(Number(heartbeatConfig.value.threshold_minutes) || 5)));
|
||||||
|
const newMeta = {
|
||||||
|
...(channelRecord.value.metadata || {}),
|
||||||
|
heartbeat_threshold_minutes: threshold,
|
||||||
|
heartbeat_alerts_enabled: !!heartbeatConfig.value.alerts_enabled
|
||||||
|
};
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('notification_channels')
|
||||||
|
.update({ metadata: newMeta })
|
||||||
|
.eq('id', channelRecord.value.id);
|
||||||
|
if (error) throw error;
|
||||||
|
channelRecord.value.metadata = newMeta;
|
||||||
|
heartbeatConfig.value.threshold_minutes = threshold;
|
||||||
|
toast.add({ severity: 'success', summary: 'Configuração salva', life: 2000 });
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||||
|
} finally {
|
||||||
|
heartbeatConfigSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIncidents() {
|
||||||
|
if (!tenantId.value || !channelRecord.value?.id) return;
|
||||||
|
incidentsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('whatsapp_connection_incidents')
|
||||||
|
.select('id, kind, last_state, started_at, resolved_at, duration_seconds, notified_at')
|
||||||
|
.eq('channel_id', channelRecord.value.id)
|
||||||
|
.gte('started_at', sevenDaysAgo)
|
||||||
|
.order('started_at', { ascending: false })
|
||||||
|
.limit(30);
|
||||||
|
if (error) throw error;
|
||||||
|
incidents.value = data || [];
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Histórico indisponível', detail: e.message, life: 3000 });
|
||||||
|
} finally {
|
||||||
|
incidentsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds && seconds !== 0) return '—';
|
||||||
|
const s = Number(seconds);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}min ${s % 60}s`;
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
return `${h}h ${m}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function incidentKindLabel(kind) {
|
||||||
|
return {
|
||||||
|
disconnected: 'Desconectado',
|
||||||
|
error: 'Erro',
|
||||||
|
qr_pending: 'Aguardando QR',
|
||||||
|
connecting: 'Conectando',
|
||||||
|
unknown: 'Desconhecido'
|
||||||
|
}[kind] || kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function incidentKindSeverity(kind) {
|
||||||
|
return {
|
||||||
|
disconnected: 'danger',
|
||||||
|
error: 'danger',
|
||||||
|
qr_pending: 'warn',
|
||||||
|
connecting: 'info',
|
||||||
|
unknown: 'secondary'
|
||||||
|
}[kind] || 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
// Buscar QR Code para conectar
|
// Buscar QR Code para conectar
|
||||||
async function fetchQrCode() {
|
async function fetchQrCode() {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
@@ -593,7 +686,11 @@ function onPageChange(event) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadUser();
|
await loadUser();
|
||||||
await Promise.all([loadCredentials(), loadTemplates(), loadLogs()]);
|
await Promise.all([loadCredentials(), loadTemplates(), loadLogs()]);
|
||||||
if (hasCredentials.value) await checkConnectionStatus();
|
if (hasCredentials.value) {
|
||||||
|
await checkConnectionStatus();
|
||||||
|
await loadHeartbeatConfig();
|
||||||
|
await loadIncidents();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -664,6 +761,66 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Monitoramento de conexão (heartbeat) -->
|
||||||
|
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)] flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="grid place-items-center w-10 h-10 rounded-full bg-amber-100 text-amber-600">
|
||||||
|
<i class="pi pi-heart-fill text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-sm">Monitoramento de conexão</div>
|
||||||
|
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||||
|
Recebe alerta quando o celular fica offline por mais tempo que o configurado.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="incidentsLoading" v-tooltip.bottom="'Recarregar histórico'" @click="loadIncidents" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-[auto_auto_1fr] gap-3 items-end">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ToggleSwitch v-model="heartbeatConfig.alerts_enabled" inputId="hb-alerts" />
|
||||||
|
<label for="hb-alerts" class="text-sm cursor-pointer select-none">Alertas ativos</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm whitespace-nowrap">Alertar após</label>
|
||||||
|
<InputNumber v-model="heartbeatConfig.threshold_minutes" :min="1" :max="60" showButtons buttonLayout="horizontal" :inputStyle="{ width: '3.5rem', textAlign: 'center' }" incrementButtonIcon="pi pi-plus" decrementButtonIcon="pi pi-minus" />
|
||||||
|
<span class="text-sm text-[var(--text-color-secondary)]">min sem conexão</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button label="Salvar" icon="pi pi-check" size="small" :loading="heartbeatConfigSaving" @click="saveHeartbeatConfig" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Histórico de incidents -->
|
||||||
|
<div class="border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)] mb-2">
|
||||||
|
Últimos 7 dias ({{ incidents.length }} {{ incidents.length === 1 ? 'evento' : 'eventos' }})
|
||||||
|
</div>
|
||||||
|
<div v-if="incidentsLoading" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
|
||||||
|
Carregando…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!incidents.length" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
|
||||||
|
Nenhum evento registrado nos últimos 7 dias — conexão estável.
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-1 max-h-[240px] overflow-y-auto text-xs">
|
||||||
|
<div v-for="inc in incidents" :key="inc.id"
|
||||||
|
class="grid grid-cols-[auto_auto_1fr_auto] items-center gap-3 px-2 py-1.5 rounded hover:bg-[var(--surface-hover)]">
|
||||||
|
<Tag :value="incidentKindLabel(inc.kind)" :severity="incidentKindSeverity(inc.kind)" class="text-[0.62rem]" />
|
||||||
|
<span :class="inc.resolved_at ? 'text-green-600' : 'text-orange-600'" class="text-[0.62rem] font-bold uppercase">
|
||||||
|
{{ inc.resolved_at ? 'Resolvido' : 'Aberto' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)]">
|
||||||
|
<span class="font-mono">{{ new Date(inc.started_at).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) }}</span>
|
||||||
|
<span v-if="inc.resolved_at"> → {{ new Date(inc.resolved_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)] font-mono">{{ formatDuration(inc.duration_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Inbox / CRM de conversas (Fase 5a) -->
|
<!-- Inbox / CRM de conversas (Fase 5a) -->
|
||||||
<div v-if="connectionStatus === 'open'" class="flex flex-col gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
<div v-if="connectionStatus === 'open'" class="flex flex-col gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
|||||||
@@ -352,13 +352,29 @@ function closeQrDialog() {
|
|||||||
// ── Visão geral: todos os canais WhatsApp ─────────────────────
|
// ── Visão geral: todos os canais WhatsApp ─────────────────────
|
||||||
const allChannels = ref([]);
|
const allChannels = ref([]);
|
||||||
const loadingAll = ref(false);
|
const loadingAll = ref(false);
|
||||||
|
const openIncidentsByChannel = ref({}); // { channel_id: incident_row }
|
||||||
|
const incidents7dByChannel = ref({}); // { channel_id: count }
|
||||||
|
const heartbeatRunning = ref(false);
|
||||||
|
|
||||||
async function loadAllChannels() {
|
async function loadAllChannels() {
|
||||||
loadingAll.value = true;
|
loadingAll.value = true;
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false });
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
|
||||||
if (error) throw error;
|
const [channelsRes, openRes, countRes] = await Promise.all([
|
||||||
allChannels.value = data || [];
|
supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false }),
|
||||||
|
supabase.from('whatsapp_connection_incidents').select('id, channel_id, kind, started_at, last_state').is('resolved_at', null),
|
||||||
|
supabase.from('whatsapp_connection_incidents').select('channel_id').gte('started_at', sevenDaysAgo)
|
||||||
|
]);
|
||||||
|
if (channelsRes.error) throw channelsRes.error;
|
||||||
|
allChannels.value = channelsRes.data || [];
|
||||||
|
|
||||||
|
const openMap = {};
|
||||||
|
for (const inc of openRes.data || []) openMap[inc.channel_id] = inc;
|
||||||
|
openIncidentsByChannel.value = openMap;
|
||||||
|
|
||||||
|
const countMap = {};
|
||||||
|
for (const row of countRes.data || []) countMap[row.channel_id] = (countMap[row.channel_id] || 0) + 1;
|
||||||
|
incidents7dByChannel.value = countMap;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Erro ao carregar canais', detail: e.message, life: 4000 });
|
toast.add({ severity: 'error', summary: 'Erro ao carregar canais', detail: e.message, life: 4000 });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -366,6 +382,25 @@ async function loadAllChannels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runHeartbeatNow() {
|
||||||
|
heartbeatRunning.value = true;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke('whatsapp-heartbeat-check', { body: {} });
|
||||||
|
if (error) throw error;
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Heartbeat executado',
|
||||||
|
detail: `${data?.checked || 0} canais: ${data?.ok || 0} ok · ${data?.opened || 0} novos incidents · ${data?.resolved || 0} resolvidos`,
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
await loadAllChannels();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || 'Falha ao invocar heartbeat', life: 5000 });
|
||||||
|
} finally {
|
||||||
|
heartbeatRunning.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function channelStatusTag(ch) {
|
function channelStatusTag(ch) {
|
||||||
if (!ch.is_active) return { label: 'Desativado', severity: 'secondary' };
|
if (!ch.is_active) return { label: 'Desativado', severity: 'secondary' };
|
||||||
switch (ch.connection_status) {
|
switch (ch.connection_status) {
|
||||||
@@ -430,9 +465,14 @@ onBeforeUnmount(() => {
|
|||||||
<!-- ══ ABA 1 — Visão geral ════════════════════════════════ -->
|
<!-- ══ ABA 1 — Visão geral ════════════════════════════════ -->
|
||||||
<TabPanel :value="0">
|
<TabPanel :value="0">
|
||||||
<div class="flex flex-col gap-3 pt-3">
|
<div class="flex flex-col gap-3 pt-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="text-sm text-[var(--text-color-secondary)]"> {{ allChannels.length }} canal(is) WhatsApp configurado(s) </span>
|
<span class="text-sm text-[var(--text-color-secondary)]">{{ allChannels.length }} canal(is) WhatsApp configurado(s)</span>
|
||||||
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="loadingAll" v-tooltip.bottom="'Atualizar'" class="ml-auto" @click="loadAllChannels" />
|
<span v-if="Object.keys(openIncidentsByChannel).length" class="inline-flex items-center gap-1 text-xs font-semibold text-red-600 bg-red-500/10 px-2 py-0.5 rounded-full">
|
||||||
|
<i class="pi pi-exclamation-circle" />
|
||||||
|
{{ Object.keys(openIncidentsByChannel).length }} incident(s) aberto(s)
|
||||||
|
</span>
|
||||||
|
<Button icon="pi pi-heart-fill" label="Verificar tudo agora" size="small" severity="warn" outlined :loading="heartbeatRunning" class="ml-auto" @click="runHeartbeatNow" />
|
||||||
|
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="loadingAll" v-tooltip.bottom="'Recarregar'" @click="loadAllChannels" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable :value="allChannels" :loading="loadingAll" responsive-layout="scroll" striped-rows class="text-sm">
|
<DataTable :value="allChannels" :loading="loadingAll" responsive-layout="scroll" striped-rows class="text-sm">
|
||||||
@@ -451,7 +491,22 @@ onBeforeUnmount(() => {
|
|||||||
</Column>
|
</Column>
|
||||||
<Column header="Status" style="min-width: 120px">
|
<Column header="Status" style="min-width: 120px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="channelStatusTag(data).label" :severity="channelStatusTag(data).severity" class="text-[0.7rem]" />
|
<div class="flex flex-col gap-1">
|
||||||
|
<Tag :value="channelStatusTag(data).label" :severity="channelStatusTag(data).severity" class="text-[0.7rem]" />
|
||||||
|
<Tag v-if="openIncidentsByChannel[data.id]" value="Incident aberto" severity="danger" class="text-[0.6rem]" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Última check" style="min-width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs font-mono text-[var(--text-color-secondary)]">{{ data.last_health_check ? formatDate(data.last_health_check) : '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Incidents 7d" style="min-width: 100px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs font-mono" :class="(incidents7dByChannel[data.id] || 0) > 0 ? 'text-orange-600 font-bold' : 'text-[var(--text-color-secondary)]'">
|
||||||
|
{{ incidents7dByChannel[data.id] || 0 }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column header="Ativo" style="min-width: 80px">
|
<Column header="Ativo" style="min-width: 80px">
|
||||||
@@ -459,11 +514,6 @@ onBeforeUnmount(() => {
|
|||||||
<Tag :value="data.is_active ? 'Sim' : 'Não'" :severity="data.is_active ? 'success' : 'secondary'" class="text-[0.7rem]" />
|
<Tag :value="data.is_active ? 'Sim' : 'Não'" :severity="data.is_active ? 'success' : 'secondary'" class="text-[0.7rem]" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column header="Criado em" style="min-width: 140px">
|
|
||||||
<template #body="{ data }">
|
|
||||||
{{ formatDate(data.created_at) }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column header="" style="width: 60px">
|
<Column header="" style="width: 60px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" v-tooltip.left="'Configurar'" @click="selectTenantFromTable(data.tenant_id)" />
|
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" v-tooltip.left="'Configurar'" @click="selectTenantFromTable(data.tenant_id)" />
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 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 notifyTenantAdmins(supa, {
|
||||||
|
tenant_id: channel.tenant_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 notifyTenantAdmins(supa: SupabaseClient, params: {
|
||||||
|
tenant_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
|
||||||
|
|
||||||
|
// Busca admins ativos do tenant
|
||||||
|
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')
|
||||||
|
|
||||||
|
if (!admins || admins.length === 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 = admins.map((a) => ({
|
||||||
|
owner_id: a.user_id,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user