Files
agenciapsilmno/Obsidian/Brain/wiki/Migracao Schema-per-Tenant.md
T
Leonardo c3220f159c wiki: F6.0+F6.1 done, plano F6.2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:53:22 -03:00

15 KiB
Raw Blame History

Migração Schema-per-Tenant

Status: F6.0 + F6.1 concluídas e verificadas (2026-06-13). Dados dos 9 tenants migrados pros schemas, AINDA espelhados em public (nada dropado), backup em database-novo/backups/pre-F6/. Próximo: F6.2 (rewrite das funções — push dedicado), depois checkpoint de teste do app, depois F6.3 (DROP, só com OK do Leonardo). F0-F2 em main; F3+/F1b/F5/F6.0-1 no branch.

F6.0 + F6.1 — entregue (commit 003f2eb)

  • F6.0 (migration 20260613000003): clone dos 9 tenants reais (schemas vazios, expostos no PostgREST via trigger F5).
  • F6.1 (manual database-novo/manual/f6_1_migrate_data.supabase_admin.sql, rodar como supabase_admin): copia dados public→schemas com session_replication_role=replica. Tabelas com tenant_id por filtro; 3 filhas sem tenant_id (commitment_services, insurance_plan_services, recurrence_rule_services) por JOIN no pai; exclui colunas GENERATED (net_amount, margin_brl); reset de 4 sequences; ON CONFLICT DO NOTHING.
  • Verificado: contagens public vs schemas batem (35 patients, 37 eventos, 355 mensagens, 54 financeiro, 13 commitment_services…). notification_templates "146" = 144 seeds (16×9) + 2 tenant — esperado.
  • Gotcha: colunas GENERATED não aceitam INSERT → excluir via is_generated='NEVER'. DO block é atômico → erro no meio dá rollback total (re-rodar é seguro com ON CONFLICT).

F6.2 — PLANO (rewrite de 66 funções + split notifications) — próximo push

Decisões já tomadas: parar antes do DROP pra testar app; split notifications JUNTO na F6.

Inventário de triggers (81 attachments nas tabelas tenant em public):

  • Schema-agnósticos (só NEW/OLD, sem refs a tabela): família set_updated_at/set_*_updated_at, fn_clinical_notes_updated_at, prevent_promoting_to_system, prevent_system_group_changes, patients_validate_member_consistency, fn_agenda_regras_semanais_no_overlap → anexar nos schemas como estão.
  • Schema-aware (~10, escrevem em OUTRA tabela tenant / audit / notifications) → reescrever com set_config('search_path', TG_TABLE_SCHEMA||',public,pg_temp', true) e tenant_id_for_schema(TG_TABLE_SCHEMA) onde precisam do tenant_id (audit_logs/notifications_sistema são globais): auto_create_financial_record_from_session, sync_busy_mirror_agenda_eventos, notify_on_session_status, fanout_inbound_message_to_notifications, log_audit_change, fn_sla_resolve_on_outbound, fn_clinical_note_version, fn_document_signature_timeline, fn_documents_timeline_insert, trg_fn_patient_status_history/timeline/risco, sync_legacy_email/phone_fields, agenda_cfg_sync, cancel_notifications_*, fn_notify_agenda_status_change, trg_fn_financial_records_auto_overdue.
  • financial_records_inject_tenant → OBSOLETO no schema (não há coluna tenant_id) — NÃO anexar.

