diff --git a/Obsidian/Brain/log.md b/Obsidian/Brain/log.md index 62d2c2c..2173601 100644 --- a/Obsidian/Brain/log.md +++ b/Obsidian/Brain/log.md @@ -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 diff --git a/Obsidian/Brain/wiki/Migracao Schema-per-Tenant.md b/Obsidian/Brain/wiki/Migracao Schema-per-Tenant.md new file mode 100644 index 0000000..9f26fe1 --- /dev/null +++ b/Obsidian/Brain/wiki/Migracao Schema-per-Tenant.md @@ -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 20260612000001–05 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('')` + 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_`), 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_` +- 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]] diff --git a/Obsidian/Brain/wiki/index.md b/Obsidian/Brain/wiki/index.md index 75473ed..606a667 100644 --- a/Obsidian/Brain/wiki/index.md +++ b/Obsidian/Brain/wiki/index.md @@ -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) diff --git a/database-novo/db.cjs b/database-novo/db.cjs index ffcd420..d35c5dd 100644 --- a/database-novo/db.cjs +++ b/database-novo/db.cjs @@ -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', diff --git a/database-novo/migrations/20260612000001_f1_tenants_slug.sql b/database-novo/migrations/20260612000001_f1_tenants_slug.sql new file mode 100644 index 0000000..48e8369 --- /dev/null +++ b/database-novo/migrations/20260612000001_f1_tenants_slug.sql @@ -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_. 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; diff --git a/database-novo/migrations/20260612000002_f1_tenant_schema_helpers.sql b/database-novo/migrations/20260612000002_f1_tenant_schema_helpers.sql new file mode 100644 index 0000000..65846e2 --- /dev/null +++ b/database-novo/migrations/20260612000002_f1_tenant_schema_helpers.sql @@ -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; diff --git a/database-novo/migrations/20260612000003_f1_tenant_template.sql b/database-novo/migrations/20260612000003_f1_tenant_template.sql new file mode 100644 index 0000000..0b34550 --- /dev/null +++ b/database-novo/migrations/20260612000003_f1_tenant_template.sql @@ -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; diff --git a/database-novo/migrations/20260612000004_f1_clone_drop_functions.sql b/database-novo/migrations/20260612000004_f1_clone_drop_functions.sql new file mode 100644 index 0000000..64f755a --- /dev/null +++ b/database-novo/migrations/20260612000004_f1_clone_drop_functions.sql @@ -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_.notification_channels. +-- * clone_tenant_template(tenant_id) — instancia tenant_ a partir do +-- _tenant_template: tabelas + sequences locais + FKs + seeds + views + RLS +-- (policies com tenant_id EMBUTIDO — modelo multi-membership) + realtime + +-- grants + trigger de roteamento. +-- * drop_tenant_schema(tenant_id) — protegido (assert tenant_%). +-- +-- NOTA: clones criados na F1/F2 ainda NÃO têm triggers de negócio (F6) e não +-- estão expostos no PostgREST (F5). _meta.triggers_pending registra isso. +-- ============================================================================= + +BEGIN; + +-- --------------------------------------------------------------------------- +-- Registro de schemas provisionados +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.tenant_schemas ( + tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE, + schema_name text NOT NULL UNIQUE, + template_version int NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.tenant_schemas ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_schemas_select ON public.tenant_schemas; +CREATE POLICY tenant_schemas_select ON public.tenant_schemas + FOR SELECT TO authenticated + USING (public.is_tenant_member(tenant_id) OR public.is_saas_admin()); + +-- --------------------------------------------------------------------------- +-- Índice global de roteamento de canais (webhook inbound -> tenant) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.channel_routing ( + channel_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, + channel text NOT NULL, + provider text, + sender_address text, + twilio_subaccount_sid text, + twilio_phone_number text, + metadata jsonb, + is_active boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS channel_routing_tenant_idx ON public.channel_routing (tenant_id); +CREATE INDEX IF NOT EXISTS channel_routing_sender_idx ON public.channel_routing (sender_address) WHERE sender_address IS NOT NULL; +CREATE INDEX IF NOT EXISTS channel_routing_twilio_phone_idx ON public.channel_routing (twilio_phone_number) WHERE twilio_phone_number IS NOT NULL; +CREATE INDEX IF NOT EXISTS channel_routing_twilio_sid_idx ON public.channel_routing (twilio_subaccount_sid) WHERE twilio_subaccount_sid IS NOT NULL; + +-- Tabela de infra: só service_role (edge functions) e saas admin enxergam +ALTER TABLE public.channel_routing ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS channel_routing_saas_admin ON public.channel_routing; +CREATE POLICY channel_routing_saas_admin ON public.channel_routing + FOR ALL TO authenticated + USING (public.is_saas_admin()) + WITH CHECK (public.is_saas_admin()); + +-- Trigger anexado a cada tenant_.notification_channels pelo clone +CREATE OR REPLACE FUNCTION public.trg_sync_channel_routing() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_tenant_id uuid; +BEGIN + v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); + IF v_tenant_id IS NULL THEN + RAISE WARNING 'trg_sync_channel_routing: schema % sem tenant correspondente', TG_TABLE_SCHEMA; + RETURN COALESCE(NEW, OLD); + END IF; + IF TG_OP = 'DELETE' THEN + DELETE FROM public.channel_routing WHERE channel_id = OLD.id; + RETURN OLD; + END IF; + INSERT INTO public.channel_routing AS cr + (channel_id, tenant_id, channel, provider, sender_address, + twilio_subaccount_sid, twilio_phone_number, metadata, is_active, updated_at) + VALUES + (NEW.id, v_tenant_id, NEW.channel, NEW.provider, NEW.sender_address, + NEW.twilio_subaccount_sid, NEW.twilio_phone_number, NEW.metadata, + COALESCE(NEW.is_active, false) AND NEW.deleted_at IS NULL, now()) + ON CONFLICT (channel_id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + channel = EXCLUDED.channel, + provider = EXCLUDED.provider, + sender_address = EXCLUDED.sender_address, + twilio_subaccount_sid = EXCLUDED.twilio_subaccount_sid, + twilio_phone_number = EXCLUDED.twilio_phone_number, + metadata = EXCLUDED.metadata, + is_active = EXCLUDED.is_active, + updated_at = now(); + RETURN NEW; +END; +$$; + +-- --------------------------------------------------------------------------- +-- clone_tenant_template +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.clone_tenant_template(p_tenant_id uuid) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_slug text; + v_schema text; + v_version int; + t text; + r record; + v_def text; + v_seq text; + v_n int; + v_pending text[]; + v_failed text[]; +BEGIN + SELECT slug INTO v_slug FROM public.tenants WHERE id = p_tenant_id; + IF v_slug IS NULL THEN + RAISE EXCEPTION 'clone_tenant_template: tenant % não existe ou sem slug', p_tenant_id; + END IF; + v_schema := public.tenant_schema_name(v_slug); + IF v_schema IS NULL THEN + RAISE EXCEPTION 'clone_tenant_template: slug % inválido', v_slug; + END IF; + IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN + RAISE EXCEPTION 'clone_tenant_template: schema % já existe', v_schema; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '_tenant_template') THEN + RAISE EXCEPTION 'clone_tenant_template: _tenant_template não existe (rode a F1.3)'; + END IF; + SELECT (value)::int INTO v_version FROM _tenant_template._meta WHERE key = 'template_version'; + + EXECUTE format('CREATE SCHEMA %I', v_schema); + + -- nomes qualificados nas definições geradas pelo catálogo + PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true); + + -- 1. tabelas + FOR r IN + SELECT table_name AS tab FROM information_schema.tables + WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE' + AND table_name NOT LIKE '\_%' + ORDER BY table_name + LOOP + EXECUTE format('CREATE TABLE %I.%I (LIKE _tenant_template.%I INCLUDING ALL)', + v_schema, r.tab, r.tab); + END LOOP; + + -- 2. sequences locais (defaults que apontam pro template) + FOR r IN + SELECT c.relname AS tab, a.attname AS col + FROM pg_attrdef d + JOIN pg_class c ON c.oid = d.adrelid + JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum + WHERE c.relnamespace = v_schema::regnamespace + AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''_tenant_template.%' + LOOP + v_seq := r.tab || '_' || r.col || '_seq'; + EXECUTE format('CREATE SEQUENCE %I.%I', v_schema, v_seq); + EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)', + v_schema, r.tab, r.col, format('%I.%I', v_schema, v_seq)); + EXECUTE format('ALTER SEQUENCE %I.%I OWNED BY %I.%I.%I', + v_schema, v_seq, v_schema, r.tab, r.col); + END LOOP; + + -- 3. seeds (linhas-default do sistema guardadas no template) + -- Sem session_replication_role (postgres não é superuser no Supabase): + -- ordem de FK resolvida por tentativa-e-repetição em rounds. + v_pending := ARRAY[]::text[]; + FOR r IN + SELECT table_name AS tab FROM information_schema.tables + WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE' + AND table_name NOT LIKE '\_%' + ORDER BY table_name + LOOP + EXECUTE format('SELECT count(*) FROM _tenant_template.%I', r.tab) INTO v_n; + IF v_n > 0 THEN v_pending := v_pending || r.tab; END IF; + END LOOP; + + WHILE coalesce(array_length(v_pending, 1), 0) > 0 LOOP + v_failed := ARRAY[]::text[]; + FOR r IN SELECT unnest(v_pending) AS tab LOOP + BEGIN + EXECUTE format('INSERT INTO %I.%I SELECT * FROM _tenant_template.%I', + v_schema, r.tab, r.tab); + EXCEPTION WHEN foreign_key_violation THEN + v_failed := v_failed || r.tab; + END; + END LOOP; + IF array_length(v_failed, 1) = array_length(v_pending, 1) THEN + RAISE EXCEPTION 'clone_tenant_template: dependência circular nos seeds: %', v_failed; + END IF; + v_pending := v_failed; + END LOOP; + + -- 4. FKs (intra-schema e pra public/auth) + FOR r IN + SELECT cl.relname AS tab, con.conname, pg_get_constraintdef(con.oid) AS def + FROM pg_constraint con + JOIN pg_class cl ON cl.oid = con.conrelid + WHERE con.contype = 'f' + AND cl.relnamespace = '_tenant_template'::regnamespace + ORDER BY cl.relname, con.conname + LOOP + v_def := replace(r.def, ' REFERENCES _tenant_template.', format(' REFERENCES %I.', v_schema)); + EXECUTE format('ALTER TABLE %I.%I ADD CONSTRAINT %I %s', v_schema, r.tab, r.conname, v_def); + END LOOP; + + -- 5. views (placeholders __SCHEMA__ / __TENANT_ID__) + PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true); + FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP + EXECUTE replace(replace(r.definition, '__SCHEMA__', quote_ident(v_schema)), + '__TENANT_ID__', p_tenant_id::text); + END LOOP; + + -- 6. RLS: tenant_id embutido (multi-membership: o usuário só enxerga + -- schemas de tenants onde tenant_members o lista como ativo) + FOR r IN + SELECT table_name AS tab FROM information_schema.tables + WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE' + AND table_name NOT LIKE '\_%' + LOOP + EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', v_schema, r.tab); + EXECUTE format( + 'CREATE POLICY tenant_member_full ON %I.%I FOR ALL TO authenticated USING (public.is_tenant_member(%L::uuid)) WITH CHECK (public.is_tenant_member(%L::uuid))', + v_schema, r.tab, p_tenant_id, p_tenant_id); + EXECUTE format( + 'CREATE POLICY saas_admin_full ON %I.%I FOR ALL TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin())', + v_schema, r.tab); + END LOOP; + + -- 7. trigger de roteamento de canais + EXECUTE format( + 'CREATE TRIGGER trg_channel_routing AFTER INSERT OR UPDATE OR DELETE ON %I.notification_channels FOR EACH ROW EXECUTE FUNCTION public.trg_sync_channel_routing()', + v_schema); + + -- 8. realtime + FOR r IN SELECT table_name FROM _tenant_template._realtime_tables LOOP + EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE %I.%I', v_schema, r.table_name); + END LOOP; + + -- 9. grants (espelha o padrão do Supabase pra schemas expostos) + EXECUTE format('GRANT USAGE ON SCHEMA %I TO anon, authenticated, service_role', v_schema); + EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO anon, authenticated, service_role', v_schema); + EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO anon, authenticated, service_role', v_schema); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO anon, authenticated, service_role', v_schema); + EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO anon, authenticated, service_role', v_schema); + + INSERT INTO public.tenant_schemas (tenant_id, schema_name, template_version) + VALUES (p_tenant_id, v_schema, COALESCE(v_version, 1)); + + RETURN v_schema; +END; +$$; + +-- --------------------------------------------------------------------------- +-- drop_tenant_schema +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.drop_tenant_schema(p_tenant_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'pg_temp' +AS $$ +DECLARE + v_schema text; +BEGIN + SELECT schema_name INTO v_schema FROM public.tenant_schemas WHERE tenant_id = p_tenant_id; + IF v_schema IS NULL THEN + v_schema := public.tenant_schema_for(p_tenant_id); + END IF; + IF v_schema IS NULL OR v_schema NOT LIKE 'tenant\_%' THEN + RAISE EXCEPTION 'drop_tenant_schema: schema inválido pra tenant % (%)', p_tenant_id, v_schema; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN + RAISE EXCEPTION 'drop_tenant_schema: schema % não existe', v_schema; + END IF; + DELETE FROM public.channel_routing WHERE tenant_id = p_tenant_id; + DELETE FROM public.tenant_schemas WHERE tenant_id = p_tenant_id; + EXECUTE format('DROP SCHEMA %I CASCADE', v_schema); +END; +$$; + +-- Clone/drop são operações de provisionamento: só service_role (edge) e postgres +REVOKE ALL ON FUNCTION public.clone_tenant_template(uuid) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.drop_tenant_schema(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.clone_tenant_template(uuid) TO service_role; +GRANT EXECUTE ON FUNCTION public.drop_tenant_schema(uuid) TO service_role; + +COMMIT; diff --git a/database-novo/migrations/20260612000005_f1_template_seed_whitelist.sql b/database-novo/migrations/20260612000005_f1_template_seed_whitelist.sql new file mode 100644 index 0000000..1f66761 --- /dev/null +++ b/database-novo/migrations/20260612000005_f1_template_seed_whitelist.sql @@ -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; diff --git a/database-novo/migrations/20260612000006_f2_provision_clone.sql b/database-novo/migrations/20260612000006_f2_provision_clone.sql new file mode 100644 index 0000000..cb65624 --- /dev/null +++ b/database-novo/migrations/20260612000006_f2_provision_clone.sql @@ -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; diff --git a/docs/F0_categorizacao.md b/docs/F0_categorizacao.md new file mode 100644 index 0000000..c82eb5a --- /dev/null +++ b/docs/F0_categorizacao.md @@ -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_`) — 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_`). +**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(''))`. 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_` (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_.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_`). 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_` | +| 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 diff --git a/novo-rumo.txt b/novo-rumo.txt new file mode 100644 index 0000000..bcf7242 --- /dev/null +++ b/novo-rumo.txt @@ -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_ + 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(''). + 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 = ''). + + 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('') → db().from('') 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_", "tenant_", ...] + 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_.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_', 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('') → db().from(...). Importe useTenantDb. + 2. Caça por .eq('tenant_id': remova nos from(''), mantenha nos from(''). + 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_ pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl > + backups/pre-loteN/public.sql + docker exec supabase_db_ pg_dump -U postgres -d postgres --schema=tenant_ --no-owner --no-acl > + backups/pre-loteN/tenant_.sql + + Pra recarregar cache do PostgREST após mudanças: + docker exec supabase_db_ psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'" + + Se mudou config.toml (schemas expostos), restart obrigatório: + docker restart supabase_rest_ + + 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.