F6.4: resolve superficie SaaS-admin (destrava F6.3 DROP)

DB (supabase_admin, manual/f6_4_saas_admin_rpcs.supabase_admin.sql): RPCs
gated por is_saas_admin que operam no _tenant_template + fan-out pros schemas:
- Feriados defaults: saas_list/add/remove_default_feriado (template + todos schemas)
- Notif templates defaults: saas_list/upsert/set_active/delete_default_notif_
  template + saas_count_notif_template_overrides (so a copia-default owner_id NULL;
  preserva overrides do tenant)
- saas_list_all_whatsapp_channels: fan-out cross-tenant (substitui a view
  v_twilio_whatsapp_overview) com tenant_name + open_incident por canal

Frontend:
- SaasFeriadosPage / SaasNotificationTemplatesPage: supabase.from(tenant) ->
  RPCs saas_*_default
- SaasWhatsappPage: gestao por-tenant-selecionado via supabase.schema(tenant_
  <slug>) (RLS permite saas_admin); overview via saas_list_all_whatsapp_channels
- twilioWhatsappService.getAllChannels: v_twilio_whatsapp_overview -> RPC

Verificado: ZERO supabase.from('<tabela_tenant>') publico restante no FE
(so SaaS-admin estava pendente). Build passa. F6.3 agora desbloqueada.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 17:01:46 -03:00
parent 4493e78349
commit dc2363b4e1
5 changed files with 284 additions and 149 deletions
@@ -0,0 +1,181 @@
-- =============================================================================
-- F6.4 — RPCs SaaS-admin: defaults do sistema (template + fan-out) e views
-- cross-tenant (fan-out). Destrava o F6.3 (páginas SaaS deixam de ler
-- public.<tabela_tenant>).
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- Defaults (feriados nacionais, notification_templates is_default): editados
-- pelo SaaS no _tenant_template (fonte da verdade, propaga p/ tenants NOVOS no
-- clone) E fan-out pros schemas EXISTENTES. Só toca cópias-default (owner_id
-- NULL / is_default), preserva overrides do tenant (owner_id próprio).
-- Todas gated por is_saas_admin().
-- =============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public._assert_saas_admin()
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$ BEGIN
IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Apenas SaaS admin' USING ERRCODE='42501'; END IF;
END $$;
-- ── FERIADOS (defaults nacionais) ───────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.saas_list_default_feriados(p_ano integer)
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v jsonb;
BEGIN
PERFORM public._assert_saas_admin();
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',id,'data',data,'nome',nome,'tipo',tipo,'bloqueia_sessoes',bloqueia_sessoes) ORDER BY data),'[]'::jsonb)
INTO v FROM _tenant_template.feriados WHERE EXTRACT(YEAR FROM data) = p_ano;
RETURN v;
END $$;
CREATE OR REPLACE FUNCTION public.saas_add_default_feriado(p_data date, p_nome text, p_tipo text DEFAULT 'municipal')
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_owner uuid := auth.uid();
BEGIN
PERFORM public._assert_saas_admin();
INSERT INTO _tenant_template.feriados (owner_id, tipo, nome, data, bloqueia_sessoes)
VALUES (v_owner, p_tipo, p_nome, p_data, false) ON CONFLICT (data, nome) DO NOTHING;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('INSERT INTO %I.feriados (owner_id, tipo, nome, data, bloqueia_sessoes) VALUES ($1,$2,$3,$4,false) ON CONFLICT (data, nome) DO NOTHING', t.schema_name)
USING v_owner, p_tipo, p_nome, p_data;
END LOOP;
RETURN jsonb_build_object('data', p_data, 'nome', p_nome, 'tipo', p_tipo);
END $$;
CREATE OR REPLACE FUNCTION public.saas_remove_default_feriado(p_data date, p_nome text)
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int := 0;
BEGIN
PERFORM public._assert_saas_admin();
DELETE FROM _tenant_template.feriados WHERE data = p_data AND nome = p_nome;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('DELETE FROM %I.feriados WHERE data = $1 AND nome = $2', t.schema_name) USING p_data, p_nome;
v_n := v_n + 1;
END LOOP;
RETURN v_n;
END $$;
-- ── NOTIFICATION_TEMPLATES (defaults) ───────────────────────────────────────
CREATE OR REPLACE FUNCTION public.saas_list_default_notif_templates()
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v jsonb;
BEGIN
PERFORM public._assert_saas_admin();
SELECT COALESCE(jsonb_agg(to_jsonb(nt) ORDER BY nt.domain, nt.event_type),'[]'::jsonb)
INTO v FROM _tenant_template.notification_templates nt WHERE nt.is_default = true AND nt.deleted_at IS NULL;
RETURN v;
END $$;
-- upsert por key (defaults têm owner_id NULL). Cria/atualiza no template + schemas.
CREATE OR REPLACE FUNCTION public.saas_upsert_default_notif_template(p_payload jsonb)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_key text := p_payload->>'key'; v_exists boolean;
v_domain text := p_payload->>'domain'; v_channel text := p_payload->>'channel';
v_event text := p_payload->>'event_type'; v_body text := p_payload->>'body_text';
v_vars jsonb := COALESCE(p_payload->'variables','[]'::jsonb);
v_active boolean := COALESCE((p_payload->>'is_active')::boolean, true);
BEGIN
PERFORM public._assert_saas_admin();
IF v_key IS NULL THEN RAISE EXCEPTION 'key obrigatório'; END IF;
-- template
SELECT EXISTS(SELECT 1 FROM _tenant_template.notification_templates WHERE key=v_key AND owner_id IS NULL AND is_default=true) INTO v_exists;
IF v_exists THEN
UPDATE _tenant_template.notification_templates SET body_text=v_body, domain=v_domain, channel=v_channel,
event_type=v_event, variables=v_vars, is_active=v_active WHERE key=v_key AND owner_id IS NULL AND is_default=true;
ELSE
INSERT INTO _tenant_template.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES (NULL, v_key, v_domain, v_channel, v_event, v_body, v_vars, true, v_active);
END IF;
-- fan-out schemas (só a cópia-default; preserva overrides do tenant owner_id<>NULL)
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format(
'INSERT INTO %I.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active) '
|| 'VALUES (NULL,$1,$2,$3,$4,$5,$6,true,$7) '
|| 'ON CONFLICT (owner_id, key, deleted_at) DO UPDATE SET body_text=EXCLUDED.body_text, domain=EXCLUDED.domain, '
|| 'channel=EXCLUDED.channel, event_type=EXCLUDED.event_type, variables=EXCLUDED.variables, is_active=EXCLUDED.is_active',
t.schema_name) USING v_key, v_domain, v_channel, v_event, v_body, v_vars, v_active;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.saas_set_default_notif_template_active(p_key text, p_active boolean)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
PERFORM public._assert_saas_admin();
UPDATE _tenant_template.notification_templates SET is_active=p_active WHERE key=p_key AND owner_id IS NULL AND is_default=true;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_templates SET is_active=$1 WHERE key=$2 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_active, p_key;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.saas_delete_default_notif_template(p_key text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
PERFORM public._assert_saas_admin();
UPDATE _tenant_template.notification_templates SET deleted_at=now() WHERE key=p_key AND owner_id IS NULL AND is_default=true;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_templates SET deleted_at=now() WHERE key=$1 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_key;
END LOOP;
END $$;
-- quantos tenants têm override (tenant_id<>NULL no modelo antigo = owner_id<>NULL aqui) por key
CREATE OR REPLACE FUNCTION public.saas_count_notif_template_overrides(p_key text)
RETURNS integer LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int := 0; v_has int;
BEGIN
PERFORM public._assert_saas_admin();
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('SELECT count(*) FROM %I.notification_templates WHERE key=$1 AND owner_id IS NOT NULL AND is_active=true AND deleted_at IS NULL', t.schema_name) INTO v_has USING p_key;
v_n := v_n + v_has;
END LOOP;
RETURN v_n;
END $$;
-- ── WHATSAPP admin (cross-tenant) — substitui v_twilio_whatsapp_overview ─────
CREATE OR REPLACE FUNCTION public.saas_list_all_whatsapp_channels()
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_rows jsonb := '[]'::jsonb; v_part jsonb;
BEGIN
PERFORM public._assert_saas_admin();
FOR t IN SELECT ts.tenant_id, ts.schema_name, tn.name AS tenant_name
FROM public.tenant_schemas ts JOIN public.tenants tn ON tn.id = ts.tenant_id LOOP
EXECUTE format(
'SELECT COALESCE(jsonb_agg(jsonb_build_object('
|| '''id'',nc.id, ''tenant_id'',$1::uuid, ''tenant_name'',$2::text, ''owner_id'',nc.owner_id,'
|| '''provider'',nc.provider, ''is_active'',nc.is_active, ''connection_status'',nc.connection_status,'
|| '''sender_address'',nc.sender_address, ''twilio_phone_number'',nc.twilio_phone_number,'
|| '''last_health_check'',nc.last_health_check,'
|| '''open_incident'',(SELECT i.kind FROM %1$I.whatsapp_connection_incidents i WHERE i.channel_id=nc.id AND i.resolved_at IS NULL LIMIT 1)'
|| ')),''[]''::jsonb) FROM %1$I.notification_channels nc WHERE nc.channel=''whatsapp'' AND nc.deleted_at IS NULL',
t.schema_name) INTO v_part USING t.tenant_id, t.tenant_name;
v_rows := v_rows || v_part;
END LOOP;
RETURN v_rows;
END $$;
-- grants: gated por is_saas_admin internamente, mas exposto a authenticated
DO $g$ DECLARE fn text; BEGIN
FOREACH fn IN ARRAY ARRAY[
'saas_list_default_feriados(integer)','saas_add_default_feriado(date,text,text)','saas_remove_default_feriado(date,text)',
'saas_list_default_notif_templates()','saas_upsert_default_notif_template(jsonb)',
'saas_set_default_notif_template_active(text,boolean)','saas_delete_default_notif_template(text)',
'saas_count_notif_template_overrides(text)','saas_list_all_whatsapp_channels()'
] LOOP
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO authenticated', fn);
END LOOP;
END $g$;
COMMIT;
+3 -4
View File
@@ -158,10 +158,9 @@ export async function getChannel(tenantId) {
* Busca todos os canais WhatsApp Twilio (para SaaS admin).
*/
export async function getAllChannels() {
const { data, error } = await supabase
.from('v_twilio_whatsapp_overview')
.select('*')
.order('created_at', { ascending: false })
// schema-per-tenant: v_twilio_whatsapp_overview (public) virou RPC fan-out
// saas_admin que varre os schemas e agrega os canais de todos os tenants.
const { data, error } = await supabase.rpc('saas_list_all_whatsapp_channels')
if (error) throw error
return data ?? []
}
+27 -68
View File
@@ -27,7 +27,6 @@ const toast = useToast();
// ── Estado ───────────────────────────────────────────────────
const loading = ref(false);
const feriados = ref([]);
const tenants = ref([]);
const ano = ref(new Date().getFullYear());
const search = ref('');
@@ -46,7 +45,6 @@ function emptyForm() {
data: null,
cidade: '',
estado: '',
tenant_id: null,
observacao: '',
bloqueia_sessoes: false
};
@@ -69,26 +67,15 @@ async function salvar() {
if (!formValid.value) return;
saving.value = true;
try {
const { data: me } = await supabase.auth.getUser();
const isoData = dateToISO(form.value.data);
const tenantId = form.value.tenant_id || null;
const payload = {
owner_id: me?.user?.id || null,
tenant_id: tenantId,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: isoData,
cidade: form.value.cidade.trim() || null,
estado: form.value.estado.trim() || null,
observacao: form.value.observacao.trim() || null,
bloqueia_sessoes: form.value.bloqueia_sessoes
};
const { data, error } = await supabase.from('feriados').insert(payload).select('*, tenants(name)').single();
const nome = form.value.nome.trim();
const { error } = await supabase.rpc('saas_add_default_feriado', { p_data: isoData, p_nome: nome, p_tipo: 'municipal' });
if (error) throw error;
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data));
// Re-lista os defaults do ano (RPC retorna {data, nome, tipo}; re-listar é mais simples e fica consistente)
await load();
// ── Auto-aviso global: somente para feriados globais (tenant_id = null) ──
if (!tenantId && isoData) {
// ── Auto-aviso global: defaults são sempre globais ──
if (isoData) {
try {
await createNotice({
title: form.value.nome.trim(),
@@ -126,9 +113,10 @@ async function salvar() {
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.from('feriados').select('*, tenants(name)').gte('data', `${ano.value}-01-01`).lte('data', `${ano.value}-12-31`).order('data');
const { data, error } = await supabase.rpc('saas_list_default_feriados', { p_ano: ano.value });
if (error) throw error;
feriados.value = data || [];
// RPC retorna jsonb array direto (sem join de tenants; defaults não têm tenant)
feriados.value = (data || []).slice().sort((a, b) => a.data.localeCompare(b.data));
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 });
} finally {
@@ -136,15 +124,8 @@ async function load() {
}
}
// ── Load tenants (para o select do dialog) ────────────────────
async function loadTenants() {
const { data } = await supabase.from('tenants').select('id, name').order('name');
tenants.value = data || [];
}
onMounted(() => {
load();
loadTenants();
carregarDeclinados();
});
@@ -183,8 +164,6 @@ const cidadeOptions = computed(() => {
return [{ label: 'Todas as cidades', value: null }, ...[...set].sort().map((c) => ({ label: c, value: c }))];
});
const tenantOptions = computed(() => [{ label: 'Sem vínculo (global)', value: null }, ...tenants.value.map((t) => ({ label: t.name, value: t.id }))]);
// ── Lista filtrada ────────────────────────────────────────────
const listaFiltrada = computed(() => {
let list = feriados.value;
@@ -207,6 +186,8 @@ const agrupados = computed(() => {
// ── Stats ─────────────────────────────────────────────────────
const totalFeriados = computed(() => feriados.value.length);
// TODO(schema-per-tenant): defaults não têm tenant_id/cidade/estado — estes stats e os filtros
// estado/cidade ficam sempre vazios/0. Remover os cards/filtros ou repensar a UI numa próxima passada.
const totalTenants = computed(() => new Set(feriados.value.map((f) => f.tenant_id).filter(Boolean)).size);
const totalMunicipios = computed(() => new Set(feriados.value.map((f) => f.cidade).filter(Boolean)).size);
@@ -223,8 +204,8 @@ function dateMinus2(iso) {
return `${d.toISOString().slice(0, 10)}T08:00`;
}
// Datas de nacionais já publicados no DB como feriado global (tenant_id = null)
const publicadosDatas = computed(() => new Set(feriados.value.filter((f) => f.tenant_id === null).map((f) => f.data)));
// Datas de nacionais já publicados no DB como feriado default (todos os defaults são globais)
const publicadosDatas = computed(() => new Set(feriados.value.map((f) => f.data)));
// Datas marcadas como "não publicar" — persiste em localStorage por ano
function lsKey() {
@@ -305,9 +286,8 @@ const feriadoParaDespublicar = ref(null);
const despublicando = ref(false);
function abrirDlgUnpublish(feriado) {
// Pega o registro do banco correspondente (tenant_id=null, mesma data)
const registro = feriados.value.find((f) => f.tenant_id === null && f.data === feriado.data);
feriadoParaDespublicar.value = { ...feriado, _dbId: registro?.id || null };
// Despublicação agora é por (data, nome) via RPC — não precisa mais do id do registro
feriadoParaDespublicar.value = { ...feriado };
dlgUnpublish.value = true;
}
@@ -317,12 +297,10 @@ async function confirmarDespublicacao() {
despublicando.value = true;
dlgUnpublish.value = false;
try {
// 1. Remove o feriado do banco
if (feriado._dbId) {
const { error } = await supabase.from('feriados').delete().eq('id', feriado._dbId);
if (error) throw error;
feriados.value = feriados.value.filter((f) => f.id !== feriado._dbId);
}
// 1. Remove o feriado default do banco (por data + nome; propaga aos schemas)
const { error } = await supabase.rpc('saas_remove_default_feriado', { p_data: feriado.data, p_nome: feriado.nome });
if (error) throw error;
feriados.value = feriados.value.filter((f) => !(f.data === feriado.data && f.nome === feriado.nome));
// 2. Remove avisos globais associados (mesmo título + ends_at no dia do feriado)
const { data: avisos } = await supabase.from('global_notices').select('id').eq('title', feriado.nome).gte('ends_at', `${feriado.data}T00:00`).lte('ends_at', `${feriado.data}T23:59`);
@@ -349,21 +327,10 @@ async function confirmarPublicacao() {
salvandoNacional.value = feriado.data;
dlgPublicar.value = false;
try {
const { data: me } = await supabase.auth.getUser();
const { data, error } = await supabase
.from('feriados')
.insert({
owner_id: me?.user?.id || null,
tenant_id: null,
tipo: 'municipal',
nome: feriado.nome,
data: feriado.data,
bloqueia_sessoes: false // cada tenant decide bloquear individualmente
})
.select('*, tenants(name)')
.single();
const { error } = await supabase.rpc('saas_add_default_feriado', { p_data: feriado.data, p_nome: feriado.nome, p_tipo: 'municipal' });
if (error) throw error;
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data));
// Re-lista para refletir o novo default publicado
await load();
// Auto-aviso global
try {
@@ -397,11 +364,12 @@ async function confirmarPublicacao() {
}
// ── Excluir ───────────────────────────────────────────────────
async function excluir(id) {
async function excluir(feriado) {
try {
const { error } = await supabase.from('feriados').delete().eq('id', id);
// Agora deleta por (data, nome) — defaults não têm id de tenant associado
const { error } = await supabase.rpc('saas_remove_default_feriado', { p_data: feriado.data, p_nome: feriado.nome });
if (error) throw error;
feriados.value = feriados.value.filter((f) => f.id !== id);
feriados.value = feriados.value.filter((f) => !(f.data === feriado.data && f.nome === feriado.nome));
toast.add({ severity: 'success', summary: 'Removido', life: 1500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 });
@@ -565,12 +533,8 @@ async function excluir(id) {
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" />
</div>
<div v-if="f.tenants?.name" class="text-[1rem] text-[var(--text-color-secondary)] w-full flex items-center gap-1"><i class="pi pi-building" /> {{ f.tenants.name }}</div>
<div v-if="f.observacao" class="text-[1rem] text-[var(--text-color-secondary)] w-full italic">{{ f.observacao }}</div>
<div class="ml-auto">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f)" />
</div>
</div>
</div>
@@ -666,11 +630,6 @@ async function excluir(id) {
</div>
</div>
<div>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<Select v-model="form.tenant_id" :options="tenantOptions" optionLabel="label" optionValue="value" class="w-full mt-1" placeholder="Sem vínculo (global)" />
</div>
<div>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
@@ -73,9 +73,10 @@ const loading = ref(false);
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.from('notification_templates').select('*').is('tenant_id', null).eq('is_default', true).is('deleted_at', null).order('domain').order('event_type');
const { data, error } = await supabase.rpc('saas_list_default_notif_templates');
if (error) throw error;
templates.value = data || [];
// RPC retorna array direto com as linhas completas dos templates default
templates.value = (data || []).slice().sort((a, b) => (a.domain || '').localeCompare(b.domain || '') || (a.event_type || '').localeCompare(b.event_type || ''));
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
} finally {
@@ -163,44 +164,23 @@ async function save() {
dlg.value.saving = true;
try {
if (dlg.value.isNew) {
// Detecta variáveis usadas
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
// Detecta variáveis usadas
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const { error } = await supabase.from('notification_templates').insert({
tenant_id: null,
owner_id: null,
key: form.value.key,
domain: form.value.domain,
channel: form.value.channel,
event_type: form.value.event_type,
body_text: form.value.body_text,
variables: vars,
is_default: true,
is_active: form.value.is_active
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template criado', life: 3000 });
} else {
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
// Upsert por key (cria se nova, atualiza se já existe; propaga ao _tenant_template + schemas)
const payload = {
key: form.value.key,
domain: form.value.domain,
channel: form.value.channel,
event_type: form.value.event_type,
body_text: form.value.body_text,
variables: vars,
is_active: form.value.is_active
};
const { error } = await supabase.rpc('saas_upsert_default_notif_template', { p_payload: payload });
if (error) throw error;
const currentVersion = templates.value.find((t) => t.id === dlg.value.id)?.version || 0;
const { error } = await supabase
.from('notification_templates')
.update({
body_text: form.value.body_text,
domain: form.value.domain,
event_type: form.value.event_type,
variables: vars,
is_active: form.value.is_active,
version: currentVersion + 1
})
.eq('id', dlg.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template atualizado', life: 3000 });
}
toast.add({ severity: 'success', summary: dlg.value.isNew ? 'Template criado' : 'Template atualizado', life: 3000 });
closeDlg();
await load();
} catch (e) {
@@ -214,21 +194,19 @@ async function save() {
const togglingId = ref(null);
async function countAffectedTenants(t) {
const [{ data: schedules }, { data: overrides }] = await Promise.all([
supabase.from('notification_schedules').select('tenant_id').eq('event_type', t.event_type).eq('channel', t.channel).eq('is_active', true).is('deleted_at', null),
supabase.from('notification_templates').select('tenant_id').eq('key', t.key).eq('is_active', true).is('deleted_at', null).not('tenant_id', 'is', null)
]);
const overrideIds = new Set((overrides || []).map((o) => o.tenant_id).filter(Boolean));
const affected = new Set((schedules || []).map((s) => s.tenant_id).filter((id) => id && !overrideIds.has(id)));
return affected.size;
// TODO(schema-per-tenant): a RPC retorna o nº de tenants COM override ativo desta key.
// A métrica antiga contava tenants agendando SEM override (impacto de desativar o default).
// Semântica mudou; o número exibido na confirmação agora reflete overrides, não impacto direto.
const { data, error } = await supabase.rpc('saas_count_notif_template_overrides', { p_key: t.key });
if (error) throw error;
return data || 0;
}
async function doToggleActive(t) {
togglingId.value = t.id;
try {
const next = !t.is_active;
const { error } = await supabase.from('notification_templates').update({ is_active: next }).eq('id', t.id);
const { error } = await supabase.rpc('saas_set_default_notif_template_active', { p_key: t.key, p_active: next });
if (error) throw error;
t.is_active = next;
toast.add({
@@ -296,7 +274,7 @@ function deleteTemplate(t) {
color: '#ef4444',
accept: async () => {
try {
const { error } = await supabase.from('notification_templates').update({ deleted_at: new Date().toISOString() }).eq('id', t.id);
const { error } = await supabase.rpc('saas_delete_default_notif_template', { p_key: t.key });
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template excluído', life: 3000 });
await load();
+47 -29
View File
@@ -19,6 +19,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { tenantSchemaName } from '@/lib/supabase/tenantClient';
const toast = useToast();
const confirm = useConfirm();
@@ -26,17 +27,31 @@ const confirm = useConfirm();
// ── Tenants ───────────────────────────────────────────────────
const tenants = ref([]);
const tenantMap = ref({});
const tenantSlugMap = ref({}); // { tenant_id: slug }
const loadingTenants = ref(false);
const selectedTenantId = ref(null);
// Slug do tenant selecionado — necessário pra montar o schema (tenant_<slug>),
// já que notification_channels/whatsapp_connection_incidents vivem no schema do tenant.
const selectedTenantSlug = computed(() => (selectedTenantId.value ? tenantSlugMap.value[selectedTenantId.value] || null : null));
// Client apontando pro schema do tenant selecionado. Lança se faltar slug —
// chamada a tabela tenant sem schema é sempre bug. RLS dos schemas permite is_saas_admin().
function selectedTenantDb() {
const schema = tenantSchemaName(selectedTenantSlug.value);
if (!schema) throw new Error('Tenant selecionado sem slug válido — não foi possível resolver o schema.');
return supabase.schema(schema);
}
async function loadTenants() {
loadingTenants.value = true;
try {
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
const { data, error } = await supabase.from('tenants').select('id, name, kind, slug').order('name', { ascending: true });
if (error) throw error;
const list = data || [];
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
tenantSlugMap.value = Object.fromEntries(list.map((t) => [t.id, t.slug]));
tenants.value = list.map((t) => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`
@@ -109,11 +124,11 @@ async function loadChannel() {
loadingChannel.value = true;
softDeletedChannel.value = null;
try {
// 1) Tenta canal ativo (evolution_api)
const { data: active, error: activeErr } = await supabase
const tdb = selectedTenantDb();
// 1) Tenta canal ativo (evolution_api) — schema do tenant já isola, sem .eq('tenant_id')
const { data: active, error: activeErr } = await tdb
.from('notification_channels')
.select('*')
.eq('tenant_id', selectedTenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.is('deleted_at', null)
@@ -135,10 +150,9 @@ async function loadChannel() {
}
// 2) Não tem ativo — busca soft-deleted (evolution_api) pra poder reativar
const { data: deleted } = await supabase
const { data: deleted } = await tdb
.from('notification_channels')
.select('*')
.eq('tenant_id', selectedTenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.not('deleted_at', 'is', null)
@@ -182,7 +196,8 @@ async function saveCredentials() {
}
savingCredentials.value = true;
try {
// Buscar o owner_id (dono do tenant)
const tdb = selectedTenantDb();
// Buscar o owner_id (dono do tenant) — tenant_members é GLOBAL (public)
const { data: members, error: memErr } = await supabase.from('tenant_members').select('user_id').eq('tenant_id', selectedTenantId.value).in('role', ['tenant_admin', 'admin']).limit(1).single();
if (memErr) throw memErr;
@@ -193,7 +208,7 @@ async function saveCredentials() {
if (channel.value?.id) {
// Canal ativo — só atualizar creds
const { error } = await supabase
const { error } = await tdb
.from('notification_channels')
.update({
credentials: creds,
@@ -210,7 +225,7 @@ async function saveCredentials() {
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
const { error: updErr } = await tdb
.from('notification_channels')
.update({
credentials: creds,
@@ -228,12 +243,13 @@ async function saveCredentials() {
life: 4000
});
} else {
// 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();
// Novo — recarregar para evitar duplicata por race condition.
// owner_id permanece (existe no schema) pra desambiguar canal por dono.
const { data: existing } = await tdb.from('notification_channels').select('id').eq('owner_id', ownerId).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
if (existing?.id) {
// Já existe ativo (outro provider) — atualizar convertendo para evolution_api
const { error } = await supabase
const { error } = await tdb
.from('notification_channels')
.update({
credentials: creds,
@@ -244,11 +260,10 @@ async function saveCredentials() {
.eq('id', existing.id);
if (error) throw error;
} else {
const { data, error } = await supabase
const { data, error } = await tdb
.from('notification_channels')
.insert({
owner_id: ownerId,
tenant_id: selectedTenantId.value,
channel: 'whatsapp',
provider: 'evolution_api',
is_active: true,
@@ -286,7 +301,7 @@ function confirmDeactivate() {
acceptClass: 'p-button-danger',
accept: async () => {
try {
const { error } = await supabase.from('notification_channels').update({ is_active: false }).eq('id', channel.value.id);
const { error } = await selectedTenantDb().from('notification_channels').update({ is_active: false }).eq('id', channel.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'WhatsApp desativado', life: 3000 });
await loadChannel();
@@ -320,7 +335,7 @@ async function checkConnectionStatus() {
if (channel.value?.id) {
const dbStatus = rawState === 'open' ? 'connected' : rawState === 'connecting' ? 'connecting' : 'disconnected';
if (channel.value.connection_status !== dbStatus) {
await supabase
await selectedTenantDb()
.from('notification_channels')
.update({ connection_status: dbStatus, last_health_check: new Date().toISOString() })
.eq('id', channel.value.id);
@@ -426,22 +441,25 @@ const heartbeatRunning = ref(false);
async function loadAllChannels() {
loadingAll.value = true;
try {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
const [channelsRes, openRes, countRes] = await Promise.all([
supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false }),
supabase.from('whatsapp_connection_incidents').select('id, channel_id, kind, started_at, last_state').is('resolved_at', null),
supabase.from('whatsapp_connection_incidents').select('channel_id').gte('started_at', sevenDaysAgo)
]);
if (channelsRes.error) throw channelsRes.error;
allChannels.value = channelsRes.data || [];
// Overview cross-tenant: RPC agrega notification_channels + incidente aberto
// de TODOS os schemas tenant_* (substitui os 3 selects em public, que sumiram
// com a migração schema-per-tenant). Retorno é jsonb array direto.
const { data, error } = await supabase.rpc('saas_list_all_whatsapp_channels');
if (error) throw error;
allChannels.value = data || [];
// open_incident já vem por canal (kind do incidente aberto, ou null).
// Reconstrói o mapa que a UI consome (chaveado por channel.id).
const openMap = {};
for (const inc of openRes.data || []) openMap[inc.channel_id] = inc;
for (const ch of allChannels.value) {
if (ch.open_incident) openMap[ch.id] = { kind: ch.open_incident };
}
openIncidentsByChannel.value = openMap;
const countMap = {};
for (const row of countRes.data || []) countMap[row.channel_id] = (countMap[row.channel_id] || 0) + 1;
incidents7dByChannel.value = countMap;
// TODO(schema-per-tenant): a RPC não expõe contagem de incidents nos últimos 7d
// (o 3º select cross-tenant deixou de existir). Coluna "Incidents 7d" agora
// mostra sempre 0. Adicionar agregação na RPC se a métrica voltar a ser útil.
incidents7dByChannel.value = {};
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar canais', detail: e.message, life: 4000 });
} finally {
@@ -553,7 +571,7 @@ onBeforeUnmount(() => {
</Column>
<Column header="Instância" style="min-width: 140px">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.credentials?.instance_name || '—' }}</span>
<span class="font-mono text-xs">{{ data.credentials?.instance_name || data.sender_address || data.twilio_phone_number || '—' }}</span>
</template>
</Column>
<Column header="Status" style="min-width: 120px">