-- ============================================================================= -- 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_.notification_channels. -- * clone_tenant_template(tenant_id) — instancia tenant_ 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_.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;