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>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
-- =============================================================================
|
||||
-- F1.1 — Schema-per-tenant: coluna tenants.slug
|
||||
-- Plano: novo-rumo.txt + docs/F0_categorizacao.md (decisão Q1)
|
||||
-- Slug é a base do nome do schema físico: tenant_<slug>. Unico e imutável.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS slug text;
|
||||
|
||||
-- Geração de slug a partir do nome (sanitizado pra identificador Postgres)
|
||||
CREATE OR REPLACE FUNCTION public.generate_tenant_slug(p_name text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
base text;
|
||||
cand text;
|
||||
n int := 1;
|
||||
BEGIN
|
||||
base := lower(coalesce(nullif(trim(p_name), ''), 'tenant'));
|
||||
base := translate(base,
|
||||
'áàâãäåéèêëíìîïóòôõöúùûüçñýÿ',
|
||||
'aaaaaaeeeeiiiiooooouuuucnyy');
|
||||
base := regexp_replace(base, '[^a-z0-9]+', '_', 'g');
|
||||
base := regexp_replace(base, '^_+|_+$', '', 'g');
|
||||
base := left(base, 48);
|
||||
IF base = '' OR base !~ '^[a-z]' THEN
|
||||
base := 't_' || base;
|
||||
base := left(base, 48);
|
||||
END IF;
|
||||
cand := base;
|
||||
WHILE EXISTS (SELECT 1 FROM public.tenants WHERE slug = cand) LOOP
|
||||
n := n + 1;
|
||||
cand := left(base, 44) || '_' || n;
|
||||
END LOOP;
|
||||
RETURN cand;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Backfill dos tenants existentes
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN SELECT id, name FROM public.tenants WHERE slug IS NULL ORDER BY created_at, id LOOP
|
||||
UPDATE public.tenants SET slug = public.generate_tenant_slug(r.name) WHERE id = r.id;
|
||||
RAISE NOTICE 'tenant % -> slug %', r.id, (SELECT slug FROM public.tenants WHERE id = r.id);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.tenants ALTER COLUMN slug SET NOT NULL;
|
||||
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_key UNIQUE (slug);
|
||||
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z][a-z0-9_]{1,47}$');
|
||||
|
||||
-- Auto-gera no INSERT (provisionamento atual não conhece slug); imutável no UPDATE
|
||||
CREATE OR REPLACE FUNCTION public.trg_tenants_slug()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.slug IS NULL OR trim(NEW.slug) = '' THEN
|
||||
NEW.slug := public.generate_tenant_slug(NEW.name);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.slug IS DISTINCT FROM OLD.slug THEN
|
||||
RAISE EXCEPTION 'tenants.slug é imutável (tenant %, % -> %)', OLD.id, OLD.slug, NEW.slug;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenants_slug_ins ON public.tenants;
|
||||
CREATE TRIGGER trg_tenants_slug_ins BEFORE INSERT ON public.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenants_slug_upd ON public.tenants;
|
||||
CREATE TRIGGER trg_tenants_slug_upd BEFORE UPDATE OF slug ON public.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- =============================================================================
|
||||
-- F1.2 — Schema-per-tenant: helpers de resolução de schema
|
||||
-- Adaptação ao modelo multi-membership deste projeto (docs/F0_categorizacao.md D2):
|
||||
-- profiles.tenant_id é NULL; membership vive em tenant_members (multi-tenant).
|
||||
-- Logo NÃO existe current_tenant_schema() — RPCs recebem p_tenant_id explícito
|
||||
-- e validam via tenant_schema_checked(p_tenant_id).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- slug -> nome de schema (validado). Retorna NULL se slug inválido.
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_name(p_slug text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN p_slug ~ '^[a-z][a-z0-9_]{1,47}$' THEN 'tenant_' || p_slug
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- tenant_id -> nome de schema
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_for(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
SELECT public.tenant_schema_name(t.slug) FROM public.tenants t WHERE t.id = p_tenant_id;
|
||||
$$;
|
||||
|
||||
-- nome de schema -> tenant_id (CRÍTICO pra triggers: a coluna tenant_id não
|
||||
-- existe mais nas tabelas tenant; o schema é a identidade)
|
||||
CREATE OR REPLACE FUNCTION public.tenant_id_for_schema(p_schema text)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
SELECT t.id FROM public.tenants t WHERE public.tenant_schema_name(t.slug) = p_schema;
|
||||
$$;
|
||||
|
||||
-- Resolve schema de um tenant COM validação de acesso do usuário logado.
|
||||
-- Substitui o current_tenant_schema() do blueprint (que assumia 1 tenant/usuário).
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_checked(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schema text;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_schema_checked: p_tenant_id obrigatório';
|
||||
END IF;
|
||||
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'acesso negado ao tenant %', p_tenant_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant % não encontrado ou slug inválido', p_tenant_id;
|
||||
END IF;
|
||||
RETURN v_schema;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,513 @@
|
||||
-- =============================================================================
|
||||
-- F1.3 — Schema-per-tenant: construção do schema _tenant_template
|
||||
--
|
||||
-- Clona a ESTRUTURA das 84 tabelas tenant-scoped (docs/F0_categorizacao.md §1)
|
||||
-- a partir de public, SEM a coluna tenant_id:
|
||||
-- * PK/UNIQUE compostos perdem tenant_id; PK/UNIQUE que eram SÓ (tenant_id)
|
||||
-- viram coluna `singleton boolean` (tabela de config 1-linha-por-tenant)
|
||||
-- * índices parciais WHERE tenant_id IS [NOT] NULL são fundidos/deduplicados
|
||||
-- * sequences bigserial são localizadas no template (não compartilham public)
|
||||
-- * FKs locais apontam pro template; FKs pra tabelas globais ficam em public/auth
|
||||
-- * linhas-default do sistema (tenant_id IS NULL) viram SEED do template e
|
||||
-- são copiadas pra cada tenant no clone
|
||||
-- * 6 views adaptadas ficam registradas em _tenant_template._views com
|
||||
-- placeholders __SCHEMA__ / __TENANT_ID__ (instanciadas no clone)
|
||||
--
|
||||
-- O template NUNCA é exposto no PostgREST nem recebe dados de tenant.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP SCHEMA IF EXISTS _tenant_template CASCADE;
|
||||
CREATE SCHEMA _tenant_template;
|
||||
|
||||
-- Helper interno: remove tenant_id de uma definição de índice e simplifica
|
||||
-- predicados parciais que testavam tenant_id.
|
||||
CREATE FUNCTION _tenant_template._adapt_indexdef(p_def text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE d text := p_def;
|
||||
BEGIN
|
||||
-- coluna no início/meio/fim da lista
|
||||
d := regexp_replace(d, '\(tenant_id,\s*', '(', 'g');
|
||||
d := regexp_replace(d, ',\s*tenant_id\)', ')', 'g');
|
||||
d := regexp_replace(d, ',\s*tenant_id,', ',', 'g');
|
||||
-- predicados parciais
|
||||
d := replace(d, '(tenant_id IS NOT NULL) AND ', '');
|
||||
d := replace(d, ' AND (tenant_id IS NOT NULL)', '');
|
||||
d := replace(d, ' WHERE ((tenant_id IS NOT NULL))', '');
|
||||
d := replace(d, ' WHERE (tenant_id IS NOT NULL)', '');
|
||||
d := replace(d, '(tenant_id IS NULL) AND ', '');
|
||||
d := replace(d, ' AND (tenant_id IS NULL)', '');
|
||||
d := replace(d, ' WHERE ((tenant_id IS NULL))', '');
|
||||
d := replace(d, ' WHERE (tenant_id IS NULL)', '');
|
||||
RETURN d;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
tabs text[] := ARRAY[
|
||||
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||
'agendador_configuracoes','agendador_solicitacoes',
|
||||
'asaas_customers','asaas_payments',
|
||||
'billing_contracts',
|
||||
'clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||
'commitment_services','commitment_time_logs',
|
||||
'company_profiles',
|
||||
'contact_email_types','contact_emails','contact_phones','contact_types',
|
||||
'conversation_assignments','conversation_autoreply_log','conversation_autoreply_settings',
|
||||
'conversation_bot_sessions','conversation_bots','conversation_messages','conversation_notes',
|
||||
'conversation_optout_keywords','conversation_optouts','conversation_sla_breaches',
|
||||
'conversation_sla_rules','conversation_tags','conversation_thread_tags',
|
||||
'determined_commitment_fields','determined_commitments',
|
||||
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||
'document_templates','documents',
|
||||
'email_layout_config','email_templates_tenant',
|
||||
'feriados',
|
||||
'financial_categories','financial_exceptions','financial_records',
|
||||
'insurance_plan_services','insurance_plans',
|
||||
'medicos',
|
||||
'notification_channels','notification_logs','notification_preferences','notification_queue',
|
||||
'notification_schedules','notification_templates','notifications',
|
||||
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||
'payment_settings','professional_pricing',
|
||||
'recurrence_exceptions','recurrence_rule_services','recurrence_rules',
|
||||
'services',
|
||||
'session_reminder_logs','session_reminder_settings',
|
||||
'therapist_payout_records','therapist_payouts',
|
||||
'twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||
];
|
||||
t text;
|
||||
r record;
|
||||
r2 record;
|
||||
v_def text;
|
||||
v_sig text;
|
||||
v_seq text;
|
||||
v_cols text;
|
||||
v_remaining text;
|
||||
v_n int;
|
||||
seen_sigs text[];
|
||||
pending text[] := ARRAY[]::text[];
|
||||
failed text[];
|
||||
BEGIN
|
||||
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
|
||||
|
||||
IF array_length(tabs, 1) <> 84 THEN
|
||||
RAISE EXCEPTION 'lista de tabelas tenant deveria ter 84, tem %', array_length(tabs, 1);
|
||||
END IF;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 1: clonar estrutura
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = t AND table_type = 'BASE TABLE') THEN
|
||||
RAISE EXCEPTION 'tabela public.% não existe — lista F0 desatualizada', t;
|
||||
END IF;
|
||||
EXECUTE format('CREATE TABLE _tenant_template.%I (LIKE public.%I INCLUDING ALL)', t, t);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'PASS 1 ok: % tabelas clonadas', array_length(tabs, 1);
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 2: localizar sequences (defaults nextval apontando pra public)
|
||||
---------------------------------------------------------------------------
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, a.attname AS col,
|
||||
pg_get_expr(d.adbin, d.adrelid) AS def
|
||||
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 = '_tenant_template'::regnamespace
|
||||
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''public.%'
|
||||
LOOP
|
||||
v_seq := r.tab || '_' || r.col || '_seq';
|
||||
EXECUTE format('CREATE SEQUENCE _tenant_template.%I', v_seq);
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
|
||||
r.tab, r.col, '_tenant_template.' || v_seq);
|
||||
EXECUTE format('ALTER SEQUENCE _tenant_template.%I OWNED BY _tenant_template.%I.%I',
|
||||
v_seq, r.tab, r.col);
|
||||
RAISE NOTICE 'PASS 2: sequence local _tenant_template.% (%.%)', v_seq, r.tab, r.col;
|
||||
END LOOP;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 3: drop tenant_id + recriar constraints/índices sem a coluna
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND attname = 'tenant_id' AND NOT attisdropped) THEN
|
||||
CONTINUE; -- joins/children sem tenant_id (commitment_services etc.)
|
||||
END IF;
|
||||
|
||||
-- 3a. capturar PK/UNIQUE que contêm tenant_id (no template)
|
||||
CREATE TEMP TABLE IF NOT EXISTS _f1_cons (tab text, conname text, contype char, remaining text) ON COMMIT DROP;
|
||||
DELETE FROM _f1_cons WHERE tab = t;
|
||||
INSERT INTO _f1_cons
|
||||
SELECT t, con.conname, con.contype,
|
||||
(SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY k.ord)
|
||||
FROM unnest(con.conkey) WITH ORDINALITY k(attnum, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
||||
WHERE a.attname <> 'tenant_id')
|
||||
FROM pg_constraint con
|
||||
WHERE con.conrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND con.contype IN ('p', 'u')
|
||||
AND EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||
WHERE a.attname = 'tenant_id');
|
||||
|
||||
-- 3b. capturar índices "soltos" (não-constraint) que usam tenant_id
|
||||
CREATE TEMP TABLE IF NOT EXISTS _f1_idx (tab text, idxname text, def text) ON COMMIT DROP;
|
||||
DELETE FROM _f1_idx WHERE tab = t;
|
||||
INSERT INTO _f1_idx
|
||||
SELECT t, c2.relname, pg_get_indexdef(i.indexrelid)
|
||||
FROM pg_index i
|
||||
JOIN pg_class c2 ON c2.oid = i.indexrelid
|
||||
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint cc WHERE cc.conindid = i.indexrelid)
|
||||
AND pg_get_indexdef(i.indexrelid) ~ '\mtenant_id\M';
|
||||
|
||||
-- 3c. drop da coluna (leva junto constraints/índices que a usam)
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I DROP COLUMN tenant_id CASCADE', t);
|
||||
|
||||
-- 3d. recriar PK/UNIQUE
|
||||
FOR r IN SELECT * FROM _f1_cons WHERE tab = t LOOP
|
||||
IF r.remaining IS NULL OR r.remaining = '' THEN
|
||||
-- era PK/UNIQUE exatamente (tenant_id): tabela 1-linha-por-tenant
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD COLUMN singleton boolean NOT NULL DEFAULT true', t);
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I CHECK (singleton = true)',
|
||||
t, t || '_singleton_chk');
|
||||
IF r.contype = 'p' THEN
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I PRIMARY KEY (singleton)',
|
||||
t, r.conname);
|
||||
ELSE
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I UNIQUE (singleton)',
|
||||
t, r.conname);
|
||||
END IF;
|
||||
RAISE NOTICE 'PASS 3: %.% era (tenant_id) -> singleton (%)', t, r.conname,
|
||||
CASE r.contype WHEN 'p' THEN 'PK' ELSE 'UNIQUE' END;
|
||||
ELSE
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s (%s)',
|
||||
t, r.conname,
|
||||
CASE r.contype WHEN 'p' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END,
|
||||
r.remaining);
|
||||
RAISE NOTICE 'PASS 3: %.% recriado sem tenant_id -> (%)', t, r.conname, r.remaining;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- 3e. recriar índices soltos transformados (com dedupe)
|
||||
seen_sigs := ARRAY[]::text[];
|
||||
-- assinaturas dos índices que já existem na tabela (pós-recriação de constraints)
|
||||
FOR r2 IN
|
||||
SELECT regexp_replace(pg_get_indexdef(i.indexrelid),
|
||||
'^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '') AS sig
|
||||
FROM pg_index i
|
||||
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
LOOP
|
||||
seen_sigs := seen_sigs || r2.sig;
|
||||
END LOOP;
|
||||
|
||||
FOR r IN SELECT * FROM _f1_idx WHERE tab = t LOOP
|
||||
-- índice cuja ÚNICA coluna era tenant_id: descartar
|
||||
IF r.def ~ '\(tenant_id\)( WHERE .*)?$' THEN
|
||||
RAISE NOTICE 'PASS 3: índice % descartado (era só tenant_id)', r.idxname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
v_def := _tenant_template._adapt_indexdef(r.def);
|
||||
v_sig := regexp_replace(v_def, '^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '');
|
||||
IF v_sig = ANY (seen_sigs) THEN
|
||||
RAISE NOTICE 'PASS 3: índice % deduplicado', r.idxname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
EXECUTE v_def;
|
||||
seen_sigs := seen_sigs || v_sig;
|
||||
RAISE NOTICE 'PASS 3: índice % recriado: %', r.idxname, v_sig;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 4: FKs (a partir das FKs reais de public)
|
||||
---------------------------------------------------------------------------
|
||||
FOR r IN
|
||||
SELECT con.conname,
|
||||
cl.relname AS tab,
|
||||
ns2.nspname AS fschema,
|
||||
cl2.relname AS ftab,
|
||||
pg_get_constraintdef(con.oid) AS def,
|
||||
EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||
WHERE a.attname = 'tenant_id') AS uses_tenant_id
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class cl ON cl.oid = con.conrelid
|
||||
JOIN pg_class cl2 ON cl2.oid = con.confrelid
|
||||
JOIN pg_namespace ns2 ON ns2.oid = cl2.relnamespace
|
||||
WHERE con.contype = 'f'
|
||||
AND cl.relnamespace = 'public'::regnamespace
|
||||
AND cl.relname = ANY (tabs)
|
||||
ORDER BY cl.relname, con.conname
|
||||
LOOP
|
||||
IF r.uses_tenant_id THEN
|
||||
RAISE NOTICE 'PASS 4: FK %.% descartada (coluna tenant_id removida)', r.tab, r.conname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
v_def := r.def;
|
||||
IF r.fschema = 'public' AND r.ftab = ANY (tabs) THEN
|
||||
-- alvo também é tenant-scoped -> referência intra-template
|
||||
v_def := regexp_replace(v_def,
|
||||
' REFERENCES (public\.)?' || r.ftab || '\(',
|
||||
' REFERENCES _tenant_template.' || r.ftab || '(');
|
||||
END IF;
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s', r.tab, r.conname, v_def);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'PASS 4 ok: FKs recriadas';
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 5: seeds — linhas-default do sistema (tenant_id IS NULL em public)
|
||||
-- APENAS tabelas de lookup/template (whitelist): linhas operacionais órfãs
|
||||
-- com tenant_id NULL (intakes, convites, notifs) NÃO são defaults.
|
||||
-- Sem session_replication_role (postgres não é superuser no Supabase):
|
||||
-- resolve ordem de FK por tentativa-e-repetição em rounds.
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'clinical_note_templates','contact_email_types','contact_types',
|
||||
'conversation_optout_keywords','conversation_tags','document_templates',
|
||||
'notification_templates','feriados'
|
||||
] LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = t AND column_name = 'tenant_id') THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
EXECUTE format('SELECT count(*) FROM public.%I WHERE tenant_id IS NULL', t) INTO v_n;
|
||||
IF v_n = 0 THEN CONTINUE; END IF;
|
||||
pending := pending || t;
|
||||
END LOOP;
|
||||
|
||||
WHILE coalesce(array_length(pending, 1), 0) > 0 LOOP
|
||||
failed := ARRAY[]::text[];
|
||||
FOREACH t IN ARRAY pending LOOP
|
||||
SELECT string_agg(quote_ident(c.column_name), ', ' ORDER BY c.ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = '_tenant_template' AND c.table_name = t
|
||||
AND c.column_name <> 'singleton'
|
||||
AND EXISTS (SELECT 1 FROM information_schema.columns p
|
||||
WHERE p.table_schema = 'public' AND p.table_name = t
|
||||
AND p.column_name = c.column_name);
|
||||
BEGIN
|
||||
EXECUTE format('INSERT INTO _tenant_template.%I (%s) SELECT %s FROM public.%I WHERE tenant_id IS NULL',
|
||||
t, v_cols, v_cols, t);
|
||||
RAISE NOTICE 'PASS 5: linhas-default semeadas em _tenant_template.%', t;
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
failed := failed || t;
|
||||
END;
|
||||
END LOOP;
|
||||
IF array_length(failed, 1) = array_length(pending, 1) THEN
|
||||
RAISE EXCEPTION 'PASS 5: dependência circular/externa nos seeds: %', failed;
|
||||
END IF;
|
||||
pending := failed;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Metadados do template
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE _tenant_template._meta (key text PRIMARY KEY, value jsonb NOT NULL);
|
||||
INSERT INTO _tenant_template._meta VALUES
|
||||
('template_version', '1'::jsonb),
|
||||
('built_from', '"docs/F0_categorizacao.md"'::jsonb),
|
||||
('triggers_pending', 'true'::jsonb); -- triggers de negócio só na F6
|
||||
|
||||
-- Tabelas que entram na publication supabase_realtime a cada clone
|
||||
-- (espelha o estado atual da publication em public: conversation_messages, notifications)
|
||||
CREATE TABLE _tenant_template._realtime_tables (table_name text PRIMARY KEY);
|
||||
INSERT INTO _tenant_template._realtime_tables
|
||||
SELECT tablename FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime' AND schemaname = 'public'
|
||||
AND tablename IN (
|
||||
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||
'agendador_configuracoes','agendador_solicitacoes','asaas_customers','asaas_payments',
|
||||
'billing_contracts','clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||
'commitment_services','commitment_time_logs','company_profiles','contact_email_types',
|
||||
'contact_emails','contact_phones','contact_types','conversation_assignments',
|
||||
'conversation_autoreply_log','conversation_autoreply_settings','conversation_bot_sessions',
|
||||
'conversation_bots','conversation_messages','conversation_notes','conversation_optout_keywords',
|
||||
'conversation_optouts','conversation_sla_breaches','conversation_sla_rules','conversation_tags',
|
||||
'conversation_thread_tags','determined_commitment_fields','determined_commitments',
|
||||
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||
'document_templates','documents','email_layout_config','email_templates_tenant','feriados',
|
||||
'financial_categories','financial_exceptions','financial_records','insurance_plan_services',
|
||||
'insurance_plans','medicos','notification_channels','notification_logs','notification_preferences',
|
||||
'notification_queue','notification_schedules','notification_templates','notifications',
|
||||
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||
'payment_settings','professional_pricing','recurrence_exceptions','recurrence_rule_services',
|
||||
'recurrence_rules','services','session_reminder_logs','session_reminder_settings',
|
||||
'therapist_payout_records','therapist_payouts','twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||
);
|
||||
|
||||
-- Views adaptadas (instanciadas pelo clone com __SCHEMA__ / __TENANT_ID__)
|
||||
CREATE TABLE _tenant_template._views (
|
||||
view_name text PRIMARY KEY,
|
||||
position int NOT NULL,
|
||||
definition text NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO _tenant_template._views VALUES
|
||||
('conversation_threads', 1, $vw$
|
||||
CREATE VIEW __SCHEMA__.conversation_threads WITH (security_invoker = true) AS
|
||||
WITH base AS (
|
||||
SELECT cm.id, cm.patient_id, cm.channel, cm.body, cm.direction,
|
||||
cm.kanban_status, cm.read_at, cm.created_at,
|
||||
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
|
||||
COALESCE(cm.patient_id::text,
|
||||
'anon:' || COALESCE(CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
|
||||
'unknown')) AS thread_key
|
||||
FROM __SCHEMA__.conversation_messages cm
|
||||
), latest AS (
|
||||
SELECT DISTINCT ON (base.thread_key)
|
||||
base.thread_key, base.patient_id, base.channel, base.contact_number,
|
||||
base.body AS last_message_body, base.direction AS last_message_direction,
|
||||
base.kanban_status, base.created_at AS last_message_at
|
||||
FROM base
|
||||
ORDER BY base.thread_key, base.created_at DESC
|
||||
), counts AS (
|
||||
SELECT base.thread_key, count(*) AS message_count,
|
||||
count(*) FILTER (WHERE base.direction = 'inbound' AND base.read_at IS NULL) AS unread_count
|
||||
FROM base
|
||||
GROUP BY base.thread_key
|
||||
)
|
||||
SELECT '__TENANT_ID__'::uuid AS tenant_id,
|
||||
l.thread_key, l.patient_id, p.nome_completo AS patient_name,
|
||||
l.contact_number, l.channel, c.message_count, c.unread_count,
|
||||
l.last_message_at, l.last_message_body, l.last_message_direction,
|
||||
l.kanban_status, ca.assigned_to, ca.assigned_at
|
||||
FROM latest l
|
||||
JOIN counts c ON c.thread_key = l.thread_key
|
||||
LEFT JOIN __SCHEMA__.patients p ON p.id = l.patient_id
|
||||
LEFT JOIN __SCHEMA__.conversation_assignments ca ON ca.thread_key = l.thread_key
|
||||
$vw$),
|
||||
('audit_log_unified', 2, $vw$
|
||||
CREATE VIEW __SCHEMA__.audit_log_unified WITH (security_invoker = true) AS
|
||||
SELECT 'audit:' || al.id::text AS uid, al.tenant_id, al.user_id, al.entity_type, al.entity_id, al.action,
|
||||
CASE al.action
|
||||
WHEN 'insert' THEN 'Criou ' || al.entity_type
|
||||
WHEN 'update' THEN ('Alterou ' || al.entity_type) || COALESCE((' (' || array_to_string(al.changed_fields, ', ')) || ')', '')
|
||||
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
|
||||
END AS description,
|
||||
al.created_at AS occurred_at, 'audit_logs' AS source,
|
||||
jsonb_build_object('old_values', al.old_values, 'new_values', al.new_values, 'changed_fields', al.changed_fields) AS details
|
||||
FROM public.audit_logs al
|
||||
WHERE al.tenant_id = '__TENANT_ID__'::uuid
|
||||
UNION ALL
|
||||
SELECT 'doc_access:' || dal.id::text, '__TENANT_ID__'::uuid, dal.user_id, 'document', dal.documento_id::text, dal.acao,
|
||||
CASE dal.acao
|
||||
WHEN 'visualizou' THEN 'Visualizou documento'
|
||||
WHEN 'baixou' THEN 'Baixou documento'
|
||||
WHEN 'imprimiu' THEN 'Imprimiu documento'
|
||||
WHEN 'compartilhou' THEN 'Compartilhou documento'
|
||||
WHEN 'assinou' THEN 'Assinou documento'
|
||||
ELSE dal.acao
|
||||
END,
|
||||
dal.acessado_em, 'document_access_logs',
|
||||
jsonb_build_object('ip', dal.ip::text, 'user_agent', dal.user_agent)
|
||||
FROM __SCHEMA__.document_access_logs dal
|
||||
UNION ALL
|
||||
SELECT 'psh:' || psh.id::text, '__TENANT_ID__'::uuid, psh.alterado_por, 'patient_status', psh.patient_id::text, 'status_change',
|
||||
((('Status do paciente: ' || COALESCE(psh.status_anterior, '—')) || ' → ') || psh.status_novo) || COALESCE((' (' || psh.motivo) || ')', ''),
|
||||
psh.alterado_em, 'patient_status_history',
|
||||
jsonb_build_object('status_anterior', psh.status_anterior, 'status_novo', psh.status_novo, 'motivo', psh.motivo,
|
||||
'encaminhado_para', psh.encaminhado_para, 'data_saida', psh.data_saida)
|
||||
FROM __SCHEMA__.patient_status_history psh
|
||||
UNION ALL
|
||||
SELECT 'notif:' || nl.id::text, '__TENANT_ID__'::uuid, nl.owner_id, 'notification', nl.patient_id::text, nl.status,
|
||||
((('Notificação ' || nl.channel) || ' ') || nl.status) || COALESCE(' para ' || nl.recipient_address, ''),
|
||||
nl.created_at, 'notification_logs',
|
||||
jsonb_build_object('channel', nl.channel, 'template_key', nl.template_key, 'status', nl.status,
|
||||
'provider', nl.provider, 'failure_reason', nl.failure_reason)
|
||||
FROM __SCHEMA__.notification_logs nl
|
||||
UNION ALL
|
||||
SELECT 'addon:' || at.id::text, at.tenant_id, at.admin_user_id, 'addon_transaction', at.id::text, at.type,
|
||||
CASE at.type
|
||||
WHEN 'purchase' THEN (('Compra de ' || at.amount) || ' créditos de ') || at.addon_type
|
||||
WHEN 'consumption' THEN (('Consumo de ' || abs(at.amount)) || ' crédito(s) ') || at.addon_type
|
||||
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
|
||||
WHEN 'refund' THEN (('Reembolso de ' || abs(at.amount)) || ' créditos ') || at.addon_type
|
||||
ELSE (at.type || ' ') || at.addon_type
|
||||
END,
|
||||
at.created_at, 'addon_transactions',
|
||||
jsonb_build_object('addon_type', at.addon_type, 'amount', at.amount, 'balance_after', at.balance_after,
|
||||
'price_cents', at.price_cents, 'payment_reference', at.payment_reference)
|
||||
FROM public.addon_transactions at
|
||||
WHERE at.tenant_id = '__TENANT_ID__'::uuid
|
||||
$vw$),
|
||||
('v_cashflow_projection', 3, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_cashflow_projection WITH (security_invoker = true) AS
|
||||
SELECT gs.mes,
|
||||
to_char(gs.mes, 'YYYY-MM') AS mes_label,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS receitas_projetadas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS despesas_projetadas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'pending'), 0) AS receitas_pendentes,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'overdue'), 0) AS receitas_vencidas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'pending'), 0) AS despesas_pendentes,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'overdue'), 0) AS despesas_vencidas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0)
|
||||
- COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS saldo_projetado,
|
||||
count(fr.id) FILTER (WHERE fr.status = ANY (ARRAY['pending', 'overdue'])) AS count_registros
|
||||
FROM generate_series(date_trunc('month', CURRENT_DATE::timestamp with time zone)::date::timestamp with time zone,
|
||||
(date_trunc('month', CURRENT_DATE::timestamp with time zone) + '5 mons'::interval)::date::timestamp with time zone,
|
||||
'1 mon'::interval) gs(mes)
|
||||
LEFT JOIN __SCHEMA__.financial_records fr
|
||||
ON fr.deleted_at IS NULL
|
||||
AND (fr.status = ANY (ARRAY['pending', 'overdue']))
|
||||
AND date_trunc('month', fr.due_date::timestamp with time zone)::date = gs.mes
|
||||
GROUP BY gs.mes
|
||||
ORDER BY gs.mes
|
||||
$vw$),
|
||||
('v_commitment_totals', 4, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_commitment_totals WITH (security_invoker = true) AS
|
||||
SELECT '__TENANT_ID__'::uuid AS tenant_id,
|
||||
c.id AS commitment_id,
|
||||
COALESCE(sum(l.minutes), 0)::integer AS total_minutes
|
||||
FROM __SCHEMA__.determined_commitments c
|
||||
LEFT JOIN __SCHEMA__.commitment_time_logs l ON l.commitment_id = c.id
|
||||
GROUP BY c.id
|
||||
$vw$),
|
||||
('v_patient_groups_with_counts', 5, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_patient_groups_with_counts WITH (security_invoker = true) AS
|
||||
SELECT pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at,
|
||||
COALESCE(count(pgp.patient_id), 0)::integer AS patients_count
|
||||
FROM __SCHEMA__.patient_groups pg
|
||||
LEFT JOIN __SCHEMA__.patient_group_patient pgp ON pgp.patient_group_id = pg.id
|
||||
GROUP BY pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at
|
||||
$vw$),
|
||||
('v_tag_patient_counts', 6, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_tag_patient_counts WITH (security_invoker = true) AS
|
||||
SELECT t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at,
|
||||
COALESCE(count(ppt.patient_id), 0)::integer AS pacientes_count,
|
||||
COALESCE(count(ppt.patient_id), 0)::integer AS patient_count
|
||||
FROM __SCHEMA__.patient_tags t
|
||||
LEFT JOIN __SCHEMA__.patient_patient_tag ppt ON ppt.tag_id = t.id AND ppt.owner_id = t.owner_id
|
||||
GROUP BY t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at
|
||||
$vw$);
|
||||
|
||||
-- Valida as views instanciando no próprio template (tenant nulo)
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
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__', '_tenant_template'),
|
||||
'__TENANT_ID__', '00000000-0000-0000-0000-000000000000');
|
||||
RAISE NOTICE 'view _tenant_template.% validada', r.view_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,301 @@
|
||||
-- =============================================================================
|
||||
-- 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;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =============================================================================
|
||||
-- F1.5 — Correção dos seeds do _tenant_template
|
||||
--
|
||||
-- O PASS 5 da F1.3 semeou TODA linha com tenant_id IS NULL de public — mas
|
||||
-- patient_intake_requests (2), patient_invites (1) e notifications (2) eram
|
||||
-- dados operacionais órfãos, não defaults do sistema. Cada tenant novo nasceria
|
||||
-- com esses registros fantasmas.
|
||||
--
|
||||
-- Whitelist canônica de seeds do template (lookups/templates do sistema):
|
||||
-- clinical_note_templates, contact_email_types, contact_types,
|
||||
-- conversation_optout_keywords, conversation_tags, document_templates,
|
||||
-- notification_templates, feriados
|
||||
--
|
||||
-- (20260612000003 foi corrigida em retrospecto pra instalações do zero;
|
||||
-- esta migration corrige bancos que já aplicaram a versão original.)
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM _tenant_template.patient_intake_requests;
|
||||
DELETE FROM _tenant_template.patient_invites;
|
||||
DELETE FROM _tenant_template.notifications;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =============================================================================
|
||||
-- F2 — Schema-per-tenant: provisionamento cria o schema físico
|
||||
--
|
||||
-- Os 3 pontos de criação de tenant passam a chamar clone_tenant_template()
|
||||
-- logo após inserir em tenants/tenant_members. Tudo na mesma transação:
|
||||
-- se o clone falhar, o tenant não nasce (atomicidade).
|
||||
--
|
||||
-- Pontos cobertos (F0 §levantamento — não há outros INSERT INTO tenants):
|
||||
-- * provision_account_tenant — wizard de cadastro (therapist/clinic_*)
|
||||
-- * create_clinic_tenant — criação avulsa de clínica
|
||||
-- * ensure_personal_tenant_for_user — tenant pessoal (kind='saas'),
|
||||
-- chamado também pelo trigger de signup (handle_new_user_create_personal_tenant)
|
||||
--
|
||||
-- Decisão Q2: TODO tenant ganha schema, inclusive therapist e pessoal.
|
||||
-- Clones nascem sem triggers de negócio (F6) e fora do PostgREST (F5).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_clinic_tenant(p_name text)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_tenant uuid;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := auth.uid();
|
||||
if v_uid is null then
|
||||
raise exception 'Not authenticated';
|
||||
end if;
|
||||
|
||||
v_name := nullif(trim(coalesce(p_name, '')), '');
|
||||
if v_name is null then
|
||||
v_name := 'Clínica';
|
||||
end if;
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name, 'clinic', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
perform public.clone_tenant_template(v_tenant);
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.ensure_personal_tenant_for_user(p_user_id uuid)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_existing uuid;
|
||||
v_tenant uuid;
|
||||
v_email text;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := p_user_id;
|
||||
if v_uid is null then
|
||||
raise exception 'Missing user id';
|
||||
end if;
|
||||
|
||||
-- só considera tenant pessoal (kind='saas')
|
||||
select tm.tenant_id
|
||||
into v_existing
|
||||
from public.tenant_members tm
|
||||
join public.tenants t on t.id = tm.tenant_id
|
||||
where tm.user_id = v_uid
|
||||
and tm.status = 'active'
|
||||
and t.kind = 'saas'
|
||||
order by tm.created_at desc
|
||||
limit 1;
|
||||
|
||||
if v_existing is not null then
|
||||
return v_existing;
|
||||
end if;
|
||||
|
||||
select email into v_email
|
||||
from auth.users
|
||||
where id = v_uid;
|
||||
|
||||
v_name := coalesce(split_part(v_email, '@', 1), 'Conta');
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name || ' (Pessoal)', 'saas', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
perform public.clone_tenant_template(v_tenant);
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_tenant_id uuid;
|
||||
v_account_type text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
|
||||
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = p_user_id
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND t.kind = p_kind
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
v_name := COALESCE(
|
||||
NULLIF(TRIM(p_name), ''),
|
||||
(
|
||||
SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
|
||||
FROM public.profiles pr
|
||||
JOIN auth.users au ON au.id = pr.id
|
||||
WHERE pr.id = p_user_id
|
||||
),
|
||||
'Conta'
|
||||
);
|
||||
|
||||
INSERT INTO public.tenants (name, kind, created_at)
|
||||
VALUES (v_name, p_kind, now())
|
||||
RETURNING id INTO v_tenant_id;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
|
||||
|
||||
UPDATE public.profiles
|
||||
SET account_type = v_account_type
|
||||
WHERE id = p_user_id;
|
||||
|
||||
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||
|
||||
RETURN v_tenant_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user