From 881fa16c271db7497d0aa0fe898e373cbeb95a4f Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 23 Apr 2026 09:21:29 -0300 Subject: [PATCH] =?UTF-8?q?Fluxo=20de=20reativa=C3=A7=C3=A3o=20de=20canal?= =?UTF-8?q?=20WhatsApp=20+=20alerta=20toast=20sticky=20+=20notify=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...003_notif_channels_select_soft_deleted.sql | 37 ++++ src/composables/useNotifications.js | 23 ++- .../ConfiguracoesWhatsappChooserPage.vue | 123 ++++++++++-- .../ConfiguracoesWhatsappPage.vue | 87 ++++++++- src/stores/notificationStore.js | 5 +- src/views/pages/saas/SaasWhatsappPage.vue | 112 +++++++++-- .../reactivate-notification-channel/index.ts | 176 ++++++++++++++++++ .../whatsapp-heartbeat-check/index.ts | 21 ++- 8 files changed, 534 insertions(+), 50 deletions(-) create mode 100644 database-novo/migrations/20260423000003_notif_channels_select_soft_deleted.sql create mode 100644 supabase/functions/reactivate-notification-channel/index.ts diff --git a/database-novo/migrations/20260423000003_notif_channels_select_soft_deleted.sql b/database-novo/migrations/20260423000003_notif_channels_select_soft_deleted.sql new file mode 100644 index 0000000..fefd05b --- /dev/null +++ b/database-novo/migrations/20260423000003_notif_channels_select_soft_deleted.sql @@ -0,0 +1,37 @@ +-- ========================================================================== +-- Agencia PSI — Migracao: RLS notification_channels permite ler soft-deleted +-- ========================================================================== +-- Criado por: Leonardo Nohama +-- Data: 2026-04-23 · Sao Carlos/SP — Brasil +-- +-- Contexto: a policy antiga "notif_channels_select" filtrava +-- `deleted_at IS NULL` como primeira condicao AND, bloqueando qualquer +-- leitura de canais soft-deleted mesmo pros donos/saas_admin. Isso impedia +-- o fluxo de reativacao (chooser e pagina do tenant nao conseguiam detectar +-- que havia canal deletado pra oferecer "Reativar"). +-- +-- Solucao: remover o filtro da policy. O controle de soft-delete fica no +-- codigo aplicativo (cada query filtra .is('deleted_at', null) quando +-- quer apenas canais ativos). Canais soft-deleted continuam acessiveis +-- apenas pros roles autorizados (owner, membro do tenant, saas_admin) — +-- a privacidade nao muda. +-- ========================================================================== + +DROP POLICY IF EXISTS notif_channels_select ON public.notification_channels; + +CREATE POLICY notif_channels_select + ON public.notification_channels + FOR SELECT + USING ( + public.is_saas_admin() + OR owner_id = auth.uid() + OR tenant_id IN ( + SELECT tm.tenant_id + FROM public.tenant_members tm + WHERE tm.user_id = auth.uid() + AND tm.status = 'active' + ) + ); + +COMMENT ON POLICY notif_channels_select ON public.notification_channels IS + 'Owner, membros ativos do tenant e saas_admin leem todos os canais (inclusive soft-deleted). Filtro deleted_at fica no codigo aplicativo.'; diff --git a/src/composables/useNotifications.js b/src/composables/useNotifications.js index d7f9410..06cf300 100644 --- a/src/composables/useNotifications.js +++ b/src/composables/useNotifications.js @@ -15,11 +15,31 @@ |-------------------------------------------------------------------------- */ import { onMounted, onUnmounted } from 'vue'; +import { useToast } from 'primevue/usetoast'; import { supabase } from '@/lib/supabase/client'; import { useNotificationStore } from '@/stores/notificationStore'; +// Alertas de sistema ficam fixos em vermelho até o usuário fechar manualmente. +// 24h em ms — na prática "sticky" (PrimeVue Toast não aceita Infinity). +const STICKY_TOAST_LIFE_MS = 24 * 60 * 60 * 1000; + export function useNotifications() { const store = useNotificationStore(); + const toast = useToast(); + + function onRealtimeNotification(item) { + // Toast aparece pra alertas de sistema (heartbeat, infra, etc). + // Inbound/agendas têm seus próprios notifiers visuais. + if (item?.type !== 'system_alert') return; + const payload = item.payload || {}; + toast.add({ + severity: 'error', + summary: payload.title || 'Alerta do sistema', + detail: payload.detail || '', + life: STICKY_TOAST_LIFE_MS, + closable: true + }); + } onMounted(async () => { const { data, error } = await supabase.auth.getUser(); @@ -27,7 +47,7 @@ export function useNotifications() { const ownerId = data.user.id; await store.load(ownerId); - store.subscribeRealtime(ownerId); + store.subscribeRealtime(ownerId, onRealtimeNotification); }); onUnmounted(() => { @@ -36,3 +56,4 @@ export function useNotifications() { return store; } + diff --git a/src/layout/configuracoes/ConfiguracoesWhatsappChooserPage.vue b/src/layout/configuracoes/ConfiguracoesWhatsappChooserPage.vue index 1a398b1..043c6b6 100644 --- a/src/layout/configuracoes/ConfiguracoesWhatsappChooserPage.vue +++ b/src/layout/configuracoes/ConfiguracoesWhatsappChooserPage.vue @@ -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 clínicas e alto volume - {{ activeProvider === 'twilio' ? 'Ativo' : 'Ativar' }} + {{ activeProvider === 'twilio' ? 'Ativo' : (softDeletedForProvider('twilio') ? 'Reativar' : 'Ativar') }} @@ -271,7 +358,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); }); diff --git a/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue b/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue index 674ca6f..933ee71 100644 --- a/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue +++ b/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue @@ -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(() => {
- -
+ +
+
+ +
+
WhatsApp Pessoal foi usado anteriormente
+

