Files
agenciapsilmno/docs/F0_categorizacao.md
T
Leonardo 05c6746e33 schema-per-tenant: F0 categorizacao + F1 template/helpers + F2 provisionamento
- docs/F0_categorizacao.md: varredura completa (137 tabelas -> 84 tenant + 53
  global, 66 funcoes, FKs, policies, edge functions) + decisoes Q1-Q4
- F1 (migrations 01-05): tenants.slug, helpers de schema, _tenant_template
  (84 tabelas sem tenant_id, singletons, views __SCHEMA__/__TENANT_ID__),
  clone_tenant_template/drop_tenant_schema, channel_routing, tenant_schemas
- F2 (migration 06): provision_account_tenant/create_clinic_tenant/
  ensure_personal_tenant_for_user clonam schema na mesma transacao
- db.cjs: psqlFile agora usa ON_ERROR_STOP=1 (falha de migration nao passa
  mais como sucesso silencioso)
- blueprint original em novo-rumo.txt; wiki Obsidian atualizada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:58:46 -03:00

155 lines
19 KiB
Markdown

# F0 — Categorização para migração Schema-per-Tenant
> Gerado em 2026-06-12 a partir de varredura direta no banco local (`supabase_db_agenciapsi-primesakai`),
> grep nas edge functions (`supabase/functions/`) e no frontend (`src/`).
> Fonte do plano: `novo-rumo.txt` (blueprint do projeto irmão), adaptado às divergências deste projeto.
## Sumário executivo
| Item | Quantidade |
|---|---|
| Tabelas em `public` (BASE TABLE) | 137 |
| **Tenant-scoped** (vão pra `tenant_<x>`) — decidido Q3 | **84** |
| **Globais** (ficam em `public`) | **53** |
| Funções que referenciam tabelas-tenant | **66** (não 29 — o aviso do blueprint se confirmou) |
| Views que referenciam tabelas-tenant | 6 |
| FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) |
| Policies de tabelas globais usando funções a refatorar | **0** (risco de policy órfã é baixo) |
| Edge functions que tocam tabelas-tenant | ~25 de 29 |
| Tenants existentes | 9 (3 clínicas + 6 terapeutas individuais) |
| Volumetria | Baixa (maior tabela tenant: `conversation_messages` 355 linhas) — migração de dados é barata |
## ⚠️ Divergências críticas vs blueprint (novo-rumo.txt)
Estas diferenças exigem adaptação do plano — o blueprint NÃO se aplica literalmente:
### D1 — Não existe `tenants.slug`
Colunas de `tenants`: `id, name, created_at, kind, papel_timbrado, cpf_cnpj`.
O blueprint assume `slug` para nomear schemas (`tenant_<slug>`).
**Opções:** (a) adicionar coluna `slug` (unique, imutável, sanitizada a partir de `name`); (b) usar `tenant_` + uuid sem hífens (feio, mas sem coluna nova).
### D2 — Membership multi-tenant via `tenant_members` (profiles.tenant_id está 100% NULL)
- `profiles.tenant_id` existe mas tem **0 linhas preenchidas**.
- Membership real: `tenant_members` (15 linhas), com **4 usuários membros de mais de um tenant**.
- Tenant ativo é resolvido no **frontend**: RPC `my_tenants()` → Pinia `tenantStore.activeTenantId` → localStorage. Sem claim no JWT. Router troca tenant por `meta.tenantScope` (clinic/personal/supervisor).
**Consequência:** o helper `current_tenant_schema()` do blueprint (baseado em `profiles.tenant_id`) **não funciona aqui**. Adaptação proposta:
- Frontend escolhe o schema diretamente: `db()` = `supabase.schema(tenantSchemaName(activeTenant))` — já sabe o tenant ativo.
- Segurança não depende da escolha do cliente: cada schema clonado ganha policies com o tenant_id **embutido**: `USING (public.is_tenant_member('<uuid-do-tenant>'))`. Usuário só lê schema de tenant onde é membro, mesmo apontando o client pra outro schema.
- RPCs que precisam de "tenant atual" passam a receber `p_tenant_id` explícito, validado com `is_tenant_member()` antes do `set_config('search_path', ...)`. Substitui `current_tenant_schema()` por `tenant_schema_checked(p_tenant_id)`.
- Edge functions: client envia o tenant ativo (header `X-Tenant-Id` ou body); a function valida membership via `tenant_members` antes de usar `.schema()`.
### D3 — 6 dos 9 tenants são terapeutas individuais (`kind='therapist'`)
Schema-per-tenant aqui significa **um schema por terapeuta** que se cadastrar. Com 9 tenants é trivial; em escala self-serve (centenas/milhares), o array `schemas` do PostgREST e o catálogo do Postgres crescem linearmente. Funciona, mas é um custo operacional permanente (config.toml + restart a cada signup, a menos que automatize).
**Recomendação:** modelo uniforme (todo tenant ganha schema, qualquer `kind`) — modelo misto (clínica com schema, terapeuta em public) dobraria a complexidade de todas as funções e do frontend.
### D4 — `tenant_id` que aponta pra `auth.users` (legado)
`email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` têm FK pra **auth.users**, não pra `tenants`. Tratar na migração de dados (mapear user→tenant via `tenant_members` ou `owner_id`).
### D5 — View `current_tenant_id` é código morto
`SELECT current_setting('request.jwt.claim.tenant_id', true)` — claim nunca populado. Remover na F6.
---
## 1. Classificação das 137 tabelas
### 1.1 TENANT-SCOPED — movem pra `tenant_<x>` (79)
**Agenda (7):** agenda_bloqueios, agenda_configuracoes, agenda_eventos, agenda_online_slots, agenda_regras_semanais, agenda_slots_bloqueados_semanais, agenda_slots_regras
**Agendador público (2):** agendador_configuracoes, agendador_solicitacoes
**Asaas — cobrança de pacientes (2):** asaas_customers, asaas_payments *(FKs → patients/financial_records confirmam: é billing da clínica→paciente, não SaaS→tenant; ver decisão Q4)*
**Billing clínico (1):** billing_contracts
**Prontuário (3):** clinical_note_templates, clinical_note_versions, clinical_notes
**Compromissos (4):** commitment_services *(sem tenant_id — join, FK confirma)*, commitment_time_logs, determined_commitment_fields, determined_commitments
**Cadastro da clínica (2):** company_profiles, medicos
**Contatos (4):** contact_email_types, contact_emails, contact_phones, contact_types
**Conversas/WhatsApp — conteúdo (13):** conversation_assignments, conversation_autoreply_log, conversation_autoreply_settings, conversation_bot_sessions, conversation_bots, conversation_messages, conversation_notes, conversation_optout_keywords, conversation_optouts, conversation_sla_breaches, conversation_sla_rules, conversation_tags, conversation_thread_tags *(dados clínicos sensíveis → LGPD favorece isolamento físico)*
**Documentos (6):** document_access_logs, document_generated, document_share_links, document_signatures, document_templates, documents *(⚠️ owned por `supabase_admin` — usar psql direto, gotcha conhecido)*
**E-mail do tenant (2):** email_layout_config, email_templates_tenant *(⚠️ D4)*
**Financeiro (4):** financial_categories *(sem tenant_id, 0 linhas, FK de financial_records — mover junto evita FK cross-schema)*, financial_exceptions, financial_records, feriados
**Convênios (2):** insurance_plan_services *(sem tenant_id — join)*, insurance_plans
**Notificações do tenant (4):** notifications *(SPLIT — ver §5)*, notification_preferences, notification_schedules, notification_templates
**Pacientes (13):** patient_contacts, patient_discounts, patient_group_patient, patient_groups, patient_intake_requests, patient_invite_attempts, patient_invites, patient_patient_tag, patient_status_history, patient_support_contacts, patient_tags, patient_timeline, patients
**Precificação/pagamento (3):** payment_settings, professional_pricing, services
**Recorrência (3):** recurrence_exceptions, recurrence_rule_services *(sem tenant_id — join)*, recurrence_rules
**Lembretes de sessão (2):** session_reminder_logs, session_reminder_settings
**Repasse (2):** therapist_payout_records *(sem tenant_id — child, FK confirma)*, therapist_payouts
> 5 tabelas tenant-scoped **não têm** coluna tenant_id (joins/children): commitment_services, insurance_plan_services, recurrence_rule_services, therapist_payout_records, financial_categories. A heurística "tem tenant_id" sozinha teria errado — confirmadas via FK.
### 1.2 Infra de mensageria (5) — ✅ DECIDIDO (Q3): TENANT-SCOPED
notification_channels, notification_queue, notification_logs, twilio_subaccount_usage, whatsapp_connection_incidents
**Decisão do Leonardo (2026-06-12):** isolamento físico máximo — as 5 movem pro schema do tenant (LGPD; conteúdo de mensagem nunca fica em public). Consequências assumidas:
- Os crons `process-notification-queue`, `process-email-queue`, `process-sms-queue`, `process-whatsapp-queue`, `whatsapp-heartbeat-check`, `conversation-sla-check` passam a varrer tenants em loop (`FOR t IN SELECT … FROM tenants` / loop no Deno com `.schema()` por tenant).
- Webhooks inbound (twilio/evolution) resolvem o tenant pelo canal ANTES de gravar → precisa de um índice global de roteamento `public.channel_routing` (channel_external_id → tenant_id) mantido por trigger no schema tenant, já que não dá pra procurar o canal em N schemas.
- `twilio_subaccount_usage.channel_id → notification_channels` fica intra-schema (OK).
- FK global→tenant continua sendo só `whatsapp_credits_transactions.conversation_message_id` (vira coluna solta).
### 1.3 GLOBAIS — ficam em `public` (53)
**SaaS core:** tenants, tenant_members, tenant_invites, tenant_features, tenant_feature_exceptions_log, tenant_modules, profiles, profile_specialties, specialties, owner_users, saas_admins, user_settings
**Planos/assinaturas:** plans, plan_features, plan_prices, plan_public, plan_public_bullets, subscriptions, subscription_events, subscription_intents_legacy, subscription_intents_personal, subscription_intents_tenant, features, modules, module_features, entitlements_invalidation
**Créditos/addons (billing SaaS→tenant):** addon_credits, addon_products, addon_transactions, whatsapp_credit_packages, whatsapp_credit_purchases, whatsapp_credits_balance, whatsapp_credits_transactions
**Plataforma:** _db_migrations, asaas_webhook_events *(staging de webhook — roteia pro tenant na F6)*, audit_logs *(auditoria cross-tenant, padrão `tenant_audit_log` do blueprint)*, email_templates_global, email_layout? não — global_notices, notice_dismissals, login_carousel_slides, math_challenges, public_submission_attempts, submission_rate_limits, support_sessions, saas_doc_votos, saas_docs, saas_faq, saas_faq_itens, saas_security_config, saas_twilio_config
**Dev/tracking (11):** dev_auditoria_items, dev_comparison_competitor_status, dev_comparison_matrix, dev_competitor_features, dev_competitors, dev_generation_log, dev_roadmap_items, dev_roadmap_phases, dev_test_items, dev_user_credentials, dev_verificacoes_items
> Tabelas com tenant_id que **ficam** em public (manter `.eq('tenant_id')` no FE): tenant_features, tenant_feature_exceptions_log, tenant_invites, tenant_members, subscriptions, subscription_intents_*, addon_credits, addon_transactions, whatsapp_credit_*, audit_logs, support_sessions, profiles (+ grupo 1.2 se ficar global).
## 2. Funções — 66 referenciam tabelas-tenant (de 445 em public)
Por categoria (lista completa no fim):
- **Triggers em tabelas tenant (18):** agendador_gerar_slug, auto_create_financial_record_from_session, fanout_inbound_message_to_notifications, fn_agenda_regras_semanais_no_overlap, fn_clinical_note_version, fn_document_signature_timeline, fn_documents_timeline_insert, fn_sla_resolve_on_outbound, fn_whatsapp_low_balance_notify, notify_on_intake, notify_on_scheduling, notify_on_session_status, sync_busy_mirror_agenda_eventos, sync_legacy_email_fields, sync_legacy_phone_fields, trg_fn_patient_risco_timeline, trg_fn_patient_status_history, trg_fn_patient_status_timeline → padrão `TG_TABLE_SCHEMA` + `tenant_id_for_schema()`
- **RPCs chamadas por usuário logado (~30):** cancel_recurrence_from, cancelar_eventos_serie, can_delete_patient, create_financial_record_for_session, create_therapist_payout, delete_commitment_full, delete_determined_commitment, export_patient_data, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, issue_patient_invite, list_financial_records, list_my_signatures, mark_as_paid, mark_payout_as_paid, rotate_patient_invite_token(+v2), safe_delete_patient, search_global, seed_default_patient_groups, seed_determined_commitments, split_recurrence_at, tenant_remove_member… → padrão `p_tenant_id` + `is_tenant_member()` + `set_config('search_path')` (adaptação D2)
- **RPCs globais/cron (~10):** cleanup_notification_queue, convert_abandoned_intake_to_lead, populate_notification_queue, sync_overdue_financial_records, unstick_notification_queue, whatsapp_heartbeat_*, sla_open_breach, sla_mark_notified, first_response_stats, _first_response_runs → padrão loop `FOR t_row IN SELECT … FROM tenants`
- **RPCs públicas/anon por token (~8):** create_patient_intake_request(+v2), get_patient_intake_invite_info, get_signable_document_by_token, sign_document_by_token, sign_document_by_signature_id, validate_share_token, match_patient_by_phone, agendador_dias_disponiveis, agendador_slots_disponiveis → o token/slug identifica o tenant → resolver schema a partir do registro
- **SQL puro (8):** _first_response_runs, can_delete_patient, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, list_financial_records → converter pra plpgsql (limitação 3 do blueprint; exige DROP+CREATE)
## 3. Views, FKs e policies
**6 views referenciam tabelas-tenant** → recriar dentro do schema template: audit_log_unified *(parcial — mistura audit_logs global)*, conversation_threads *(9 usos FE + 1 edge)*, v_cashflow_projection, v_commitment_totals, v_patient_groups_with_counts, v_tag_patient_counts. Demais 23 views só tocam globais — intactas.
**FK global→tenant (a única problemática):** `whatsapp_credits_transactions.conversation_message_id → conversation_messages`. Decisão: converter pra coluna solta (uuid sem constraint) — billing não pode impedir DROP de schema de tenant.
**FKs tenant→public (ok fisicamente, perdem embed PostgREST):** patients→tenant_members (responsible/therapist_member_id), *→auth.users, financial_records→tenants (coluna some), etc. → helper `attachProfiles`/fake-embed no FE (limitação 1 do blueprint).
**Policies:** nenhuma policy de tabela global usa as 66 funções → zero recriação de policy em public. Policies das tabelas movidas morrem com elas; schemas tenant ganham policies novas no clone. Helpers RLS atuais (`is_tenant_member` 65 usos, `is_saas_admin` 177, `tenant_has_feature` 56, `is_clinic_tenant` 56) continuam válidos e são reaproveitados nas policies dos schemas tenant.
## 4. Edge functions — 29 no total, ~25 tocam tabelas-tenant
Mais afetadas (refs a tabelas do grupo tenant): process-notification-queue, process-email-queue, process-sms-queue, process-whatsapp-queue, send-session-reminders(+manual,+status), conversation-sla-check, evolution-whatsapp-inbound, twilio-whatsapp-inbound, send-whatsapp-message, submit-patient-intake, save-intake-progress, get-intake-invite-info, convert-abandoned-intakes, asaas-webhook, asaas-create-payment-record, asaas-cancel-payment, asaas-sync-payment, notification-webhook, sync-email-templates.
Se grupo 1.2 ficar global (Q3), os processadores de fila mantêm a maior parte do código; o refactor concentra-se em: conversation_* (12 refs), session_reminder_logs (7), notifications (2), intake (6), asaas (4), agenda_eventos (3), patients (2).
Padrão: inbound/webhook resolve tenant pelo canal/token → `.schema(tenant_schema_for(tenant_id))`; crons varrem `tenants` em loop.
## 5. Split de `notifications` (172 linhas)
Igual ao blueprint: `tenant_<x>.notifications` (locais) + `public.notifications_sistema` (cross-tenant: avisos SaaS, suporte, system_alert). Funções notify_* ganham 2 variantes; `useNotifications.js` (18 usos FE) mescla 2 fontes com `_origem`; realtime em 2 canais (canal tenant usa `schema: tenant_<x>`). Detalhar tipos cross-tenant vs locais na F6-Lote 1 (query por `type` antes do split).
## 6. Frontend — alvos do refactor (F3)
- `src/lib/supabase/client.js` — cliente único; criar `useTenantDb.js` ao lado.
- ~100 usos `from('agenda_eventos')`, 64 `financial_records`, 45 `patients`… (tabela completa no grep de F0).
- Remover `.eq('tenant_id', …)` APENAS nas tabelas que saem; manter nas globais (tenant_members, tenant_features, subscriptions, etc.).
- `tenantStore` ganha `activeTenantSchema` (computed de slug); repositories trocam `supabase.from``db().from`.
- Realtime: canais de tabelas tenant trocam `schema: 'public'``schema: tenantSchemaName`.
- Embeds `profiles!fkey(...)` em tabelas tenant → `attachProfiles()`.
## 7. Volumetria (migração de dados barata)
Top tenant-scoped: conversation_messages 355, notifications 172, determined_commitment_fields 117, financial_records 54, determined_commitments 47, agenda_eventos 37, patients 35. Todo o resto < 40 linhas. `audit_logs` (608) fica em public. Migração completa roda em segundos; ainda assim com backup por lote (regra do blueprint).
## 8. Decisões — ✅ respondidas pelo Leonardo em 2026-06-12
| # | Decisão | Resposta |
|---|---|---|
| Q1 | Nome do schema | **Criar coluna `tenants.slug`** (unique, imutável, gerado de name) → `tenant_<slug>` |
| Q2 | Quais tenants ganham schema | **Todos** (clínicas e therapists — modelo uniforme) |
| Q3 | Infra de mensageria (5 tabelas) | **Tenant-scoped** (isolamento máximo; ver §1.2 — crons em loop + índice de roteamento de canais) |
| Q4 | asaas_customers/asaas_payments | **Tenant** (webhook roteia via staging global `asaas_webhook_events`) |
## Anexo — 66 funções (nome | kind | linguagem | é trigger)
_first_response_runs|sql · agendador_dias_disponiveis|plpgsql · agendador_gerar_slug|plpgsql|trg · agendador_slots_disponiveis|plpgsql · auto_create_financial_record_from_session|plpgsql|trg · can_delete_patient|sql · cancel_patient_pending_notifications|plpgsql · cancel_recurrence_from|plpgsql · cancelar_eventos_serie|plpgsql · cleanup_notification_queue|plpgsql · convert_abandoned_intake_to_lead|plpgsql · create_financial_record_for_session|plpgsql · create_patient_intake_request|plpgsql · create_patient_intake_request_v2|plpgsql · create_therapist_payout|plpgsql · delete_commitment_full|plpgsql · delete_determined_commitment|plpgsql · export_patient_data|plpgsql · fanout_inbound_message_to_notifications|plpgsql|trg · first_response_stats|plpgsql · fn_agenda_regras_semanais_no_overlap|plpgsql|trg · fn_clinical_note_version|plpgsql|trg · fn_document_signature_timeline|plpgsql|trg · fn_documents_timeline_insert|plpgsql|trg · fn_sla_resolve_on_outbound|plpgsql|trg · fn_whatsapp_low_balance_notify|plpgsql|trg · get_entity_primary_phone|sql · get_financial_report|sql · get_financial_summary|sql · get_patient_intake_invite_info|plpgsql · get_patient_session_counts|sql · get_signable_document_by_token|plpgsql · issue_patient_invite|plpgsql · list_financial_records|sql · list_my_signatures|plpgsql · mark_as_paid|plpgsql · mark_payout_as_paid|plpgsql · match_patient_by_phone|plpgsql · notify_on_intake|plpgsql|trg · notify_on_scheduling|plpgsql|trg · notify_on_session_status|plpgsql|trg · populate_notification_queue|plpgsql · rotate_patient_invite_token|plpgsql · rotate_patient_invite_token_v2|plpgsql · safe_delete_patient|plpgsql · search_global|plpgsql · seed_default_patient_groups|plpgsql · seed_determined_commitments|plpgsql · sign_document_by_signature_id|plpgsql · sign_document_by_token|plpgsql · sla_mark_notified|plpgsql · sla_open_breach|plpgsql · split_recurrence_at|plpgsql · sync_busy_mirror_agenda_eventos|plpgsql|trg · sync_legacy_email_fields|plpgsql|trg · sync_legacy_phone_fields|plpgsql|trg · sync_overdue_financial_records|plpgsql · tenant_remove_member|plpgsql · trg_fn_patient_risco_timeline|plpgsql|trg · trg_fn_patient_status_history|plpgsql|trg · trg_fn_patient_status_timeline|plpgsql|trg · unstick_notification_queue|plpgsql · validate_share_token|plpgsql · whatsapp_heartbeat_mark_notified|plpgsql · whatsapp_heartbeat_open_incident|plpgsql · whatsapp_heartbeat_resolve_open_incidents|plpgsql