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>
This commit is contained in:
Leonardo
2026-04-23 09:21:29 -03:00
parent e1f756ea82
commit 881fa16c27
8 changed files with 534 additions and 50 deletions
@@ -80,16 +80,35 @@ const connectionTag = computed(() => {
}
});
const softDeletedRecord = ref(null);
const reactivating = ref(false);
// Carregar credenciais do banco — busca por tenant_id (consistente com SaaS)
// com fallback para owner_id (caso tenantId == userId)
async function loadCredentials() {
if (!tenantId.value) return;
// Tentar por tenant_id primeiro (como o SaaS salva)
let { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
softDeletedRecord.value = null;
// Fallback: buscar por owner_id (cenário legado ou tenant solo)
// 1) Tentar canal ativo por tenant_id (evolution_api)
let { data, error } = await supabase
.from('notification_channels')
.select('*')
.eq('tenant_id', tenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.is('deleted_at', null)
.maybeSingle();
// Fallback 1: buscar por owner_id (cenário legado ou tenant solo)
if (!data && userId.value && userId.value !== tenantId.value) {
const fallback = await supabase.from('notification_channels').select('*').eq('owner_id', userId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
const fallback = await supabase
.from('notification_channels')
.select('*')
.eq('owner_id', userId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.is('deleted_at', null)
.maybeSingle();
data = fallback.data;
error = fallback.error;
}
@@ -98,6 +117,7 @@ async function loadCredentials() {
toast.add({ severity: 'error', summary: 'Erro ao carregar credenciais', detail: error.message, life: 4000 });
return;
}
if (data?.credentials) {
credentials.value = {
api_url: data.credentials.api_url || '',
@@ -106,6 +126,49 @@ async function loadCredentials() {
};
hasCredentials.value = true;
channelRecord.value = data;
return;
}
// 2) Não tem ativo — verifica soft-deleted pra oferecer reativar
const { data: deleted } = await supabase
.from('notification_channels')
.select('*')
.eq('tenant_id', tenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.not('deleted_at', 'is', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (deleted) softDeletedRecord.value = deleted;
}
async function reactivateChannel() {
if (!softDeletedRecord.value?.id) return;
reactivating.value = true;
try {
const { data, error } = await supabase.functions.invoke('reactivate-notification-channel', {
body: { channel_id: softDeletedRecord.value.id }
});
if (error || !data?.ok) throw new Error(error?.message || data?.error || 'reactivation_failed');
toast.add({
severity: 'success',
summary: 'Canal reativado',
detail: data.deactivated_others > 0
? `Conexão WhatsApp restaurada. ${data.deactivated_others} canal(is) alternativo(s) foi/foram desativado(s).`
: 'Conexão WhatsApp restaurada. Agora escaneie o QR Code pra conectar o celular.',
life: 4500
});
await loadCredentials();
await loadHeartbeatConfig();
await loadIncidents();
if (hasCredentials.value) await checkConnectionStatus();
} catch (e) {
toast.add({ severity: 'error', summary: 'Não foi possível reativar', detail: e.message || 'Tente novamente ou contate o suporte.', life: 5000 });
} finally {
reactivating.value = false;
}
}
@@ -719,8 +782,20 @@ onBeforeUnmount(() => {
<!-- ABA 1 Conexão -->
<TabPanel :value="0">
<div class="flex flex-col gap-4 pt-3">
<!-- Sem credenciais WhatsApp não configurado pelo admin -->
<div v-if="!hasCredentials" class="border border-[var(--surface-border)] rounded-lg p-6 bg-[var(--surface-card)] text-center">
<!-- Canal soft-deleted oferecer reativação -->
<div v-if="!hasCredentials && softDeletedRecord" class="border border-amber-500/40 bg-amber-500/5 rounded-lg p-6 flex flex-col items-center text-center gap-3">
<div class="grid place-items-center w-14 h-14 rounded-full bg-amber-100 text-amber-600">
<i class="pi pi-history text-2xl" />
</div>
<div class="font-semibold text-sm text-amber-800 dark:text-amber-300">WhatsApp Pessoal foi usado anteriormente</div>
<p class="text-sm text-[var(--text-color-secondary)] m-0 max-w-md">
As credenciais continuam salvas basta reativar e escanear o QR Code novamente. Se você tem outro canal WhatsApp ativo (ex: WhatsApp Oficial), ele será desativado.
</p>
<Button label="Reativar WhatsApp Pessoal" icon="pi pi-refresh" severity="warn" :loading="reactivating" @click="reactivateChannel" />
</div>
<!-- Sem credenciais nem histórico WhatsApp nunca configurado -->
<div v-else-if="!hasCredentials" class="border border-[var(--surface-border)] rounded-lg p-6 bg-[var(--surface-card)] text-center">
<div class="grid place-items-center w-14 h-14 rounded-full bg-gray-100 text-gray-400 mx-auto mb-3">
<i class="pi pi-comments text-2xl" />
</div>