# 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** ✅ DONE (commit d58b939, migration 20260613000004): `attach_agnostic_triggers(schema)` recria os triggers agnósticos (8 setters updated_at + 2 prevent_*) nos 9 schemas (54 triggers/schema). Smoke: set_updated_at dispara. Wiring no clone fica pro fim da F6.2. - **B** ✅ DONE (commit 5741e10, manual/f6_2b_schema_aware_triggers.supabase_admin.sql — roda como supabase_admin pois trigger fns são owned por supabase_admin). 14 funcs reescritas (set_config search_path dinâmico + tenant_id_for_schema p/ audit_logs global); sync_busy_mirror cross-tenant via tenant_schema_for+EXECUTE format; financial_records_inject_tenant obsoleto (não anexado). Detach dos 14 de public + attach 22 triggers/schema (defs reais, tenant_id removido de WHEN/UPDATE OF). Smoke: sessão→realizado cria financial_record no schema + audit roteia tenant_id certo + timeline OK. - **Gotchas Lote B**: (1) trigger functions owned por supabase_admin → CREATE OR REPLACE só como supabase_admin (vira manual, não db.cjs). (2) Triggers reais tinham `WHEN (new.tenant_id=new.owner_id)` e `UPDATE OF tenant_id,...` → quebram no schema; remover tenant_id dos WHEN/colunas ao re-anexar. (3) Estratégia hybrid: detach de public pra função reescrita não rodar errada lá. - **C** ✅ DONE (commit bedbb9b, manual/f6_2c_notifications_split.supabase_admin.sql). DESCOBERTA: neste projeto TODAS as notifs atuais (inbound_message, session_status, system_alert, new_patient) são tenant-LOCAIS — avisos cross-tenant do SaaS vivem em `global_notices`, não em notifications. Então: notifications fica tenant-local (já nos schemas); `public.notifications_sistema` criado como canal SaaS→tenant FUTURO (vazio hoje) + RLS + realtime + notify_user_sistema(). 4 notif-triggers tenant reescritos schema-aware + detach public + attach (5/schema); notify_on_intake/scheduling disparam em tabelas PUBLIC (F1b) → roteiam pro schema via tenant_schema_for+EXECUTE format; cancel_patient_pending herda search_path do chamador. Smoke: msg inbound → notif no schema, destinatário certo. Frontend notificationStore.js: load 2 fontes + merge por created_at + `_origem`; realtime 2 canais; markRead/archive roteiam por _origem. conversation_messages.id é bigint (gotcha no teste). - **D** ⏳ SCOPED (corpos puxados, ripple medido). 15 RPCs user-facing. Padrão: validar `is_tenant_member(p_tenant_id)` + `set_config('search_path', tenant_schema_for(p_tenant_id)||',public,pg_temp', true)` + remover `WHERE tenant_id=` e `tenant_id` de inserts + unqualify. Categorias: - **CREATE OR REPLACE, já têm p_tenant_id, RETURNS jsonb/void** (sem ripple FE): `delete_commitment_full`, `delete_determined_commitment`, `seed_default_patient_groups`, `seed_determined_commitments` (⚠️ provision_account_tenant chama seed ANTES de clone — inverter ordem no wiring do clone, senão seed escreve em public). 0 chamadas FE. - **DROP+CREATE (novo p_tenant_id 1º param) + FE passa p_tenant_id, RETURNS scalar/jsonb**: `cancel_recurrence_from`(void,1 FE), `cancelar_eventos_serie`(int,0), `split_recurrence_at`(uuid,1), `safe_delete_patient`(jsonb,1), `export_patient_data`(jsonb,1 — toca ~10 tabelas tenant), `search_global`(jsonb STABLE,2), `list_my_signatures`(jsonb,1). - **RETURNS ``/%ROWTYPE → jsonb (ripple FE: consumidores esperam row)**: `mark_as_paid`(SETOF financial_records,3 FE), `create_financial_record_for_session`(SETOF financial_records,6 FE — já tem p_tenant_id), `mark_payout_as_paid`(therapist_payouts,0), `create_therapist_payout`(therapist_payouts,0 — agregação financeira, testar com cuidado). - Owned mix postgres/supabase_admin → rodar migration como supabase_admin. - **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_eventos`→`patients`,`insurance_plans`) ou global→global (`profile_specialties`→`profiles`). 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 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]]