Sub-lotes propostos (cada um com smoke test + commit):

  • A triggers schema-agnósticos: anexar aos 9 schemas + embutir no clone_tenant_template (backfill + futuro).
  • B triggers schema-aware: reescrever (search_path dinâmico) + anexar.
  • C split notifications: public.notifications_sistema (tipos cross-tenant: avisos SaaS, suporte, system_alert) + tenant_<x>.notifications (locais); refatorar notify_user/notify_tenant_admins/notify_all_devs/mark/archive (2 variantes); migrar dados por type; useNotifications.js lê 2 fontes (campo _origem), realtime 2 canais; ALTER PUBLICATION add notifications_sistema.
  • D RPCs de usuário (~30: mark_as_paid, create_financial_record_for_session, safe_delete_patient, search_global, cancel_recurrence_from, export_patient_data…) → p_tenant_id validado com is_tenant_member + set_config(search_path).
  • E RPCs global/cron (~10: populate/cleanup/unstick_notification_queue, sync_overdue_financial_records, whatsapp_heartbeat_, sla_) → loop FROM tenants.
  • F RPCs anon/token (~8: get_patient_intake_invite_info, sign_document_by_token, validate_share_token, agendador_*) → leem tabelas anon (public, F1b) e roteiam writes de tabela tenant (ex. document_signatures) pro schema via tenant resolvido do token.
  • G SQL→plpgsql (8: can_delete_patient, get_financial_report/summary, list_financial_records, get_patient_session_counts, get_entity_primary_phone, _first_response_runs) — DROP+CREATE (muda linguagem).
  • Depois: re-anexar todos triggers reescritos nos 9 + clone. Então F6.3 DROP (com OK).

F5 — entregue (commit 6b542cd) — PRIMEIRO teste real via HTTP do PostgREST

  • postgres NÃO é superuser neste stack → não consegue ALTER ROLE authenticator. Quem consegue: supabase_admin (superuser, conecta com senha postgres via psql -U supabase_admin -h 127.0.0.1).
  • database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql (aplicar como supabase_admin, fora do db.cjs): public.refresh_pgrst_schemas() (SECDEF owned supabase_admin) deriva a lista de tenant_schemas, seta pgrst.db_schemas in-database na role authenticator, NOTIFY pgrst reload config/schema. Expõe/retira schema SEM restart; a GUC persiste em pg_db_role_setting (sobrevive a stop/start) e SUPERSEDE o config.toml em runtime.
  • migration 20260613000002: trigger em tenant_schemas (AFTER INSERT/DELETE/UPDATE, statement-level) dispara o refresh → clone_tenant_template e drop_tenant_schema NÃO precisaram ser tocados.
  • config.toml (gitignored): baseline public, graphql_public + comentário; in-db config supersede.
  • E2E via curl: clone → pgrst.db_schemas inclui tenant_x → GET /rest/v1/patients com Accept-Profile: tenant_x retorna 200 (vs 406 pra schema inexistente); drop → volta 406. Tudo sem restart de container. Primeira validação real do stack F1-F5 pelo caminho HTTP do PostgREST.

Gotcha F5

  • PostgREST in-database config (db-config ligada por padrão, sem PGRST_DB_CONFIG=false): ALTER ROLE authenticator SET pgrst.db_schemas + NOTIFY pgrst, 'reload config' é a via pra schemas dinâmicos sem restart. reload schema sozinho NÃO adiciona schema novo à lista exposta — só recarrega o cache dos já expostos.

