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
+94 -18
View File
@@ -102,24 +102,62 @@ function resetChannelState() {
qrCodeBase64.value = null;
}
const softDeletedChannel = ref(null); // canal soft-deleted detectado p/ reativação
async function loadChannel() {
if (!selectedTenantId.value) return;
loadingChannel.value = true;
softDeletedChannel.value = null;
try {
// Buscar pelo owner_id do tenant (que é o user_id do dono)
const { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', selectedTenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
if (error) throw error;
// 1) Tenta canal ativo (evolution_api)
const { data: active, error: activeErr } = await supabase
.from('notification_channels')
.select('*')
.eq('tenant_id', selectedTenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.is('deleted_at', null)
.maybeSingle();
if (activeErr) throw activeErr;
channel.value = data;
if (data?.credentials) {
credentials.value = {
api_url: data.credentials.api_url || '',
api_key: data.credentials.api_key || '',
instance_name: data.credentials.instance_name || ''
};
hasCredentials.value = true;
// Só verificar conexão se o canal estiver ativo
if (data.is_active) await checkConnectionStatus();
if (active) {
channel.value = active;
if (active.credentials) {
credentials.value = {
api_url: active.credentials.api_url || '',
api_key: active.credentials.api_key || '',
instance_name: active.credentials.instance_name || ''
};
hasCredentials.value = true;
if (active.is_active) await checkConnectionStatus();
}
return;
}
// 2) Não tem ativo — busca soft-deleted (evolution_api) pra poder reativar
const { data: deleted } = await supabase
.from('notification_channels')
.select('*')
.eq('tenant_id', selectedTenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.not('deleted_at', 'is', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (deleted) {
softDeletedChannel.value = deleted;
channel.value = null; // UI vai mostrar estado de reativação
if (deleted.credentials) {
credentials.value = {
api_url: deleted.credentials.api_url || '',
api_key: deleted.credentials.api_key || '',
instance_name: deleted.credentials.instance_name || ''
};
}
} else {
channel.value = null;
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar canal', detail: e.message, life: 4000 });
@@ -154,7 +192,7 @@ async function saveCredentials() {
creds.api_url = creds.api_url.replace(/\/+$/, '');
if (channel.value?.id) {
// Atualizar existente
// Canal ativo — só atualizar creds
const { error } = await supabase
.from('notification_channels')
.update({
@@ -164,12 +202,37 @@ async function saveCredentials() {
})
.eq('id', channel.value.id);
if (error) throw error;
} else if (softDeletedChannel.value?.id) {
// Soft-deleted — reativa via edge (bypass RLS + garante exclusividade)
const { data: reactData, error: reactErr } = await supabase.functions.invoke('reactivate-notification-channel', {
body: { channel_id: softDeletedChannel.value.id }
});
if (reactErr || !reactData?.ok) throw new Error(reactErr?.message || reactData?.error || 'reactivation_failed');
// Depois de reativado, atualiza creds + display_name
const { error: updErr } = await supabase
.from('notification_channels')
.update({
credentials: creds,
display_name: `WhatsApp — ${selectedTenantName.value}`
})
.eq('id', softDeletedChannel.value.id);
if (updErr) throw updErr;
toast.add({
severity: 'success',
summary: 'Canal reativado',
detail: reactData.deactivated_others > 0
? `Canal anterior foi reativado. ${reactData.deactivated_others} outro(s) canal(is) desativado(s).`
: 'Canal soft-deleted foi restaurado.',
life: 4000
});
} else {
// Inserir novo — recarregar para evitar duplicata por race condition
// Novo — recarregar para evitar duplicata por race condition
const { data: existing } = await supabase.from('notification_channels').select('id').eq('owner_id', ownerId).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
if (existing?.id) {
// Já existe (criado por outra aba/sessão) — atualizar
// Já existe ativo (outro provider) — atualizar convertendo para evolution_api
const { error } = await supabase
.from('notification_channels')
.update({
@@ -200,9 +263,13 @@ async function saveCredentials() {
}
hasCredentials.value = true;
toast.add({ severity: 'success', summary: 'Credenciais salvas', detail: `WhatsApp configurado para ${selectedTenantName.value}`, life: 3000 });
if (!softDeletedChannel.value) {
toast.add({ severity: 'success', summary: 'Credenciais salvas', detail: `WhatsApp configurado para ${selectedTenantName.value}`, life: 3000 });
}
// Recarregar canal para sincronizar estado
await loadChannel();
// Atualiza lista da aba "Visão geral"
loadAllChannels();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
@@ -573,6 +640,15 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Aviso de canal soft-deleted detectado -->
<div v-if="softDeletedChannel" class="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 flex items-start gap-2 text-xs">
<i class="pi pi-history text-amber-600 mt-0.5" />
<div class="flex-1">
<strong class="text-amber-700 dark:text-amber-400">Canal configurado anteriormente</strong><br>
Este tenant tem um canal Evolution desativado (criado em <strong>{{ formatDate(softDeletedChannel.created_at) }}</strong>). As credenciais foram pré-carregadas abaixo ajuste se precisar e clique em <strong>"Reativar e salvar"</strong>. Qualquer outro canal WhatsApp ativo será desativado pra manter apenas um por tenant.
</div>
</div>
<!-- Formulário de credenciais -->
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 bg-[var(--surface-ground)]">
@@ -593,7 +669,7 @@ onBeforeUnmount(() => {
<InputText v-model="credentials.instance_name" placeholder="nome-da-instancia" class="w-full" />
</div>
<div class="flex justify-end">
<Button label="Salvar credenciais" icon="pi pi-save" size="small" :loading="savingCredentials" :disabled="savingCredentials" @click="saveCredentials" />
<Button :label="softDeletedChannel ? 'Reativar e salvar' : 'Salvar credenciais'" :icon="softDeletedChannel ? 'pi pi-refresh' : 'pi pi-save'" size="small" :severity="softDeletedChannel ? 'warn' : undefined" :loading="savingCredentials" :disabled="savingCredentials" @click="saveCredentials" />
</div>
</div>
</div>