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:
Leonardo
2026-06-12 11:58:46 -03:00
parent b0b636c660
commit 05c6746e33
12 changed files with 1718 additions and 1 deletions
+6
View File
@@ -1642,3 +1642,9 @@ PROXIMA SESSAO (retomar amanha 22/05):
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
do Gitea exige `git -c http.sslVerify=false push origin main`
+ credenciais (user faz manual).
## [2026-06-12 10:47] session | F0 schema-per-tenant: varredura e categorizacao
Touched: Migracao Schema-per-Tenant, index
## [2026-06-12 11:49] session | F1 schema-per-tenant: template + helpers + clone
Touched: Migracao Schema-per-Tenant
@@ -0,0 +1,53 @@
# Migração Schema-per-Tenant
**Status:** F2 concluída e smoke-testada (2026-06-12). Próximo: F3 (frontend useTenantDb).
## F2 — entregue (migration 20260612000006)
Os 3 únicos pontos de criação de tenant (`provision_account_tenant`, `create_clinic_tenant`, `ensure_personal_tenant_for_user` — este último também acionado pelo trigger de signup `handle_new_user_create_personal_tenant`) agora chamam `clone_tenant_template()` na mesma transação: clone falhou → tenant não nasce. Smoke: ensure_personal criou tenant pessoal `tenant_terapeuta_pessoal` com 84 tabelas + registro, 2ª chamada idempotente, drop limpou tudo. Não há fluxo de exclusão de tenant no sistema (drop_tenant_schema fica pra uso admin/manual).
## F1 — entregue (migrations 2026061200000105 em database-novo/migrations/)
- `tenants.slug` criado + backfill dos 9 + trigger auto-gera/imutável
- Helpers: `tenant_schema_name/for`, `tenant_id_for_schema`, `tenant_schema_checked(p_tenant_id)` (valida `is_tenant_member` — substitui current_tenant_schema do blueprint)
- `_tenant_template`: 84 tabelas sem tenant_id, 6 singletons (`singleton boolean PK/UQ` nas configs 1-linha: company_profiles, email_layout_config, conversation_autoreply_settings/bots/sla_rules, session_reminder_settings), 4 sequences locais, 94 FKs (62 intra + 32 pra public/auth), 6 views com placeholders `__SCHEMA__`/`__TENANT_ID__` em `_views`, seeds de sistema (whitelist 8 lookups)
- `clone_tenant_template(uuid)` → tabelas+seqs+seeds+FKs+views+RLS (policies com tenant_id EMBUTIDO: `is_tenant_member('<uuid>')` + saas_admin_full)+realtime+grants+trigger routing+registro em `tenant_schemas`
- `drop_tenant_schema(uuid)` protegido; `public.channel_routing` (webhook inbound acha tenant do canal) sincronizada por trigger
- Smoke: clone tenant_smoke_f1 → 84 tabelas/168 policies/roundtrip/routing sync/singleton rejeitando 2ª linha → drop limpo
### Gotchas aprendidos na F1
- **`postgres` não é superuser no Supabase** → `session_replication_role` proibido; seeds usam retry-loop de FK (rounds). Vale pro F6 (migração de dados): rodar como `supabase_admin` ou retry-loop.
- **db.cjs aplicava migration sem `ON_ERROR_STOP`** → rollback silencioso reportado como sucesso. Corrigido (psqlFile agora usa `-v ON_ERROR_STOP=1`).
- Linhas operacionais órfãs com tenant_id NULL (intakes/convites/notifs) NÃO são seeds — whitelist explícita.
- Clones F1/F2 ainda SEM triggers de negócio (F6) e fora do PostgREST (F5) — `_meta.triggers_pending=true`.
Migração de multi-tenant RLS-only (tenant_id em cada tabela) para schema físico por tenant (`tenant_<slug>`), seguindo blueprint do projeto irmão (`novo-rumo.txt` na raiz), adaptado.
## Artefatos
- `docs/F0_categorizacao.md` — varredura completa: classificação das 137 tabelas, 66 funções, 6 views, FKs, edge functions, divergências.
- `novo-rumo.txt` (raiz) — blueprint original com lições do projeto irmão.
## Números-chave
- 137 tabelas public → 79 tenant-scoped + 5 em decisão (infra mensageria) + 53 globais
- 66 funções afetadas (blueprint avisava: listas pré-feitas subestimam — era "29" lá, 66 aqui)
- 1 única FK global→tenant problemática: `whatsapp_credits_transactions.conversation_message_id`
- 0 policies de tabelas globais usando funções a refatorar
- 9 tenants (3 clínicas + 6 therapists), volumetria minúscula (<400 linhas/tabela)
## Divergências vs blueprint (decisivas)
1. **Sem `tenants.slug`** — precisa criar coluna ou usar uuid no nome do schema.
2. **Multi-membership**: `profiles.tenant_id` 100% NULL; verdade vive em `tenant_members` (4 users multi-tenant). `current_tenant_schema()` do blueprint não funciona → frontend escolhe schema ([[tenantStore]] já tem `activeTenantId`), segurança via policy com tenant_id embutido por schema + RPCs recebem `p_tenant_id` validado com `is_tenant_member()`.
3. **6/9 tenants são terapeutas individuais** — schema por signup; custo operacional do config.toml do PostgREST cresce com tenants.
4. `email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` apontam pra **auth.users** (legado) — mapear na migração de dados.
5. View `current_tenant_id` é código morto (claim JWT nunca populado).
## Decisões (2026-06-12)
- Q1: **criar `tenants.slug`** → schemas `tenant_<slug>`
- Q2: **todo tenant ganha schema** (clínicas e therapists)
- Q3: **mensageria tenant-scoped** (isolamento máximo, contra rec. global) → crons varrem tenants em loop; webhooks inbound precisam de índice global `channel_routing` (channel_external_id → tenant_id) pra rotear antes de gravar
- Q4: **asaas tenant** (staging `asaas_webhook_events` global roteia)
Total final: **84 tabelas tenant-scoped, 53 globais.**
## Fases (tasks #1#7 na sessão)
F0 categorização ✅ · F1 template+helpers · F2 provisionamento · F3 frontend useTenantDb · F4 edge functions · F5 PostgREST config · F6 rewrite funções + migração dados + drops (lotes, backup antes de cada um)
Relacionados: [[Decisões de Billing da Agenda]], [[Supabase Local]], [[index]]
+1
View File
@@ -30,3 +30,4 @@ _(synthesized answers to questions you've asked, filed back as pages)_
---
*This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.*
- [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4)
+1 -1
View File
@@ -89,7 +89,7 @@ function psqlFile(filePath) {
const absPath = path.resolve(filePath);
const content = fs.readFileSync(absPath, 'utf8');
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q -v ON_ERROR_STOP=1`;
return execSync(cmd, {
input: utf8Content,
encoding: 'utf8',
@@ -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;
+154
View File
@@ -0,0 +1,154 @@
# F0 — Categorização para migração Schema-per-Tenant
> Gerado em 2026-06-12 a partir de varredura direta no banco local (`supabase_db_agenciapsi-primesakai`),
> grep nas edge functions (`supabase/functions/`) e no frontend (`src/`).
> Fonte do plano: `novo-rumo.txt` (blueprint do projeto irmão), adaptado às divergências deste projeto.
## Sumário executivo
| Item | Quantidade |
|---|---|
| Tabelas em `public` (BASE TABLE) | 137 |
| **Tenant-scoped** (vão pra `tenant_<x>`) — decidido Q3 | **84** |
| **Globais** (ficam em `public`) | **53** |
| Funções que referenciam tabelas-tenant | **66** (não 29 — o aviso do blueprint se confirmou) |
| Views que referenciam tabelas-tenant | 6 |
| FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) |
| Policies de tabelas globais usando funções a refatorar | **0** (risco de policy órfã é baixo) |
| Edge functions que tocam tabelas-tenant | ~25 de 29 |
| Tenants existentes | 9 (3 clínicas + 6 terapeutas individuais) |
| Volumetria | Baixa (maior tabela tenant: `conversation_messages` 355 linhas) — migração de dados é barata |
## ⚠️ Divergências críticas vs blueprint (novo-rumo.txt)
Estas diferenças exigem adaptação do plano — o blueprint NÃO se aplica literalmente:
### D1 — Não existe `tenants.slug`
Colunas de `tenants`: `id, name, created_at, kind, papel_timbrado, cpf_cnpj`.
O blueprint assume `slug` para nomear schemas (`tenant_<slug>`).
**Opções:** (a) adicionar coluna `slug` (unique, imutável, sanitizada a partir de `name`); (b) usar `tenant_` + uuid sem hífens (feio, mas sem coluna nova).
### D2 — Membership multi-tenant via `tenant_members` (profiles.tenant_id está 100% NULL)
- `profiles.tenant_id` existe mas tem **0 linhas preenchidas**.
- Membership real: `tenant_members` (15 linhas), com **4 usuários membros de mais de um tenant**.
- Tenant ativo é resolvido no **frontend**: RPC `my_tenants()` → Pinia `tenantStore.activeTenantId` → localStorage. Sem claim no JWT. Router troca tenant por `meta.tenantScope` (clinic/personal/supervisor).
**Consequência:** o helper `current_tenant_schema()` do blueprint (baseado em `profiles.tenant_id`) **não funciona aqui**. Adaptação proposta:
- Frontend escolhe o schema diretamente: `db()` = `supabase.schema(tenantSchemaName(activeTenant))` — já sabe o tenant ativo.
- Segurança não depende da escolha do cliente: cada schema clonado ganha policies com o tenant_id **embutido**: `USING (public.is_tenant_member('<uuid-do-tenant>'))`. Usuário só lê schema de tenant onde é membro, mesmo apontando o client pra outro schema.
- RPCs que precisam de "tenant atual" passam a receber `p_tenant_id` explícito, validado com `is_tenant_member()` antes do `set_config('search_path', ...)`. Substitui `current_tenant_schema()` por `tenant_schema_checked(p_tenant_id)`.
- Edge functions: client envia o tenant ativo (header `X-Tenant-Id` ou body); a function valida membership via `tenant_members` antes de usar `.schema()`.
### D3 — 6 dos 9 tenants são terapeutas individuais (`kind='therapist'`)
Schema-per-tenant aqui significa **um schema por terapeuta** que se cadastrar. Com 9 tenants é trivial; em escala self-serve (centenas/milhares), o array `schemas` do PostgREST e o catálogo do Postgres crescem linearmente. Funciona, mas é um custo operacional permanente (config.toml + restart a cada signup, a menos que automatize).
**Recomendação:** modelo uniforme (todo tenant ganha schema, qualquer `kind`) — modelo misto (clínica com schema, terapeuta em public) dobraria a complexidade de todas as funções e do frontend.
### D4 — `tenant_id` que aponta pra `auth.users` (legado)
`email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` têm FK pra **auth.users**, não pra `tenants`. Tratar na migração de dados (mapear user→tenant via `tenant_members` ou `owner_id`).
### D5 — View `current_tenant_id` é código morto
`SELECT current_setting('request.jwt.claim.tenant_id', true)` — claim nunca populado. Remover na F6.
---
## 1. Classificação das 137 tabelas
### 1.1 TENANT-SCOPED — movem pra `tenant_<x>` (79)
**Agenda (7):** agenda_bloqueios, agenda_configuracoes, agenda_eventos, agenda_online_slots, agenda_regras_semanais, agenda_slots_bloqueados_semanais, agenda_slots_regras
**Agendador público (2):** agendador_configuracoes, agendador_solicitacoes
**Asaas — cobrança de pacientes (2):** asaas_customers, asaas_payments *(FKs → patients/financial_records confirmam: é billing da clínica→paciente, não SaaS→tenant; ver decisão Q4)*
**Billing clínico (1):** billing_contracts
**Prontuário (3):** clinical_note_templates, clinical_note_versions, clinical_notes
**Compromissos (4):** commitment_services *(sem tenant_id — join, FK confirma)*, commitment_time_logs, determined_commitment_fields, determined_commitments
**Cadastro da clínica (2):** company_profiles, medicos
**Contatos (4):** contact_email_types, contact_emails, contact_phones, contact_types
**Conversas/WhatsApp — conteúdo (13):** 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 *(dados clínicos sensíveis → LGPD favorece isolamento físico)*
**Documentos (6):** document_access_logs, document_generated, document_share_links, document_signatures, document_templates, documents *(⚠️ owned por `supabase_admin` — usar psql direto, gotcha conhecido)*
**E-mail do tenant (2):** email_layout_config, email_templates_tenant *(⚠️ D4)*
**Financeiro (4):** financial_categories *(sem tenant_id, 0 linhas, FK de financial_records — mover junto evita FK cross-schema)*, financial_exceptions, financial_records, feriados
**Convênios (2):** insurance_plan_services *(sem tenant_id — join)*, insurance_plans
**Notificações do tenant (4):** notifications *(SPLIT — ver §5)*, notification_preferences, notification_schedules, notification_templates
**Pacientes (13):** 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
**Precificação/pagamento (3):** payment_settings, professional_pricing, services
**Recorrência (3):** recurrence_exceptions, recurrence_rule_services *(sem tenant_id — join)*, recurrence_rules
**Lembretes de sessão (2):** session_reminder_logs, session_reminder_settings
**Repasse (2):** therapist_payout_records *(sem tenant_id — child, FK confirma)*, therapist_payouts
> 5 tabelas tenant-scoped **não têm** coluna tenant_id (joins/children): commitment_services, insurance_plan_services, recurrence_rule_services, therapist_payout_records, financial_categories. A heurística "tem tenant_id" sozinha teria errado — confirmadas via FK.
### 1.2 Infra de mensageria (5) — ✅ DECIDIDO (Q3): TENANT-SCOPED
notification_channels, notification_queue, notification_logs, twilio_subaccount_usage, whatsapp_connection_incidents
**Decisão do Leonardo (2026-06-12):** isolamento físico máximo — as 5 movem pro schema do tenant (LGPD; conteúdo de mensagem nunca fica em public). Consequências assumidas:
- Os crons `process-notification-queue`, `process-email-queue`, `process-sms-queue`, `process-whatsapp-queue`, `whatsapp-heartbeat-check`, `conversation-sla-check` passam a varrer tenants em loop (`FOR t IN SELECT … FROM tenants` / loop no Deno com `.schema()` por tenant).
- Webhooks inbound (twilio/evolution) resolvem o tenant pelo canal ANTES de gravar → precisa de um índice global de roteamento `public.channel_routing` (channel_external_id → tenant_id) mantido por trigger no schema tenant, já que não dá pra procurar o canal em N schemas.
- `twilio_subaccount_usage.channel_id → notification_channels` fica intra-schema (OK).
- FK global→tenant continua sendo só `whatsapp_credits_transactions.conversation_message_id` (vira coluna solta).
### 1.3 GLOBAIS — ficam em `public` (53)
**SaaS core:** tenants, tenant_members, tenant_invites, tenant_features, tenant_feature_exceptions_log, tenant_modules, profiles, profile_specialties, specialties, owner_users, saas_admins, user_settings
**Planos/assinaturas:** plans, plan_features, plan_prices, plan_public, plan_public_bullets, subscriptions, subscription_events, subscription_intents_legacy, subscription_intents_personal, subscription_intents_tenant, features, modules, module_features, entitlements_invalidation
**Créditos/addons (billing SaaS→tenant):** addon_credits, addon_products, addon_transactions, whatsapp_credit_packages, whatsapp_credit_purchases, whatsapp_credits_balance, whatsapp_credits_transactions
**Plataforma:** _db_migrations, asaas_webhook_events *(staging de webhook — roteia pro tenant na F6)*, audit_logs *(auditoria cross-tenant, padrão `tenant_audit_log` do blueprint)*, email_templates_global, email_layout? não — global_notices, notice_dismissals, login_carousel_slides, math_challenges, public_submission_attempts, submission_rate_limits, support_sessions, saas_doc_votos, saas_docs, saas_faq, saas_faq_itens, saas_security_config, saas_twilio_config
**Dev/tracking (11):** dev_auditoria_items, dev_comparison_competitor_status, dev_comparison_matrix, dev_competitor_features, dev_competitors, dev_generation_log, dev_roadmap_items, dev_roadmap_phases, dev_test_items, dev_user_credentials, dev_verificacoes_items
> Tabelas com tenant_id que **ficam** em public (manter `.eq('tenant_id')` no FE): tenant_features, tenant_feature_exceptions_log, tenant_invites, tenant_members, subscriptions, subscription_intents_*, addon_credits, addon_transactions, whatsapp_credit_*, audit_logs, support_sessions, profiles (+ grupo 1.2 se ficar global).
## 2. Funções — 66 referenciam tabelas-tenant (de 445 em public)
Por categoria (lista completa no fim):
- **Triggers em tabelas tenant (18):** agendador_gerar_slug, auto_create_financial_record_from_session, fanout_inbound_message_to_notifications, fn_agenda_regras_semanais_no_overlap, fn_clinical_note_version, fn_document_signature_timeline, fn_documents_timeline_insert, fn_sla_resolve_on_outbound, fn_whatsapp_low_balance_notify, notify_on_intake, notify_on_scheduling, notify_on_session_status, sync_busy_mirror_agenda_eventos, sync_legacy_email_fields, sync_legacy_phone_fields, trg_fn_patient_risco_timeline, trg_fn_patient_status_history, trg_fn_patient_status_timeline → padrão `TG_TABLE_SCHEMA` + `tenant_id_for_schema()`
- **RPCs chamadas por usuário logado (~30):** cancel_recurrence_from, cancelar_eventos_serie, can_delete_patient, create_financial_record_for_session, create_therapist_payout, delete_commitment_full, delete_determined_commitment, export_patient_data, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, issue_patient_invite, list_financial_records, list_my_signatures, mark_as_paid, mark_payout_as_paid, rotate_patient_invite_token(+v2), safe_delete_patient, search_global, seed_default_patient_groups, seed_determined_commitments, split_recurrence_at, tenant_remove_member… → padrão `p_tenant_id` + `is_tenant_member()` + `set_config('search_path')` (adaptação D2)
- **RPCs globais/cron (~10):** cleanup_notification_queue, convert_abandoned_intake_to_lead, populate_notification_queue, sync_overdue_financial_records, unstick_notification_queue, whatsapp_heartbeat_*, sla_open_breach, sla_mark_notified, first_response_stats, _first_response_runs → padrão loop `FOR t_row IN SELECT … FROM tenants`
- **RPCs públicas/anon por token (~8):** create_patient_intake_request(+v2), get_patient_intake_invite_info, get_signable_document_by_token, sign_document_by_token, sign_document_by_signature_id, validate_share_token, match_patient_by_phone, agendador_dias_disponiveis, agendador_slots_disponiveis → o token/slug identifica o tenant → resolver schema a partir do registro
- **SQL puro (8):** _first_response_runs, can_delete_patient, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, list_financial_records → converter pra plpgsql (limitação 3 do blueprint; exige DROP+CREATE)
## 3. Views, FKs e policies
**6 views referenciam tabelas-tenant** → recriar dentro do schema template: audit_log_unified *(parcial — mistura audit_logs global)*, conversation_threads *(9 usos FE + 1 edge)*, v_cashflow_projection, v_commitment_totals, v_patient_groups_with_counts, v_tag_patient_counts. Demais 23 views só tocam globais — intactas.
**FK global→tenant (a única problemática):** `whatsapp_credits_transactions.conversation_message_id → conversation_messages`. Decisão: converter pra coluna solta (uuid sem constraint) — billing não pode impedir DROP de schema de tenant.
**FKs tenant→public (ok fisicamente, perdem embed PostgREST):** patients→tenant_members (responsible/therapist_member_id), *→auth.users, financial_records→tenants (coluna some), etc. → helper `attachProfiles`/fake-embed no FE (limitação 1 do blueprint).
**Policies:** nenhuma policy de tabela global usa as 66 funções → zero recriação de policy em public. Policies das tabelas movidas morrem com elas; schemas tenant ganham policies novas no clone. Helpers RLS atuais (`is_tenant_member` 65 usos, `is_saas_admin` 177, `tenant_has_feature` 56, `is_clinic_tenant` 56) continuam válidos e são reaproveitados nas policies dos schemas tenant.
## 4. Edge functions — 29 no total, ~25 tocam tabelas-tenant
Mais afetadas (refs a tabelas do grupo tenant): process-notification-queue, process-email-queue, process-sms-queue, process-whatsapp-queue, send-session-reminders(+manual,+status), conversation-sla-check, evolution-whatsapp-inbound, twilio-whatsapp-inbound, send-whatsapp-message, submit-patient-intake, save-intake-progress, get-intake-invite-info, convert-abandoned-intakes, asaas-webhook, asaas-create-payment-record, asaas-cancel-payment, asaas-sync-payment, notification-webhook, sync-email-templates.
Se grupo 1.2 ficar global (Q3), os processadores de fila mantêm a maior parte do código; o refactor concentra-se em: conversation_* (12 refs), session_reminder_logs (7), notifications (2), intake (6), asaas (4), agenda_eventos (3), patients (2).
Padrão: inbound/webhook resolve tenant pelo canal/token → `.schema(tenant_schema_for(tenant_id))`; crons varrem `tenants` em loop.
## 5. Split de `notifications` (172 linhas)
Igual ao blueprint: `tenant_<x>.notifications` (locais) + `public.notifications_sistema` (cross-tenant: avisos SaaS, suporte, system_alert). Funções notify_* ganham 2 variantes; `useNotifications.js` (18 usos FE) mescla 2 fontes com `_origem`; realtime em 2 canais (canal tenant usa `schema: tenant_<x>`). Detalhar tipos cross-tenant vs locais na F6-Lote 1 (query por `type` antes do split).
## 6. Frontend — alvos do refactor (F3)
- `src/lib/supabase/client.js` — cliente único; criar `useTenantDb.js` ao lado.
- ~100 usos `from('agenda_eventos')`, 64 `financial_records`, 45 `patients`… (tabela completa no grep de F0).
- Remover `.eq('tenant_id', …)` APENAS nas tabelas que saem; manter nas globais (tenant_members, tenant_features, subscriptions, etc.).
- `tenantStore` ganha `activeTenantSchema` (computed de slug); repositories trocam `supabase.from``db().from`.
- Realtime: canais de tabelas tenant trocam `schema: 'public'``schema: tenantSchemaName`.
- Embeds `profiles!fkey(...)` em tabelas tenant → `attachProfiles()`.
## 7. Volumetria (migração de dados barata)
Top tenant-scoped: conversation_messages 355, notifications 172, determined_commitment_fields 117, financial_records 54, determined_commitments 47, agenda_eventos 37, patients 35. Todo o resto < 40 linhas. `audit_logs` (608) fica em public. Migração completa roda em segundos; ainda assim com backup por lote (regra do blueprint).
## 8. Decisões — ✅ respondidas pelo Leonardo em 2026-06-12
| # | Decisão | Resposta |
|---|---|---|
| Q1 | Nome do schema | **Criar coluna `tenants.slug`** (unique, imutável, gerado de name) → `tenant_<slug>` |
| Q2 | Quais tenants ganham schema | **Todos** (clínicas e therapists — modelo uniforme) |
| Q3 | Infra de mensageria (5 tabelas) | **Tenant-scoped** (isolamento máximo; ver §1.2 — crons em loop + índice de roteamento de canais) |
| Q4 | asaas_customers/asaas_payments | **Tenant** (webhook roteia via staging global `asaas_webhook_events`) |
## Anexo — 66 funções (nome | kind | linguagem | é trigger)
_first_response_runs|sql · agendador_dias_disponiveis|plpgsql · agendador_gerar_slug|plpgsql|trg · agendador_slots_disponiveis|plpgsql · auto_create_financial_record_from_session|plpgsql|trg · can_delete_patient|sql · cancel_patient_pending_notifications|plpgsql · cancel_recurrence_from|plpgsql · cancelar_eventos_serie|plpgsql · cleanup_notification_queue|plpgsql · convert_abandoned_intake_to_lead|plpgsql · create_financial_record_for_session|plpgsql · create_patient_intake_request|plpgsql · create_patient_intake_request_v2|plpgsql · create_therapist_payout|plpgsql · delete_commitment_full|plpgsql · delete_determined_commitment|plpgsql · export_patient_data|plpgsql · fanout_inbound_message_to_notifications|plpgsql|trg · first_response_stats|plpgsql · fn_agenda_regras_semanais_no_overlap|plpgsql|trg · fn_clinical_note_version|plpgsql|trg · fn_document_signature_timeline|plpgsql|trg · fn_documents_timeline_insert|plpgsql|trg · fn_sla_resolve_on_outbound|plpgsql|trg · fn_whatsapp_low_balance_notify|plpgsql|trg · get_entity_primary_phone|sql · get_financial_report|sql · get_financial_summary|sql · get_patient_intake_invite_info|plpgsql · get_patient_session_counts|sql · get_signable_document_by_token|plpgsql · issue_patient_invite|plpgsql · list_financial_records|sql · list_my_signatures|plpgsql · mark_as_paid|plpgsql · mark_payout_as_paid|plpgsql · match_patient_by_phone|plpgsql · notify_on_intake|plpgsql|trg · notify_on_scheduling|plpgsql|trg · notify_on_session_status|plpgsql|trg · populate_notification_queue|plpgsql · rotate_patient_invite_token|plpgsql · rotate_patient_invite_token_v2|plpgsql · safe_delete_patient|plpgsql · search_global|plpgsql · seed_default_patient_groups|plpgsql · seed_determined_commitments|plpgsql · sign_document_by_signature_id|plpgsql · sign_document_by_token|plpgsql · sla_mark_notified|plpgsql · sla_open_breach|plpgsql · split_recurrence_at|plpgsql · sync_busy_mirror_agenda_eventos|plpgsql|trg · sync_legacy_email_fields|plpgsql|trg · sync_legacy_phone_fields|plpgsql|trg · sync_overdue_financial_records|plpgsql · tenant_remove_member|plpgsql · trg_fn_patient_risco_timeline|plpgsql|trg · trg_fn_patient_status_history|plpgsql|trg · trg_fn_patient_status_timeline|plpgsql|trg · unstick_notification_queue|plpgsql · validate_share_token|plpgsql · whatsapp_heartbeat_mark_notified|plpgsql · whatsapp_heartbeat_open_incident|plpgsql · whatsapp_heartbeat_resolve_open_incidents|plpgsql
+338
View File
@@ -0,0 +1,338 @@
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase
Contexto e objetivo
Estou migrando meu sistema multi-tenant de RLS-only com tenant_id em cada tabela para schema-per-tenant (tenant_<slug>
com clones físicos da estrutura). Quero isolamento físico das tabelas que pertencem a um tenant, mantendo em public
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.).
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui,
considerando as lições que aprendi.
Antes de começar — varredura obrigatória
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo:
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem
coluna tenant_id? É candidata a tenant-scoped. Mas reveja caso a caso — algumas globais (tenant_features,
tenant_audit_log, support_messages) também têm tenant_id como FK e devem ficar em public.
SELECT table_name,
EXISTS(SELECT 1 FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=t.table_name
AND c.column_name='tenant_id') AS has_tenant_id
FROM information_schema.tables t
WHERE table_schema='public' AND table_type='BASE TABLE'
ORDER BY table_name;
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi
"29 funções" e eram na verdade 52. Use:
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab)
SELECT DISTINCT p.proname, p.prokind, l.lanname
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
JOIN pg_language l ON l.oid = p.prolang
CROSS JOIN tenant_tabs t
WHERE n.nspname='public'
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M')
ORDER BY 1;
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje
cuidado especial.
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>').
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas.
Plano de execução em fases
F0 — Categorização (não codar nada ainda)
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir.
F1 — Template + helpers
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints.
- Crie helpers em public:
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado.
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema.
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o
tenant_id (porque a coluna não existe mais nas tabelas tenant).
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
dele.
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo.
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE.
F2 — Provisionamento
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar
tenant novo.
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando
por profiles.tenant_id = '<id-do-tenant>').
F3 — Frontend: composable de acesso tenant
- Crie useTenantDb.js:
export function useTenantDb() {
const { perfil } = useAuth();
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
const isReady = computed(() => Boolean(schemaName.value));
function db() {
if (!schemaName.value) throw new Error('tenant não disponível');
return supabase.schema(schemaName.value);
}
return { db, schemaName, isReady };
}
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as
views/components/composables que tocam tabelas tenant.
F4 — Edge functions
Padrão pra qualquer edge function que precisa acessar tabela tenant:
const userClient = createClient(SUPABASE_URL, ANON_KEY, {
global: { headers: { Authorization: authHeader } }
});
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema');
const tenantDb = userClient.schema(tenantSchema as string);
await tenantDb.from('oficios').update(...).eq(...);
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto.
F5 — Expor schemas no PostgREST
Edite supabase/config.toml:
[api]
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...]
extra_search_path = ["public", "extensions"]
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via
migration que regenera config.toml, ou aceite gerenciamento manual.
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
Divida em lotes pequenos e teste cada um:
Lote 1 — split de notifications
Caso especial crítico. Antes do split, identifique:
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant,
system_alert global).
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos).
Decisão estrutural: notifications precisa virar duas tabelas:
- tenant_<slug>.notifications — locais do tenant.
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev).
Migration faz:
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime).
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types).
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs,
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant).
4. DROP TABLE public.notifications.
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem.
Lote 2-4 — refator das demais funções
Padrão pra TRIGGER em tabela tenant:
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE v_tenant_id uuid;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar
-- ... lógica com tabelas tenant SEM prefixo `public.` ...
END $$;
Padrão pra RPC chamada por user logado em um tenant:
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ...
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE v_schema text := public.current_tenant_schema();
BEGIN
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
-- ... lógica ...
END $$;
Padrão pra RPC global (cron, dev, varre múltiplos tenants):
FOR t_row IN SELECT id, slug FROM public.tenants WHERE ativo = true LOOP
v_schema := public.tenant_schema_name(t_row.slug);
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN CONTINUE; END IF;
EXECUTE format('UPDATE %I.tabela ...', v_schema);
END LOOP;
Padrão pra função que escreve no schema de OUTRO tenant (notify_user com p_tenant_id, etc.):
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema NOT LIKE 'tenant_%' THEN RETURN; END IF;
EXECUTE format('INSERT INTO %I.notifications (...) VALUES ($1, $2, ...)', v_schema)
USING ...;
Lote 4.5 — migração de DADOS (esqueci de avisar primeiro, vai se ferrar)
ESSE É O ERRO MAIS COMUM: o template clona estrutura, mas você esquece dos DADOS. Depois descobre que
tenant_sindspam.os está vazio porque você nunca migrou. Faça uma migration que:
SET session_replication_role = replica; -- desabilita FK checks
DO $$
DECLARE
tenant_id_target uuid := '...';
tenant_schema text := 'tenant_...';
tabs text[] := ARRAY[/* lista */];
t text;
v_cols text;
BEGIN
FOREACH t IN ARRAY tabs LOOP
-- Lista colunas do schema tenant (sem tenant_id já)
SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position)
INTO v_cols
FROM information_schema.columns
WHERE table_schema = tenant_schema AND table_name = t;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name=t AND column_name='tenant_id') THEN
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
tenant_schema, t, v_cols, v_cols, t, tenant_id_target);
ELSE
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ON CONFLICT DO NOTHING',
tenant_schema, t, v_cols, v_cols, t);
END IF;
END LOOP;
END $$;
-- Reset sequences:
FOR r IN SELECT t.table_name, c.column_name FROM information_schema.tables t
JOIN information_schema.columns c ON c.table_schema=t.table_schema AND c.table_name=t.table_name
WHERE t.table_schema=tenant_schema AND c.data_type='bigint' AND c.column_default LIKE 'nextval(%' LOOP
v_seq := pg_get_serial_sequence(format('%I.%I', tenant_schema, r.table_name), r.column_name);
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0))',
v_seq, r.column_name, tenant_schema, r.table_name);
END LOOP;
SET session_replication_role = origin;
Lote 5 — DROP CASCADE das tabelas em public
Só depois de TODAS as funções refatoradas e dados migrados:
SET session_replication_role = replica;
DO $$ BEGIN
FOREACH t IN ARRAY tabs LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=t) THEN
EXECUTE format('DROP TABLE public.%I CASCADE', t);
END IF;
END LOOP;
END $$;
SET session_replication_role = origin;
Limitações conhecidas e workarounds
1. PostgREST não suporta embed FK cross-schema
Você vai pagar esse pato. O PostgREST 14.x não consegue resolver embeds tipo db().from('os').select('*,
profiles!os_solicitante_profile_id_fkey(nome)') quando os está em tenant_X e profiles em public, mesmo com FK física
existindo. Mensagem: PGRST200: Could not find a relationship between 'os' and 'profiles' in the schema cache.
Solução: helper de "fake embed" no frontend. Crie useProfileEmbed.js:
export async function attachProfiles(rows, mappings, columns = 'id, nome, email, role') {
if (!rows?.length) return rows;
const allIds = new Set();
for (const m of mappings) rows.forEach(r => { if (r?.[m.idField]) allIds.add(r[m.idField]); });
const { data } = await supabase.from('profiles').select(columns).in('id', [...allIds]);
const map = new Map((data || []).map(p => [p.id, p]));
return rows.map(r => {
const out = { ...r };
for (const m of mappings) out[m.alias] = r?.[m.idField] ? map.get(r[m.idField]) || null : null;
return out;
});
}
// Variantes: attachProfilesNested(rows, nestedKey, mappings), attachProfilesById(rows, idField, alias)
Faz 2 queries + merge em JS. Toda tela que tinha profiles!fkey(...) precisa virar duas queries + attach.
2. %ROWTYPE de tabelas tenant
Funções que declaravam v_plano public.convenio_planos%ROWTYPE quebram quando a tabela some do public. Troque por
RECORD em todas. Quando precisar retornar tabela (RETURNS os_problemas), troque por RETURNS jsonb e construa via
jsonb_build_object(...).
3. SQL functions com SET search_path TO 'public' declarado
Algumas funções são LANGUAGE sql com declaração estática SET search_path TO 'public'. Não dá pra usar set_config
dinâmico em SQL puro. Converta pra LANGUAGE plpgsql. Atenção: isso exige DROP + CREATE (CREATE OR REPLACE não muda
linguagem) → se tiver policy dependendo da função, drope a policy primeiro.
4. Triggers de notif que filtram cada destinatário
notify_tenant_admins insere em múltiplos owners via SELECT ... FROM profiles WHERE role IN (...). Pra respeitar
preferências individuais, adicione AND public.should_notify(p.id, p_type) no WHERE.
5. Realtime
- A tabela notifications_sistema precisa ser adicionada explicitamente à publication: ALTER PUBLICATION
supabase_realtime ADD TABLE public.notifications_sistema.
- Canais realtime no frontend precisam do schema correto: { event: '*', schema: 'tenant_<slug>', table:
'notifications', filter: 'owner_id=eq.X' } — não mais schema: 'public'.
6. Filtros .eq('tenant_id', X) no frontend
Após o split, qualquer db().from('tabela_tenant').eq('tenant_id', X) quebra com column tenant_id does not exist — a
coluna sumiu. Faça grep e remova esses filtros (o isolamento agora é pelo schema). Mantenha em tabelas que ficam em
public (tenant_features, tenant_audit_log, profiles).
7. session_replication_role na migração de dados
INSERTs em massa com FKs entre tabelas tenant podem falhar por ordem topológica. SET session_replication_role =
replica desabilita checks de FK durante o INSERT. Lembre de voltar pra origin ao final.
8. Reset de sequences
Tabelas tenant com id bigint generated by sequence precisam de setval pós-migração — senão próximo INSERT vai colidir
com PKs existentes.
9. Policies que usam funções refatoradas
unidade_in_current_tenant(uuid) aparecia como USING (...) em policies de public.prestador_unidade_acessos. Antes de
DROP+CREATE da função, dropei as 2 policies. Tabelas que vão sumir não precisam recriar policy. Se a função é usada em
policies de tabelas que ficam, recrie a policy depois.
10. FKs de tabelas que ficam em public apontando pras que saem
Antes de DROP, rode query pra detectar. Se houver, decida: migra a tabela referenciadora pro tenant também, ou
converte FK pra coluna solta sem constraint.
Frontend — refactor sistemático
1. Find/replace em massa: supabase.from('<lista_tabelas_tenant>') → db().from(...). Importe useTenantDb.
2. Caça por .eq('tenant_id': remova nos from('<tenant_table>'), mantenha nos from('<public_table>').
3. Caça por embed profiles!fkey(...) em queries de tabelas tenant: refatore com attachProfiles.
4. Caça por subscribeRealtime com schema: 'public' pra tabelas que viraram tenant — troque pra schema:
tenantSchemaName(slug).
5. Composables/serviços que usam supabase.from(...) em vez de db() direto: idem.
Backups e segurança
Sempre faça backup antes de cada lote:
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl >
backups/pre-loteN/public.sql
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=tenant_<slug> --no-owner --no-acl >
backups/pre-loteN/tenant_<slug>.sql
Pra recarregar cache do PostgREST após mudanças:
docker exec supabase_db_<projeto> psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'"
Se mudou config.toml (schemas expostos), restart obrigatório:
docker restart supabase_rest_<projeto>
Checklist final por lote
Antes de marcar um lote como concluído:
- Migration aplica sem erro (psql -v ON_ERROR_STOP=1)
- Smoke test SQL chamando as funções refatoradas via SET LOCAL request.jwt.claim.sub
- NOTIFY pgrst, 'reload schema' rodado
- Usuário testou as telas do FE que tocam essas funções
- Sem erros novos no console do navegador (network 4xx/5xx, PGRST200, etc.)
Como interagir comigo durante o trabalho
- Antes de codar qualquer fase, mostre o plano resumido e pergunte se prossegue.
- Para decisões estruturais (ex: notifications split, função X retorna jsonb ou record composto, drop CASCADE de
policy órfã), use perguntas múltipla escolha — não decida sozinho.
- Ao terminar um lote, sumarize o que mudou + lista de coisas pra eu testar no FE.
- Não confie em listas pré-feitas (suas ou do usuário). Sempre re-confirme via query no banco.
- Backup antes de cada DROP destrutivo.
- PostgREST cache é teimoso — NOTIFY pgrst resolve tabelas/funções; restart do container pra mudanças de config.toml.