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:
@@ -21,66 +21,153 @@ const tenantStore = useTenantStore();
|
||||
const loading = ref(true);
|
||||
const switching = ref(false);
|
||||
const activeChannel = ref(null); // row de notification_channels (provider, is_active, etc) ou null
|
||||
const softDeletedByProvider = ref({}); // { evolution_api: row, twilio: row }
|
||||
|
||||
const activeProvider = computed(() => {
|
||||
if (!activeChannel.value) return null;
|
||||
return activeChannel.value.provider; // 'twilio' | 'evolution'
|
||||
return activeChannel.value.provider; // 'twilio' | 'evolution_api'
|
||||
});
|
||||
|
||||
// Versão normalizada pra usar no template (botões comparam com 'evolution')
|
||||
const activeProviderKey = computed(() => providerKey(activeProvider.value));
|
||||
|
||||
// Normaliza 'evolution_api' ↔ 'evolution' (o chooser usa 'evolution' nos botões)
|
||||
function providerKey(p) {
|
||||
return p === 'evolution_api' ? 'evolution' : p;
|
||||
}
|
||||
|
||||
async function loadChannel() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
activeChannel.value = null;
|
||||
softDeletedByProvider.value = {};
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data: active } = await supabase
|
||||
.from('notification_channels')
|
||||
.select('id, provider, is_active, connection_status, twilio_phone_number, credentials, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('channel', 'whatsapp')
|
||||
.is('deleted_at', null)
|
||||
.maybeSingle();
|
||||
activeChannel.value = data || null;
|
||||
activeChannel.value = active || null;
|
||||
|
||||
// Busca soft-deleted (pra oferecer reativação por provider)
|
||||
const { data: deletedList } = await supabase
|
||||
.from('notification_channels')
|
||||
.select('id, provider, credentials, created_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('channel', 'whatsapp')
|
||||
.not('deleted_at', 'is', null)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
const mapByProvider = {};
|
||||
for (const row of deletedList || []) {
|
||||
// Só o mais recente por provider
|
||||
if (!mapByProvider[row.provider]) mapByProvider[row.provider] = row;
|
||||
}
|
||||
softDeletedByProvider.value = mapByProvider;
|
||||
} catch (e) {
|
||||
console.error('[whatsapp-chooser] load:', e?.message);
|
||||
activeChannel.value = null;
|
||||
softDeletedByProvider.value = {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pega o row soft-deleted do provider escolhido (normalizando 'evolution' → 'evolution_api')
|
||||
function softDeletedForProvider(provider) {
|
||||
const dbProvider = provider === 'evolution' ? 'evolution_api' : provider;
|
||||
return softDeletedByProvider.value[dbProvider] || null;
|
||||
}
|
||||
|
||||
async function reactivateProvider(provider) {
|
||||
const soft = softDeletedForProvider(provider);
|
||||
if (!soft?.id) return false;
|
||||
switching.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('reactivate-notification-channel', {
|
||||
body: { channel_id: soft.id }
|
||||
});
|
||||
if (error || !data?.ok) throw new Error(error?.message || data?.error || 'reactivation_failed');
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Canal restaurado',
|
||||
detail: data.deactivated_others > 0
|
||||
? `Canal anterior foi reativado. ${data.deactivated_others} outro canal foi desativado pra manter só um por vez.`
|
||||
: 'Canal anterior foi reativado.',
|
||||
life: 4000
|
||||
});
|
||||
await loadChannel();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Não foi possível reativar', detail: e.message || 'Tente novamente.', life: 4500 });
|
||||
return false;
|
||||
} finally {
|
||||
switching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goSetup(provider) {
|
||||
if (provider === 'evolution') router.push('/configuracoes/whatsapp-pessoal');
|
||||
else if (provider === 'twilio') router.push('/configuracoes/whatsapp-oficial');
|
||||
}
|
||||
|
||||
async function handleChoose(provider) {
|
||||
// Se nao tem canal ativo, segue direto pro setup
|
||||
const activeKey = providerKey(activeProvider.value);
|
||||
|
||||
// Mesmo provider ativo → só navega pro setup/gerenciar
|
||||
if (activeKey && activeKey === provider) {
|
||||
goSetup(provider);
|
||||
return;
|
||||
}
|
||||
|
||||
// Provider diferente (ou nenhum ativo) → verifica se tem soft-deleted pra reativar
|
||||
const softDeleted = softDeletedForProvider(provider);
|
||||
|
||||
if (!activeProvider.value) {
|
||||
goSetup(provider);
|
||||
// Sem canal ativo atualmente
|
||||
if (softDeleted) {
|
||||
// Tem canal antigo daquele provider — reativa e manda pro setup
|
||||
const ok = await reactivateProvider(provider);
|
||||
if (ok) goSetup(provider);
|
||||
} else {
|
||||
// Primeiro setup
|
||||
goSetup(provider);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Mesmo provider → so navega
|
||||
if (activeProvider.value === provider) {
|
||||
goSetup(provider);
|
||||
return;
|
||||
}
|
||||
// Provider diferente → confirmar troca
|
||||
const from = activeProvider.value === 'twilio' ? 'Oficial AgenciaPSI' : 'Pessoal';
|
||||
|
||||
// Tem canal ativo de outro provider → confirmar troca
|
||||
const from = activeKey === 'twilio' ? 'Oficial AgenciaPSI' : 'Pessoal';
|
||||
const to = provider === 'twilio' ? 'Oficial AgenciaPSI' : 'Pessoal';
|
||||
const willReactivate = !!softDeleted;
|
||||
|
||||
const message = willReactivate
|
||||
? `Você está usando o WhatsApp ${from}. Trocar pro ${to} vai desativar o canal atual e restaurar o ${to} que já foi configurado antes. Continuar?`
|
||||
: `Você está usando o WhatsApp ${from}. Trocar pro ${to} vai desativar o canal atual e você vai precisar reconfigurar. Continuar?`;
|
||||
|
||||
confirm.require({
|
||||
message: `Você está usando o WhatsApp ${from}. Trocar pro ${to} vai desativar o canal atual e você vai precisar reconfigurar. Continuar?`,
|
||||
message,
|
||||
header: 'Trocar canal WhatsApp',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Trocar',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptClass: 'p-button-warning',
|
||||
accept: async () => {
|
||||
await deactivateCurrent();
|
||||
goSetup(provider);
|
||||
if (willReactivate) {
|
||||
// reactivate edge já cuida do soft-delete do outro canal (exclusividade)
|
||||
const ok = await reactivateProvider(provider);
|
||||
if (ok) goSetup(provider);
|
||||
} else {
|
||||
await deactivateCurrent();
|
||||
goSetup(provider);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -263,7 +350,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
|
||||
Ideal pra <strong>clínicas</strong> e alto volume
|
||||
</span>
|
||||
<span class="text-[0.72rem] font-bold text-emerald-500 flex items-center gap-1">
|
||||
{{ activeProvider === 'twilio' ? 'Ativo' : 'Ativar' }} <i class="pi pi-arrow-right text-[0.6rem]" />
|
||||
{{ activeProvider === 'twilio' ? 'Ativo' : (softDeletedForProvider('twilio') ? 'Reativar' : 'Ativar') }} <i class="pi pi-arrow-right text-[0.6rem]" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -271,7 +358,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
|
||||
<!-- Cartão: WhatsApp Pessoal (Evolution) -->
|
||||
<button
|
||||
class="flex flex-col text-left gap-3 p-5 rounded-md border-2 bg-[var(--surface-card)] cursor-pointer transition-all hover:shadow-md"
|
||||
:class="activeProvider === 'evolution'
|
||||
:class="activeProviderKey === 'evolution'
|
||||
? 'border-indigo-500 shadow-[0_0_0_4px_rgba(99,102,241,0.12)]'
|
||||
: 'border-[var(--surface-border)] hover:border-indigo-400/50'"
|
||||
:disabled="switching"
|
||||
@@ -320,7 +407,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
|
||||
Ideal pra <strong>uso pessoal</strong> e baixo volume
|
||||
</span>
|
||||
<span class="text-[0.72rem] font-bold text-indigo-500 flex items-center gap-1">
|
||||
{{ activeProvider === 'evolution' ? 'Ativo' : 'Ativar' }} <i class="pi pi-arrow-right text-[0.6rem]" />
|
||||
{{ activeProviderKey === 'evolution' ? 'Ativo' : (softDeletedForProvider('evolution') ? 'Reativar' : 'Ativar') }} <i class="pi pi-arrow-right text-[0.6rem]" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user