Files
agenciapsilmno/database-novo/manual/f6_4_saas_admin_rpcs.supabase_admin.sql
T
Leonardo dc2363b4e1 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>
2026-06-13 17:01:46 -03:00

182 lines
9.8 KiB
PL/PgSQL

-- =============================================================================
-- 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;