F4 — entregue (branch, commit 9b21642)

  • _shared/tenant.ts: helper das edge functions — adminClient() (service_role/public), tenantDbForId(admin, tenantId), schemaForTenant, listTenantSchemas (crons varrem todos), resolveTenantByChannel (webhook→tenant via channel_routing), tenantSchemaName
  • _shared/whatsapp-hooks.ts refatorado: hooks de tabela tenant recebem tdb; RPCs de crédito (deduct/add_whatsapp_credits) + tenant_members continuam em supa+p_tenant_id
  • 23 edge functions migradas. Categorias:
    • inbound (twilio/evolution): tenant_id da URL → tdb
    • crons de fila (process-notification/email/sms/whatsapp-queue): varrem listTenantSchemas e drenam a fila de CADA schema — consequência direta da Q3 (filas viraram per-tenant). Modo single-tenant se body.tenant_id vier.
    • crons reminders/checks (send-session-reminders, conversation-sla-check, whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates): loop por tenant
    • routing por tenant_id (send-whatsapp-message, send-session-reminder-manual, twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId; channel-actions sem tenant_id varrem schemas por channel_id (O(n) tenants)
    • asaas-*: tenant_id do body → tdb; asaas-webhook fica global
    • notification-webhook (Meta Cloud API): resolve via channel_routing por phone_number_id, fan-out por message_id quando não casa
  • caller useAgendaEventLifecycle.js passa tenant_id pro send-session-reminder-manual (evento vive no schema)
  • Sem deno local → validado por grep (zero tenant_id em cadeias tdb, clients todos declarados, imports batem). Type-check real só no deploy.

⚠️ DECISÃO PENDENTE — roteamento anon-por-token (bloqueia F5/F6)

Fluxos anônimos identificam o tenant por TOKEN/SLUG, não por login, então não sabem o schema: save-intake-progress (lê patient_intake_requests por token), intake RPCs (get-intake-invite-info, submit-patient-intake), AgendadorPublicoPage+RPCs do agendador (link_slug), document share links (validate_share_token, sign_document_by_token). Opções:

  • A Índice global public_access_tokens(token_hash→tenant_id) + triggers de sync (O(1), +1 tabela global + triggers)
  • B RPCs SECURITY DEFINER que varrem schemas pelo token (sem tabela nova, O(n) por request)
  • C Manter as tabelas anon-facing (patient_intake_requests, patient_invites, document_share_links, agendador_configuracoes/solicitacoes) em PUBLIC com RLS por token — sidesteppa o problema; custo: essas não ganham isolamento físico (mas são as menos sensíveis, feitas pra acesso anon)

F3 — entregue (branch feat/schema-per-tenant, migration 07)

  • src/lib/supabase/tenantClient.js (tenantDb(), tenantSchemaName()) + src/composables/useTenantDb.js
  • tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC agora devolve slug+name (migration 20260612000007)
  • codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views tenant>')tenantDb().from(...) em 139 arquivos (777 chamadas), removeu 173 .eq('tenant_id') de cadeias tenant
  • 4 agentes (2 ondas) fizeram a passada manual: tenant_id fora de payloads/selects/.or/.is; onConflict ajustado (singletons → 'singleton'); realtime de tabelas tenant aponta pro activeTenantSchema; repos dropam tenant_id defensivamente de payloads de callers externos
  • descoberta importante: ZERO embeds cross-schema — todos os FK embeds são tenant→tenant (mesmo schema, ex. agenda_eventospatients,insurance_plans) ou global→global (profile_specialtiesprofiles). O attachProfiles/fake-embed do blueprint NÃO é necessário aqui.
  • gotcha: AGENDA_EVENT_SELECT (constante de select) tinha tenant_id — selecionar coluna inexistente quebra PostgREST; varrer constantes *_SELECT, não só .from()

Pendências F3 (fora do escopo, cross-tenant/anon → tratar em F4/F6)

  • AgendadorPublicoPage.vue — scheduler público anon, resolve tenant por link_slug (precisa RPC/edge de resolução slug→schema, igual channel_routing)
  • Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page.vue — gerenciam defaults do sistema (tenant_id NULL) ou views cross-tenant; após F6 devem mirar _tenant_template ou channel_routing. Continuam apontando pra public (funcional até o drop da F6).

F2 — entregue (migration 20260612000006)

Os 3 únicos pontos de criação de tenant (provision_account_tenant, create_clinic_tenant, ensure_personal_tenant_for_user — este último também acionado pelo trigger de signup handle_new_user_create_personal_tenant) agora chamam clone_tenant_template() na mesma transação: clone falhou → tenant não nasce. Smoke: ensure_personal criou tenant pessoal tenant_terapeuta_pessoal com 84 tabelas + registro, 2ª chamada idempotente, drop limpou tudo. Não há fluxo de exclusão de tenant no sistema (drop_tenant_schema fica pra uso admin/manual).

F1 — entregue (migrations 2026061200000105 em database-novo/migrations/)

  • tenants.slug criado + backfill dos 9 + trigger auto-gera/imutável
  • Helpers: tenant_schema_name/for, tenant_id_for_schema, tenant_schema_checked(p_tenant_id) (valida is_tenant_member — substitui current_tenant_schema do blueprint)
  • _tenant_template: 84 tabelas sem tenant_id, 6 singletons (singleton boolean PK/UQ nas configs 1-linha: company_profiles, email_layout_config, conversation_autoreply_settings/bots/sla_rules, session_reminder_settings), 4 sequences locais, 94 FKs (62 intra + 32 pra public/auth), 6 views com placeholders __SCHEMA__/__TENANT_ID__ em _views, seeds de sistema (whitelist 8 lookups)
  • clone_tenant_template(uuid) → tabelas+seqs+seeds+FKs+views+RLS (policies com tenant_id EMBUTIDO: is_tenant_member('<uuid>') + saas_admin_full)+realtime+grants+trigger routing+registro em tenant_schemas
  • drop_tenant_schema(uuid) protegido; public.channel_routing (webhook inbound acha tenant do canal) sincronizada por trigger
  • Smoke: clone tenant_smoke_f1 → 84 tabelas/168 policies/roundtrip/routing sync/singleton rejeitando 2ª linha → drop limpo

Gotchas aprendidos na F1

  • postgres não é superuser no Supabasesession_replication_role proibido; seeds usam retry-loop de FK (rounds). Vale pro F6 (migração de dados): rodar como supabase_admin ou retry-loop.
  • db.cjs aplicava migration sem ON_ERROR_STOP → rollback silencioso reportado como sucesso. Corrigido (psqlFile agora usa -v ON_ERROR_STOP=1).
  • Linhas operacionais órfãs com tenant_id NULL (intakes/convites/notifs) NÃO são seeds — whitelist explícita.
  • Clones F1/F2 ainda SEM triggers de negócio (F6) e fora do PostgREST (F5) — _meta.triggers_pending=true.

Migração de multi-tenant RLS-only (tenant_id em cada tabela) para schema físico por tenant (tenant_<slug>), seguindo blueprint do projeto irmão (novo-rumo.txt na raiz), adaptado.

Artefatos

  • docs/F0_categorizacao.md — varredura completa: classificação das 137 tabelas, 66 funções, 6 views, FKs, edge functions, divergências.
  • novo-rumo.txt (raiz) — blueprint original com lições do projeto irmão.

Números-chave

  • 137 tabelas public → 79 tenant-scoped + 5 em decisão (infra mensageria) + 53 globais
  • 66 funções afetadas (blueprint avisava: listas pré-feitas subestimam — era "29" lá, 66 aqui)
  • 1 única FK global→tenant problemática: whatsapp_credits_transactions.conversation_message_id
  • 0 policies de tabelas globais usando funções a refatorar
  • 9 tenants (3 clínicas + 6 therapists), volumetria minúscula (<400 linhas/tabela)

Divergências vs blueprint (decisivas)

  1. Sem tenants.slug — precisa criar coluna ou usar uuid no nome do schema.
  2. Multi-membership: profiles.tenant_id 100% NULL; verdade vive em tenant_members (4 users multi-tenant). current_tenant_schema() do blueprint não funciona → frontend escolhe schema (tenantStore já tem activeTenantId), segurança via policy com tenant_id embutido por schema + RPCs recebem p_tenant_id validado com is_tenant_member().
  3. 6/9 tenants são terapeutas individuais — schema por signup; custo operacional do config.toml do PostgREST cresce com tenants.
  4. email_layout_config.tenant_id e email_templates_tenant.tenant_id apontam pra auth.users (legado) — mapear na migração de dados.
  5. View current_tenant_id é código morto (claim JWT nunca populado).

Decisões (2026-06-12)

  • Q1: criar tenants.slug → schemas tenant_<slug>
  • Q2: todo tenant ganha schema (clínicas e therapists)
  • Q3: mensageria tenant-scoped (isolamento máximo, contra rec. global) → crons varrem tenants em loop; webhooks inbound precisam de índice global channel_routing (channel_external_id → tenant_id) pra rotear antes de gravar
  • Q4: asaas tenant (staging asaas_webhook_events global roteia)

Total final: 84 tabelas tenant-scoped, 53 globais.

Fases (tasks #1#7 na sessão)

F0 categorização · F1 template+helpers · F2 provisionamento · F3 frontend useTenantDb · F4 edge functions · F5 PostgREST config · F6 rewrite funções + migração dados + drops (lotes, backup antes de cada um)

Relacionados: Decisões de Billing da Agenda, Supabase Local, index