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:
@@ -207,8 +207,9 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
const newIncidentId = incidentId as unknown as string
|
||||
|
||||
if (alertsEnabled && newIncidentId) {
|
||||
await notifyTenantAdmins(supa, {
|
||||
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,
|
||||
@@ -226,8 +227,9 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyTenantAdmins(supa: SupabaseClient, params: {
|
||||
async function notifyChannelStakeholders(supa: SupabaseClient, params: {
|
||||
tenant_id: string
|
||||
channel_owner_id: string
|
||||
incident_id: string
|
||||
channel_display: string
|
||||
kind: string
|
||||
@@ -242,7 +244,12 @@ async function notifyTenantAdmins(supa: SupabaseClient, params: {
|
||||
|
||||
if (incident?.notified_at) return // anti-spam: só notifica 1x pelo mesmo incident
|
||||
|
||||
// Busca admins ativos do tenant
|
||||
// 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')
|
||||
@@ -250,7 +257,9 @@ async function notifyTenantAdmins(supa: SupabaseClient, params: {
|
||||
.in('role', ['clinic_admin', 'tenant_admin'])
|
||||
.eq('status', 'active')
|
||||
|
||||
if (!admins || admins.length === 0) return
|
||||
for (const a of admins || []) userIds.add(a.user_id)
|
||||
|
||||
if (userIds.size === 0) return
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
disconnected: 'desconectado',
|
||||
@@ -263,8 +272,8 @@ async function notifyTenantAdmins(supa: SupabaseClient, params: {
|
||||
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,
|
||||
const rows = Array.from(userIds).map((uid) => ({
|
||||
owner_id: uid,
|
||||
tenant_id: params.tenant_id,
|
||||
type: 'system_alert',
|
||||
ref_id: params.incident_id,
|
||||
|
||||
Reference in New Issue
Block a user