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