Files
agenciapsilmno/docs/F0_categorizacao.md
T
Leonardo f17e9ee786 F1b: 6 tabelas anon-facing ficam em public (decisao roteamento anon)
Fluxos anon identificam tenant por token/slug e nao resolvem o schema fisico.
Decisao (opcao C): manter em public com RLS por token. Volta a global:
patient_intake_requests, patient_invites, patient_invite_attempts,
document_share_links, agendador_configuracoes, agendador_solicitacoes.

- migration 20260613000001_f1b: remove as 6 do _tenant_template (template v2,
  78 tabelas). Smoke: clone gera 78, zero tabelas anon no schema, drop limpo
- frontend: 38 cadeias em 14 arquivos revertidas tenantDb().from() ->
  supabase.from() com tenant_id/owner_id restaurado (via comparacao com main)
- edge: convert-abandoned-intakes restaurada do main (SELECT global)
- save-intake-progress: ja usava public, sem mudanca
- doc F0 atualizado: 78 tenant + 59 global

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

20 KiB

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>) — Q3 + Q5 78
Globais (ficam em public) 59

Atualização 2026-06-13 (Q5 — roteamento anon): 6 tabelas anon-facing voltaram pra public (decisão opção C): patient_intake_requests, patient_invites, patient_invite_attempts, document_share_links, agendador_configuracoes, agendador_solicitacoes. Fluxos anônimos identificam o tenant por token/slug e não resolvem o schema físico — mantê-las em public com RLS por token evita um índice global de tokens. Removidas do _tenant_template na migration 20260613000001_f1b (template v2, 78 tabelas). FK a observar na F6: public.document_share_links.documento_id → documents (tenant) vira coluna solta no drop. | 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.fromdb().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