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:
@@ -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.';
|
||||||
@@ -15,11 +15,31 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
import { onMounted, onUnmounted } from 'vue';
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useNotificationStore } from '@/stores/notificationStore';
|
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() {
|
export function useNotifications() {
|
||||||
const store = useNotificationStore();
|
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 () => {
|
onMounted(async () => {
|
||||||
const { data, error } = await supabase.auth.getUser();
|
const { data, error } = await supabase.auth.getUser();
|
||||||
@@ -27,7 +47,7 @@ export function useNotifications() {
|
|||||||
|
|
||||||
const ownerId = data.user.id;
|
const ownerId = data.user.id;
|
||||||
await store.load(ownerId);
|
await store.load(ownerId);
|
||||||
store.subscribeRealtime(ownerId);
|
store.subscribeRealtime(ownerId, onRealtimeNotification);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -36,3 +56,4 @@ export function useNotifications() {
|
|||||||
|
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,67 +21,154 @@ const tenantStore = useTenantStore();
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const switching = ref(false);
|
const switching = ref(false);
|
||||||
const activeChannel = ref(null); // row de notification_channels (provider, is_active, etc) ou null
|
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(() => {
|
const activeProvider = computed(() => {
|
||||||
if (!activeChannel.value) return null;
|
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() {
|
async function loadChannel() {
|
||||||
const tenantId = tenantStore.activeTenantId;
|
const tenantId = tenantStore.activeTenantId;
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
activeChannel.value = null;
|
activeChannel.value = null;
|
||||||
|
softDeletedByProvider.value = {};
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await supabase
|
const { data: active } = await supabase
|
||||||
.from('notification_channels')
|
.from('notification_channels')
|
||||||
.select('id, provider, is_active, connection_status, twilio_phone_number, credentials, updated_at')
|
.select('id, provider, is_active, connection_status, twilio_phone_number, credentials, updated_at')
|
||||||
.eq('tenant_id', tenantId)
|
.eq('tenant_id', tenantId)
|
||||||
.eq('channel', 'whatsapp')
|
.eq('channel', 'whatsapp')
|
||||||
.is('deleted_at', null)
|
.is('deleted_at', null)
|
||||||
.maybeSingle();
|
.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) {
|
} catch (e) {
|
||||||
console.error('[whatsapp-chooser] load:', e?.message);
|
console.error('[whatsapp-chooser] load:', e?.message);
|
||||||
activeChannel.value = null;
|
activeChannel.value = null;
|
||||||
|
softDeletedByProvider.value = {};
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
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) {
|
function goSetup(provider) {
|
||||||
if (provider === 'evolution') router.push('/configuracoes/whatsapp-pessoal');
|
if (provider === 'evolution') router.push('/configuracoes/whatsapp-pessoal');
|
||||||
else if (provider === 'twilio') router.push('/configuracoes/whatsapp-oficial');
|
else if (provider === 'twilio') router.push('/configuracoes/whatsapp-oficial');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleChoose(provider) {
|
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) {
|
if (!activeProvider.value) {
|
||||||
|
// 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);
|
goSetup(provider);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mesmo provider → so navega
|
|
||||||
if (activeProvider.value === provider) {
|
// Tem canal ativo de outro provider → confirmar troca
|
||||||
goSetup(provider);
|
const from = activeKey === 'twilio' ? 'Oficial AgenciaPSI' : 'Pessoal';
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Provider diferente → confirmar troca
|
|
||||||
const from = activeProvider.value === 'twilio' ? 'Oficial AgenciaPSI' : 'Pessoal';
|
|
||||||
const to = provider === '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({
|
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',
|
header: 'Trocar canal WhatsApp',
|
||||||
icon: 'pi pi-exclamation-triangle',
|
icon: 'pi pi-exclamation-triangle',
|
||||||
acceptLabel: 'Trocar',
|
acceptLabel: 'Trocar',
|
||||||
rejectLabel: 'Cancelar',
|
rejectLabel: 'Cancelar',
|
||||||
acceptClass: 'p-button-warning',
|
acceptClass: 'p-button-warning',
|
||||||
accept: async () => {
|
accept: async () => {
|
||||||
|
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();
|
await deactivateCurrent();
|
||||||
goSetup(provider);
|
goSetup(provider);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +350,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
|
|||||||
Ideal pra <strong>clínicas</strong> e alto volume
|
Ideal pra <strong>clínicas</strong> e alto volume
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[0.72rem] font-bold text-emerald-500 flex items-center gap-1">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -271,7 +358,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
|
|||||||
<!-- Cartão: WhatsApp Pessoal (Evolution) -->
|
<!-- Cartão: WhatsApp Pessoal (Evolution) -->
|
||||||
<button
|
<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="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-indigo-500 shadow-[0_0_0_4px_rgba(99,102,241,0.12)]'
|
||||||
: 'border-[var(--surface-border)] hover:border-indigo-400/50'"
|
: 'border-[var(--surface-border)] hover:border-indigo-400/50'"
|
||||||
:disabled="switching"
|
:disabled="switching"
|
||||||
@@ -320,7 +407,7 @@ watch(() => tenantStore.activeTenantId, () => { loadChannel(); });
|
|||||||
Ideal pra <strong>uso pessoal</strong> e baixo volume
|
Ideal pra <strong>uso pessoal</strong> e baixo volume
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[0.72rem] font-bold text-indigo-500 flex items-center gap-1">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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)
|
// Carregar credenciais do banco — busca por tenant_id (consistente com SaaS)
|
||||||
// com fallback para owner_id (caso tenantId == userId)
|
// com fallback para owner_id (caso tenantId == userId)
|
||||||
async function loadCredentials() {
|
async function loadCredentials() {
|
||||||
if (!tenantId.value) return;
|
if (!tenantId.value) return;
|
||||||
// Tentar por tenant_id primeiro (como o SaaS salva)
|
softDeletedRecord.value = null;
|
||||||
let { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
|
|
||||||
|
|
||||||
// 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) {
|
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;
|
data = fallback.data;
|
||||||
error = fallback.error;
|
error = fallback.error;
|
||||||
}
|
}
|
||||||
@@ -98,6 +117,7 @@ async function loadCredentials() {
|
|||||||
toast.add({ severity: 'error', summary: 'Erro ao carregar credenciais', detail: error.message, life: 4000 });
|
toast.add({ severity: 'error', summary: 'Erro ao carregar credenciais', detail: error.message, life: 4000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.credentials) {
|
if (data?.credentials) {
|
||||||
credentials.value = {
|
credentials.value = {
|
||||||
api_url: data.credentials.api_url || '',
|
api_url: data.credentials.api_url || '',
|
||||||
@@ -106,6 +126,49 @@ async function loadCredentials() {
|
|||||||
};
|
};
|
||||||
hasCredentials.value = true;
|
hasCredentials.value = true;
|
||||||
channelRecord.value = data;
|
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 ════════════════════════════════════ -->
|
<!-- ══ ABA 1 — Conexão ════════════════════════════════════ -->
|
||||||
<TabPanel :value="0">
|
<TabPanel :value="0">
|
||||||
<div class="flex flex-col gap-4 pt-3">
|
<div class="flex flex-col gap-4 pt-3">
|
||||||
<!-- Sem credenciais — WhatsApp não configurado pelo admin -->
|
<!-- Canal soft-deleted — oferecer reativação -->
|
||||||
<div v-if="!hasCredentials" class="border border-[var(--surface-border)] rounded-lg p-6 bg-[var(--surface-card)] text-center">
|
<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">
|
<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" />
|
<i class="pi pi-comments text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const useNotificationStore = defineStore('notifications', {
|
|||||||
this.items = data || [];
|
this.items = data || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeRealtime(ownerId) {
|
subscribeRealtime(ownerId, onInsert = null) {
|
||||||
if (this._channel) return;
|
if (this._channel) return;
|
||||||
|
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
@@ -130,6 +130,9 @@ export const useNotificationStore = defineStore('notifications', {
|
|||||||
(payload) => {
|
(payload) => {
|
||||||
this.items.unshift(payload.new);
|
this.items.unshift(payload.new);
|
||||||
fireBrowserNotification(payload.new);
|
fireBrowserNotification(payload.new);
|
||||||
|
if (typeof onInsert === 'function') {
|
||||||
|
try { onInsert(payload.new); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|||||||
@@ -102,24 +102,62 @@ function resetChannelState() {
|
|||||||
qrCodeBase64.value = null;
|
qrCodeBase64.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const softDeletedChannel = ref(null); // canal soft-deleted detectado p/ reativação
|
||||||
|
|
||||||
async function loadChannel() {
|
async function loadChannel() {
|
||||||
if (!selectedTenantId.value) return;
|
if (!selectedTenantId.value) return;
|
||||||
loadingChannel.value = true;
|
loadingChannel.value = true;
|
||||||
|
softDeletedChannel.value = null;
|
||||||
try {
|
try {
|
||||||
// Buscar pelo owner_id do tenant (que é o user_id do dono)
|
// 1) Tenta canal ativo (evolution_api)
|
||||||
const { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', selectedTenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
|
const { data: active, error: activeErr } = await supabase
|
||||||
if (error) throw error;
|
.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 (active) {
|
||||||
if (data?.credentials) {
|
channel.value = active;
|
||||||
|
if (active.credentials) {
|
||||||
credentials.value = {
|
credentials.value = {
|
||||||
api_url: data.credentials.api_url || '',
|
api_url: active.credentials.api_url || '',
|
||||||
api_key: data.credentials.api_key || '',
|
api_key: active.credentials.api_key || '',
|
||||||
instance_name: data.credentials.instance_name || ''
|
instance_name: active.credentials.instance_name || ''
|
||||||
};
|
};
|
||||||
hasCredentials.value = true;
|
hasCredentials.value = true;
|
||||||
// Só verificar conexão se o canal estiver ativo
|
if (active.is_active) await checkConnectionStatus();
|
||||||
if (data.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) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Erro ao carregar canal', detail: e.message, life: 4000 });
|
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(/\/+$/, '');
|
creds.api_url = creds.api_url.replace(/\/+$/, '');
|
||||||
|
|
||||||
if (channel.value?.id) {
|
if (channel.value?.id) {
|
||||||
// Atualizar existente
|
// Canal ativo — só atualizar creds
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('notification_channels')
|
.from('notification_channels')
|
||||||
.update({
|
.update({
|
||||||
@@ -164,12 +202,37 @@ async function saveCredentials() {
|
|||||||
})
|
})
|
||||||
.eq('id', channel.value.id);
|
.eq('id', channel.value.id);
|
||||||
if (error) throw error;
|
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 {
|
} 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();
|
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) {
|
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
|
const { error } = await supabase
|
||||||
.from('notification_channels')
|
.from('notification_channels')
|
||||||
.update({
|
.update({
|
||||||
@@ -200,9 +263,13 @@ async function saveCredentials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasCredentials.value = true;
|
hasCredentials.value = true;
|
||||||
|
if (!softDeletedChannel.value) {
|
||||||
toast.add({ severity: 'success', summary: 'Credenciais salvas', detail: `WhatsApp configurado para ${selectedTenantName.value}`, life: 3000 });
|
toast.add({ severity: 'success', summary: 'Credenciais salvas', detail: `WhatsApp configurado para ${selectedTenantName.value}`, life: 3000 });
|
||||||
|
}
|
||||||
// Recarregar canal para sincronizar estado
|
// Recarregar canal para sincronizar estado
|
||||||
await loadChannel();
|
await loadChannel();
|
||||||
|
// Atualiza lista da aba "Visão geral"
|
||||||
|
loadAllChannels();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
|
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -573,6 +640,15 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Formulário de credenciais -->
|
||||||
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
|
<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)]">
|
<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" />
|
<InputText v-model="credentials.instance_name" placeholder="nome-da-instancia" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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: "<uuid>" } — reativa canal específico
|
||||||
|
| { tenant_id: "<uuid>", provider: "evolution_api" } — reativa por tenant+provider
|
||||||
|
| { tenant_id: "<uuid>", 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<string, unknown> | 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<string, unknown> = { ...(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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -207,8 +207,9 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
|||||||
const newIncidentId = incidentId as unknown as string
|
const newIncidentId = incidentId as unknown as string
|
||||||
|
|
||||||
if (alertsEnabled && newIncidentId) {
|
if (alertsEnabled && newIncidentId) {
|
||||||
await notifyTenantAdmins(supa, {
|
await notifyChannelStakeholders(supa, {
|
||||||
tenant_id: channel.tenant_id,
|
tenant_id: channel.tenant_id,
|
||||||
|
channel_owner_id: channel.owner_id,
|
||||||
incident_id: newIncidentId,
|
incident_id: newIncidentId,
|
||||||
channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'),
|
channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'),
|
||||||
kind,
|
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
|
tenant_id: string
|
||||||
|
channel_owner_id: string
|
||||||
incident_id: string
|
incident_id: string
|
||||||
channel_display: string
|
channel_display: string
|
||||||
kind: 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
|
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
|
const { data: admins } = await supa
|
||||||
.from('tenant_members')
|
.from('tenant_members')
|
||||||
.select('user_id')
|
.select('user_id')
|
||||||
@@ -250,7 +257,9 @@ async function notifyTenantAdmins(supa: SupabaseClient, params: {
|
|||||||
.in('role', ['clinic_admin', 'tenant_admin'])
|
.in('role', ['clinic_admin', 'tenant_admin'])
|
||||||
.eq('status', 'active')
|
.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> = {
|
const kindLabel: Record<string, string> = {
|
||||||
disconnected: 'desconectado',
|
disconnected: 'desconectado',
|
||||||
@@ -263,8 +272,8 @@ async function notifyTenantAdmins(supa: SupabaseClient, params: {
|
|||||||
const title = `${params.channel_display} ${kindLabel[params.kind] || 'offline'}`
|
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 detail = `A conexão está fora há cerca de ${params.minutes_unhealthy} min. Envios automáticos podem estar falhando.`
|
||||||
|
|
||||||
const rows = admins.map((a) => ({
|
const rows = Array.from(userIds).map((uid) => ({
|
||||||
owner_id: a.user_id,
|
owner_id: uid,
|
||||||
tenant_id: params.tenant_id,
|
tenant_id: params.tenant_id,
|
||||||
type: 'system_alert',
|
type: 'system_alert',
|
||||||
ref_id: params.incident_id,
|
ref_id: params.incident_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user