Files
agenciapsilmno/database-novo/migrations/20260612000004_f1_clone_drop_functions.sql
T
Leonardo 05c6746e33 schema-per-tenant: F0 categorizacao + F1 template/helpers + F2 provisionamento
- docs/F0_categorizacao.md: varredura completa (137 tabelas -> 84 tenant + 53
  global, 66 funcoes, FKs, policies, edge functions) + decisoes Q1-Q4
- F1 (migrations 01-05): tenants.slug, helpers de schema, _tenant_template
  (84 tabelas sem tenant_id, singletons, views __SCHEMA__/__TENANT_ID__),
  clone_tenant_template/drop_tenant_schema, channel_routing, tenant_schemas
- F2 (migration 06): provision_account_tenant/create_clinic_tenant/
  ensure_personal_tenant_for_user clonam schema na mesma transacao
- db.cjs: psqlFile agora usa ON_ERROR_STOP=1 (falha de migration nao passa
  mais como sucesso silencioso)
- blueprint original em novo-rumo.txt; wiki Obsidian atualizada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:58:46 -03:00

302 lines
14 KiB
PL/PgSQL

-- =============================================================================
-- F1.4 — Schema-per-tenant: clone/drop + registro + roteamento de canais
--
-- * public.tenant_schemas — registro dos schemas provisionados (alimenta o
-- gerador do config.toml na F5)
-- * public.channel_routing — índice global de roteamento: webhooks inbound
-- (Twilio/Evolution) precisam descobrir o tenant do canal ANTES de saber o
-- schema (decisão Q3: notification_channels mora no schema do tenant).
-- Mantido por trigger em cada tenant_<slug>.notification_channels.
-- * clone_tenant_template(tenant_id) — instancia tenant_<slug> a partir do
-- _tenant_template: tabelas + sequences locais + FKs + seeds + views + RLS
-- (policies com tenant_id EMBUTIDO — modelo multi-membership) + realtime +
-- grants + trigger de roteamento.
-- * drop_tenant_schema(tenant_id) — protegido (assert tenant_%).
--
-- NOTA: clones criados na F1/F2 ainda NÃO têm triggers de negócio (F6) e não
-- estão expostos no PostgREST (F5). _meta.triggers_pending registra isso.
-- =============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- Registro de schemas provisionados
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.tenant_schemas (
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
schema_name text NOT NULL UNIQUE,
template_version int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.tenant_schemas ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_schemas_select ON public.tenant_schemas;
CREATE POLICY tenant_schemas_select ON public.tenant_schemas
FOR SELECT TO authenticated
USING (public.is_tenant_member(tenant_id) OR public.is_saas_admin());
-- ---------------------------------------------------------------------------
-- Índice global de roteamento de canais (webhook inbound -> tenant)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.channel_routing (
channel_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
channel text NOT NULL,
provider text,
sender_address text,
twilio_subaccount_sid text,
twilio_phone_number text,
metadata jsonb,
is_active boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS channel_routing_tenant_idx ON public.channel_routing (tenant_id);
CREATE INDEX IF NOT EXISTS channel_routing_sender_idx ON public.channel_routing (sender_address) WHERE sender_address IS NOT NULL;
CREATE INDEX IF NOT EXISTS channel_routing_twilio_phone_idx ON public.channel_routing (twilio_phone_number) WHERE twilio_phone_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS channel_routing_twilio_sid_idx ON public.channel_routing (twilio_subaccount_sid) WHERE twilio_subaccount_sid IS NOT NULL;
-- Tabela de infra: só service_role (edge functions) e saas admin enxergam
ALTER TABLE public.channel_routing ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS channel_routing_saas_admin ON public.channel_routing;
CREATE POLICY channel_routing_saas_admin ON public.channel_routing
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- Trigger anexado a cada tenant_<slug>.notification_channels pelo clone
CREATE OR REPLACE FUNCTION public.trg_sync_channel_routing()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_tenant_id uuid;
BEGIN
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
IF v_tenant_id IS NULL THEN
RAISE WARNING 'trg_sync_channel_routing: schema % sem tenant correspondente', TG_TABLE_SCHEMA;
RETURN COALESCE(NEW, OLD);
END IF;
IF TG_OP = 'DELETE' THEN
DELETE FROM public.channel_routing WHERE channel_id = OLD.id;
RETURN OLD;
END IF;
INSERT INTO public.channel_routing AS cr
(channel_id, tenant_id, channel, provider, sender_address,
twilio_subaccount_sid, twilio_phone_number, metadata, is_active, updated_at)
VALUES
(NEW.id, v_tenant_id, NEW.channel, NEW.provider, NEW.sender_address,
NEW.twilio_subaccount_sid, NEW.twilio_phone_number, NEW.metadata,
COALESCE(NEW.is_active, false) AND NEW.deleted_at IS NULL, now())
ON CONFLICT (channel_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
channel = EXCLUDED.channel,
provider = EXCLUDED.provider,
sender_address = EXCLUDED.sender_address,
twilio_subaccount_sid = EXCLUDED.twilio_subaccount_sid,
twilio_phone_number = EXCLUDED.twilio_phone_number,
metadata = EXCLUDED.metadata,
is_active = EXCLUDED.is_active,
updated_at = now();
RETURN NEW;
END;
$$;
-- ---------------------------------------------------------------------------
-- clone_tenant_template
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.clone_tenant_template(p_tenant_id uuid)
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_slug text;
v_schema text;
v_version int;
t text;
r record;
v_def text;
v_seq text;
v_n int;
v_pending text[];
v_failed text[];
BEGIN
SELECT slug INTO v_slug FROM public.tenants WHERE id = p_tenant_id;
IF v_slug IS NULL THEN
RAISE EXCEPTION 'clone_tenant_template: tenant % não existe ou sem slug', p_tenant_id;
END IF;
v_schema := public.tenant_schema_name(v_slug);
IF v_schema IS NULL THEN
RAISE EXCEPTION 'clone_tenant_template: slug % inválido', v_slug;
END IF;
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
RAISE EXCEPTION 'clone_tenant_template: schema % já existe', v_schema;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '_tenant_template') THEN
RAISE EXCEPTION 'clone_tenant_template: _tenant_template não existe (rode a F1.3)';
END IF;
SELECT (value)::int INTO v_version FROM _tenant_template._meta WHERE key = 'template_version';
EXECUTE format('CREATE SCHEMA %I', v_schema);
-- nomes qualificados nas definições geradas pelo catálogo
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
-- 1. tabelas
FOR r IN
SELECT table_name AS tab FROM information_schema.tables
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
AND table_name NOT LIKE '\_%'
ORDER BY table_name
LOOP
EXECUTE format('CREATE TABLE %I.%I (LIKE _tenant_template.%I INCLUDING ALL)',
v_schema, r.tab, r.tab);
END LOOP;
-- 2. sequences locais (defaults que apontam pro template)
FOR r IN
SELECT c.relname AS tab, a.attname AS col
FROM pg_attrdef d
JOIN pg_class c ON c.oid = d.adrelid
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE c.relnamespace = v_schema::regnamespace
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''_tenant_template.%'
LOOP
v_seq := r.tab || '_' || r.col || '_seq';
EXECUTE format('CREATE SEQUENCE %I.%I', v_schema, v_seq);
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
v_schema, r.tab, r.col, format('%I.%I', v_schema, v_seq));
EXECUTE format('ALTER SEQUENCE %I.%I OWNED BY %I.%I.%I',
v_schema, v_seq, v_schema, r.tab, r.col);
END LOOP;
-- 3. seeds (linhas-default do sistema guardadas no template)
-- Sem session_replication_role (postgres não é superuser no Supabase):
-- ordem de FK resolvida por tentativa-e-repetição em rounds.
v_pending := ARRAY[]::text[];
FOR r IN
SELECT table_name AS tab FROM information_schema.tables
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
AND table_name NOT LIKE '\_%'
ORDER BY table_name
LOOP
EXECUTE format('SELECT count(*) FROM _tenant_template.%I', r.tab) INTO v_n;
IF v_n > 0 THEN v_pending := v_pending || r.tab; END IF;
END LOOP;
WHILE coalesce(array_length(v_pending, 1), 0) > 0 LOOP
v_failed := ARRAY[]::text[];
FOR r IN SELECT unnest(v_pending) AS tab LOOP
BEGIN
EXECUTE format('INSERT INTO %I.%I SELECT * FROM _tenant_template.%I',
v_schema, r.tab, r.tab);
EXCEPTION WHEN foreign_key_violation THEN
v_failed := v_failed || r.tab;
END;
END LOOP;
IF array_length(v_failed, 1) = array_length(v_pending, 1) THEN
RAISE EXCEPTION 'clone_tenant_template: dependência circular nos seeds: %', v_failed;
END IF;
v_pending := v_failed;
END LOOP;
-- 4. FKs (intra-schema e pra public/auth)
FOR r IN
SELECT cl.relname AS tab, con.conname, pg_get_constraintdef(con.oid) AS def
FROM pg_constraint con
JOIN pg_class cl ON cl.oid = con.conrelid
WHERE con.contype = 'f'
AND cl.relnamespace = '_tenant_template'::regnamespace
ORDER BY cl.relname, con.conname
LOOP
v_def := replace(r.def, ' REFERENCES _tenant_template.', format(' REFERENCES %I.', v_schema));
EXECUTE format('ALTER TABLE %I.%I ADD CONSTRAINT %I %s', v_schema, r.tab, r.conname, v_def);
END LOOP;
-- 5. views (placeholders __SCHEMA__ / __TENANT_ID__)
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
EXECUTE replace(replace(r.definition, '__SCHEMA__', quote_ident(v_schema)),
'__TENANT_ID__', p_tenant_id::text);
END LOOP;
-- 6. RLS: tenant_id embutido (multi-membership: o usuário só enxerga
-- schemas de tenants onde tenant_members o lista como ativo)
FOR r IN
SELECT table_name AS tab FROM information_schema.tables
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
AND table_name NOT LIKE '\_%'
LOOP
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', v_schema, r.tab);
EXECUTE format(
'CREATE POLICY tenant_member_full ON %I.%I FOR ALL TO authenticated USING (public.is_tenant_member(%L::uuid)) WITH CHECK (public.is_tenant_member(%L::uuid))',
v_schema, r.tab, p_tenant_id, p_tenant_id);
EXECUTE format(
'CREATE POLICY saas_admin_full ON %I.%I FOR ALL TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin())',
v_schema, r.tab);
END LOOP;
-- 7. trigger de roteamento de canais
EXECUTE format(
'CREATE TRIGGER trg_channel_routing AFTER INSERT OR UPDATE OR DELETE ON %I.notification_channels FOR EACH ROW EXECUTE FUNCTION public.trg_sync_channel_routing()',
v_schema);
-- 8. realtime
FOR r IN SELECT table_name FROM _tenant_template._realtime_tables LOOP
EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE %I.%I', v_schema, r.table_name);
END LOOP;
-- 9. grants (espelha o padrão do Supabase pra schemas expostos)
EXECUTE format('GRANT USAGE ON SCHEMA %I TO anon, authenticated, service_role', v_schema);
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO anon, authenticated, service_role', v_schema);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO anon, authenticated, service_role', v_schema);
INSERT INTO public.tenant_schemas (tenant_id, schema_name, template_version)
VALUES (p_tenant_id, v_schema, COALESCE(v_version, 1));
RETURN v_schema;
END;
$$;
-- ---------------------------------------------------------------------------
-- drop_tenant_schema
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.drop_tenant_schema(p_tenant_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_schema text;
BEGIN
SELECT schema_name INTO v_schema FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
IF v_schema IS NULL THEN
v_schema := public.tenant_schema_for(p_tenant_id);
END IF;
IF v_schema IS NULL OR v_schema NOT LIKE 'tenant\_%' THEN
RAISE EXCEPTION 'drop_tenant_schema: schema inválido pra tenant % (%)', p_tenant_id, v_schema;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
RAISE EXCEPTION 'drop_tenant_schema: schema % não existe', v_schema;
END IF;
DELETE FROM public.channel_routing WHERE tenant_id = p_tenant_id;
DELETE FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
EXECUTE format('DROP SCHEMA %I CASCADE', v_schema);
END;
$$;
-- Clone/drop são operações de provisionamento: só service_role (edge) e postgres
REVOKE ALL ON FUNCTION public.clone_tenant_template(uuid) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.drop_tenant_schema(uuid) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.clone_tenant_template(uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.drop_tenant_schema(uuid) TO service_role;
COMMIT;