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:
@@ -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;
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user