+ 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. +

+
+ + +
diff --git a/src/stores/notificationStore.js b/src/stores/notificationStore.js index 0aa8668..8639e87 100644 --- a/src/stores/notificationStore.js +++ b/src/stores/notificationStore.js @@ -114,7 +114,7 @@ export const useNotificationStore = defineStore('notifications', { this.items = data || []; }, - subscribeRealtime(ownerId) { + subscribeRealtime(ownerId, onInsert = null) { if (this._channel) return; const channel = supabase @@ -130,6 +130,9 @@ export const useNotificationStore = defineStore('notifications', { (payload) => { this.items.unshift(payload.new); fireBrowserNotification(payload.new); + if (typeof onInsert === 'function') { + try { onInsert(payload.new); } catch { /* ignore */ } + } } ) .subscribe(); diff --git a/src/views/pages/saas/SaasWhatsappPage.vue b/src/views/pages/saas/SaasWhatsappPage.vue index 5018c75..155df15 100644 --- a/src/views/pages/saas/SaasWhatsappPage.vue +++ b/src/views/pages/saas/SaasWhatsappPage.vue @@ -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(() => {
+ +
+ +
+ Canal configurado anteriormente
+ Este tenant tem um canal Evolution desativado (criado em {{ formatDate(softDeletedChannel.created_at) }}). As credenciais foram pré-carregadas abaixo — ajuste se precisar e clique em "Reativar e salvar". Qualquer outro canal WhatsApp ativo será desativado pra manter apenas um por tenant. +
+
+
@@ -593,7 +669,7 @@ onBeforeUnmount(() => {
-
diff --git a/supabase/functions/reactivate-notification-channel/index.ts b/supabase/functions/reactivate-notification-channel/index.ts new file mode 100644 index 0000000..f936ede --- /dev/null +++ b/supabase/functions/reactivate-notification-channel/index.ts @@ -0,0 +1,176 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI — Edge Function: reactivate-notification-channel +|-------------------------------------------------------------------------- +| Reativa um canal de notificação previamente soft-deleted (deleted_at IS NOT NULL +| ou is_active=false). Usa service_role pra bypass RLS. +| +| Casos de uso: +| - SaaS admin reconfigura WhatsApp pra tenant que já tinha canal antigo +| - Tenant volta de Twilio pra Evolution (ou vice-versa) pelo chooser +| +| Inputs aceitos (um OU o outro): +| { channel_id: "" } — reativa canal específico +| { tenant_id: "", provider: "evolution_api" } — reativa por tenant+provider +| { tenant_id: "", provider: "twilio" } +| +| Exclusividade: soft-deleta qualquer OUTRO canal ativo do mesmo tenant+channel +| (ex: se estava ativo Twilio e reativa Evolution, Twilio é soft-deletado). +| +| Autoriza: saas_admin OU membro ativo do tenant dono do canal. +| Limpa `metadata.first_unhealthy_at` e `connection_status='disconnected'` ao +| reativar (garante heartbeat começa de estado limpo). +|-------------------------------------------------------------------------- +*/ + +import { createClient } 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, OPTIONS', +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }) +} + +Deno.serve(async (req: Request) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) + if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405) + + try { + const body = await req.json().catch(() => null) as { + channel_id?: string + tenant_id?: string + provider?: string + } | null + + const channelId = body?.channel_id + const tenantId = body?.tenant_id + const provider = body?.provider + + if (!channelId && (!tenantId || !provider)) { + return json({ ok: false, error: 'invalid_input: precisa de channel_id OU (tenant_id + provider)' }, 400) + } + + // Auth: valida user via JWT + const authHeader = req.headers.get('Authorization') + if (!authHeader) return json({ ok: false, error: 'unauthorized' }, 401) + + const supaAuth = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_ANON_KEY')!, + { global: { headers: { Authorization: authHeader } } } + ) + const { data: authData, error: authErr } = await supaAuth.auth.getUser() + if (authErr || !authData?.user) return json({ ok: false, error: 'unauthorized' }, 401) + const userId = authData.user.id + + // Service role pra bypass RLS + const supaSvc = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + ) + + // Localiza o canal alvo + let target: { id: string, tenant_id: string, channel: string, provider: string, metadata: Record | null } | null = null + + if (channelId) { + const { data } = await supaSvc + .from('notification_channels') + .select('id, tenant_id, channel, provider, metadata') + .eq('id', channelId) + .maybeSingle() + target = data + } else { + // Busca o mais recente soft-deleted daquele tenant+provider + const { data } = await supaSvc + .from('notification_channels') + .select('id, tenant_id, channel, provider, metadata') + .eq('tenant_id', tenantId!) + .eq('provider', provider!) + .eq('channel', 'whatsapp') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + target = data + } + + if (!target) return json({ ok: false, error: 'channel_not_found' }, 404) + + // Autoriza: saas_admin OU membro ativo do tenant + const { data: isAdmin } = await supaSvc.rpc('is_saas_admin') + let authorized = !!isAdmin + if (!authorized) { + const { data: membership } = await supaSvc + .from('tenant_members') + .select('id') + .eq('tenant_id', target.tenant_id) + .eq('user_id', userId) + .eq('status', 'active') + .maybeSingle() + authorized = !!membership + } + if (!authorized) return json({ ok: false, error: 'forbidden' }, 403) + + // Exclusividade: soft-deleta outros canais ativos do mesmo tenant+channel + // (se estava Twilio ativo e reativa Evolution, Twilio é desativado) + const nowIso = new Date().toISOString() + const { data: others } = await supaSvc + .from('notification_channels') + .select('id') + .eq('tenant_id', target.tenant_id) + .eq('channel', target.channel) + .neq('id', target.id) + .is('deleted_at', null) + + let deactivatedOthers = 0 + if (others && others.length > 0) { + const { error: deactErr } = await supaSvc + .from('notification_channels') + .update({ is_active: false, deleted_at: nowIso }) + .in('id', others.map((o) => o.id)) + if (deactErr) { + console.error('[reactivate] failed to deactivate others:', deactErr.message) + return json({ ok: false, error: 'failed_to_ensure_exclusivity', detail: deactErr.message }, 500) + } + deactivatedOthers = others.length + } + + // Reativa o alvo: limpa deleted_at, ativa, reseta connection_status e + // first_unhealthy_at do metadata (heartbeat começa zerado). + const cleanedMeta: Record = { ...(target.metadata || {}) } + delete cleanedMeta.first_unhealthy_at + + const { error: updErr } = await supaSvc + .from('notification_channels') + .update({ + is_active: true, + deleted_at: null, + connection_status: 'disconnected', + last_health_check: null, + metadata: cleanedMeta + }) + .eq('id', target.id) + + if (updErr) { + console.error('[reactivate] update error:', updErr.message) + return json({ ok: false, error: updErr.message }, 500) + } + + return json({ + ok: true, + channel_id: target.id, + provider: target.provider, + tenant_id: target.tenant_id, + deactivated_others: deactivatedOthers + }) + } catch (err) { + console.error('[reactivate-notification-channel] fatal:', err) + return json({ ok: false, error: String(err) }, 500) + } +}) diff --git a/supabase/functions/whatsapp-heartbeat-check/index.ts b/supabase/functions/whatsapp-heartbeat-check/index.ts index 3bc3d5f..7df58e6 100644 --- a/supabase/functions/whatsapp-heartbeat-check/index.ts +++ b/supabase/functions/whatsapp-heartbeat-check/index.ts @@ -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() + 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 = { 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,