diff --git a/database-novo/manual/f6_4_saas_admin_rpcs.supabase_admin.sql b/database-novo/manual/f6_4_saas_admin_rpcs.supabase_admin.sql new file mode 100644 index 0000000..a723051 --- /dev/null +++ b/database-novo/manual/f6_4_saas_admin_rpcs.supabase_admin.sql @@ -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.). +-- +-- ⚠️ 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; diff --git a/src/services/twilioWhatsappService.js b/src/services/twilioWhatsappService.js index ae51fd9..48fd6c8 100644 --- a/src/services/twilioWhatsappService.js +++ b/src/services/twilioWhatsappService.js @@ -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 ?? [] } diff --git a/src/views/pages/saas/SaasFeriadosPage.vue b/src/views/pages/saas/SaasFeriadosPage.vue index 44961d6..c87c3f0 100644 --- a/src/views/pages/saas/SaasFeriadosPage.vue +++ b/src/views/pages/saas/SaasFeriadosPage.vue @@ -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) { -
{{ f.tenants.name }}
- -
{{ f.observacao }}
-
-
@@ -666,11 +630,6 @@ async function excluir(id) { -
- -