54 Commits

Author SHA1 Message Date
Leonardo 2ce3612135 log: restart do stack + gotcha pgrst pos-restart 2026-06-13 22:42:31 -03:00
Leonardo eb9dcc714f docs: gotcha pgrst.db_schemas nao sobrevive ao restart do stack
Apos supabase stop/start a GUC de exposicao dos schemas tenant some -> 404 nas
tabelas tenant. HANDOFF Passo 0 agora manda rodar refresh_pgrst_schemas() pos-start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:02:02 -03:00
Leonardo 6383a550a6 log: handoff + runbooks 2026-06-13 20:50:11 -03:00
Leonardo c3dac5eeec docs: handoff completo (onde paramos + riscos + passo a passo de teste)
docs/HANDOFF_E_TESTES.md consolida os dois epicos:
- estado atual (branches, banco local, o que falta em cada um)
- TODOS os riscos (3 criticos, 6 importantes, 5 menores)
- passo a passo de teste local (0..7) cobrindo schema-per-tenant + freemium

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:49:44 -03:00
Leonardo 9acce9c19d log: runbook schema-per-tenant 2026-06-13 20:46:56 -03:00
Leonardo 91b89b7b5d docs: runbook de deploy da schema-per-tenant (hosted)
docs/DEPLOY_SCHEMA_PER_TENANT.md — pre-requisito do freemium. Cutover em fases:
- estrategia copia-nao-move (public + schemas coexistem ate o DROP)
- Risco #1 hosted: exposicao dinamica de schemas no PostgREST (ALTER ROLE
  authenticator) + fallback Exposed schemas no dashboard
- Fase A migrations aditivas / B manual privilegiados / C pgrst dinamico (testar
  cedo) / D migracao de dados (janela) / E frontend+edge / F smoke+soak /
  G F6.3 DROP (gated, irreversivel)
- rollback por fase (botao de panico = redeploy do codigo antigo ate o DROP)
- freemium pode entrar apos as Fases A-F, sem depender do DROP

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:46:56 -03:00
Leonardo 1082123967 freemium F4: referencia o runbook no wiki/log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:42:47 -03:00
Leonardo 2f72886d4b freemium F4: runbook de deploy hosted
docs/DEPLOY_FREEMIUM_F4.md — passo a passo ordenado:
- PRE-REQUISITO #0: schema-per-tenant precisa estar no hosted antes (freemium
  depende de tenant_schemas/clone/is_saas_admin/pgrst schemas)
- migrations 05/06/07 + 5 manual supabase_admin (ordem + nota de permissoes hosted)
- Auth dashboard (confirm email + redirect URLs + SMTP GoTrue)
- deploy das edges recover-access/send-welcome-email + secrets SMTP
- rebuild front + smoke test (8 passos) + rollback/kill-switch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:41:28 -03:00
Leonardo 403b7234a9 freemium F2: registra polish concluido no wiki/log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:38:07 -03:00
Leonardo 52c34cf63a freemium F2 polish: welcome email + plano gratuito na vitrine
- edge function send-welcome-email: e-mail de boas-vindas ao DONO do tenant
  recem-provisionado (destinatario do JWT, SMTP global/sistema, defaults Mailpit).
  Best-effort, disparada fire-and-forget no OnboardingPage so no provisionamento novo.
- vitrine: seed plan_public + bullets dos planos free (cartao "Gratis"); Landingpage
  passa a mostrar "Gratis para sempre" (isFreePlan) em vez de "—".
- build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:36:33 -03:00
Leonardo f6470718b7 freemium F3: registra conclusao no wiki/log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:26:42 -03:00
Leonardo 3730b71150 freemium F3: UI de config (blacklist CRUD + toggle root_redirect)
- SaasAppConfigPage + rota /saas/app-config + menu "Config / Bloqueios"
- gerencia blacklist (email/slug, add/remove) e o root_redirect
- build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:26:40 -03:00
Leonardo d50073da1a freemium F3: frontend dos extras (usuarios, esqueci-email, root_redirect, sino)
- SaasUsuariosPage + rota /saas/usuarios + menu: 1 linha/tenant com dono/slug/
  email/plano, realce verde + selo "Novo" 24h (saas_list_account_owners)
- esqueci-email no Login: dialog que chama a edge recover-access (acha dono por
  slug, manda magic link, mostra so dica mascarada). Edge function recover-access.
- root_redirect: guard roteia "/" do visitante nao-logado pra /lp ou /auth/login
  conforme get_root_redirect (cache TTL 5min)
- pegadinha #4: notificationStore.reset() no logout (limpa sino ao trocar user)
- build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:46 -03:00
Leonardo 03790ecb9e freemium F3c: root_redirect (config + RPC publica)
- saas_app_config singleton (root_redirect landing|login, RLS saas_admin write)
- get_root_redirect() anon-callable; default 'landing'
- guard/front usam pra rotear "/" do visitante nao-logado (front na sequencia)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:02:40 -03:00
Leonardo cb153165c3 freemium F3b: /saas/usuarios (donos) + notify_all_devs
- saas_list_account_owners(): 1 linha/tenant com dono (master), nome/slug/email/
  plano + selo "novo" (24h). Dev-only (is_saas_admin), cast text p/ varchar.
- notify_all_devs() insere em notifications_sistema p/ cada saas_admin
- trigger AFTER INSERT/UPDATE OF status em subscriptions avisa os devs com
  deeplink /saas/usuarios
- testado em ROLLBACK: lista + notify ao inserir subscription

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:01:27 -03:00
Leonardo c189906c58 freemium F3a: blacklist de e-mails e slugs
- tabela blacklist (kind email|slug, value normalizado, RLS saas_admin)
- is_email_blacklisted (exato + '@dominio.com') / is_slug_blacklisted
- trigger BEFORE INSERT em auth.users bloqueia cadastro DE VERDADE (EMAIL_BLOCKED)
- slug_disponivel passa a retornar motivo 'bloqueado'
- testado em ROLLBACK: email exato/dominio/signUp/slug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:30:55 -03:00
Leonardo 5a87c29dd0 freemium F2: registra nucleo no wiki/log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:05:55 -03:00
Leonardo a2f3b9fae4 freemium F2: signup self-service com confirmacao + /onboarding
- Signup.vue reescrito: coleta tipo de conta + nome + nome do negocio + slug
  (disponibilidade ao vivo via slug_disponivel) + email/senha; grava tudo no
  raw_user_meta_data do signUp; PEGADINHA #2: signOut scope:local se nao veio
  sessao + tela "confirme seu e-mail". Removido provisionamento/intent inline.
- OnboardingPage.vue (PEGADINHA #3): 1o login chama auto_provision_free_tenant
  + processar_pos_signup; resolve estados provisionando/slug-colidiu/erro;
  redireciona pro painel conforme kind.
- guard: logado-sem-tenant (nao saas_admin) -> /onboarding em vez de /login
- rota /onboarding (meta.public; a pagina exige sessao)

NOTA: supabase/config.toml e gitignored — enable_confirmations=true foi setado
local (ativa no proximo restart do stack). No hosted, ligar em Auth>Email>Confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:00:22 -03:00
Leonardo 1594dc9426 freemium F2: RPCs idempotentes do self-service
- slug_disponivel(p_slug) -> {ok, motivo} (formato + reservados + uso),
  chamavel por anon (signup acontece antes do login)
- auto_provision_free_tenant(p_slug_override): le raw_user_meta_data, cria
  tenant (slug escolhido OU auto), vira master, clona schema, cria subscription
  gratuita ativa (XOR clinic->tenant_id / therapist->user_id). Idempotente.
- processar_pos_signup(): cria intent SO pro caminho pago, na tabela real por
  target (a view subscription_intents nao propaga user_id). Idempotente.
- testado em ROLLBACK: clinica + terapeuta, provision+idempotencia+intents OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:20:52 -03:00
Leonardo 31c4f08451 fix(audit): log_audit_change quebrava INSERT em tabelas globais
Regressao do schema-per-tenant: o trigger de auditoria deriva tenant_id via
tenant_id_for_schema(TG_TABLE_SCHEMA), que retorna NULL para public.* (ex.:
tenant_members) -> violava audit_logs.tenant_id NOT NULL -> QUALQUER novo
membership (provisionamento, aceite de convite) falhava.

Fix: quando o schema nao resolve tenant, cai no tenant_id da propria linha;
se ainda NULL, nao audita mas nunca quebra a operacao.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:20:51 -03:00
Leonardo 12d5c3b6dc freemium F1: registra conclusao no wiki/log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:07:00 -03:00
Leonardo a979bdf1de freemium F1: frontend do enforcement (toast + botao Upgrade PRO)
- utils/planLimit.js: parsePlanLimitError + maybeShowPlanLimitToast (traduz
  PLAN_LIMIT_REACHED em toast amigavel com CTA via grupo system-alerts)
- AppTopbar: botao "Upgrade PRO" quando plano ativo e gratuito (reusa
  resolveActiveSubscriptionContext; plan_key nos selects)
- ligado nos 3 pontos de criacao de paciente: PatientsCadastroPage,
  CadastrosRecebidos (intake), ComponentCadastroRapido (quick-create)
- build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:05:28 -03:00
Leonardo a73b82fa86 freemium F1: enforcement de limite de pacientes (schema-per-tenant)
- therapist_free ganha max_patients=20 (clinic_free ja tinha 30)
- trigger BEFORE INSERT em patients le plan_features.limits em runtime,
  resolve tenant por TG_TABLE_SCHEMA, plano ativo (clinica via tenant_id +
  pessoal via owner user_id), conta vivos (status<>Arquivado) e da RAISE
  PLAN_LIMIT_REACHED|patients|<n>
- helpers tenant_active_plan_id / plan_feature_limit (globais, sobrevivem F6.3)
- wiring: tenants novos ganham via trg_attach_business_triggers; 9 existentes backfill
- testado: clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado (rollback)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:05:19 -03:00
Leonardo 98fe183bac log: sessao schema-per-tenant (parada antes do F6.3 DROP)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 17:14:23 -03:00
Leonardo 8b620f9b04 F6.3: doc de rollback (branch-safety + cenarios pre/pos-DROP)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 17:13:38 -03:00
Leonardo 96f4543138 wiki: F6.4 SaaS-admin resolvida, F6.3 desbloqueada
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 17:02:53 -03:00
Leonardo dc2363b4e1 F6.4: resolve superficie SaaS-admin (destrava F6.3 DROP)
DB (supabase_admin, manual/f6_4_saas_admin_rpcs.supabase_admin.sql): RPCs
gated por is_saas_admin que operam no _tenant_template + fan-out pros schemas:
- Feriados defaults: saas_list/add/remove_default_feriado (template + todos schemas)
- Notif templates defaults: saas_list/upsert/set_active/delete_default_notif_
  template + saas_count_notif_template_overrides (so a copia-default owner_id NULL;
  preserva overrides do tenant)
- saas_list_all_whatsapp_channels: fan-out cross-tenant (substitui a view
  v_twilio_whatsapp_overview) com tenant_name + open_incident por canal

Frontend:
- SaasFeriadosPage / SaasNotificationTemplatesPage: supabase.from(tenant) ->
  RPCs saas_*_default
- SaasWhatsappPage: gestao por-tenant-selecionado via supabase.schema(tenant_
  <slug>) (RLS permite saas_admin); overview via saas_list_all_whatsapp_channels
- twilioWhatsappService.getAllChannels: v_twilio_whatsapp_overview -> RPC

Verificado: ZERO supabase.from('<tabela_tenant>') publico restante no FE
(so SaaS-admin estava pendente). Build passa. F6.3 agora desbloqueada.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 17:01:46 -03:00
Leonardo 4493e78349 wiki: F6.3 preparada + itens SaaS-admin em aberto
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:45:36 -03:00
Leonardo cdb9ce10ee F6.3 (PREPARADA, NAO APLICADA): DROP das tabelas tenant em public
manual/f6_3_drop_public_tenant_tables.supabase_admin.sql — ponto de nao-retorno,
revisar antes de aplicar:
- pre-flight assert (tenants=schemas, 78 tabelas, 6 anon NAO no template)
- 2 FKs public->tenant viram coluna solta (document_share_links.documento_id,
  whatsapp_credits_transactions.conversation_message_id)
- dropa 9 views public (6 recriadas por schema + 2 dead + v_twilio item#1)
- DROP CASCADE das 78 tabelas (derivado de _tenant_template; dados ja nos schemas)
- limpa financial_records_inject_tenant (obsoleto)

ITENS EM ABERTO verificados (16 reads FE remanescentes, TODOS na superficie
SaaS-admin ja flagada na F3): SaasFeriadosPage (feriados defaults),
SaasNotificationTemplatesPage (templates default), SaasWhatsappPage +
v_twilio_whatsapp_overview/getAllChannels (whatsapp admin cross-tenant).
Resolver (apontar pra _tenant_template ou fan-out por schema) ANTES do DROP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:44:37 -03:00
Leonardo af2395c723 wiki: F6 wiring done, app testavel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:26:39 -03:00
Leonardo dc7826d0b5 F6.2 wiring: tenants novos nascem com todos os triggers de negocio
- attach_agnostic_triggers reescrito SELF-CONTAINED (dirigido por colunas: set_
  updated_at em toda tabela com updated_at + prevent_* em patient_groups). Nao
  le mais de public -> sobrevive ao DROP da F6.3.
- trigger AFTER INSERT em tenant_schemas (trg_attach_business_triggers) dispara
  os 3 attach (agnostic + schema_aware + notif) pro schema novo. clone_tenant_
  template nao precisou ser tocado (ele insere em tenant_schemas). GRANT EXECUTE
  dos attach pra postgres/service_role.
- provision_account_tenant: clone ANTES do seed (seed_determined_commitments e
  no-op se schema nao existe; precisa do schema criado primeiro)

Smoke: tenant novo nasce com 84 triggers automaticamente (vs 81 nos existentes,
que sao mais que suficientes). Provision order corrigida.

App agora testavel: dados nos schemas (F6.1) + funcoes/triggers/RPCs roteiam
(F6.2). Falta so F6.3 DROP (com OK + app testado).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:25:58 -03:00
Leonardo 218d342181 wiki: F6.2 completa (Lote G)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:01:29 -03:00
Leonardo ee82985dc3 F6.2 Lote G: funcoes SQL puras -> plpgsql + roteamento (completa F6.2)
DB (supabase_admin, manual/f6_2g_sql_to_plpgsql.supabase_admin.sql): SQL puro
nao permite set_config dinamico -> converte 5 pra plpgsql + p_tenant_id +
_tenant_route:
- get_financial_summary, get_financial_report, get_patient_session_counts
  (remove filtro tenant_id IN, schema-scoped)
- list_financial_records: RETURNS SETOF financial_records -> jsonb (array,
  transparente no FE)
- get_entity_primary_phone (interno, 0 callers): herda search_path do chamador
  (sem SET, unqualified)
Smoke: get_financial_summary + list_financial_records (array 5) OK.

Frontend (3 arquivos): p_tenant_id de activeTenantId/resolveTenantId nas
chamadas de get_financial_summary/list_financial_records/get_patient_session_
counts. Build passa.

=== F6.2 COMPLETA: 66 funcoes migradas (triggers A/B/C + RPCs D/E/F/G) ===

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:00:16 -03:00
Leonardo 423aa5ac2a wiki: F6.2 Lote F done
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:51:49 -03:00
Leonardo 1243a12ced F6.2 Lote F: RPCs anon/token resolvem tenant por token/slug + roteiam
DB (supabase_admin, manual/f6_2f_anon_token_rpcs.supabase_admin.sql):
- Documentos anon (token): validate_share_token, get_signable_document_by_token,
  sign_document_by_token resolvem tenant de document_share_links.tenant_id
  (public/F1b) -> set_config search_path; documents/document_signatures/
  document_access_logs no schema (RECORD em vez de %ROWTYPE; RETURNS
  document_signatures->jsonb; document_access_logs sem tenant_id).
  document_share_links continua public.
- sign_document_by_signature_id (paciente LOGADO, nao e tenant_member):
  +p_tenant_id via _tenant_schema_unchecked + autorizacao por LINHA (signatario_id
  =uid OU email OU documento do paciente). RETURNS->jsonb.
- agendador_dias/slots_disponiveis (anon): resolvem tenant de agendador_
  configuracoes.tenant_id (public/F1b); agenda/recurrence no schema;
  agendador_configuracoes/solicitacoes ficam public.
- match_patient_by_phone (edge service): _tenant_schema_unchecked + REVOKE de
  anon/authenticated, GRANT service_role.
- list_my_signatures: CROSS-TENANT -> fan-out por schema (RETURN QUERY EXECUTE
  por tenant_schemas, tenant_id injetado; document_share_links global).
- RPCs public-only (create_patient_intake_request, get_patient_intake_invite_info,
  issue_patient_invite, rotate_*, agendador_gerar_slug): SEM mudanca (F1b public).

Frontend: signByPortal(tenantId, signatureId, hash) + composable resolve
tenant_id da linha (list_my_signatures retorna tenant_id). Build passa.

Gotcha: paciente assinante NAO e tenant_member -> auth por linha, nao membership.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:51:22 -03:00
Leonardo f079192698 wiki: F6.2 Lote E done
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:26:27 -03:00
Leonardo 02acc88da5 F6.2 Lote E: RPCs de cron/global roteadas/loopadas por tenant
DB (supabase_admin, manual/f6_2e_cron_rpcs.supabase_admin.sql):
- E2 (varrem TODOS os tenants via loop FROM tenant_schemas): cleanup/unstick_
  notification_queue, sync_overdue_financial_records (EXECUTE format por schema),
  populate_notification_queue (set_config search_path por tenant; profiles global)
- E1 (per-tenant via service_role): novo helper _tenant_schema_unchecked (sem
  is_tenant_member — service_role nao e membro; REVOKE de anon/authenticated).
  sla_open_breach, sla_mark_notified(+p_tenant_id), whatsapp_heartbeat_open_
  incident/mark_notified/resolve(+p_tenant_id). convert_abandoned_intake_to_lead
  resolve tenant internamente (intake public/F1b -> writes no schema).
  first_response_stats/_runs: _tenant_route (user-facing, frontend ja passa
  p_tenant_id); _first_response_runs computa thread_key (coluna nao existe).
- REVOKE das RPCs de servico de anon/authenticated; GRANT service_role

Edge: whatsapp-heartbeat-check (tdb.rpc->admin.rpc + p_tenant_id nos heartbeat
RPCs), conversation-sla-check (sla_mark_notified + p_tenant_id).

Gotchas: (1) service_role nao e tenant_member -> helper unchecked + REVOKE;
(2) conversation_messages nao tem coluna thread_key (computar); (3) DROP+CREATE
de nova assinatura precisa dropar ambas p/ idempotencia.

Smoke: E2 sync_overdue 13 across tenants; E1 sla_open_breach roteia; first_
response_stats user OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:25:36 -03:00
Leonardo d3620f99ea wiki: F6.2 Lote D done
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:13:59 -03:00
Leonardo d240c6678f F6.2 Lote D: RPCs user-facing roteadas pro schema do tenant
DB (supabase_admin, manual/f6_2d_user_rpcs.supabase_admin.sql): 14 RPCs.
Helper _tenant_route(p_tenant_id) valida is_tenant_member + retorna schema
(retorna, nao seta — set_config em helper com SET search_path proprio seria
revertido na saida). Cada RPC: set_config search_path pro schema + unqualify
tabelas tenant + remove WHERE tenant_id= e tenant_id de inserts.
- Grupo 1 (ja tinham p_tenant_id, jsonb/void): delete_commitment_full,
  delete_determined_commitment, seed_default_patient_groups,
  seed_determined_commitments (no-op se schema nao existe)
- Grupo 2 (novo p_tenant_id 1o param, DROP+CREATE): cancel_recurrence_from,
  cancelar_eventos_serie, split_recurrence_at, safe_delete_patient,
  export_patient_data (audit_logs global mantido), search_global
  (patient_intake_requests fica em public/F1b -> qualificado + filtro tenant_id)
- Grupo 3 (RETURNS <tabela>->jsonb): mark_as_paid, create_financial_record_
  for_session, mark_payout_as_paid, create_therapist_payout
- can_delete_patient: unqualified, herda search_path do chamador
Smoke: mark_as_paid (status=paid, jsonb) + search_global (acha paciente) OK.

Frontend (18 sites): p_tenant_id de useTenantStore().activeTenantId (ou helper
local resolveTenantId/currentTenantId). create_financial_record_for_session ja
passava tenant; retorno SETOF->jsonb transparente (nenhum consumidor indexava
array). Build passa.

list_my_signatures (cross-tenant) -> Lote F.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:13:16 -03:00
Leonardo 120b1e44d8 wiki: F6.2 Lote D scoped + checkpoint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:37:23 -03:00
Leonardo b6bd6fdd89 wiki: F6.2 Lote C done
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:10:07 -03:00
Leonardo bedbb9bafc F6.2 Lote C: split de notifications (tenant-local + notifications_sistema)
DB (supabase_admin):
- public.notifications_sistema (cross-tenant SaaS->tenant: suporte, billing;
  vazio hoje, future-proof) + RLS owner_id + realtime + notify_user_sistema()
- notify_on_session_status, fanout_inbound_message_to_notifications,
  cancel_notifications_on_opt_out/session_cancel reescritos schema-aware
  (search_path dinamico; notifications/notification_queue no schema;
  tenant_members/patients global/schema)
- notify_on_intake/scheduling disparam em tabelas PUBLIC (F1b) -> roteiam pro
  schema via tenant_schema_for + EXECUTE format
- cancel_patient_pending_notifications: notification_queue unqualified (herda
  search_path do trigger chamador)
- detach dos 4 notif-triggers tenant de public; attach_notif_triggers recria
  5 notif triggers/schema
- smoke: msg inbound -> notification no schema, destinatario correto

Frontend (notificationStore.js): load le das 2 fontes (tenantDb + public.
notifications_sistema), merge por created_at, campo _origem; realtime 2 canais;
markRead/markAllRead/archive roteiam por _origem

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:06:58 -03:00
Leonardo 77ef06fde7 wiki: F6.2 Lote B done
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:34:51 -03:00
Leonardo 5741e10e28 F6.2 Lote B: triggers schema-aware (rewrite + detach public + attach schemas)
Aplicado como supabase_admin (trigger functions sao owned por supabase_admin).
14 funcoes reescritas pra operar no schema do TG_TABLE_SCHEMA (set_config
search_path dinamico + tenant_id_for_schema p/ tabelas globais audit_logs):
log_audit_change, trg_fn_patient_status_history/timeline/risco,
auto_create_financial_record_from_session, fn_sla_resolve_on_outbound,
fn_clinical_note_version, fn_document_signature_timeline,
fn_documents_timeline_insert, sync_legacy_email/phone_fields,
fn_agenda_regras_semanais_no_overlap, patients_validate_member_consistency.
sync_busy_mirror_agenda_eventos: cross-tenant via tenant_schema_for +
EXECUTE format (espelha "Ocupado" nos schemas das clinicas).
financial_records_inject_tenant: obsoleto, nao anexado nos schemas.

Detach dos 14 schema-aware das tabelas tenant em public (quebrariam la);
attach_schema_aware_triggers recria 22 triggers/schema (defs reais, tenant_id
removido de WHEN/UPDATE OF). agenda_cfg_sync e trg_fn_financial_records_auto_
overdue (agnosticos) ficam em public E nos schemas.

Smoke: sessao->realizado cria financial_record (R$250) no schema + marca billed;
audit roteia tenant_id correto; patient status muda -> timeline no schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:21:00 -03:00
Leonardo d58b939e1c F6.2 Lote A: anexa triggers schema-agnosticos aos schemas tenant
attach_agnostic_triggers(schema) recria nos schemas os triggers de public cuja
funcao e provadamente schema-agnostica (so mexe em NEW/OLD): familia updated_at
(8: set_updated_at, fn_clinical_notes_updated_at, set_insurance_plans/medicos/
services_updated_at, set_updated_at_recurrence, update_payment_settings/
professional_pricing_updated_at) + prevent_promoting_to_system +
prevent_system_group_changes. Backfill dos 9 (54 triggers/schema). Smoke:
set_updated_at dispara no schema. Schema-aware vem no Lote B; wiring no clone
no fim da F6.2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:57:43 -03:00
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
Leonardo 003f2eb2a6 F6.0+F6.1 schema-per-tenant: clones dos 9 tenants + migracao de dados
- F6.0 (migration): clone_tenant_template pros 9 tenants existentes (schemas
  vazios; dispara trigger F5 -> expostos no PostgREST)
- F6.1 (manual supabase_admin): copia dados public -> schemas com
  session_replication_role=replica (desabilita FK check, so supabase_admin).
  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 (idempotente). Dados continuam em public (DROP so na F6.3)
- Verificado: contagens public vs schemas batem (35 patients, 37 eventos,
  355 mensagens, 54 financeiro...); seeds default replicados por schema

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:41:03 -03:00
Leonardo a85716b0ea wiki: F5 schema-per-tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:26:16 -03:00
Leonardo 6b542cd03a F5 schema-per-tenant: PostgREST expoe schemas tenant dinamicamente
- manual/f5_pgrst_refresh_schemas.supabase_admin.sql: refresh_pgrst_schemas()
  (owned supabase_admin, postgres nao e superuser neste stack) seta
  pgrst.db_schemas in-database na role authenticator a partir de tenant_schemas
  + NOTIFY pgrst reload config/schema. Expoe/retira schema SEM restart; GUC
  persiste entre stop/start
- migration 20260613000002: trigger em tenant_schemas dispara o refresh a cada
  clone/drop (clone/drop nao precisam ser tocados)
- config.toml: baseline public,graphql_public + comentario explicando que a
  config in-db supersede em runtime
- E2E testado via HTTP: clone -> pgrst.db_schemas inclui tenant_x -> REST
  Accept-Profile retorna 200 (vs 406 schema inexistente); drop -> volta 406.
  Sem restart de container

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:25:25 -03:00
Leonardo 07437c9ff4 wiki: F4 + F1b schema-per-tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:10:16 -03:00
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
Leonardo 9b21642e15 F4 schema-per-tenant: edge functions roteiam pro schema do tenant
- _shared/tenant.ts: helper (adminClient, tenantDbForId, schemaForTenant,
  listTenantSchemas, resolveTenantByChannel, tenantSchemaName)
- _shared/whatsapp-hooks.ts: hooks de tabela tenant recebem tdb; RPCs de
  credito (deduct/add_whatsapp_credits) e tenant_members seguem em supa+p_tenant_id
- inbound (twilio/evolution): tenant_id da URL -> tdb pra conversation_messages
  e notification_channels
- crons de fila (process-notification/email/sms/whatsapp-queue): varrem
  listTenantSchemas e drenam a fila de cada schema (Q3: filas sao 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
- asaas-*: tenant_id do body -> tdb; asaas-webhook fica global (whatsapp_credit_purchases)
- notification-webhook (Meta): resolve tenant via channel_routing por phone_number_id,
  fan-out por message_id quando nao resolve
- caller send-session-reminder-manual passa tenant_id (evento vive no schema)

Pendente: save-intake-progress e fluxos anon por token (decisao de roteamento)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 08:44:09 -03:00
Leonardo ba8348d4a6 wiki: F3 schema-per-tenant (branch)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:52:35 -03:00
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00
220 changed files with 8312 additions and 2835 deletions
+72
View File
@@ -1648,3 +1648,75 @@ Touched: Migracao Schema-per-Tenant, index
## [2026-06-12 11:49] session | F1 schema-per-tenant: template + helpers + clone ## [2026-06-12 11:49] session | F1 schema-per-tenant: template + helpers + clone
Touched: Migracao Schema-per-Tenant Touched: Migracao Schema-per-Tenant
## [2026-06-13 04:52] session | F3 schema-per-tenant: frontend tenantDb
Touched: Migracao Schema-per-Tenant
## [2026-06-13 09:10] session | F4 edge functions + F1b anon-tables-public
Touched: Migracao Schema-per-Tenant
## [2026-06-13 09:26] session | F5 PostgREST expoe schemas tenant (E2E HTTP)
Touched: Migracao Schema-per-Tenant
## [2026-06-13 12:53] session | F6.0+F6.1 clones + migracao dados; plano F6.2
Touched: Migracao Schema-per-Tenant
## [2026-06-13 13:34] session | F6.2 Lote A+B triggers (agnosticos + schema-aware)
Touched: Migracao Schema-per-Tenant
## [2026-06-13 14:10] session | F6.2 Lote C split notifications
Touched: Migracao Schema-per-Tenant
## [2026-06-13 14:37] session | F6.2 Lote D scoped (15 RPCs); checkpoint
Touched: Migracao Schema-per-Tenant
## [2026-06-13 15:13] session | F6.2 Lote D RPCs user-facing
Touched: Migracao Schema-per-Tenant
## [2026-06-13 15:26] session | F6.2 Lote E cron/global RPCs
Touched: Migracao Schema-per-Tenant
## [2026-06-13 15:51] session | F6.2 Lote F anon/token RPCs
Touched: Migracao Schema-per-Tenant
## [2026-06-13 16:01] session | F6.2 Lote G + F6.2 COMPLETA (66 funcoes)
Touched: Migracao Schema-per-Tenant
## [2026-06-13 16:26] session | F6 wiring no clone (tenants novos completos)
Touched: Migracao Schema-per-Tenant
## [2026-06-13 16:45] session | F6.3 preparada (nao-aplicada) + itens SaaS-admin
Touched: Migracao Schema-per-Tenant
## [2026-06-13 17:02] session | F6.4 superficie SaaS-admin resolvida (F6.3 desbloqueada)
Touched: Migracao Schema-per-Tenant
## [2026-06-13 17:14] session | schema-per-tenant F0-F6.4 + wiring + rollback (F6.3 nao aplicada)
Touched: Migracao Schema-per-Tenant
## [2026-06-13 18:30] session | Freemium/PLG F0 descoberta + 4 decisões
Touched: Freemium PLG
## [2026-06-13 19:45] session | Freemium F1 done (enforcement pacientes + Upgrade PRO)
Touched: Freemium PLG
## [2026-06-13 21:40] session | Freemium F2 nucleo (RPCs + signup + onboarding + audit fix)
Touched: Freemium PLG
## [2026-06-13 22:30] session | Freemium F3 done (4 extras: blacklist, usuarios+notify, root_redirect, esqueci-email)
Touched: Freemium PLG
## [2026-06-13 23:10] session | Freemium F2 polish done (welcome email + vitrine free); F1/F2/F3 completos
Touched: Freemium PLG
## [2026-06-13 23:30] session | Freemium F4 runbook de deploy (docs/DEPLOY_FREEMIUM_F4.md)
Touched: Freemium PLG
## [2026-06-13 23:55] session | Runbook deploy schema-per-tenant (docs/DEPLOY_SCHEMA_PER_TENANT.md)
Touched: Migracao Schema-per-Tenant, Freemium PLG
## [2026-06-14 00:10] session | Handoff completo (estado + riscos + testes) + 2 runbooks de deploy
Touched: Freemium PLG, Migracao Schema-per-Tenant
## [2026-06-14 00:25] session | Stack reiniciado (confirmacao e-mail ON) + gotcha pgrst.db_schemas pos-restart
Touched: Freemium PLG, Migracao Schema-per-Tenant
+42
View File
@@ -0,0 +1,42 @@
# Freemium / PLG
Épico iniciado em 2026-06-13, branch `feat/freemium-plg` (sobre [[Migracao Schema-per-Tenant]]). Objetivo: qualquer visitante cria conta gratuita sozinho, confirma e-mail, e o ambiente do tenant é provisionado automaticamente. Plano gratuito limitado + botão "Upgrade PRO". Blueprint-diretor: `novo-rumo.txt` (raiz), vindo do sistema-irmão (sindicato) e adaptado a clínica.
## Descoberta (Fase 0) — o que já existia
O sistema já estava ~70-85% pronto:
- **Planos free existem**: `clinic_free`, `therapist_free` (+ supervisor/patient) com `plan_features.limits` semeado (`clinic_free``clinic_calendar {max_patients:30, max_therapists:5}`, `online_scheduling {sessions_per_month:40}`, `reminders {reminders_per_month:50}`, `documents.upload {max_storage_mb:500}`; 14 features premium OFF).
- **Feature gating completo**: `entitlementsStore.js` (views `v_tenant_entitlements`/`v_user_entitlements`), `FeatureGate.vue`, guard `meta.feature``/upgrade` (`guards.js:814`), badge PRO no menu.
- **Provisionamento schema-per-tenant**: `ensure_personal_tenant`/`provision_account_tenant``clone_tenant_template`. Setup Wizard.
- **Signup self-service**: `/lp` (pricing dinâmico de `v_public_pricing`) → `/auth/signup` (`Signup.vue:219` `signUp` inline, cria intent só no pago).
- RPCs `activate_subscription_from_intent`, `change_subscription_plan`. `tenants.slug` 100% populado.
**Gap confirmado:** limites semeados mas **ninguém lê/enforça**. Sem confirmação de e-mail (`enable_confirmations=false`), sem /onboarding, signup só coleta email+senha, sem welcome email, sem os extras.
## Decisões (Fase 0.5)
1. **Modelo do blueprint** — confirmação de e-mail ON; signup grava escolha em `raw_user_meta_data` + signOut-local + tela "confirme e-mail"; provisionamento+intent viram RPCs idempotentes no 1º login (`auto_provision_free_tenant(p_slug_override)`, `processar_pos_signup`); guard manda logado-sem-tenant → `/onboarding`. Reescreve o signup inline.
2. **Pacientes** = recurso limitado. Trigger BEFORE INSERT em `patients` lê limits em runtime, resolve tenant por `TG_TABLE_SCHEMA`, conta linhas vivas, `RAISE 'PLAN_LIMIT_REACHED|patients|<n>'`. clinic_free=30, therapist_free=20. No template + backfill 9 schemas.
3. **Slug escolhido** no signup (sugestão sanitizada + `slug_disponivel(p_slug)→{ok,motivo}`), imutável, trava 3 camadas.
4. **Todos os 4 extras**: /saas/usuarios + `notify_all_devs`; esqueci-email (magic link por slug, dica mascarada); blacklist (email|slug); root_redirect.
## Pegadinhas (do blueprint, ⚠️ caras no irmão)
- **#1** Signup sem sessão (confirmação ON) → tudo com `auth.uid()` quebra em silêncio. Gravar escolha em metadata, processar pós-confirmação.
- **#2** signOut `scope:'local'` se não veio sessão — senão vaza sessão anterior e joga no painel errado.
- **#3** Logado-sem-tenant nunca cai em painel quebrado → `/onboarding` resolve estados (provisionando, slug-colidiu, pago-aguardando, sem-acesso, erro).
- **#4** Sino de notificação singleton precisa re-buscar ao trocar de user (logout+login).
## Divergência de infra
Blueprint pede welcome email via **Resend**; aqui é **SMTP/Mailpit** (`process-email-queue`). Reusar o pipeline SMTP existente (best-effort), não Resend.
## Fases
- **F1** ✅ DONE (2026-06-13) — therapist_free ganhou max_patients=20; trigger `enforce_patient_plan_limit` em patients (lê `plan_features.limits` em runtime, resolve plano via `tenant_active_plan_id`, conta vivos, RAISE `PLAN_LIMIT_REACHED|patients|n`); helpers globais + wiring + backfill 9 schemas. Front: `utils/planLimit.js` (toast com CTA via grupo system-alerts) nos 3 pontos de criação de paciente + botão **Upgrade PRO** no AppTopbar quando plano é free. Migrations: `20260613000005_*` + `manual/freemium_f1_plan_limits.supabase_admin.sql`. Testado em ROLLBACK (clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado).
- **F2** 🟡 NÚCLEO DONE (2026-06-13) — `enable_confirmations=true` (config.toml, gitignored, ativa no restart do stack); RPCs `slug_disponivel`/`auto_provision_free_tenant`/`processar_pos_signup` (manual/freemium_f2_provisioning.supabase_admin.sql, testados em ROLLBACK clínica+terapeuta); **fix de regressão** `log_audit_change` (migration 20260613000006) que quebrava INSERT em tenant_members; Signup.vue reescrito (kind+nome+slug ao vivo+metadata, signOut-local + tela confirme-email); OnboardingPage.vue (provision+estados slug-colidiu/erro); guard → /onboarding; rota registrada. Build OK. **Restam (polish):** welcome email best-effort (infra SMTP schema-per-tenant) + apresentação do free na vitrine (public_name/preço "Grátis"/bullets — os planos já são is_visible=true mas sem nome/preço).
- **F3** ✅ DONE (2026-06-13) — 4 extras. DB/edge: `blacklist` (tabela + trigger BEFORE INSERT em auth.users + integra slug_disponivel motivo 'bloqueado'); `saas_list_account_owners()` (donos por tenant, dev-only) + `notify_all_devs` + trigger em subscriptions; `saas_app_config`/`get_root_redirect()`; edge `recover-access` (esqueci-email por slug → magic link, dica mascarada). Front: SaasUsuariosPage (/saas/usuarios, selo Novo 24h) + SaasAppConfigPage (/saas/app-config, blacklist CRUD + toggle root_redirect); esqueci-email dialog no Login; root_redirect no guard ("/" não-logado→/lp|/login, cache TTL); pegadinha #4 (notificationStore.reset no logout). Arquivos: manual/freemium_f3a/b/c + functions/recover-access. Build OK, DB testado em ROLLBACK. ⚠️ edge recover-access precisa deploy (F4).
- **F2 polish** ✅ DONE (2026-06-13) — welcome email: edge `send-welcome-email` (dono do tenant, destinatário do JWT, SMTP global/sistema com defaults Mailpit; best-effort fire-and-forget no OnboardingPage só no provision novo). Vitrine: seed `plan_public`+bullets dos free (migration 20260613000007); Landingpage mostra "Grátis para sempre" via `isFreePlan`. ⚠️ send-welcome-email precisa deploy + envs SMTP no hosted (F4). Com isso **F2 está 100%**.
- **F4** — Deploy (hosted, dirigido pelo Leonardo). **Runbook completo em `docs/DEPLOY_FREEMIUM_F4.md`** (commit 2f72886): pré-req #0 = schema-per-tenant no hosted antes; migrations 05/06/07 + 5 manual/freemium_f* + Auth dashboard + deploy das 2 edges + secrets SMTP + rebuild + smoke 8 passos + kill-switches.
Método: commits por assunto; cada migration testada em transação com ROLLBACK antes de aplicar; build a cada bloco front.
@@ -1,6 +1,98 @@
# Migração Schema-per-Tenant # Migração Schema-per-Tenant
**Status:** F2 concluída e smoke-testada (2026-06-12). Próximo: F3 (frontend useTenantDb). **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** ✅ DONE (commit d240c66, manual/f6_2d_user_rpcs.supabase_admin.sql + 18 sites FE; build passa). 14 RPCs (list_my_signatures→F). Helper `_tenant_route(p_tenant_id)` valida + RETORNA schema (não seta — set_config em helper com SET search_path próprio é revertido na saída! cada RPC faz o set_config). Grupo3 RETURNS<tabela>→jsonb (mark_as_paid, create_financial_record_for_session, mark_payout, create_therapist_payout). FE: p_tenant_id de activeTenantId; SETOF→jsonb transparente (nenhum consumidor indexava array). Smoke: mark_as_paid + search_global OK.
- **Gotchas Lote D**: (1) set_config em função-helper com `SET search_path` próprio é REVERTIDO ao retornar → helper retorna schema, RPC faz o set_config. (2) %ROWTYPE/RETURNS<tabela_tenant> quebram → RECORD/jsonb. (3) search_global é MISTO (patients/agenda no schema, patient_intake_requests em public/F1b). (4) seed_* chamados por provision ANTES do clone → no-op se schema não existe (fix de ordem no wiring). (5) can_delete_patient SQL sem SET search_path herda do chamador.
- Categorias originais (ref):
- **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 `<tabela_tenant>`/%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** ✅ DONE (commit 02acc88, manual/f6_2e_cron_rpcs.supabase_admin.sql + 2 edge). E2 (cleanup/unstick/sync_overdue/populate_notification_queue) varrem todos os schemas via loop `FROM tenant_schemas`. E1 (sla_*, whatsapp_heartbeat_*, convert_abandoned_intake_to_lead) per-tenant via service_role: helper `_tenant_schema_unchecked` (SEM is_tenant_member, pq service_role não é membro) + REVOKE de anon/authenticated. first_response_stats/_runs user-facing via _tenant_route. Edge whatsapp-heartbeat/sla ajustadas (admin.rpc + p_tenant_id). Smoke OK.
- **Gotchas Lote E**: (1) service_role NÃO é tenant_member → RPCs de cron precisam de helper sem auth-check + REVOKE de authenticated (senão um user chamaria com tenant arbitrário). (2) conversation_messages NÃO tem coluna thread_key (é computada na view) → analytics computa inline. (3) DROP+CREATE de nova assinatura: dropar AMBAS (velha+nova) p/ idempotência.
- **F** ✅ DONE (commit 1243a12, manual/f6_2f_anon_token_rpcs.supabase_admin.sql + 2 FE; build passa). Documentos anon resolvem tenant de `document_share_links.tenant_id` (public/F1b); agendador de `agendador_configuracoes.tenant_id`. document/document_signatures/access_logs/agenda no schema; share_links/agendador_* ficam public. %ROWTYPE→RECORD, RETURNS document_signatures→jsonb. sign_document_by_signature_id (paciente logado, NÃO é member): unchecked + auth por LINHA (signatario_id/email/doc do paciente). match_patient_by_phone: unchecked + REVOKE authenticated (só service). list_my_signatures: fan-out cross-schema. RPCs public-only (intake/invite/agendador_gerar_slug) SEM mudança. FE: signByPortal(tenantId,...).
- **Gotcha Lote F**: paciente assinante NÃO é tenant_member → autorizar por LINHA (dono da assinatura), não por membership. Anon resolve tenant SEMPRE da tabela public que tem o token+tenant_id.
- **G** ✅ DONE (commit ee82985, manual/f6_2g_sql_to_plpgsql.supabase_admin.sql + 3 FE; build passa). 5 funções SQL→plpgsql + p_tenant_id + _tenant_route (get_financial_summary/report, list_financial_records SETOF→jsonb, get_patient_session_counts sem filtro tenant_id). get_entity_primary_phone (interno) herda search_path. can_delete_patient/_first_response_runs já feitas em D/E. FE: p_tenant_id nas 3 RPCs financeiras.
## ✅✅ F6.2 COMPLETA (2026-06-13) — 66 funções migradas
Triggers (A agnósticos + B schema-aware + C notif) + RPCs (D usuário + E cron + F anon/token + G SQL→plpgsql). Tudo smoke-testado, build passa. Próximo: **wiring no clone** + **F6.3 DROP** (com OK do Leonardo).
-**wiring DONE** (commit dc7826d, manual/f6_2h_clone_wiring.supabase_admin.sql): trigger AFTER INSERT em tenant_schemas (trg_attach_business_triggers) dispara os 3 attach pro schema novo → tenant novo nasce com 84 triggers. attach_agnostic agora SELF-CONTAINED (dirigido por colunas, não lê public — sobrevive ao DROP). provision_account_tenant: clone ANTES do seed. Smoke OK.
- **F6.3 DROP** 📋 PREPARADA não-aplicada (commit cdb9ce1, manual/f6_3_drop_public_tenant_tables.supabase_admin.sql): pré-flight assert + 2 FKs viram coluna solta (document_share_links.documento_id, whatsapp_credits_transactions.conversation_message_id) + dropa 9 views public + DROP CASCADE das 78 + limpa financial_records_inject_tenant. **BLOQUEADA** pelos itens em aberto abaixo.
## ✅ SUPERFÍCIE SaaS-ADMIN RESOLVIDA (F6.4, commit dc2363b)
RPCs `saas_admin` (manual/f6_4_saas_admin_rpcs.supabase_admin.sql): defaults editados no `_tenant_template` + fan-out pros schemas (saas_list/add/remove_default_feriado; saas_*_default_notif_template; saas_count_notif_template_overrides). Cross-tenant: `saas_list_all_whatsapp_channels` (fan-out, substitui v_twilio_whatsapp_overview). FE: SaasFeriadosPage/SaasNotificationTemplatesPage → RPCs; SaasWhatsappPage → `supabase.schema(tenant_<slug>)` (RLS permite saas_admin) p/ tenant selecionado + RPC p/ overview; getAllChannels → RPC. **Varredura confirma ZERO supabase.from('<tabela_tenant>') público no FE.** F6.3 DESBLOQUEADA (falta só Leonardo testar app + backup). TODOs deixados: stat-cards de feriados (cidade/estado) e incidents-7d viraram 0 (UI degrada sem crash).
## (histórico) ITENS EM ABERTO antes do F6.3 DROP — RESOLVIDOS acima
Superfície **SaaS-admin / cross-tenant** que ainda lê `public.<tabela_tenant>` e quebraria no DROP:
1. **SaasWhatsappPage.vue + v_twilio_whatsapp_overview + twilioWhatsappService.getAllChannels()** — admin cross-tenant de canais WhatsApp (notification_channels/whatsapp_connection_incidents). Reescrever fan-out por schema OU usar `public.channel_routing`.
2. **SaasNotificationTemplatesPage.vue** — gerencia templates DEFAULT do sistema (tenant_id NULL). Apontar pra `_tenant_template.notification_templates` (os defaults vivem lá agora).
3. **SaasFeriadosPage.vue** — gerencia feriados nacionais default. Idem `_tenant_template.feriados`.
4. **notification-webhook** (Meta) — conferir fan-out/channel_routing.
Decisão de arquitetura: as páginas que editam DEFAULTS do sistema devem editar `_tenant_template` (propaga a tenants novos); as views cross-tenant admin devem fan-out por schema ou usar channel_routing. Resolver, testar, então aplicar F6.3.
## 🟢 APP TESTÁVEL AGORA (pós-wiring, pré-DROP)
Dados nos schemas (F6.1) + 66 funções/triggers/RPCs roteiam (F6.2) + PostgREST expõe (F5) + frontend usa tenantDb (F3) + edge roteia (F4). Os dados ainda estão ESPELHADOS em public (nada dropado). Leonardo deve abrir o app no branch `feat/schema-per-tenant` e testar fluxos reais (agenda, financeiro, pacientes, documentos, notificações). Só após validação → F6.3 DROP.
## 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) ## 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). 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).
+1
View File
@@ -31,3 +31,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.* *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) - [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4)
- [[Freemium PLG]] — signup self-service + Upgrade PRO; plano gratuito limitado (pacientes); confirmação de e-mail + onboarding; branch feat/freemium-plg
@@ -0,0 +1,57 @@
-- =============================================================================
-- F5 (parte supabase_admin) — refresh dinâmico dos schemas expostos no PostgREST
--
-- ⚠️ APLICAR COMO supabase_admin (postgres NÃO é superuser neste stack e não
-- consegue ALTER ROLE authenticator). Mesmo padrão do gotcha de `documents`.
--
-- docker exec -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres \
-- -f /dev/stdin < database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql
--
-- A config in-database do PostgREST (db-config, ligada por padrão) lê
-- pgrst.db_schemas da role `authenticator`. Setar essa GUC + NOTIFY reload
-- expõe/retira schemas tenant SEM restart do container. A GUC persiste em
-- pg_db_role_setting (sobrevive a supabase stop/start).
--
-- A lista é derivada SEMPRE de public.tenant_schemas (fonte da verdade dos
-- schemas provisionados). Disparada pelo trigger em tenant_schemas (migration
-- 20260613000002) a cada clone/drop de tenant.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.refresh_pgrst_schemas()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER -- roda como o OWNER (supabase_admin/superuser)
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_list text;
BEGIN
SELECT string_agg(s, ', ' ORDER BY ord, s)
INTO v_list
FROM (
SELECT 'public'::text AS s, 0 AS ord
UNION ALL SELECT 'graphql_public', 1
UNION ALL SELECT schema_name, 2 FROM public.tenant_schemas
) x;
-- baseline defensivo se a tabela ainda não existir / vazia
IF v_list IS NULL OR v_list = '' THEN
v_list := 'public, graphql_public';
END IF;
EXECUTE format('ALTER ROLE authenticator SET pgrst.db_schemas = %L', v_list);
NOTIFY pgrst, 'reload config'; -- re-lê db_schemas
NOTIFY pgrst, 'reload schema'; -- reconstrói o cache de schema
RETURN v_list;
END;
$$;
-- Garante owner superuser (caso a função já existisse owned por postgres)
ALTER FUNCTION public.refresh_pgrst_schemas() OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.refresh_pgrst_schemas() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.refresh_pgrst_schemas() TO postgres, service_role;
-- Seta o baseline imediatamente
SELECT public.refresh_pgrst_schemas();
@@ -0,0 +1,140 @@
-- =============================================================================
-- F6.1 — Migração de DADOS public -> schemas tenant (cutover)
--
-- ⚠️ APLICAR COMO supabase_admin (precisa SET session_replication_role=replica
-- pra desabilitar checagem de FK durante o bulk insert — postgres não pode).
--
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_1_migrate_data.supabase_admin.sql
--
-- COPIA (não move) os dados de cada tenant pras suas tabelas no schema. Os
-- dados continuam em public até o DROP da F6.3. Idempotente via ON CONFLICT
-- DO NOTHING (rodar de novo não duplica).
--
-- * tabelas com tenant_id: INSERT ... SELECT WHERE tenant_id = <id>, sem a
-- coluna tenant_id (não existe no schema)
-- * 3 filhas sem tenant_id (commitment_services, insurance_plan_services,
-- recurrence_rule_services): particionadas via JOIN no pai
-- * financial_categories / therapist_payout_records: 0 linhas, ignoradas
-- * as 6 tabelas anon-facing (F1b) NÃO existem no schema → naturalmente fora
-- * reset de sequences (4 tabelas bigserial) ao final
-- =============================================================================
SET session_replication_role = replica;
DO $$
DECLARE
t_row record;
tab record;
v_cols text;
v_n bigint;
-- filhas sem tenant_id: tabela -> (pai, fk_local, pk_pai)
child_joins jsonb := jsonb_build_object(
'commitment_services', jsonb_build_object('parent','agenda_eventos','fk','commitment_id'),
'insurance_plan_services', jsonb_build_object('parent','insurance_plans','fk','insurance_plan_id'),
'recurrence_rule_services', jsonb_build_object('parent','recurrence_rules','fk','rule_id')
);
cj jsonb;
BEGIN
FOR t_row IN
SELECT t.id AS tenant_id, ts.schema_name
FROM public.tenants t
JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
ORDER BY t.created_at, t.id
LOOP
FOR tab IN
SELECT c.relname AS table_name
FROM pg_class c
WHERE c.relnamespace = t_row.schema_name::regnamespace
AND c.relkind = 'r'
AND c.relname NOT LIKE '\_%'
ORDER BY c.relname
LOOP
-- pula se a tabela não existe em public (defensivo)
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema='public' AND table_name=tab.table_name) THEN
CONTINUE;
END IF;
-- colunas presentes em AMBOS (schema e public): exclui tenant_id
-- (some no schema), singleton (só no schema, fica no default) e
-- colunas GENERATED (net_amount, margin_brl — não aceitam INSERT)
SELECT string_agg(quote_ident(sc.column_name), ', ' ORDER BY sc.ordinal_position)
INTO v_cols
FROM information_schema.columns sc
WHERE sc.table_schema = t_row.schema_name AND sc.table_name = tab.table_name
AND sc.is_generated = 'NEVER'
AND EXISTS (SELECT 1 FROM information_schema.columns pc
WHERE pc.table_schema='public' AND pc.table_name=tab.table_name
AND pc.column_name = sc.column_name
AND pc.is_generated = 'NEVER');
IF v_cols IS NULL THEN CONTINUE; END IF;
cj := child_joins -> tab.table_name;
IF cj IS NOT NULL THEN
-- filha sem tenant_id: particiona via JOIN no pai
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ch '
|| 'JOIN public.%I p ON p.id = ch.%I WHERE p.tenant_id = %L '
|| 'ON CONFLICT DO NOTHING',
t_row.schema_name, tab.table_name, v_cols,
(SELECT string_agg('ch.'||quote_ident(x), ', ' ORDER BY ord)
FROM (SELECT trim(both ' ' from unnest(string_to_array(v_cols, ','))) AS x,
generate_subscripts(string_to_array(v_cols, ','),1) AS ord) y),
tab.table_name,
(cj->>'parent'), (cj->>'fk'),
t_row.tenant_id
);
ELSIF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name=tab.table_name AND column_name='tenant_id') THEN
-- tabela com tenant_id: filtro direto
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
t_row.schema_name, tab.table_name, v_cols, v_cols, tab.table_name, t_row.tenant_id
);
ELSE
-- sem tenant_id e não é filha mapeada (financial_categories etc.):
-- só migra se tiver 0 dependência de tenant — pula (vazias hoje)
CONTINUE;
END IF;
GET DIAGNOSTICS v_n = ROW_COUNT;
IF v_n > 0 THEN
RAISE NOTICE 'F6.1 %.%: % linhas', t_row.schema_name, tab.table_name, v_n;
END IF;
END LOOP;
END LOOP;
END $$;
-- ---------------------------------------------------------------------------
-- Reset de sequences (tabelas bigserial) em cada schema
-- ---------------------------------------------------------------------------
DO $$
DECLARE
t_row record;
r record;
v_seq text;
BEGIN
FOR t_row IN SELECT schema_name FROM public.tenant_schemas LOOP
FOR r IN
SELECT c.relname AS table_name, a.attname AS column_name
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 = t_row.schema_name::regnamespace
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'
LOOP
v_seq := pg_get_serial_sequence(format('%I.%I', t_row.schema_name, r.table_name), r.column_name);
IF v_seq IS NOT NULL THEN
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0) + 1, false)',
v_seq, r.column_name, t_row.schema_name, r.table_name);
RAISE NOTICE 'F6.1 seq %.% -> %', t_row.schema_name, r.table_name, v_seq;
END IF;
END LOOP;
END LOOP;
END $$;
SET session_replication_role = origin;
@@ -0,0 +1,434 @@
-- =============================================================================
-- F6.2 Lote B — triggers schema-aware
-- ⚠️ APLICAR COMO supabase_admin (trigger functions sao owned por supabase_admin):
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \n-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \n-- < database-novo/manual/f6_2b_schema_aware_triggers.supabase_admin.sql
--
-- Estratégia hybrid: as funções são reescritas IN PLACE pra operar no schema do
-- TG_TABLE_SCHEMA (search_path dinâmico + tenant_id_for_schema). Como ficariam
-- erradas nas tabelas de public (TG_TABLE_SCHEMA='public'), DESANEXAMOS dos
-- tenant-tables de public e ANEXAMOS só nos schemas. Writes de public via RPCs
-- ainda-não-migrados (Lote D) perdem esses side-effects no curto hybrid —
-- aceitável (public vai ser dropado na F6.3 e o app lê dos schemas).
--
-- Exclui os que escrevem em notifications (Lote C, com o split):
-- notify_on_session_status, fanout_inbound_message_to_notifications,
-- cancel_notifications_on_opt_out/on_session_cancel, fn_notify_agenda_status_change
-- =============================================================================
BEGIN;
-- ───────────────────────────────────────────────────────────────────────────
-- 1) Rewrites — tabelas tenant via search_path (unqualified); globais com public.
-- ───────────────────────────────────────────────────────────────────────────
-- audit_logs é GLOBAL → tenant_id vem do schema
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
BEGIN
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
IF TG_OP = 'DELETE' THEN
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
ELSE
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
IF v_changed IS NULL THEN RETURN NEW; END IF;
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
END IF;
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
RETURN COALESCE(NEW, OLD);
END $$;
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO patient_status_history (patient_id, status_anterior, status_novo, motivo, encaminhado_para, data_saida, alterado_por, alterado_em)
VALUES (NEW.id, CASE WHEN TG_OP='INSERT' THEN NULL ELSE OLD.status END, NEW.status, NEW.motivo_saida, NEW.encaminhado_para, NEW.data_saida, auth.uid(), now());
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
VALUES (NEW.id, 'status_alterado', 'Status alterado para ' || NEW.status,
CASE WHEN TG_OP='INSERT' THEN 'Paciente cadastrado'
ELSE 'De ' || OLD.status || '' || NEW.status || CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END END,
CASE NEW.status WHEN 'Ativo' THEN 'green' WHEN 'Alta' THEN 'blue' WHEN 'Inativo' THEN 'gray' WHEN 'Encaminhado' THEN 'amber' WHEN 'Arquivado' THEN 'gray' ELSE 'gray' END,
auth.uid(), now());
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
VALUES (NEW.id, CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
NEW.risco_nota, CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END, auth.uid(), now());
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.auto_create_financial_record_from_session()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_price numeric(10,2); v_services_total numeric(10,2); v_already_billed boolean;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF NEW.status::text <> 'realizado' THEN RETURN NEW; END IF;
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN RETURN NEW; END IF;
IF NEW.tipo::text <> 'sessao' THEN RETURN NEW; END IF;
IF NEW.patient_id IS NULL THEN RETURN NEW; END IF;
IF NEW.billing_contract_id IS NOT NULL THEN RETURN NEW; END IF;
SELECT billed INTO v_already_billed FROM agenda_eventos WHERE id = NEW.id;
IF v_already_billed = TRUE THEN
IF EXISTS (SELECT 1 FROM financial_records WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL) THEN
RETURN NEW;
END IF;
END IF;
v_price := NULL;
IF NEW.recurrence_id IS NOT NULL THEN
SELECT COALESCE(SUM(rrs.final_price), 0) INTO v_services_total
FROM recurrence_rule_services rrs WHERE rrs.rule_id = NEW.recurrence_id;
IF v_services_total > 0 THEN v_price := v_services_total; END IF;
IF v_price IS NULL OR v_price = 0 THEN
SELECT price INTO v_price FROM recurrence_rules WHERE id = NEW.recurrence_id;
END IF;
END IF;
IF v_price IS NULL OR v_price = 0 THEN v_price := NEW.price; END IF;
IF v_price IS NULL OR v_price <= 0 THEN RETURN NEW; END IF;
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, type, amount, discount_amount, final_amount, clinic_fee_pct, clinic_fee_amount, status, due_date)
VALUES (NEW.owner_id, NEW.patient_id, NEW.id, 'receita', v_price, 0, v_price, 0, 0, 'pending', (NEW.inicio_em::date + 7));
UPDATE agenda_eventos SET billed = TRUE WHERE id = NEW.id;
RETURN NEW;
EXCEPTION WHEN OTHERS THEN
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%', NEW.id, SQLERRM;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_sla_resolve_on_outbound()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_thread_key text;
BEGIN
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
v_thread_key := COALESCE(NEW.patient_id::text, 'anon:' || COALESCE(NEW.to_number, 'unknown'));
UPDATE conversation_sla_breaches SET resolved_at = now(), resolved_by_message_id = NEW.id
WHERE thread_key = v_thread_key AND resolved_at IS NULL;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE next_version integer; reason text;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT COALESCE(MAX(version_number), 0) + 1 INTO next_version FROM clinical_note_versions WHERE note_id = NEW.id;
IF TG_OP = 'INSERT' THEN reason := 'criacao';
ELSIF TG_OP = 'UPDATE' THEN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN reason := 'soft_delete';
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN reason := 'restore';
ELSE reason := 'edicao'; END IF;
ELSE reason := 'desconhecido'; END IF;
INSERT INTO clinical_note_versions (note_id, version_number, title, content_text, content_structured, change_reason, created_at, created_by)
VALUES (NEW.id, next_version, NEW.title, NEW.content_text, NEW.content_structured, reason, now(), COALESCE(NEW.updated_by, NEW.created_by));
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_patient_id uuid; v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT d.patient_id, d.nome_original INTO v_patient_id, v_doc_nome FROM documents d WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
VALUES (v_patient_id, 'documento_assinado', 'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo), 'green', 'documento', NEW.documento_id, NEW.signatario_id, NEW.assinado_em);
END IF;
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
VALUES (NEW.patient_id, 'documento_adicionado', 'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'), 'blue', 'documento', NEW.id, NEW.uploaded_by, NEW.uploaded_at);
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.sync_legacy_email_fields()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
SELECT email INTO v_primary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
SELECT email INTO v_secondary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE patients SET email_principal = v_primary, email_alternativo = v_secondary WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE medicos SET email = v_primary WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END $$;
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
SELECT number INTO v_primary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
SELECT number INTO v_secondary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE patients SET telefone = v_primary, telefone_alternativo = v_secondary WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE medicos SET telefone_profissional = v_primary, telefone_pessoal = v_secondary WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END $$;
CREATE OR REPLACE FUNCTION public.fn_agenda_regras_semanais_no_overlap()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_count int;
BEGIN
IF new.ativo IS false THEN RETURN new; END IF;
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT count(*) INTO v_count FROM agenda_regras_semanais r
WHERE r.owner_id = new.owner_id AND r.dia_semana = new.dia_semana AND r.ativo IS true
AND (TG_OP = 'INSERT' OR r.id <> new.id)
AND (new.hora_inicio < r.hora_fim AND new.hora_fim > r.hora_inicio);
IF v_count > 0 THEN RAISE EXCEPTION 'Janela sobreposta: já existe uma regra ativa nesse intervalo.'; END IF;
RETURN new;
END $$;
-- valida member consistency: tenant_id vem do schema; tenant_members é GLOBAL
CREATE OR REPLACE FUNCTION public.patients_validate_member_consistency()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_tid uuid; v_tenant_responsible uuid; v_tenant_therapist uuid;
BEGIN
v_tid := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
SELECT tenant_id INTO v_tenant_responsible FROM public.tenant_members WHERE id = NEW.responsible_member_id;
IF v_tenant_responsible IS NULL THEN RAISE EXCEPTION 'Responsible member not found'; END IF;
IF v_tid IS NULL THEN RAISE EXCEPTION 'tenant não resolvido para schema %', TG_TABLE_SCHEMA; END IF;
IF v_tenant_responsible <> v_tid THEN RAISE EXCEPTION 'Responsible member must belong to the same tenant'; END IF;
IF NEW.patient_scope = 'therapist' THEN
IF NEW.therapist_member_id IS NULL THEN RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist'; END IF;
SELECT tenant_id INTO v_tenant_therapist FROM public.tenant_members WHERE id = NEW.therapist_member_id;
IF v_tenant_therapist IS NULL THEN RAISE EXCEPTION 'Therapist member not found'; END IF;
IF v_tenant_therapist <> v_tid THEN RAISE EXCEPTION 'Therapist member must belong to the same tenant'; END IF;
END IF;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 2) sync_busy_mirror — CROSS-TENANT: evento pessoal espelha "Ocupado" nas
-- clínicas onde o owner é therapist. Escreve no schema de OUTROS tenants.
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sync_busy_mirror_agenda_eventos()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_source_tenant uuid;
is_personal boolean;
should_mirror boolean;
v_owner uuid;
v_src_id uuid;
clinic record;
v_cschema text;
BEGIN
-- anti-recursão: espelho não espelha
IF TG_OP <> 'DELETE' THEN
IF NEW.mirror_of_event_id IS NOT NULL THEN RETURN NEW; END IF;
v_owner := NEW.owner_id; v_src_id := NEW.id;
ELSE
IF OLD.mirror_of_event_id IS NOT NULL THEN RETURN OLD; END IF;
v_owner := OLD.owner_id; v_src_id := OLD.id;
END IF;
v_source_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
is_personal := (v_source_tenant = v_owner); -- convenção: tenant pessoal tem id = owner
IF TG_OP = 'DELETE' THEN
should_mirror := (OLD.visibility_scope IN ('busy_only','private'));
ELSE
should_mirror := (NEW.visibility_scope IN ('busy_only','private'));
END IF;
IF NOT is_personal THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
-- DELETE ou não-deve-espelhar: remove espelhos em todas as clínicas do owner
IF TG_OP = 'DELETE' OR NOT should_mirror THEN
FOR clinic IN
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
LOOP
v_cschema := public.tenant_schema_for(clinic.tenant_id);
IF v_cschema IS NULL THEN CONTINUE; END IF;
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
v_cschema, v_src_id, 'personal_busy_mirror');
END LOOP;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
-- INSERT/UPDATE com espelho: upsert "Ocupado" em cada clínica do owner
FOR clinic IN
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
LOOP
v_cschema := public.tenant_schema_for(clinic.tenant_id);
IF v_cschema IS NULL THEN CONTINUE; END IF;
EXECUTE format(
'INSERT INTO %I.agenda_eventos (owner_id, terapeuta_id, patient_id, tipo, status, titulo, observacoes, inicio_em, fim_em, mirror_of_event_id, mirror_source, visibility_scope, created_at, updated_at) '
|| 'VALUES ($1,$1,NULL,$2::public.tipo_evento_agenda,$3::public.status_evento_agenda,$4,NULL,$5,$6,$7,$8,$9,now(),now()) '
|| 'ON CONFLICT (mirror_of_event_id) WHERE mirror_of_event_id IS NOT NULL '
|| 'DO UPDATE SET owner_id=EXCLUDED.owner_id, terapeuta_id=EXCLUDED.terapeuta_id, tipo=EXCLUDED.tipo, status=EXCLUDED.status, titulo=EXCLUDED.titulo, observacoes=EXCLUDED.observacoes, inicio_em=EXCLUDED.inicio_em, fim_em=EXCLUDED.fim_em, updated_at=now()',
v_cschema)
USING v_owner, 'bloqueio', 'agendado', 'Ocupado', NEW.inicio_em, NEW.fim_em, v_src_id, 'personal_busy_mirror', 'public';
END LOOP;
-- remove espelhos de clínicas onde o vínculo therapist active sumiu
FOR clinic IN
SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts
WHERE NOT EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = v_owner AND tm.role='therapist' AND tm.status='active' AND tm.tenant_id = ts.tenant_id
)
LOOP
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
clinic.schema_name, v_src_id, 'personal_busy_mirror');
END LOOP;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 3) financial_records_inject_tenant — OBSOLETO no schema (sem coluna tenant_id).
-- Mantém em public (legacy) mas NÃO anexa nos schemas.
-- ───────────────────────────────────────────────────────────────────────────
-- ───────────────────────────────────────────────────────────────────────────
-- 4) Detach dos tenant-tables de public + attach nos schemas
-- ───────────────────────────────────────────────────────────────────────────
-- Detacha das tabelas tenant em public os triggers schema-aware (ficariam errados lá)
DO $$
DECLARE
aware text[] := ARRAY[
'log_audit_change','trg_fn_patient_status_history','trg_fn_patient_status_timeline',
'trg_fn_patient_risco_timeline','auto_create_financial_record_from_session',
'fn_sla_resolve_on_outbound','fn_clinical_note_version','fn_document_signature_timeline',
'fn_documents_timeline_insert','sync_legacy_email_fields','sync_legacy_phone_fields',
'fn_agenda_regras_semanais_no_overlap','patients_validate_member_consistency',
'sync_busy_mirror_agenda_eventos'
];
r record;
BEGIN
FOR r IN
SELECT c.relname AS tab, t.tgname
FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_proc p ON p.oid=t.tgfoid
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
END LOOP;
END $$;
-- Attach nos schemas. Specs derivadas dos triggerdefs REAIS de public, com
-- tenant_id removido de WHEN/UPDATE OF (não existe no schema). __T__ = schema.tabela.
CREATE OR REPLACE FUNCTION public.attach_schema_aware_triggers(p_schema text)
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
specs jsonb := jsonb_build_array(
jsonb_build_object('tab','patients','name','trg_patient_status_history','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history()'),
jsonb_build_object('tab','patients','name','trg_patient_status_timeline','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline()'),
jsonb_build_object('tab','patients','name','trg_patient_risco_timeline','spec','AFTER UPDATE OF risco_elevado ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline()'),
jsonb_build_object('tab','patients','name','trg_audit_patients','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','patients','name','trg_patients_validate_members','spec','BEFORE INSERT OR UPDATE OF responsible_member_id, patient_scope, therapist_member_id ON __T__ FOR EACH ROW EXECUTE FUNCTION public.patients_validate_member_consistency()'),
jsonb_build_object('tab','agenda_eventos','name','trg_audit_agenda_eventos','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','agenda_eventos','name','trg_auto_financial_from_session','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_ins','spec','AFTER INSERT ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND new.visibility_scope = ANY (ARRAY[''busy_only''::text, ''private''::text])) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_upd','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND (new.visibility_scope IS DISTINCT FROM old.visibility_scope OR new.inicio_em IS DISTINCT FROM old.inicio_em OR new.fim_em IS DISTINCT FROM old.fim_em OR new.owner_id IS DISTINCT FROM old.owner_id)) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_del','spec','AFTER DELETE ON __T__ FOR EACH ROW WHEN (old.mirror_of_event_id IS NULL) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
jsonb_build_object('tab','financial_records','name','trg_audit_financial_records','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','financial_records','name','trg_financial_records_auto_overdue','spec','BEFORE UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue()'),
jsonb_build_object('tab','documents','name','trg_audit_documents','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','documents','name','trg_documents_timeline_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert()'),
jsonb_build_object('tab','document_signatures','name','trg_ds_timeline','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_document_signature_timeline()'),
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_clinical_note_version()'),
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_update','spec','AFTER UPDATE OF content_text, content_structured, title, deleted_at ON __T__ FOR EACH ROW WHEN (old.content_text IS DISTINCT FROM new.content_text OR old.content_structured IS DISTINCT FROM new.content_structured OR old.title IS DISTINCT FROM new.title OR old.deleted_at IS DISTINCT FROM new.deleted_at) EXECUTE FUNCTION public.fn_clinical_note_version()'),
jsonb_build_object('tab','conversation_messages','name','trg_sla_resolve_on_outbound','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound()'),
jsonb_build_object('tab','contact_emails','name','trg_contact_emails_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields()'),
jsonb_build_object('tab','contact_phones','name','trg_contact_phones_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields()'),
jsonb_build_object('tab','agenda_regras_semanais','name','trg_agenda_regras_semanais_no_overlap','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap()'),
jsonb_build_object('tab','agenda_configuracoes','name','trg_agenda_cfg_sync','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.agenda_cfg_sync()')
);
el jsonb; v_count int := 0; v_target text;
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
v_target := format('%I.%I', p_schema, el->>'tab');
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec', '__T__', v_target);
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END $$;
DO $$
DECLARE r record; v int;
BEGIN
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
v := public.attach_schema_aware_triggers(r.schema_name);
RAISE NOTICE 'F6.2B %: % triggers schema-aware', r.schema_name, v;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,266 @@
-- =============================================================================
-- F6.2 Lote C — split de notifications (tenant-local vs SaaS cross-tenant)
--
-- ⚠️ APLICAR COMO supabase_admin (CREATE OR REPLACE de funções owned por
-- postgres E supabase_admin; superuser preserva o owner):
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_2c_notifications_split.supabase_admin.sql
--
-- Neste projeto, TODAS as notificações atuais (inbound_message, session_status,
-- system_alert, new_patient) são tenant-LOCAIS (avisos cross-tenant do SaaS
-- vivem em global_notices). Então:
-- * notifications continua tenant-local → já vive no schema do tenant (F6.1)
-- * public.notifications_sistema é criado como o canal SaaS→tenant / dev
-- cross-tenant (vazio hoje; pronto pro futuro: suporte, billing, etc.)
-- Triggers de notif reescritos schema-aware; os que disparam em tabelas PUBLIC
-- (notify_on_intake, notify_on_scheduling) roteiam pro schema via EXECUTE format.
-- =============================================================================
BEGIN;
-- ───────────────────────────────────────────────────────────────────────────
-- 1) notifications_sistema (GLOBAL, cross-tenant)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.notifications_sistema (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL, -- destinatário (user do tenant OU dev)
tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE, -- contexto (nullable: alerta global)
type text NOT NULL,
ref_id uuid,
ref_table text,
payload jsonb,
read_at timestamptz,
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS notif_sistema_owner_idx ON public.notifications_sistema (owner_id, created_at DESC) WHERE archived = false;
ALTER TABLE public.notifications_sistema ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS notif_sistema_owner ON public.notifications_sistema;
CREATE POLICY notif_sistema_owner ON public.notifications_sistema
FOR ALL TO authenticated USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());
-- realtime
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname='supabase_realtime' AND schemaname='public' AND tablename='notifications_sistema') THEN
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications_sistema;
END IF;
END $$;
-- helper pro futuro: emite notificação cross-tenant (dev/SaaS -> destinatário)
CREATE OR REPLACE FUNCTION public.notify_user_sistema(
p_owner_id uuid, p_type text, p_payload jsonb,
p_tenant_id uuid DEFAULT NULL, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_id uuid;
BEGIN
INSERT INTO public.notifications_sistema (owner_id, tenant_id, type, ref_id, ref_table, payload)
VALUES (p_owner_id, p_tenant_id, p_type, p_ref_id, p_ref_table, p_payload)
RETURNING id INTO v_id;
RETURN v_id;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 2) Rewrites dos triggers de notif (tenant-local) — schema-aware
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.notify_on_session_status()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_nome text;
BEGIN
IF NEW.status IN ('faltou','cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT nome_completo INTO v_nome FROM patients WHERE id = NEW.patient_id LIMIT 1;
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
VALUES (NEW.owner_id, 'session_status', NEW.id, 'agenda_eventos',
jsonb_build_object(
'title', CASE WHEN NEW.status='faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
'detail', COALESCE(v_nome,'Paciente') || '' || to_char(NEW.inicio_em,'DD/MM HH24:MI'),
'deeplink', '/therapist/agenda',
'avatar_initials', upper(left(COALESCE(v_nome,'?'),2))));
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_target_user uuid; v_title text; v_detail text; v_initials text; v_deeplink text;
v_patient_name text; v_payload jsonb; v_tenant uuid;
BEGIN
IF NEW.direction <> 'inbound' THEN RETURN NEW; END IF;
v_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF NEW.patient_id IS NOT NULL THEN
SELECT nome_completo INTO v_patient_name FROM patients WHERE id = NEW.patient_id;
END IF;
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
IF v_patient_name IS NOT NULL THEN
v_initials := upper(left(v_patient_name,1)) || COALESCE(upper(left(split_part(v_patient_name,' ',2),1)),'');
ELSE v_initials := '?'; END IF;
v_deeplink := '/admin/conversas';
v_payload := jsonb_build_object('title',v_title,'detail',v_detail,'avatar_initials',v_initials,
'deeplink',v_deeplink,'channel',NEW.channel,'conversation_message_id',NEW.id,
'patient_id',NEW.patient_id,'from_number',NEW.from_number);
-- destinatário: responsável do paciente (tenant_members é GLOBAL)
IF NEW.patient_id IS NOT NULL THEN
SELECT tm.user_id INTO v_target_user
FROM patients p JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
WHERE p.id = NEW.patient_id AND tm.status = 'active' LIMIT 1;
IF v_target_user IS NOT NULL THEN
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
VALUES (v_target_user, 'inbound_message', NULL, 'conversation_messages', v_payload);
RETURN NEW;
END IF;
END IF;
-- fallback: fan-out pros admins/therapists ativos do tenant (global)
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
SELECT tm.user_id, 'inbound_message', NULL, 'conversation_messages', v_payload
FROM public.tenant_members tm
WHERE tm.tenant_id = v_tenant AND tm.status = 'active'
AND tm.role IN ('clinic_admin','tenant_admin','therapist');
RETURN NEW;
END $$;
-- helper de cancelamento: notification_queue é tenant; herda search_path do trigger chamador
CREATE OR REPLACE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL, p_evento_id uuid DEFAULT NULL)
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE v_canceled integer;
BEGIN
UPDATE notification_queue SET status='cancelado', updated_at=now()
WHERE patient_id = p_patient_id AND status IN ('pendente','processando')
AND (p_channel IS NULL OR channel = p_channel)
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
GET DIAGNOSTICS v_canceled = ROW_COUNT;
RETURN v_canceled;
END $$;
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_opt_out()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'whatsapp');
END IF;
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'email');
END IF;
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'sms');
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, NULL, NEW.id);
END IF;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 3) Triggers que disparam em tabelas PUBLIC (intake/scheduling, F1b) —
-- roteiam a notificação pro schema do tenant via EXECUTE format
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.notify_on_intake()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_schema text;
BEGIN
IF NEW.status = 'new' THEN
v_schema := public.tenant_schema_for(NEW.tenant_id);
IF v_schema IS NULL THEN RETURN NEW; END IF;
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
USING NEW.owner_id, 'new_patient', NEW.id, 'patient_intake_requests',
jsonb_build_object('title','Novo cadastro externo','detail',COALESCE(NEW.nome_completo,'Paciente'),
'deeplink','/therapist/patients/cadastro/recebidos','avatar_initials',upper(left(COALESCE(NEW.nome_completo,'?'),2)));
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.notify_on_scheduling()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_schema text;
BEGIN
IF NEW.status = 'pendente' THEN
v_schema := public.tenant_schema_for(NEW.tenant_id);
IF v_schema IS NULL THEN RETURN NEW; END IF;
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
USING NEW.owner_id, 'new_scheduling', NEW.id, 'agendador_solicitacoes',
jsonb_build_object('title','Nova solicitação de agendamento',
'detail', COALESCE(NEW.paciente_nome,'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome,'') || '' || COALESCE(NEW.tipo,''),
'deeplink','/therapist/agendamentos-recebidos',
'avatar_initials', upper(left(COALESCE(NEW.paciente_nome,'?'),1) || left(COALESCE(NEW.paciente_sobrenome,''),1)));
END IF;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 4) Detach dos notif-triggers tenant de public + attach nos schemas (estende
-- attach_schema_aware_triggers com os 5 triggers de notif tenant)
-- ───────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
aware text[] := ARRAY['notify_on_session_status','fanout_inbound_message_to_notifications',
'cancel_notifications_on_opt_out','cancel_notifications_on_session_cancel'];
r record;
BEGIN
FOR r IN
SELECT c.relname AS tab, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid
JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_proc p ON p.oid=t.tgfoid
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.attach_notif_triggers(p_schema text)
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
specs jsonb := jsonb_build_array(
jsonb_build_object('tab','agenda_eventos','name','trg_notify_on_session_status','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status()'),
jsonb_build_object('tab','agenda_eventos','name','trg_cancel_notifs_on_session_cancel','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.status IS DISTINCT FROM old.status) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_status_notify','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change()'),
jsonb_build_object('tab','conversation_messages','name','trg_fanout_inbound_to_notifications','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications()'),
jsonb_build_object('tab','notification_preferences','name','trg_cancel_notifs_on_opt_out','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out()')
);
el jsonb; v_count int := 0; v_target text;
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
v_target := format('%I.%I', p_schema, el->>'tab');
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec','__T__',v_target);
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END $$;
DO $$
DECLARE r record; v int;
BEGIN
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
v := public.attach_notif_triggers(r.schema_name);
RAISE NOTICE 'F6.2C %: % notif triggers', r.schema_name, v;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,412 @@
-- =============================================================================
-- F6.2 Lote D — RPCs user-facing roteadas pro schema do tenant
--
-- ⚠️ APLICAR COMO supabase_admin (mix de funções owned postgres/supabase_admin).
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql
--
-- Padrão: valida is_tenant_member(p_tenant_id) + set_config search_path pro
-- schema do tenant; remove `WHERE tenant_id=` e tenant_id de inserts; unqualify
-- tabelas tenant; %ROWTYPE→RECORD; RETURNS <tabela_tenant>→jsonb.
-- Tabelas que FICAM em public (audit_logs global; patient_intake_requests,
-- document_share_links F1b) seguem com `public.` + filtro tenant_id.
--
-- list_my_signatures é CROSS-TENANT (assinante em vários tenants) → Lote F.
-- =============================================================================
BEGIN;
-- helper: valida acesso e RETORNA o schema do tenant. NÃO seta search_path
-- (set_config feito dentro de helper com SET search_path próprio seria revertido
-- na saída do helper). Cada RPC faz: PERFORM set_config('search_path',
-- public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
CREATE OR REPLACE FUNCTION public._tenant_route(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 '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 'Sem permissão no 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 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
RETURN v_schema;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- GRUPO 1 — já têm p_tenant_id, RETURNS jsonb/void (CREATE OR REPLACE)
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found'; END IF;
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
IF v_parent <> 1 THEN RAISE EXCEPTION 'Parent not deleted'; END IF;
RETURN jsonb_build_object('ok',true,'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
END $$;
CREATE OR REPLACE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found for tenant'; END IF;
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
IF v_parent <> 1 THEN RAISE EXCEPTION 'Delete did not remove the commitment'; END IF;
RETURN jsonb_build_object('ok',true,'tenant_id',p_tenant_id,'commitment_id',p_commitment_id,
'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
END $$;
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_owner_id uuid; v_schema text;
BEGIN
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF; -- schema ainda não existe (chamado antes do clone): no-op
SELECT user_id INTO v_owner_id FROM public.tenant_members
WHERE tenant_id = p_tenant_id AND role='tenant_admin' AND status='active' LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
INSERT INTO patient_groups (owner_id, nome, cor, is_system)
VALUES (v_owner_id,'Crianças','#60a5fa',true),
(v_owner_id,'Adolescentes','#a78bfa',true),
(v_owner_id,'Idosos','#34d399',true)
ON CONFLICT (owner_id, nome) DO NOTHING;
END $$;
-- seed_determined_commitments: idêntico em estrutura, sem tenant_id nos inserts.
-- Recriado integralmente.
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_id uuid; v_schema text;
BEGIN
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='session') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'session',true,true,'Sessão','Sessão com paciente');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='reading') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'reading',false,true,'Leitura','Praticar leitura');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='supervision') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'supervision',false,true,'Supervisão','Supervisão');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='class') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'class',false,false,'Aula','Dar aula');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='analysis') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'analysis',false,true,'Análise Pessoal','Minha análise pessoal');
END IF;
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='session' LIMIT 1;
IF v_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order)
VALUES (v_id,'notes','Observação','textarea',false,30);
END IF;
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='reading' LIMIT 1;
IF v_id IS NOT NULL THEN
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='book') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'book','Livro','text',false,10);
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='author') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'author','Autor','text',false,20);
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'notes','Observação','textarea',false,30);
END IF;
END IF;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- GRUPO 2 — novo p_tenant_id (1º param), RETURNS scalar/jsonb (DROP+CREATE)
-- ───────────────────────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.cancel_recurrence_from(uuid, date);
CREATE FUNCTION public.cancel_recurrence_from(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
UPDATE recurrence_rules
SET end_date = p_from_date - INTERVAL '1 day', open_ended = false,
status = CASE WHEN p_from_date <= start_date THEN 'cancelado' ELSE status END,
updated_at = now()
WHERE id = p_recurrence_id;
END $$;
DROP FUNCTION IF EXISTS public.cancelar_eventos_serie(uuid, timestamptz);
CREATE FUNCTION public.cancelar_eventos_serie(p_tenant_id uuid, p_serie_id uuid, p_a_partir_de timestamptz DEFAULT now())
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_count integer;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
UPDATE agenda_eventos SET status='cancelado', updated_at=now()
WHERE serie_id = p_serie_id AND inicio_em >= p_a_partir_de AND status NOT IN ('realizado','cancelado');
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END $$;
DROP FUNCTION IF EXISTS public.split_recurrence_at(uuid, date);
CREATE FUNCTION public.split_recurrence_at(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_old RECORD; v_new_id uuid;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_old FROM recurrence_rules WHERE id = p_recurrence_id;
IF NOT FOUND THEN RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id; END IF;
UPDATE recurrence_rules SET end_date = p_from_date - INTERVAL '1 day', open_ended=false, updated_at=now()
WHERE id = p_recurrence_id;
INSERT INTO recurrence_rules (owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min, start_date, end_date, max_occurrences, open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status)
SELECT owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min, p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status
FROM recurrence_rules WHERE id = p_recurrence_id
RETURNING id INTO v_new_id;
RETURN v_new_id;
END $$;
-- can_delete_patient: SQL sem SET search_path → herda o do chamador (schema).
-- Unqualified pra resolver no schema do tenant ativo.
CREATE OR REPLACE FUNCTION public.can_delete_patient(p_patient_id uuid)
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT NOT EXISTS (
SELECT 1 FROM agenda_eventos WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM recurrence_rules WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM billing_contracts WHERE patient_id = p_patient_id
);
$$;
DROP FUNCTION IF EXISTS public.safe_delete_patient(uuid);
CREATE FUNCTION public.safe_delete_patient(p_tenant_id uuid, p_patient_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
IF NOT public.can_delete_patient(p_patient_id) THEN
RETURN jsonb_build_object('ok',false,'error','has_history',
'message','Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.');
END IF;
-- ownership: owner_id direto ou responsible_member do caller (tenant_members é GLOBAL)
IF NOT EXISTS (SELECT 1 FROM patients
WHERE id = p_patient_id AND (owner_id = auth.uid()
OR responsible_member_id IN (SELECT id FROM public.tenant_members WHERE user_id = auth.uid()))) THEN
RETURN jsonb_build_object('ok',false,'error','forbidden','message','Sem permissão para excluir este paciente.');
END IF;
DELETE FROM patients WHERE id = p_patient_id;
RETURN jsonb_build_object('ok',true);
END $$;
DROP FUNCTION IF EXISTS public.export_patient_data(uuid);
CREATE FUNCTION public.export_patient_data(p_tenant_id uuid, p_patient_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_patient RECORD; v_caller uuid; v_result jsonb;
BEGIN
v_caller := auth.uid();
IF v_caller IS NULL THEN RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE='28000'; END IF;
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_patient FROM patients WHERE id = p_patient_id;
IF NOT FOUND THEN RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE='P0002'; END IF;
v_result := jsonb_build_object(
'export_metadata', jsonb_build_object('generated_at', now(), 'generated_by', v_caller,
'tenant_id', p_tenant_id, 'patient_id', p_patient_id,
'lgpd_basis','Art. 18, II - portabilidade dos dados do titular',
'controller','AgenciaPSI - Clinica responsavel','format_version','1.0'),
'paciente', to_jsonb(v_patient),
'contatos', COALESCE((SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at) FROM patient_contacts pc WHERE pc.patient_id = p_patient_id),'[]'::jsonb),
'contatos_apoio', COALESCE((SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at) FROM patient_support_contacts psc WHERE psc.patient_id = p_patient_id),'[]'::jsonb),
'historico_status', COALESCE((SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em) FROM patient_status_history psh WHERE psh.patient_id = p_patient_id),'[]'::jsonb),
'timeline', COALESCE((SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em) FROM patient_timeline pt WHERE pt.patient_id = p_patient_id),'[]'::jsonb),
'descontos', COALESCE((SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at) FROM patient_discounts pd WHERE pd.patient_id = p_patient_id),'[]'::jsonb),
'eventos_agenda', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',ae.id,'tipo',ae.tipo,'status',ae.status,'inicio_em',ae.inicio_em,'fim_em',ae.fim_em) ORDER BY ae.inicio_em) FROM agenda_eventos ae WHERE ae.patient_id = p_patient_id),'[]'::jsonb),
'documentos', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',d.id,'nome',d.nome_original,'tipo',d.tipo_documento,'criado_em',d.created_at) ORDER BY d.created_at) FROM documents d WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL),'[]'::jsonb),
'financeiro', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',fr.id,'tipo',fr.type,'valor',fr.final_amount,'status',fr.status,'vencimento',fr.due_date) ORDER BY fr.created_at) FROM financial_records fr WHERE fr.patient_id = p_patient_id AND fr.deleted_at IS NULL),'[]'::jsonb)
);
RETURN v_result;
END $$;
DROP FUNCTION IF EXISTS public.search_global(text, text[], integer);
CREATE FUNCTION public.search_global(p_tenant_id uuid, p_q text, p_scope text[] DEFAULT NULL, p_limit integer DEFAULT 8)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_q text; v_pattern text; v_limit int;
v_patients jsonb:='[]'::jsonb; v_appointments jsonb:='[]'::jsonb; v_documents jsonb:='[]'::jsonb;
v_services jsonb:='[]'::jsonb; v_intakes jsonb:='[]'::jsonb;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
v_q := nullif(btrim(coalesce(p_q,'')),'');
IF v_q IS NULL OR length(v_q) < 2 THEN
RETURN jsonb_build_object('patients','[]'::jsonb,'appointments','[]'::jsonb,'documents','[]'::jsonb,'services','[]'::jsonb,'intakes','[]'::jsonb);
END IF;
v_q := left(v_q,80); v_pattern := '%'||v_q||'%'; v_limit := GREATEST(1, LEAST(coalesce(p_limit,8),20));
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_completo,
'sublabel',coalesce(nullif(email_principal,''),nullif(telefone,''),''),'avatar_url',avatar_url,
'deeplink','/therapist/patients/cadastro/'||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_patients
FROM (SELECT p.id,p.nome_completo,p.email_principal,p.telefone,p.avatar_url,
GREATEST(similarity(coalesce(p.nome_completo,''),v_q),similarity(coalesce(p.email_principal,''),v_q)*0.7,
similarity(coalesce(p.telefone,''),v_q)*0.5,similarity(coalesce(p.cpf,''),v_q)*0.6) AS score
FROM patients p WHERE p.nome_completo ILIKE v_pattern OR p.email_principal ILIKE v_pattern OR p.telefone ILIKE v_pattern OR p.cpf ILIKE v_pattern
ORDER BY score DESC, p.nome_completo ASC LIMIT v_limit) ranked;
END IF;
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',label,
'sublabel',trim(both ' · ' from coalesce(patient_name,')')||' · '||to_char(inicio_em,'DD/MM/YYYY HH24:MI')),
'deeplink','/therapist/agenda?event='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_appointments
FROM (SELECT e.id, coalesce(nullif(e.titulo_custom,''),nullif(e.titulo,''),'Sessão') AS label, e.inicio_em, pat.nome_completo AS patient_name,
GREATEST(similarity(coalesce(e.titulo,''),v_q),similarity(coalesce(e.titulo_custom,''),v_q),similarity(coalesce(pat.nome_completo,''),v_q)*0.9) AS score
FROM agenda_eventos e LEFT JOIN patients pat ON pat.id = e.patient_id
WHERE e.titulo ILIKE v_pattern OR e.titulo_custom ILIKE v_pattern OR pat.nome_completo ILIKE v_pattern
ORDER BY score DESC, e.inicio_em DESC LIMIT v_limit) ranked;
END IF;
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_original,
'sublabel',trim(both ' · ' from coalesce(patient_name,'')||' · '||coalesce(tipo_documento,'')),
'deeplink','/therapist/patients/'||patient_id::text||'/documents','score',round(score::numeric,3))),'[]'::jsonb) INTO v_documents
FROM (SELECT d.id,d.patient_id,d.nome_original,d.tipo_documento,pat.nome_completo AS patient_name,
GREATEST(similarity(coalesce(d.nome_original,''),v_q),similarity(coalesce(d.descricao,''),v_q)*0.7) AS score
FROM documents d LEFT JOIN patients pat ON pat.id = d.patient_id
WHERE d.nome_original ILIKE v_pattern OR d.descricao ILIKE v_pattern
ORDER BY score DESC, d.nome_original ASC LIMIT v_limit) ranked;
END IF;
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',name,
'sublabel',trim(both ' · ' from 'R$ '||to_char(price,'FM999G999G990D00')||' · '||coalesce(duration_min::text||' min','')),
'deeplink','/configuracoes/precificacao','score',round(score::numeric,3))),'[]'::jsonb) INTO v_services
FROM (SELECT s.id,s.name,s.price,s.duration_min,
GREATEST(similarity(coalesce(s.name,''),v_q),similarity(coalesce(s.description,''),v_q)*0.7) AS score
FROM services s WHERE s.active IS TRUE AND (s.name ILIKE v_pattern OR s.description ILIKE v_pattern)
ORDER BY score DESC, s.name ASC LIMIT v_limit) ranked;
END IF;
-- intakes: patient_intake_requests FICA em public (F1b) → qualifica + filtra tenant_id
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,
'label',coalesce(nullif(trim(nome_completo),''),'(sem nome)'),
'sublabel',trim(both ' · ' from coalesce(nullif(email_principal,''),nullif(telefone,''),'')||' · '||'recebido '||to_char(created_at,'DD/MM/YYYY')),
'deeplink','/therapist/patients/cadastro/recebidos?id='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_intakes
FROM (SELECT r.id,r.nome_completo,r.email_principal,r.telefone,r.created_at,
GREATEST(similarity(coalesce(r.nome_completo,''),v_q),similarity(coalesce(r.email_principal,''),v_q)*0.7,similarity(coalesce(r.telefone,''),v_q)*0.5) AS score
FROM public.patient_intake_requests r
WHERE r.tenant_id = p_tenant_id AND r.status='new'
AND (r.nome_completo ILIKE v_pattern OR r.email_principal ILIKE v_pattern OR r.telefone ILIKE v_pattern)
ORDER BY score DESC, r.created_at DESC LIMIT v_limit) ranked;
END IF;
RETURN jsonb_build_object('patients',v_patients,'appointments',v_appointments,'documents',v_documents,'services',v_services,'intakes',v_intakes);
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- GRUPO 3 — RETURNS <tabela_tenant> → jsonb (ripple no FE)
-- ───────────────────────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.mark_as_paid(uuid, text);
CREATE FUNCTION public.mark_as_paid(p_tenant_id uuid, p_financial_record_id uuid, p_payment_method text)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_record RECORD;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_record FROM financial_records WHERE id = p_financial_record_id AND owner_id = auth.uid() AND deleted_at IS NULL;
IF NOT FOUND THEN RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.'; END IF;
IF v_record.status NOT IN ('pending','overdue') THEN RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.'; END IF;
UPDATE financial_records SET status='paid', paid_at=now(), payment_method=p_payment_method, updated_at=now()
WHERE id = p_financial_record_id RETURNING * INTO v_record;
RETURN to_jsonb(v_record);
END $$;
DROP FUNCTION IF EXISTS public.create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date);
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_existing RECORD; v_new RECORD;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_existing FROM financial_records WHERE agenda_evento_id = p_agenda_evento_id AND deleted_at IS NULL AND status != 'cancelled' LIMIT 1;
IF FOUND THEN RETURN to_jsonb(v_existing); END IF;
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, amount, discount_amount, final_amount, status, due_date)
VALUES (p_owner_id, p_patient_id, p_agenda_evento_id, p_amount, 0, p_amount, 'pending', p_due_date)
RETURNING * INTO v_new;
UPDATE agenda_eventos SET billed = TRUE WHERE id = p_agenda_evento_id;
RETURN to_jsonb(v_new);
END $$;
DROP FUNCTION IF EXISTS public.mark_payout_as_paid(uuid);
CREATE FUNCTION public.mark_payout_as_paid(p_tenant_id uuid, p_payout_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_payout RECORD;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_payout FROM therapist_payouts WHERE id = p_payout_id;
IF NOT FOUND THEN RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id; END IF;
IF NOT public.is_tenant_admin(p_tenant_id) THEN RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.'; END IF;
IF v_payout.status <> 'pending' THEN RAISE EXCEPTION 'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.', v_payout.status; END IF;
UPDATE therapist_payouts SET status='paid', paid_at=now(), updated_at=now() WHERE id = p_payout_id RETURNING * INTO v_payout;
RETURN to_jsonb(v_payout);
END $$;
DROP FUNCTION IF EXISTS public.create_therapist_payout(uuid, uuid, date, date);
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_payout RECORD; v_total int; v_gross numeric(10,2); v_clinic_fee numeric(10,2); v_net numeric(10,2);
BEGIN
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.';
END IF;
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
IF EXISTS (SELECT 1 FROM therapist_payouts WHERE owner_id=p_therapist_id AND period_start=p_period_start AND period_end=p_period_end AND status<>'cancelled') THEN
RAISE EXCEPTION 'Já existe um repasse ativo para o período % a % deste terapeuta.', p_period_start, p_period_end;
END IF;
SELECT COUNT(*), COALESCE(SUM(amount),0), COALESCE(SUM(clinic_fee_amount),0), COALESCE(SUM(net_amount),0)
INTO v_total, v_gross, v_clinic_fee, v_net
FROM financial_records fr
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
IF v_total = 0 THEN RAISE EXCEPTION 'Nenhum registro financeiro elegível encontrado para o período % a %.', p_period_start, p_period_end; END IF;
INSERT INTO therapist_payouts (owner_id, period_start, period_end, total_sessions, gross_amount, clinic_fee_total, net_amount, status)
VALUES (p_therapist_id, p_period_start, p_period_end, v_total, v_gross, v_clinic_fee, v_net, 'pending')
RETURNING * INTO v_payout;
INSERT INTO therapist_payout_records (payout_id, financial_record_id)
SELECT v_payout.id, fr.id FROM financial_records fr
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
RETURN to_jsonb(v_payout);
END $$;
COMMIT;
@@ -0,0 +1,308 @@
-- =============================================================================
-- F6.2 Lote E — RPCs de cron/global roteadas/loopadas por tenant
--
-- ⚠️ APLICAR COMO supabase_admin.
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql
--
-- E1: chamadas per-tenant pelas edge crons → p_tenant_id + set_config search_path
-- (helper public._tenant_route do Lote D). Edge ajustada (admin.rpc + p_tenant_id).
-- E2: crons sem-arg que varrem TODOS os tenants → loop FROM tenant_schemas.
-- =============================================================================
BEGIN;
-- helper SEM checagem de auth: resolve schema pra RPCs de SERVIÇO (chamadas por
-- service_role/edge, que não é tenant_member). Protegido por REVOKE das RPCs de
-- anon/authenticated (só service_role/postgres chamam).
CREATE OR REPLACE FUNCTION public._tenant_schema_unchecked(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 'p_tenant_id obrigatório'; END IF;
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
RETURN v_schema;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- E2 — crons globais: varrem todos os schemas
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.cleanup_notification_queue()
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int; v_total int := 0;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('DELETE FROM %I.notification_queue WHERE status IN (''enviado'',''cancelado'',''ignorado'') AND created_at < now() - interval ''90 days''', t.schema_name);
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
END LOOP;
RETURN v_total;
END $$;
CREATE OR REPLACE FUNCTION public.unstick_notification_queue()
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int; v_total int := 0;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_queue SET status=''pendente'', attempts=attempts+1, last_error=''Timeout: preso em processando por >10min'', next_retry_at=now()+interval ''2 minutes'' WHERE status=''processando'' AND updated_at < now() - interval ''10 minutes''', t.schema_name);
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
END LOOP;
RETURN v_total;
END $$;
CREATE OR REPLACE FUNCTION public.sync_overdue_financial_records()
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int; v_total int := 0;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.financial_records SET status=''overdue'', updated_at=now() WHERE status=''pending'' AND due_date IS NOT NULL AND due_date < CURRENT_DATE AND deleted_at IS NULL', t.schema_name);
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
END LOOP;
RETURN v_total;
END $$;
-- populate: complexo (multi-tabela). set_config search_path por tenant; profiles
-- é GLOBAL → qualificado. Remove tenant_id do INSERT e do SELECT.
CREATE OR REPLACE FUNCTION public.populate_notification_queue()
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
PERFORM set_config('search_path', t.schema_name || ',public,pg_temp', true);
INSERT INTO notification_queue (
owner_id, agenda_evento_id, patient_id, channel, template_key, schedule_key,
resolved_vars, recipient_address, scheduled_at, idempotency_key)
SELECT
ae.owner_id, ae.id, ae.patient_id, ch.channel,
'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
ns.schedule_key,
jsonb_build_object('nome_paciente', COALESCE(p.nome_completo,'Paciente'),
'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','DD/MM/YYYY'),
'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','HH24:MI'),
'nome_terapeuta', COALESCE(prof.full_name,'Terapeuta'),
'modalidade', COALESCE(ae.modalidade,'Presencial'),
'titulo', COALESCE(ae.titulo,'Sessão')),
CASE ch.channel WHEN 'whatsapp' THEN COALESCE(p.telefone,'') WHEN 'sms' THEN COALESCE(p.telefone,'') WHEN 'email' THEN COALESCE(p.email_principal,'') END,
CASE
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time < ns.allowed_time_start
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time > ns.allowed_time_end
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
ELSE ae.inicio_em - (ns.offset_minutes||' minutes')::interval END,
ae.id::text||':'||ns.schedule_key||':'||ch.channel||':'||ae.inicio_em::date::text
FROM agenda_eventos ae
JOIN patients p ON p.id = ae.patient_id
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id -- GLOBAL
JOIN notification_schedules ns ON ns.owner_id = ae.owner_id AND ns.is_active=true AND ns.deleted_at IS NULL AND ns.trigger_type='before_event' AND ns.event_type='lembrete_sessao'
JOIN notification_channels nc ON nc.owner_id = ae.owner_id AND nc.is_active=true AND nc.deleted_at IS NULL
CROSS JOIN LATERAL (
SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel='whatsapp'
UNION ALL SELECT 'email' WHERE ns.email_enabled AND nc.channel='email'
UNION ALL SELECT 'sms' WHERE ns.sms_enabled AND nc.channel='sms') ch
LEFT JOIN notification_preferences np ON np.patient_id = ae.patient_id AND np.owner_id = ae.owner_id AND np.deleted_at IS NULL
WHERE ae.tipo = 'sessao' AND ae.status NOT IN ('cancelado','realizado') AND ae.inicio_em > now()
AND (ae.inicio_em - (ns.offset_minutes||' minutes')::interval) > now()
ON CONFLICT (idempotency_key) DO NOTHING;
END LOOP;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- E1 — chamadas per-tenant (p_tenant_id + route)
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamptz, p_threshold_minutes integer)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_existing_id uuid; v_new_id uuid;
BEGIN
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN RAISE EXCEPTION 'tenant_and_thread_required'; END IF;
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
SELECT id INTO v_existing_id FROM conversation_sla_breaches WHERE thread_key = p_thread_key AND resolved_at IS NULL;
IF FOUND THEN
UPDATE conversation_sla_breaches SET assigned_to = COALESCE(p_assigned_to, assigned_to), last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at) WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
INSERT INTO conversation_sla_breaches (thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
VALUES (p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes) RETURNING id INTO v_new_id;
RETURN v_new_id;
END $$;
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid);
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid,uuid);
CREATE FUNCTION public.sla_mark_notified(p_tenant_id uuid, p_breach_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
UPDATE conversation_sla_breaches SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_breach_id;
END $$;
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, text, text, jsonb);
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, uuid, text, text, jsonb);
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_tenant_id uuid, p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL, p_details jsonb DEFAULT NULL)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_provider text; v_existing_id uuid; v_new_id uuid;
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
SELECT provider INTO v_provider FROM notification_channels WHERE id = p_channel_id AND deleted_at IS NULL;
IF NOT FOUND THEN RAISE EXCEPTION 'channel_not_found'; END IF;
IF p_kind NOT IN ('disconnected','error','qr_pending','connecting','unknown') THEN RAISE EXCEPTION 'invalid_kind: %', p_kind; END IF;
SELECT id INTO v_existing_id FROM whatsapp_connection_incidents WHERE channel_id = p_channel_id AND resolved_at IS NULL;
IF FOUND THEN
UPDATE whatsapp_connection_incidents SET last_state = COALESCE(p_last_state, last_state), details = COALESCE(p_details, details), kind = p_kind WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
INSERT INTO whatsapp_connection_incidents (channel_id, provider, kind, last_state, details)
VALUES (p_channel_id, v_provider, p_kind, p_last_state, p_details) RETURNING id INTO v_new_id;
RETURN v_new_id;
END $$;
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid);
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid,uuid);
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_tenant_id uuid, p_incident_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
UPDATE whatsapp_connection_incidents SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_incident_id;
END $$;
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid);
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid,uuid);
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_tenant_id uuid, p_channel_id uuid)
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_count int := 0;
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
UPDATE whatsapp_connection_incidents SET resolved_at = now(), duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
WHERE channel_id = p_channel_id AND resolved_at IS NULL;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END $$;
-- convert_abandoned_intake_to_lead: resolve o tenant INTERNAMENTE (intake.owner_id
-- -> tenant_members). patient_intake_requests FICA em public (F1b). Writes de
-- conversation_messages/notes vão pro schema do tenant resolvido.
CREATE OR REPLACE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_intake RECORD; v_tenant_id uuid; v_schema text; v_thread_key text; v_phone text;
v_note_body text; v_admin_id uuid;
BEGIN
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::uuid; END IF;
SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_intake.owner_id
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END LIMIT 1;
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
v_schema := public.tenant_schema_for(v_tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema_not_found'; END IF;
v_phone := regexp_replace(COALESCE(v_intake.telefone,''),'\D','','g');
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55'||v_phone; END IF;
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
v_thread_key := 'anon:'||v_phone;
v_note_body := format('📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
E'\n',E'\n', COALESCE(v_intake.nome_completo,''), E'\n', COALESCE(v_intake.telefone,''), E'\n',
COALESCE(v_intake.email_principal,''), E'\n', COALESCE(v_intake.onde_nos_conheceu,''), E'\n',E'\n',
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'),
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'));
SELECT user_id INTO v_admin_id FROM public.tenant_members
WHERE tenant_id = v_tenant_id AND role IN ('tenant_admin','clinic_admin') AND status='active' LIMIT 1;
IF v_admin_id IS NULL THEN v_admin_id := v_intake.owner_id; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
INSERT INTO conversation_messages (channel, direction, from_number, to_number, body, provider, provider_raw, kanban_status)
VALUES ('whatsapp','inbound', CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, NULL,
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.', COALESCE(v_intake.nome_completo,'Visitante')),
'system', jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id), 'awaiting_us');
INSERT INTO conversation_notes (thread_key, contact_number, body, created_by)
VALUES (v_thread_key, CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, v_note_body, v_admin_id);
UPDATE public.patient_intake_requests SET status='abandoned_lead', lead_thread_key=v_thread_key, updated_at=now() WHERE id = p_intake_id;
RETURN p_intake_id;
END $$;
-- first_response analytics: routam pelo p_tenant_id (cada função seta o seu próprio
-- search_path — _first_response_runs tem SET search_path próprio que resetaria).
CREATE OR REPLACE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamptz, p_to timestamptz)
RETURNS TABLE(thread_key text, inbound_started_at timestamptz, responded_at timestamptz, response_seconds integer, responder_id uuid)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
WITH base AS (
SELECT COALESCE(m.patient_id::text, 'anon:' || COALESCE(CASE WHEN m.direction='inbound' THEN m.from_number ELSE m.to_number END, 'unknown')) AS thread_key,
m.direction, m.created_at
FROM conversation_messages m
WHERE m.created_at >= p_from AND m.created_at < p_to
),
inbound AS (
SELECT b.thread_key AS tk, min(b.created_at) AS inbound_started_at
FROM base b WHERE b.direction='inbound' GROUP BY b.thread_key
)
SELECT i.tk, i.inbound_started_at,
(SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) AS responded_at,
EXTRACT(EPOCH FROM ((SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) - i.inbound_started_at))::int AS response_seconds,
a.assigned_to AS responder_id
FROM inbound i
LEFT JOIN conversation_assignments a ON a.thread_key = i.tk;
END $$;
CREATE OR REPLACE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamptz DEFAULT (now() - interval '30 days'), p_to timestamptz DEFAULT now(), p_therapist_id uuid DEFAULT NULL)
RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_threshold_min integer;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT threshold_minutes INTO v_threshold_min FROM conversation_sla_rules LIMIT 1;
v_threshold_min := COALESCE(v_threshold_min, 30);
RETURN QUERY
WITH runs AS (
SELECT r.response_seconds FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE r.responded_at IS NOT NULL AND (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
)
SELECT count(*)::int,
COALESCE(avg(response_seconds),0)::int,
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY response_seconds),0)::int,
COALESCE(min(response_seconds),0)::int,
COALESCE(max(response_seconds),0)::int,
(v_threshold_min*60)::int,
count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)::int,
CASE WHEN count(*)=0 THEN 0 ELSE round(100.0*count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)/count(*),1) END
FROM runs;
END $$;
-- RPCs de serviço (cron/edge): só service_role/postgres. Sem checagem de membership.
DO $g$
DECLARE fn text;
BEGIN
FOREACH fn IN ARRAY ARRAY[
'sla_open_breach(uuid,text,uuid,timestamptz,integer)',
'sla_mark_notified(uuid,uuid)',
'whatsapp_heartbeat_open_incident(uuid,uuid,text,text,jsonb)',
'whatsapp_heartbeat_mark_notified(uuid,uuid)',
'whatsapp_heartbeat_resolve_open_incidents(uuid,uuid)',
'convert_abandoned_intake_to_lead(uuid)',
'cleanup_notification_queue()','unstick_notification_queue()',
'sync_overdue_financial_records()','populate_notification_queue()'
] LOOP
EXECUTE format('REVOKE ALL ON FUNCTION public.%s FROM PUBLIC, anon, authenticated', fn);
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO service_role', fn);
END LOOP;
END $g$;
COMMIT;
@@ -0,0 +1,247 @@
-- =============================================================================
-- F6.2 Lote F — RPCs anon/token: resolvem tenant por token/slug e roteiam
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- Visitante anon não está logado → cada RPC resolve o tenant a partir do
-- token/slug do registro que VIVE em public (F1b: document_share_links,
-- agendador_configuracoes — ambos têm tenant_id), depois set_config search_path
-- pro schema só pras tabelas tenant (documents, document_signatures,
-- document_access_logs, patients, agenda_*, recurrence_*).
-- Tabelas que ficam em public seguem qualificadas (document_share_links,
-- agendador_configuracoes/solicitacoes).
-- %ROWTYPE de tabelas tenant → RECORD; RETURNS document_signatures → jsonb.
-- list_my_signatures é cross-tenant (assinante em vários tenants) → fan-out.
-- =============================================================================
BEGIN;
-- ── Documentos: tenant via document_share_links.tenant_id (public) ──────────
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE sl public.document_share_links%ROWTYPE; v_doc RECORD; v_token text; v_schema text;
BEGIN
v_token := nullif(btrim(coalesce(p_token,'')),'');
IF v_token IS NULL THEN RAISE EXCEPTION 'token obrigatório' USING ERRCODE='22023'; END IF;
SELECT * INTO sl FROM public.document_share_links WHERE token = v_token LIMIT 1;
IF NOT FOUND THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='28000'; END IF;
IF sl.ativo IS NOT TRUE THEN RAISE EXCEPTION 'Link desativado' USING ERRCODE='28000'; END IF;
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN RAISE EXCEPTION 'Link expirado' USING ERRCODE='28000'; END IF;
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE='28000'; END IF;
v_schema := public.tenant_schema_for(sl.tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='28000'; END IF;
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = sl.id;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
BEGIN
INSERT INTO document_access_logs (documento_id, action, share_link_id)
VALUES (sl.documento_id, 'shared_link_access', sl.id);
EXCEPTION WHEN OTHERS THEN NULL; END;
SELECT * INTO v_doc FROM documents WHERE id = sl.documento_id;
RETURN jsonb_build_object('document_id', sl.documento_id, 'bucket', v_doc.storage_bucket,
'bucket_path', v_doc.bucket_path, 'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type, 'tamanho_bytes', v_doc.tamanho_bytes);
END $$;
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(p_token text)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_link public.document_share_links%ROWTYPE; v_doc RECORD; v_sigs jsonb; v_schema text;
BEGIN
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
IF v_link.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid'); END IF;
v_schema := public.tenant_schema_for(v_link.tenant_id);
IF v_schema IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'tenant_invalid'); END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
SELECT * INTO v_doc FROM documents WHERE id = v_link.documento_id AND deleted_at IS NULL LIMIT 1;
IF v_doc.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'document_not_found'); END IF;
SELECT jsonb_agg(jsonb_build_object('id',s.id,'signatario_tipo',s.signatario_tipo,'signatario_nome',s.signatario_nome,
'signatario_email',s.signatario_email,'ordem',s.ordem,'status',s.status,'assinado_em',s.assinado_em) ORDER BY s.ordem) INTO v_sigs
FROM document_signatures s WHERE s.documento_id = v_doc.id;
RETURN jsonb_build_object('valid', true,
'document', jsonb_build_object('id',v_doc.id,'nome_original',v_doc.nome_original,'mime_type',v_doc.mime_type,
'tamanho_bytes',v_doc.tamanho_bytes,'bucket_path',v_doc.bucket_path,'storage_bucket',v_doc.storage_bucket,'tipo_documento',v_doc.tipo_documento),
'signatures', COALESCE(v_sigs,'[]'::jsonb), 'expira_em', v_link.expira_em, 'usos_restantes', v_link.usos_max - v_link.usos);
END $$;
DROP FUNCTION IF EXISTS public.sign_document_by_token(text, uuid, text);
CREATE FUNCTION public.sign_document_by_token(p_token text, p_signature_id uuid DEFAULT NULL, p_hash_documento text DEFAULT NULL)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_link public.document_share_links%ROWTYPE; v_sig RECORD; v_ip inet; v_ua text; v_schema text;
BEGIN
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
IF v_link.id IS NULL THEN RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE='P0002'; END IF;
v_schema := public.tenant_schema_for(v_link.tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='P0002'; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
IF p_signature_id IS NOT NULL THEN
SELECT * INTO v_sig FROM document_signatures WHERE id = p_signature_id AND documento_id = v_link.documento_id AND status IN ('pendente','enviado') LIMIT 1;
ELSE
SELECT * INTO v_sig FROM document_signatures WHERE documento_id = v_link.documento_id AND status IN ('pendente','enviado') ORDER BY ordem ASC, criado_em ASC LIMIT 1;
END IF;
IF v_sig.id IS NULL THEN RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE='P0002'; END IF;
v_ip := inet_client_addr();
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
WHERE id = v_sig.id RETURNING * INTO v_sig;
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = v_link.id;
RETURN to_jsonb(v_sig);
END $$;
-- sign_document_by_signature_id: assinante LOGADO (paciente OU therapist) via
-- portal. Paciente NÃO é tenant_member → routing UNCHECKED (p_tenant_id vem do
-- FE, da própria assinatura listada). Autorização é por LINHA: só assina se for
-- o signatário (signatario_id = uid OU email do uid).
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, text);
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, uuid, text);
CREATE FUNCTION public.sign_document_by_signature_id(p_tenant_id uuid, p_signature_id uuid, p_hash_documento text DEFAULT NULL)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_row RECORD; v_ip inet; v_ua text; v_uid uuid; v_email text;
BEGIN
IF p_signature_id IS NULL THEN RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE='22023'; END IF;
v_uid := auth.uid();
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
SELECT email INTO v_email FROM auth.users WHERE id = v_uid;
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
v_ip := inet_client_addr();
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
WHERE id = p_signature_id AND status IN ('pendente','enviado')
AND (signatario_id = v_uid OR signatario_email = v_email
OR documento_id IN (SELECT d.id FROM documents d JOIN patients p ON p.id = d.patient_id WHERE p.user_id = v_uid))
RETURNING * INTO v_row;
IF v_row.id IS NULL THEN RAISE EXCEPTION 'Assinatura não encontrada, já processada, ou sem permissão' USING ERRCODE='P0002'; END IF;
RETURN to_jsonb(v_row);
END $$;
-- list_my_signatures: cross-tenant. Fan-out por schema (tenant_id injetado do loop).
-- document_share_links é GLOBAL (public). Ordenação global é aproximada (por schema).
CREATE OR REPLACE FUNCTION public.list_my_signatures(p_status text[] DEFAULT NULL)
RETURNS TABLE(signature_id uuid, documento_id uuid, tenant_id uuid, signatario_tipo text, status text, ordem smallint, assinado_em timestamptz, criado_em timestamptz, nome_original text, tipo_documento text, mime_type text, share_token text, share_expira_em timestamptz)
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_uid uuid; t record;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
FOR t IN SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts LOOP
RETURN QUERY EXECUTE format(
'SELECT s.id, s.documento_id, $2::uuid, s.signatario_tipo, s.status, s.ordem, s.assinado_em, s.criado_em, '
|| 'd.nome_original, d.tipo_documento, d.mime_type, sl.token, sl.expira_em '
|| 'FROM %1$I.document_signatures s '
|| 'JOIN %1$I.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL '
|| 'LEFT JOIN LATERAL (SELECT token, expira_em FROM public.document_share_links WHERE documento_id = d.id AND ativo=true AND expira_em > now() AND usos < usos_max ORDER BY criado_em DESC LIMIT 1) sl ON true '
|| 'WHERE (s.signatario_id = $1 OR s.signatario_email = (SELECT email FROM auth.users WHERE id=$1) '
|| 'OR d.patient_id IN (SELECT p.id FROM %1$I.patients p WHERE p.user_id = $1)) '
|| 'AND ($3::text[] IS NULL OR s.status = ANY($3))',
t.schema_name)
USING v_uid, t.tenant_id, p_status;
END LOOP;
END $$;
-- ── match_patient_by_phone: service (edge), p_tenant_id → unchecked ──────────
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id uuid, p_phone text)
RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_normalized text; v_patient_id uuid;
BEGIN
v_normalized := public.normalize_phone_br(p_phone);
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN RETURN NULL; END IF;
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone) = v_normalized LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_alternativo) = v_normalized LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_responsavel) = v_normalized LIMIT 1;
RETURN v_patient_id;
END $$;
-- ── Agendador público: tenant via agendador_configuracoes.tenant_id (public) ─
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer)
RETURNS TABLE(data date, tem_slots boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_antecedencia int; v_agora timestamptz;
v_data date; v_data_inicio date; v_data_fim date; v_db_dow int; v_tem_slot boolean; v_bloqueado boolean;
BEGIN
SELECT c.owner_id, c.tenant_id, c.antecedencia_minima_horas INTO v_owner_id, v_tenant_id, v_antecedencia
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_schema := public.tenant_schema_for(v_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
v_agora := now(); v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date; v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
v_db_dow := extract(dow from v_data::timestamp)::int;
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=v_data AND COALESCE(b.data_fim,v_data)>=v_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_bloqueado;
IF v_bloqueado THEN v_data := v_data + 1; CONTINUE; END IF;
SELECT EXISTS (SELECT 1 FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true
AND (v_data::text||' '||s.time::text)::timestamp AT TIME ZONE 'America/Sao_Paulo' >= v_agora + (v_antecedencia||' hours')::interval) INTO v_tem_slot;
IF v_tem_slot THEN data := v_data; tem_slots := true; RETURN NEXT; END IF;
v_data := v_data + 1;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date)
RETURNS TABLE(hora time without time zone, disponivel boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_duracao int; v_antecedencia int; v_agora timestamptz;
v_db_dow int; v_slot time; v_slot_fim time; v_slot_ts timestamptz; v_ocupado boolean;
v_rule RECORD; v_rule_start_dow int; v_first_occ date; v_day_diff int; v_ex_type text;
BEGIN
SELECT c.owner_id, c.tenant_id, c.duracao_sessao_min, c.antecedencia_minima_horas
INTO v_owner_id, v_tenant_id, v_duracao, v_antecedencia
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_schema := public.tenant_schema_for(v_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
v_agora := now(); v_db_dow := extract(dow from p_data::timestamp)::int;
IF EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) THEN RETURN; END IF;
FOR v_slot IN SELECT s.time FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true ORDER BY s.time LOOP
v_slot_fim := v_slot + (v_duracao||' minutes')::interval; v_ocupado := false;
v_slot_ts := (p_data::text||' '||v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia||' hours')::interval THEN v_ocupado := true; END IF;
IF NOT v_ocupado THEN
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NOT NULL AND b.hora_inicio<v_slot_fim AND b.hora_fim>v_slot AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_ocupado;
END IF;
IF NOT v_ocupado THEN
SELECT EXISTS (SELECT 1 FROM agenda_eventos e WHERE e.owner_id=v_owner_id AND e.status::text NOT IN ('cancelado','faltou') AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date=p_data AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time<v_slot_fim AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time>v_slot) INTO v_ocupado;
END IF;
IF NOT v_ocupado THEN
FOR v_rule IN SELECT r.id, r.start_date::date AS start_date, r.end_date::date AS end_date, r.start_time::time AS start_time, r.end_time::time AS end_time, COALESCE(r.interval,1)::int AS interval
FROM recurrence_rules r WHERE r.owner_id=v_owner_id AND r.status='ativo' AND p_data>=r.start_date::date AND (r.end_date IS NULL OR p_data<=r.end_date::date) AND v_db_dow=ANY(r.weekdays) AND r.start_time::time<v_slot_fim AND r.end_time::time>v_slot LOOP
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
v_first_occ := v_rule.start_date + (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
v_day_diff := (p_data - v_first_occ)::int;
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
v_ex_type := NULL;
SELECT ex.type INTO v_ex_type FROM recurrence_exceptions ex WHERE ex.recurrence_id=v_rule.id AND ex.original_date=p_data LIMIT 1;
IF v_ex_type IS NULL OR v_ex_type NOT IN ('cancel_session','patient_missed','therapist_canceled','holiday_block','reschedule_session') THEN v_ocupado := true; EXIT; END IF;
END IF;
END LOOP;
END IF;
IF NOT v_ocupado THEN
SELECT EXISTS (SELECT 1 FROM recurrence_exceptions ex JOIN recurrence_rules r ON r.id=ex.recurrence_id WHERE r.owner_id=v_owner_id AND r.status='ativo' AND ex.type='reschedule_session' AND ex.new_date=p_data AND COALESCE(ex.new_start_time,r.start_time)::time<v_slot_fim AND COALESCE(ex.new_end_time,r.end_time)::time>v_slot) INTO v_ocupado;
END IF;
IF NOT v_ocupado THEN
-- agendador_solicitacoes FICA em public (F1b)
SELECT EXISTS (SELECT 1 FROM public.agendador_solicitacoes sol WHERE sol.owner_id=v_owner_id AND sol.status='pendente' AND sol.data_solicitada=p_data AND sol.hora_solicitada=v_slot AND (sol.reservado_ate IS NULL OR sol.reservado_ate>v_agora)) INTO v_ocupado;
END IF;
hora := v_slot; disponivel := NOT v_ocupado; RETURN NEXT;
END LOOP;
END $$;
-- match_patient_by_phone só pra service_role (edge)
REVOKE ALL ON FUNCTION public.match_patient_by_phone(uuid, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.match_patient_by_phone(uuid, text) TO service_role;
COMMIT;
@@ -0,0 +1,126 @@
-- =============================================================================
-- F6.2 Lote G — funções SQL puras → plpgsql + roteamento por tenant
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- SQL puro não permite set_config dinâmico do search_path (limitação 3 do
-- blueprint) → converter pra plpgsql. Adicionam p_tenant_id + _tenant_route.
-- RETURNS SETOF <tabela_tenant> → jsonb. get_entity_primary_phone (interno,
-- 0 callers) herda search_path do chamador (sem SET, unqualified).
-- =============================================================================
BEGIN;
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, integer, integer);
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, uuid, integer, integer);
CREATE FUNCTION public.get_financial_summary(p_tenant_id uuid, p_owner_id uuid, p_year integer, p_month integer)
RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
SELECT
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0),
COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
COALESCE(SUM(amount) FILTER (WHERE status IN ('pending','overdue')), 0),
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0) - COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
COALESCE(SUM(clinic_fee_amount) FILTER (WHERE type='receita' AND status='paid'), 0),
COUNT(*) FILTER (WHERE type='receita' AND deleted_at IS NULL),
COUNT(*) FILTER (WHERE type='despesa' AND deleted_at IS NULL)
FROM financial_records
WHERE owner_id = p_owner_id AND deleted_at IS NULL
AND EXTRACT(YEAR FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_year
AND EXTRACT(MONTH FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_month;
END $$;
-- list_financial_records: RETURNS SETOF financial_records → jsonb (array)
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, integer, integer, text, text, uuid, integer, integer);
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, uuid, integer, integer, text, text, uuid, integer, integer);
CREATE FUNCTION public.list_financial_records(p_tenant_id uuid, p_owner_id uuid, p_year integer DEFAULT NULL, p_month integer DEFAULT NULL, p_type text DEFAULT NULL, p_status text DEFAULT NULL, p_patient_id uuid DEFAULT NULL, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0)
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_result jsonb;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT COALESCE(jsonb_agg(row_json), '[]'::jsonb) INTO v_result FROM (
SELECT to_jsonb(fr) AS row_json
FROM financial_records fr
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
AND (p_type IS NULL OR fr.type::text = p_type)
AND (p_status IS NULL OR fr.status = p_status)
AND (p_patient_id IS NULL OR fr.patient_id = p_patient_id)
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_year)
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_month)
ORDER BY COALESCE(fr.paid_at, fr.due_date::timestamptz, fr.created_at) DESC
LIMIT p_limit OFFSET p_offset
) sub;
RETURN v_result;
END $$;
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid[]);
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid, uuid[]);
CREATE FUNCTION public.get_patient_session_counts(p_tenant_id uuid, p_patient_ids uuid[])
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
SELECT ae.patient_id, COUNT(*)::int, MAX(ae.inicio_em)
FROM agenda_eventos ae
WHERE ae.patient_id = ANY(p_patient_ids)
GROUP BY ae.patient_id;
END $$;
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, date, date, text);
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, uuid, date, date, text);
CREATE FUNCTION public.get_financial_report(p_tenant_id uuid, p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month')
RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
WITH base AS (
SELECT fr.type, fr.amount, fr.final_amount, fr.status, fr.deleted_at,
CASE p_group_by
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
WHEN 'category' THEN COALESCE(fr.category_id::text, fr.category, 'sem_categoria')
WHEN 'patient' THEN COALESCE(fr.patient_id::text, 'sem_paciente')
ELSE NULL END AS gkey,
CASE p_group_by
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::text, 'Sem paciente')
ELSE NULL END AS glabel
FROM financial_records fr
LEFT JOIN financial_categories fc ON fc.id = fr.category_id
LEFT JOIN patients p ON p.id = fr.patient_id
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
AND COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date) BETWEEN p_start_date AND p_end_date
)
SELECT gkey, glabel,
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0),
COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0) - COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
COALESCE(SUM(final_amount) FILTER (WHERE status='pending'),0),
COALESCE(SUM(final_amount) FILTER (WHERE status='overdue'),0),
COUNT(*)
FROM base WHERE gkey IS NOT NULL GROUP BY gkey, glabel ORDER BY gkey ASC;
END $$;
-- get_entity_primary_phone: interno (0 callers). Sem SET search_path → herda do
-- chamador (que roteia pro schema). Unqualified. Mantém assinatura.
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid)
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT number FROM contact_phones
WHERE entity_type = p_entity_type AND entity_id = p_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
$$;
COMMIT;
@@ -0,0 +1,106 @@
-- =============================================================================
-- F6.2 (wiring) — tenants NOVOS nascem com todos os triggers de negócio
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- Até aqui os 9 schemas existentes ganharam os triggers via backfills (Lotes
-- A/B/C). Um tenant NOVO (clone_tenant_template) só ganhava channel-routing +
-- RLS. Este wiring:
-- 1. attach_agnostic_triggers → SELF-CONTAINED (dirigido por colunas, não lê
-- public; sobrevive ao DROP da F6.3).
-- 2. trigger AFTER INSERT em tenant_schemas dispara os 3 attach (agnostic +
-- schema_aware + notif) pro schema novo — clone_tenant_template não precisa
-- ser tocado (ele insere em tenant_schemas).
-- 3. provision_account_tenant: clone ANTES do seed (seed é no-op se o schema
-- não existe; precisa do schema criado primeiro).
-- =============================================================================
BEGIN;
-- 1) attach_agnostic_triggers self-contained ---------------------------------
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE r record; v_count int := 0;
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
-- set_updated_at em toda tabela do schema que tem coluna updated_at
FOR r IN
SELECT c.relname AS tab
FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid
WHERE c.relnamespace = p_schema::regnamespace AND c.relkind = 'r'
AND a.attname = 'updated_at' AND NOT a.attisdropped AND c.relname NOT LIKE '\_%'
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS set_updated_at ON %I.%I', p_schema, r.tab);
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()', p_schema, r.tab);
v_count := v_count + 1;
END LOOP;
-- prevent_* em patient_groups
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = p_schema AND table_name = 'patient_groups') THEN
EXECUTE format('DROP TRIGGER IF EXISTS prevent_promoting_to_system ON %I.patient_groups', p_schema);
EXECUTE format('CREATE TRIGGER prevent_promoting_to_system BEFORE UPDATE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_promoting_to_system()', p_schema);
EXECUTE format('DROP TRIGGER IF EXISTS prevent_system_group_changes ON %I.patient_groups', p_schema);
EXECUTE format('CREATE TRIGGER prevent_system_group_changes BEFORE UPDATE OR DELETE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_system_group_changes()', p_schema);
v_count := v_count + 2;
END IF;
RETURN v_count;
END $$;
-- 2) trigger de wiring em tenant_schemas -------------------------------------
GRANT EXECUTE ON FUNCTION public.attach_agnostic_triggers(text) TO postgres, service_role;
GRANT EXECUTE ON FUNCTION public.attach_schema_aware_triggers(text) TO postgres, service_role;
GRANT EXECUTE ON FUNCTION public.attach_notif_triggers(text) TO postgres, service_role;
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
PERFORM public.attach_notif_triggers(NEW.schema_name);
RETURN NULL;
END $$;
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
DROP TRIGGER IF EXISTS trg_tenant_schemas_attach ON public.tenant_schemas;
CREATE TRIGGER trg_tenant_schemas_attach
AFTER INSERT ON public.tenant_schemas
FOR EACH ROW EXECUTE FUNCTION public.trg_attach_business_triggers();
-- 3) provision_account_tenant: clone ANTES do seed ---------------------------
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 $$
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;
-- F6 wiring: clone PRIMEIRO (cria o schema), seed DEPOIS (escreve no schema)
PERFORM public.clone_tenant_template(v_tenant_id);
PERFORM public.seed_determined_commitments(v_tenant_id);
RETURN v_tenant_id;
END $$;
COMMIT;
+99
View File
@@ -0,0 +1,99 @@
# F6.3 — Rollback da migração schema-per-tenant
> Como voltar atrás. Lê isto ANTES de aplicar a F6.3 (o DROP). A regra de ouro:
> **enquanto a F6.3 NÃO foi aplicada, o rollback é trivial e sem perda.**
## Princípio: a branch é a rede de segurança
A migração inteira (F3→F6.4) vive na branch `feat/schema-per-tenant`. A `main`
**nunca mudou** o modelo antigo — só recebeu F0/F1/F2, que são **aditivas**
(criam `tenants.slug`, o schema `_tenant_template`, helpers e o gatilho de
provisionamento; não removem nem alteram nada que o app antigo usa). Ou seja:
**`git checkout main` te devolve o app funcionando no modelo public**, desde que
o banco também volte (ver abaixo).
O único passo IRREVERSÍVEL por si só é o **DROP** (F6.3). Tudo antes dele é
reversível porque os dados continuam **espelhados em public** (a F6.1 COPIA, não
move). Por isso o checkpoint é parar ANTES do DROP.
---
## Cenário 1 — ANTES de aplicar a F6.3 (estado atual) — rollback trivial
Nada destrutivo foi feito. Public tem todas as tabelas e dados originais. Para
abandonar a migração:
```bash
# 1. código volta pro modelo antigo
git checkout main
# 2. banco: derruba os schemas tenant + artefatos da migração
docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
psql -U supabase_admin -h 127.0.0.1 -d postgres <<'SQL'
DO $$ DECLARE r record; BEGIN
FOR r IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\_%' OR nspname='_tenant_template' LOOP
EXECUTE format('DROP SCHEMA %I CASCADE', r.nspname);
END LOOP;
END $$;
-- limpa o registro + config do PostgREST
DELETE FROM public.tenant_schemas;
ALTER ROLE authenticator SET pgrst.db_schemas = 'public, graphql_public';
NOTIFY pgrst, 'reload config';
SQL
```
⚠️ As FUNÇÕES em public foram reescritas (F6.2) na branch, mas essas mudanças
**não foram aplicadas via migration em `main`** — elas vieram dos arquivos
`manual/f6_2*.sql` aplicados como supabase_admin no banco LOCAL. Se você quer o
banco local 100% igual ao `main`, restaure as funções originais do backup:
```bash
# restaura o estado public pré-migração (funções, triggers, tudo)
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
< database-novo/backups/pre-F6/public.sql # ou o backup mais antigo (pré-F1)
```
Como na prática o banco é LOCAL e descartável, o caminho mais limpo do Cenário 1
é: **`git checkout main` + `node db.cjs reset` (reinstala schema+seeds do zero)**.
---
## Cenário 2 — DEPOIS de aplicar a F6.3 (DROP já feito) — recuperável, com cuidado
O DROP removeu as 78 tabelas tenant de public. Os dados VIVOS estão nos schemas
`tenant_<slug>`. Há duas situações:
### 2a) Rollback rápido (logo após o DROP, app quase não rodou nos schemas)
Os dados em public estavam atualizados até o **backup pré-F6.3**. Se quase nada
foi escrito nos schemas depois do DROP, restaure public do backup e volte o código:
```bash
git checkout main
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
< database-novo/backups/pre-F6.3/public.sql # backup TIRADO antes do DROP
# + derrubar schemas tenant (bloco SQL do Cenário 1)
```
PERDE: qualquer escrita feita NOS SCHEMAS entre o DROP e o rollback.
### 2b) Rollback com dados atualizados (app rodou e acumulou dados nos schemas)
Aí os schemas têm a verdade mais nova. Precisa de uma **migração reversa**
(schema → public, o inverso da F6.1), re-adicionando `tenant_id`. Roteiro:
1. Restaure a ESTRUTURA das 78 tabelas em public (do backup pré-F6.3, sem os
dados, ou recriando via o schema dump).
2. Para cada tenant, `INSERT INTO public.<tab> (cols + tenant_id) SELECT cols,
'<tenant_id>' FROM tenant_<slug>.<tab>` — o inverso exato do
`manual/f6_1_migrate_data.supabase_admin.sql` (trocar origem/destino e
RE-ADICIONAR a coluna tenant_id com o id do tenant do schema).
3. Resetar sequences, recriar as 9 views + 2 FKs, voltar o código (`git checkout main`).
Esse caminho é trabalhoso — por isso a recomendação forte: **só aplique a F6.3
depois de validar o app**, e mantenha o backup pré-F6.3. O DROP é a única coisa
que transforma "trivial" em "trabalhoso".
---
## Checklist antes de aplicar a F6.3 (resumo)
- [ ] App testado no browser (fluxos autenticados sem erro PGRST/4xx).
- [ ] Backup FRESCO: `pg_dump --schema=public > backups/pre-F6.3/public.sql`.
- [ ] Branch commitada (rollback de código = `git checkout main`).
- [ ] Ciente: pós-DROP, public some; a verdade passa a ser os schemas.
@@ -0,0 +1,107 @@
-- =============================================================================
-- F6.3 — DROP das tabelas tenant em public (PONTO DE NÃO-RETORNO)
--
-- 🛑 NÃO APLICAR AINDA. Este arquivo está PREPARADO para revisão. Aplicar só
-- depois de:
-- (a) Leonardo testar o app no branch e validar os fluxos;
-- (b) os ITENS EM ABERTO abaixo resolvidos;
-- (c) backup fresco confirmado.
--
-- ⚠️ APLICAR COMO supabase_admin (DROP CASCADE; tabelas documents/document_* são
-- owned por supabase_admin).
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_3_drop_public_tenant_tables.supabase_admin.sql
--
-- BACKUP ANTES (obrigatório):
-- docker exec supabase_db_agenciapsi-primesakai pg_dump -U postgres -d postgres \
-- --schema=public --no-owner --no-acl > database-novo/backups/pre-F6.3/public.sql
--
-- ─────────────────────────────────────────────────────────────────────────────
-- ✅ ITENS EM ABERTO — RESOLVIDOS (F6.4, commit dc2363b):
-- 1. v_twilio_whatsapp_overview / getAllChannels → RPC saas_list_all_whatsapp_
-- channels (fan-out). ✓
-- 2. SaasFeriadosPage / SaasNotificationTemplatesPage / SaasWhatsappPage →
-- RPCs saas_*_default + supabase.schema(tenant_<slug>). ✓
-- 3. notification-webhook (Meta) → confirmado: usa tdb/schema (F4). ✓
-- 4. AgendadorPublicoPage → RPCs roteadas. ✓
-- Varredura FE confirma ZERO supabase.from('<tabela_tenant>') público restante.
-- PRÉ-REQUISITO FINAL: Leonardo testar o app + backup fresco.
-- ─────────────────────────────────────────────────────────────────────────────
-- =============================================================================
\set ON_ERROR_STOP on
BEGIN;
-- ── 0) PRÉ-FLIGHT: aborta se algo essencial não bate ────────────────────────
DO $$
DECLARE v_tenants int; v_schemas int; v_drop int;
BEGIN
SELECT count(*) INTO v_tenants FROM public.tenants;
SELECT count(*) INTO v_schemas FROM public.tenant_schemas;
IF v_tenants <> v_schemas THEN
RAISE EXCEPTION 'F6.3 ABORT: % tenants mas % schemas provisionados — nem todos migrados', v_tenants, v_schemas;
END IF;
SELECT count(*) INTO v_drop FROM information_schema.tables
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%';
IF v_drop <> 78 THEN
RAISE EXCEPTION 'F6.3 ABORT: _tenant_template tem % tabelas, esperava 78', v_drop;
END IF;
-- guarda: nenhuma das 6 anon-facing pode estar na lista de drop
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='_tenant_template'
AND table_name IN ('patient_intake_requests','patient_invites','patient_invite_attempts',
'document_share_links','agendador_configuracoes','agendador_solicitacoes')) THEN
RAISE EXCEPTION 'F6.3 ABORT: tabela anon-facing presente no template (não deveria sair de public)';
END IF;
RAISE NOTICE 'F6.3 pré-flight OK: % tenants = % schemas, 78 tabelas a dropar', v_tenants, v_schemas;
END $$;
-- ── 1) FKs de tabelas que FICAM → tabelas que SAEM: viram coluna solta ──────
-- (validação fica no app/RPC via token; alvo vai pro schema do tenant)
ALTER TABLE public.document_share_links DROP CONSTRAINT IF EXISTS document_share_links_documento_id_fkey;
ALTER TABLE public.whatsapp_credits_transactions DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_conversation_message_id_fkey;
-- ── 2) Views public que referenciam tabelas tenant ──────────────────────────
-- As 6 do template são recriadas POR SCHEMA (F1). v_patient_engajamento e
-- v_patients_risco são dead code (0 uso no FE). v_twilio_whatsapp_overview:
-- ⚠️ ver ITEM EM ABERTO #1 — só dropar após reescrever getAllChannels.
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
DROP VIEW IF EXISTS public.v_cashflow_projection CASCADE;
DROP VIEW IF EXISTS public.v_commitment_totals CASCADE;
DROP VIEW IF EXISTS public.v_patient_groups_with_counts CASCADE;
DROP VIEW IF EXISTS public.v_tag_patient_counts CASCADE;
DROP VIEW IF EXISTS public.v_patient_engajamento CASCADE; -- dead code
DROP VIEW IF EXISTS public.v_patients_risco CASCADE; -- dead code
DROP VIEW IF EXISTS public.v_twilio_whatsapp_overview CASCADE; -- ⚠️ item #1
-- ── 3) DROP das 78 tabelas tenant em public (derivado de _tenant_template) ──
-- CASCADE leva junto triggers das tabelas + FKs intra-tenant. Dados já estão
-- nos schemas (F6.1) — estas são as cópias velhas.
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT table_name FROM information_schema.tables
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%'
ORDER BY table_name
LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=r.table_name) THEN
EXECUTE format('DROP TABLE public.%I CASCADE', r.table_name);
RAISE NOTICE 'F6.3 dropou public.%', r.table_name;
END IF;
END LOOP;
END $$;
-- ── 4) Limpeza de funções obsoletas pós-drop ────────────────────────────────
-- financial_records_inject_tenant só fazia sentido em public.financial_records
-- (já dropada); não está anexado em nenhum schema.
DROP FUNCTION IF EXISTS public.financial_records_inject_tenant() CASCADE;
COMMIT;
-- ── 5) PÓS-DROP: verificações manuais (rodar separado) ───────────────────────
-- SELECT 'tabelas tenant restantes em public: ' || count(*) FROM information_schema.tables
-- WHERE table_schema='public' AND table_name IN (SELECT table_name FROM _tenant_template.information... );
-- NOTIFY pgrst, 'reload schema';
-- Conferir app: nenhuma query 4xx/PGRST200 no console.
@@ -0,0 +1,181 @@
-- =============================================================================
-- F6.4 — RPCs SaaS-admin: defaults do sistema (template + fan-out) e views
-- cross-tenant (fan-out). Destrava o F6.3 (páginas SaaS deixam de ler
-- public.<tabela_tenant>).
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- Defaults (feriados nacionais, notification_templates is_default): editados
-- pelo SaaS no _tenant_template (fonte da verdade, propaga p/ tenants NOVOS no
-- clone) E fan-out pros schemas EXISTENTES. Só toca cópias-default (owner_id
-- NULL / is_default), preserva overrides do tenant (owner_id próprio).
-- Todas gated por is_saas_admin().
-- =============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public._assert_saas_admin()
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$ BEGIN
IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Apenas SaaS admin' USING ERRCODE='42501'; END IF;
END $$;
-- ── FERIADOS (defaults nacionais) ───────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.saas_list_default_feriados(p_ano integer)
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v jsonb;
BEGIN
PERFORM public._assert_saas_admin();
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',id,'data',data,'nome',nome,'tipo',tipo,'bloqueia_sessoes',bloqueia_sessoes) ORDER BY data),'[]'::jsonb)
INTO v FROM _tenant_template.feriados WHERE EXTRACT(YEAR FROM data) = p_ano;
RETURN v;
END $$;
CREATE OR REPLACE FUNCTION public.saas_add_default_feriado(p_data date, p_nome text, p_tipo text DEFAULT 'municipal')
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_owner uuid := auth.uid();
BEGIN
PERFORM public._assert_saas_admin();
INSERT INTO _tenant_template.feriados (owner_id, tipo, nome, data, bloqueia_sessoes)
VALUES (v_owner, p_tipo, p_nome, p_data, false) ON CONFLICT (data, nome) DO NOTHING;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('INSERT INTO %I.feriados (owner_id, tipo, nome, data, bloqueia_sessoes) VALUES ($1,$2,$3,$4,false) ON CONFLICT (data, nome) DO NOTHING', t.schema_name)
USING v_owner, p_tipo, p_nome, p_data;
END LOOP;
RETURN jsonb_build_object('data', p_data, 'nome', p_nome, 'tipo', p_tipo);
END $$;
CREATE OR REPLACE FUNCTION public.saas_remove_default_feriado(p_data date, p_nome text)
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int := 0;
BEGIN
PERFORM public._assert_saas_admin();
DELETE FROM _tenant_template.feriados WHERE data = p_data AND nome = p_nome;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('DELETE FROM %I.feriados WHERE data = $1 AND nome = $2', t.schema_name) USING p_data, p_nome;
v_n := v_n + 1;
END LOOP;
RETURN v_n;
END $$;
-- ── NOTIFICATION_TEMPLATES (defaults) ───────────────────────────────────────
CREATE OR REPLACE FUNCTION public.saas_list_default_notif_templates()
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v jsonb;
BEGIN
PERFORM public._assert_saas_admin();
SELECT COALESCE(jsonb_agg(to_jsonb(nt) ORDER BY nt.domain, nt.event_type),'[]'::jsonb)
INTO v FROM _tenant_template.notification_templates nt WHERE nt.is_default = true AND nt.deleted_at IS NULL;
RETURN v;
END $$;
-- upsert por key (defaults têm owner_id NULL). Cria/atualiza no template + schemas.
CREATE OR REPLACE FUNCTION public.saas_upsert_default_notif_template(p_payload jsonb)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_key text := p_payload->>'key'; v_exists boolean;
v_domain text := p_payload->>'domain'; v_channel text := p_payload->>'channel';
v_event text := p_payload->>'event_type'; v_body text := p_payload->>'body_text';
v_vars jsonb := COALESCE(p_payload->'variables','[]'::jsonb);
v_active boolean := COALESCE((p_payload->>'is_active')::boolean, true);
BEGIN
PERFORM public._assert_saas_admin();
IF v_key IS NULL THEN RAISE EXCEPTION 'key obrigatório'; END IF;
-- template
SELECT EXISTS(SELECT 1 FROM _tenant_template.notification_templates WHERE key=v_key AND owner_id IS NULL AND is_default=true) INTO v_exists;
IF v_exists THEN
UPDATE _tenant_template.notification_templates SET body_text=v_body, domain=v_domain, channel=v_channel,
event_type=v_event, variables=v_vars, is_active=v_active WHERE key=v_key AND owner_id IS NULL AND is_default=true;
ELSE
INSERT INTO _tenant_template.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES (NULL, v_key, v_domain, v_channel, v_event, v_body, v_vars, true, v_active);
END IF;
-- fan-out schemas (só a cópia-default; preserva overrides do tenant owner_id<>NULL)
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format(
'INSERT INTO %I.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active) '
|| 'VALUES (NULL,$1,$2,$3,$4,$5,$6,true,$7) '
|| 'ON CONFLICT (owner_id, key, deleted_at) DO UPDATE SET body_text=EXCLUDED.body_text, domain=EXCLUDED.domain, '
|| 'channel=EXCLUDED.channel, event_type=EXCLUDED.event_type, variables=EXCLUDED.variables, is_active=EXCLUDED.is_active',
t.schema_name) USING v_key, v_domain, v_channel, v_event, v_body, v_vars, v_active;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.saas_set_default_notif_template_active(p_key text, p_active boolean)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
PERFORM public._assert_saas_admin();
UPDATE _tenant_template.notification_templates SET is_active=p_active WHERE key=p_key AND owner_id IS NULL AND is_default=true;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_templates SET is_active=$1 WHERE key=$2 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_active, p_key;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.saas_delete_default_notif_template(p_key text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
PERFORM public._assert_saas_admin();
UPDATE _tenant_template.notification_templates SET deleted_at=now() WHERE key=p_key AND owner_id IS NULL AND is_default=true;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_templates SET deleted_at=now() WHERE key=$1 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_key;
END LOOP;
END $$;
-- quantos tenants têm override (tenant_id<>NULL no modelo antigo = owner_id<>NULL aqui) por key
CREATE OR REPLACE FUNCTION public.saas_count_notif_template_overrides(p_key text)
RETURNS integer LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int := 0; v_has int;
BEGIN
PERFORM public._assert_saas_admin();
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('SELECT count(*) FROM %I.notification_templates WHERE key=$1 AND owner_id IS NOT NULL AND is_active=true AND deleted_at IS NULL', t.schema_name) INTO v_has USING p_key;
v_n := v_n + v_has;
END LOOP;
RETURN v_n;
END $$;
-- ── WHATSAPP admin (cross-tenant) — substitui v_twilio_whatsapp_overview ─────
CREATE OR REPLACE FUNCTION public.saas_list_all_whatsapp_channels()
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_rows jsonb := '[]'::jsonb; v_part jsonb;
BEGIN
PERFORM public._assert_saas_admin();
FOR t IN SELECT ts.tenant_id, ts.schema_name, tn.name AS tenant_name
FROM public.tenant_schemas ts JOIN public.tenants tn ON tn.id = ts.tenant_id LOOP
EXECUTE format(
'SELECT COALESCE(jsonb_agg(jsonb_build_object('
|| '''id'',nc.id, ''tenant_id'',$1::uuid, ''tenant_name'',$2::text, ''owner_id'',nc.owner_id,'
|| '''provider'',nc.provider, ''is_active'',nc.is_active, ''connection_status'',nc.connection_status,'
|| '''sender_address'',nc.sender_address, ''twilio_phone_number'',nc.twilio_phone_number,'
|| '''last_health_check'',nc.last_health_check,'
|| '''open_incident'',(SELECT i.kind FROM %1$I.whatsapp_connection_incidents i WHERE i.channel_id=nc.id AND i.resolved_at IS NULL LIMIT 1)'
|| ')),''[]''::jsonb) FROM %1$I.notification_channels nc WHERE nc.channel=''whatsapp'' AND nc.deleted_at IS NULL',
t.schema_name) INTO v_part USING t.tenant_id, t.tenant_name;
v_rows := v_rows || v_part;
END LOOP;
RETURN v_rows;
END $$;
-- grants: gated por is_saas_admin internamente, mas exposto a authenticated
DO $g$ DECLARE fn text; BEGIN
FOREACH fn IN ARRAY ARRAY[
'saas_list_default_feriados(integer)','saas_add_default_feriado(date,text,text)','saas_remove_default_feriado(date,text)',
'saas_list_default_notif_templates()','saas_upsert_default_notif_template(jsonb)',
'saas_set_default_notif_template_active(text,boolean)','saas_delete_default_notif_template(text)',
'saas_count_notif_template_overrides(text)','saas_list_all_whatsapp_channels()'
] LOOP
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO authenticated', fn);
END LOOP;
END $g$;
COMMIT;
@@ -0,0 +1,147 @@
-- =============================================================================
-- Freemium F1 — Enforcement de limite de plano (pacientes), schema-per-tenant
--
-- ⚠️ APLICAR COMO supabase_admin (anexa triggers em tabelas tenant + a função de
-- wiring trg_attach_business_triggers é owned por supabase_admin).
--
-- Trigger genérico BEFORE INSERT em <schema>.patients que:
-- 1. resolve o tenant pelo NOME DO SCHEMA (TG_TABLE_SCHEMA → tenant_schemas);
-- 2. resolve o plano ATIVO do tenant em runtime (clínica via tenant_id;
-- pessoal/terapeuta via owner user_id — as 6 subs pessoais têm tenant_id NULL);
-- 3. lê o limite max_patients de plan_features.limits EM RUNTIME (mudar o número
-- no painel passa a valer sem deploy);
-- 4. conta pacientes vivos (status <> 'Arquivado') e dá RAISE parseável
-- 'PLAN_LIMIT_REACHED|patients|<limite>' quando já atingiu.
--
-- Sem plano ativo OU sem limite definido (planos PRO) ⇒ não bloqueia.
-- Idempotente (CREATE OR REPLACE + DROP TRIGGER IF EXISTS). Tudo em public
-- (subscriptions/plan_features/tenant_schemas são globais) ⇒ sobrevive ao DROP F6.3.
-- =============================================================================
BEGIN;
-- 1) Resolve o plano ativo de um tenant (clínica OU pessoal) ------------------
CREATE OR REPLACE FUNCTION public.tenant_active_plan_id(p_tenant_id uuid)
RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
SELECT COALESCE(
-- clínica: subscription chaveada por tenant_id
(SELECT vas.plan_id
FROM public.v_tenant_active_subscription vas
WHERE vas.tenant_id = p_tenant_id),
-- pessoal: subscription chaveada pelo owner (user_id), tenant_id NULL
(SELECT s.plan_id
FROM public.subscriptions s
JOIN public.tenant_members tm
ON tm.user_id = s.user_id
AND tm.tenant_id = p_tenant_id
AND tm.status = 'active'
WHERE s.status = 'active'
AND s.tenant_id IS NULL
AND (s.current_period_end IS NULL OR s.current_period_end > now())
ORDER BY s.created_at DESC
LIMIT 1)
);
$$;
ALTER FUNCTION public.tenant_active_plan_id(uuid) OWNER TO supabase_admin;
-- 2) Lê um limite numérico do plano (busca a chave em qualquer feature) -------
-- Ex.: clinic_free guarda max_patients sob clinic_calendar; therapist_free
-- sob patients.manage. Retorna o MIN (mais restritivo) se houver mais de um.
CREATE OR REPLACE FUNCTION public.plan_feature_limit(p_plan_id uuid, p_limit_key text)
RETURNS int
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
SELECT min((pf.limits->>p_limit_key)::int)
FROM public.plan_features pf
WHERE pf.plan_id = p_plan_id
AND pf.enabled
AND pf.limits ? p_limit_key
AND (pf.limits->>p_limit_key) ~ '^[0-9]+$';
$$;
ALTER FUNCTION public.plan_feature_limit(uuid, text) OWNER TO supabase_admin;
-- 3) Trigger function de enforcement -----------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_patient_plan_limit()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_tenant uuid;
v_plan uuid;
v_limit int;
v_count int;
BEGIN
SELECT tenant_id INTO v_tenant
FROM public.tenant_schemas
WHERE schema_name = TG_TABLE_SCHEMA;
IF v_tenant IS NULL THEN RETURN NEW; END IF; -- schema não-tenant: ignora
v_plan := public.tenant_active_plan_id(v_tenant);
IF v_plan IS NULL THEN RETURN NEW; END IF; -- sem plano ativo: não bloqueia
v_limit := public.plan_feature_limit(v_plan, 'max_patients');
IF v_limit IS NULL THEN RETURN NEW; END IF; -- plano sem limite (PRO): ilimitado
EXECUTE format(
'SELECT count(*) FROM %I.patients WHERE status IS DISTINCT FROM %L',
TG_TABLE_SCHEMA, 'Arquivado'
) INTO v_count;
IF v_count >= v_limit THEN
RAISE EXCEPTION 'PLAN_LIMIT_REACHED|patients|%', v_limit USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END $$;
ALTER FUNCTION public.enforce_patient_plan_limit() OWNER TO supabase_admin;
-- 4) Attach helper (pendura o trigger no patients de um schema) ---------------
CREATE OR REPLACE FUNCTION public.attach_plan_limit_triggers(p_schema text)
RETURNS int
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN
RAISE EXCEPTION 'schema inválido %', p_schema;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = p_schema AND table_name = 'patients'
) THEN
EXECUTE format('DROP TRIGGER IF EXISTS enforce_patient_plan_limit ON %I.patients', p_schema);
EXECUTE format(
'CREATE TRIGGER enforce_patient_plan_limit BEFORE INSERT ON %I.patients '
'FOR EACH ROW EXECUTE FUNCTION public.enforce_patient_plan_limit()', p_schema);
RETURN 1;
END IF;
RETURN 0;
END $$;
ALTER FUNCTION public.attach_plan_limit_triggers(text) OWNER TO supabase_admin;
GRANT EXECUTE ON FUNCTION public.attach_plan_limit_triggers(text) TO postgres, service_role;
-- 5) Wiring: tenants NOVOS ganham o trigger de limite no clone ----------------
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
PERFORM public.attach_notif_triggers(NEW.schema_name);
PERFORM public.attach_plan_limit_triggers(NEW.schema_name);
RETURN NULL;
END $$;
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
-- 6) Backfill: os 9 schemas já existentes ganham o trigger agora -------------
DO $$
DECLARE r record; n int := 0;
BEGIN
FOR r IN SELECT schema_name FROM public.tenant_schemas LOOP
n := n + public.attach_plan_limit_triggers(r.schema_name);
END LOOP;
RAISE NOTICE 'enforce_patient_plan_limit anexado em % schemas', n;
END $$;
COMMIT;
@@ -0,0 +1,238 @@
-- =============================================================================
-- Freemium F2 — RPCs idempotentes do self-service (schema-per-tenant)
--
-- ⚠️ APLICAR COMO supabase_admin (auto_provision insere em tenants/members/
-- subscriptions/profiles + roda clone_tenant_template).
--
-- Com confirmação de e-mail LIGADA, o signup NÃO tem sessão — então nada que
-- dependa de auth.uid() roda no signup. A escolha do usuário (nome, slug, plano,
-- intervalo, kind) é gravada no raw_user_meta_data do signUp e PROCESSADA aqui,
-- no 1º login pós-confirmação:
-- • slug_disponivel(p_slug) → {ok, motivo} (chamável por anon no signup)
-- • auto_provision_free_tenant(...) → cria tenant + clone + master + sub free
-- • processar_pos_signup() → cria a intenção SÓ pro caminho pago
--
-- Todas idempotentes. Não há tabela de aceite legal neste sistema (pulado).
-- =============================================================================
BEGIN;
-- 1) slug_disponivel ---------------------------------------------------------
-- Valida formato (mesma regra do generate_tenant_slug), reservados e uso.
-- Chamável por ANON (signup acontece antes do login). Não vaza dados de
-- tenant além do fato "slug em uso".
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v text := lower(trim(coalesce(p_slug, '')));
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
BEGIN
IF length(v) < 3 THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'curto');
END IF;
IF length(v) > 48 THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'longo');
END IF;
-- começa com letra, só [a-z0-9_]
IF v !~ '^[a-z][a-z0-9_]*$' THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'invalido');
END IF;
IF v = ANY(v_reservados) THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'reservado');
END IF;
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso');
END IF;
-- (F3) blacklist de slug integra aqui via motivo 'bloqueado'
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
END $$;
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
-- 2) auto_provision_free_tenant ----------------------------------------------
-- Idempotente: se o usuário já tem tenant ativo, retorna esse. Senão lê o
-- raw_user_meta_data, cria o tenant (slug escolhido OU auto), vira master,
-- clona o schema e cria a subscription gratuita ativa (XOR conforme target).
-- p_slug_override permite a tela /onboarding reescolher o slug se colidiu.
CREATE OR REPLACE FUNCTION public.auto_provision_free_tenant(p_slug_override text DEFAULT NULL)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_uid uuid := auth.uid();
v_meta jsonb;
v_email text;
v_kind text;
v_acct text;
v_name text;
v_slug text;
v_display text;
v_tenant_id uuid;
v_plan_key text;
v_plan_id uuid;
v_target text;
v_existing uuid;
BEGIN
IF v_uid IS NULL THEN
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
END IF;
-- idempotência: já tem tenant ativo?
SELECT tm.tenant_id INTO v_existing
FROM public.tenant_members tm
WHERE tm.user_id = v_uid AND tm.status = 'active'
ORDER BY tm.created_at ASC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('status', 'exists', 'tenant_id', v_existing,
'slug', (SELECT slug FROM public.tenants WHERE id = v_existing));
END IF;
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
FROM auth.users au WHERE au.id = v_uid;
v_meta := COALESCE(v_meta, '{}'::jsonb);
-- kind: do metadata, default therapist (maioria). Valida contra os aceitos.
v_kind := lower(coalesce(nullif(trim(v_meta->>'account_kind'), ''), 'therapist'));
IF v_kind NOT IN ('therapist','clinic_coworking','clinic_reception','clinic_full') THEN
v_kind := 'therapist';
END IF;
v_acct := CASE WHEN v_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
v_display := nullif(trim(v_meta->>'display_name'), '');
v_name := coalesce(
nullif(trim(v_meta->>'tenant_name'), ''),
v_display,
split_part(coalesce(v_email, 'conta'), '@', 1),
'Conta');
-- slug: override > metadata > NULL (trigger auto-gera). Valida disponibilidade.
v_slug := lower(trim(coalesce(p_slug_override, v_meta->>'tenant_slug', '')));
IF v_slug = '' THEN
v_slug := NULL;
ELSE
IF NOT (public.slug_disponivel(v_slug)->>'ok')::boolean THEN
RAISE EXCEPTION 'SLUG_TAKEN|%', v_slug USING ERRCODE = 'P0001';
END IF;
END IF;
-- cria tenant (trg_tenants_slug respeita slug fornecido; gera se NULL)
INSERT INTO public.tenants (name, kind, slug, created_at)
VALUES (v_name, v_kind, v_slug, now())
RETURNING id, slug INTO v_tenant_id, v_slug;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (v_tenant_id, v_uid, 'tenant_admin', 'active', now());
UPDATE public.profiles
SET account_type = v_acct,
full_name = COALESCE(full_name, v_display)
WHERE id = v_uid;
-- provisiona o schema físico + seed
PERFORM public.clone_tenant_template(v_tenant_id);
PERFORM public.seed_determined_commitments(v_tenant_id);
-- subscription gratuita ativa (XOR: clinic→tenant_id; therapist→user_id)
v_plan_key := CASE WHEN v_acct = 'therapist' THEN 'therapist_free' ELSE 'clinic_free' END;
SELECT id, lower(target) INTO v_plan_id, v_target FROM public.plans WHERE key = v_plan_key;
INSERT INTO public.subscriptions (plan_id, plan_key, status, interval, source,
tenant_id, user_id, started_at, activated_at, current_period_start)
VALUES (v_plan_id, v_plan_key, 'active', 'month', 'auto_free',
CASE WHEN v_target = 'clinic' THEN v_tenant_id ELSE NULL END,
CASE WHEN v_target = 'clinic' THEN NULL ELSE v_uid END,
now(), now(), now());
RETURN jsonb_build_object('status', 'provisioned', 'tenant_id', v_tenant_id,
'slug', v_slug, 'kind', v_kind, 'plan_key', v_plan_key);
END $$;
ALTER FUNCTION public.auto_provision_free_tenant(text) OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.auto_provision_free_tenant(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.auto_provision_free_tenant(text) TO authenticated, service_role;
-- 3) processar_pos_signup ----------------------------------------------------
-- Caminho PAGO: se o usuário escolheu um plano PRO no signup (metadata),
-- registra a intenção (idempotente — uma por usuário+plano 'new'). O caminho
-- gratuito não gera intenção. Sem tabela de aceite legal (pulado).
CREATE OR REPLACE FUNCTION public.processar_pos_signup()
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_uid uuid := auth.uid();
v_meta jsonb;
v_email text;
v_plan_key text;
v_interval text;
v_plan record;
v_tenant uuid;
v_amount int;
BEGIN
IF v_uid IS NULL THEN
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
END IF;
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
FROM auth.users au WHERE au.id = v_uid;
v_meta := COALESCE(v_meta, '{}'::jsonb);
v_plan_key := nullif(trim(v_meta->>'plan_key'), '');
v_interval := lower(coalesce(nullif(trim(v_meta->>'billing_interval'), ''), 'month'));
IF v_interval NOT IN ('month','year') THEN v_interval := 'month'; END IF;
-- sem plano escolhido OU plano gratuito → nada a fazer
IF v_plan_key IS NULL OR v_plan_key LIKE '%\_free' THEN
RETURN jsonb_build_object('status', 'no_intent');
END IF;
SELECT * INTO v_plan FROM public.plans WHERE key = v_plan_key AND is_active;
IF NOT FOUND THEN
RETURN jsonb_build_object('status', 'plan_not_found', 'plan_key', v_plan_key);
END IF;
-- idempotência: já existe intent 'new' desse usuário+plano?
IF EXISTS (
SELECT 1 FROM public.subscription_intents
WHERE created_by_user_id = v_uid AND plan_key = v_plan_key AND status = 'new'
) THEN
RETURN jsonb_build_object('status', 'intent_exists', 'plan_key', v_plan_key);
END IF;
SELECT tm.tenant_id INTO v_tenant
FROM public.tenant_members tm WHERE tm.user_id = v_uid AND tm.status = 'active'
ORDER BY tm.created_at ASC LIMIT 1;
v_amount := CASE WHEN v_interval = 'year'
THEN COALESCE(v_plan.price_cents, 0) * 12
ELSE COALESCE(v_plan.price_cents, 0) END;
-- escreve direto na tabela real (a view subscription_intents tem INSTEAD OF
-- trigger que não propaga user_id pra _tenant; o serviço do front também
-- escreve nas tabelas reais por target).
IF lower(v_plan.target) = 'clinic' THEN
INSERT INTO public.subscription_intents_tenant
(tenant_id, user_id, created_by_user_id, email, plan_id, plan_key,
interval, amount_cents, currency, status, source)
VALUES
(v_tenant, v_uid, v_uid, v_email, v_plan.id, v_plan_key,
v_interval, v_amount, 'BRL', 'new', 'signup');
ELSE
INSERT INTO public.subscription_intents_personal
(user_id, created_by_user_id, email, plan_id, plan_key,
interval, amount_cents, currency, status, source)
VALUES
(v_uid, v_uid, v_email, v_plan.id, v_plan_key,
v_interval, v_amount, 'BRL', 'new', 'signup');
END IF;
RETURN jsonb_build_object('status', 'intent_created', 'plan_key', v_plan_key, 'interval', v_interval);
END $$;
ALTER FUNCTION public.processar_pos_signup() OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.processar_pos_signup() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.processar_pos_signup() TO authenticated, service_role;
COMMIT;
@@ -0,0 +1,104 @@
-- =============================================================================
-- Freemium F3a — Blacklist de e-mails e slugs
--
-- ⚠️ APLICAR COMO supabase_admin (cria trigger em auth.users + altera
-- slug_disponivel, que é owned por supabase_admin).
--
-- Tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
-- trigger BEFORE INSERT em auth.users (não só no front); suporta domínio inteiro
-- com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
-- Gerida por saas_admin (dev) em Configurações.
-- =============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS public.blacklist (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('email','slug')),
value text NOT NULL,
note text,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (kind, value)
);
-- normaliza value (lower+trim) sempre
CREATE OR REPLACE FUNCTION public.blacklist_normalize()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.value := lower(trim(NEW.value));
IF NEW.value = '' THEN RAISE EXCEPTION 'valor vazio'; END IF;
RETURN NEW;
END $$;
DROP TRIGGER IF EXISTS trg_blacklist_normalize ON public.blacklist;
CREATE TRIGGER trg_blacklist_normalize BEFORE INSERT OR UPDATE ON public.blacklist
FOR EACH ROW EXECUTE FUNCTION public.blacklist_normalize();
-- RLS: só saas_admin gere
ALTER TABLE public.blacklist ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS blacklist_saas_admin ON public.blacklist;
CREATE POLICY blacklist_saas_admin ON public.blacklist
FOR ALL USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
-- helpers ----------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.is_email_blacklisted(p_email text)
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
SELECT EXISTS (
SELECT 1 FROM public.blacklist
WHERE kind = 'email'
AND value IN (
lower(trim(p_email)),
'@' || split_part(lower(trim(p_email)), '@', 2)
)
);
$$;
ALTER FUNCTION public.is_email_blacklisted(text) OWNER TO supabase_admin;
CREATE OR REPLACE FUNCTION public.is_slug_blacklisted(p_slug text)
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
SELECT EXISTS (SELECT 1 FROM public.blacklist WHERE kind = 'slug' AND value = lower(trim(p_slug)));
$$;
ALTER FUNCTION public.is_slug_blacklisted(text) OWNER TO supabase_admin;
-- trigger de bloqueio real no cadastro -----------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_email_blacklist()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
IF NEW.email IS NOT NULL AND public.is_email_blacklisted(NEW.email) THEN
RAISE EXCEPTION 'EMAIL_BLOCKED' USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END $$;
ALTER FUNCTION public.enforce_email_blacklist() OWNER TO supabase_admin;
DROP TRIGGER IF EXISTS trg_enforce_email_blacklist ON auth.users;
CREATE TRIGGER trg_enforce_email_blacklist BEFORE INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.enforce_email_blacklist();
-- integra no slug_disponivel (motivo 'bloqueado') ------------------------------
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v text := lower(trim(coalesce(p_slug, '')));
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
BEGIN
IF length(v) < 3 THEN RETURN jsonb_build_object('ok', false, 'motivo', 'curto'); END IF;
IF length(v) > 48 THEN RETURN jsonb_build_object('ok', false, 'motivo', 'longo'); END IF;
IF v !~ '^[a-z][a-z0-9_]*$' THEN RETURN jsonb_build_object('ok', false, 'motivo', 'invalido'); END IF;
IF v = ANY(v_reservados) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'reservado'); END IF;
IF public.is_slug_blacklisted(v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'bloqueado'); END IF;
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso'); END IF;
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
END $$;
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.blacklist TO authenticated;
COMMIT;
@@ -0,0 +1,116 @@
-- =============================================================================
-- Freemium F3b — /saas/usuarios (donos por tenant) + notificação aos devs
--
-- ⚠️ APLICAR COMO supabase_admin (lê auth.users.email + cria trigger em
-- public.subscriptions; notify_user_sistema é chamada por SECURITY DEFINER).
--
-- • saas_list_account_owners(): 1 linha por tenant com o DONO (master),
-- nome/slug/e-mail/plano + selo "novo" (24h). Dev-only (is_saas_admin).
-- • notify_all_devs(): insere em notifications_sistema p/ cada saas_admin.
-- • trigger em subscriptions: avisa os devs quando nasce/muda uma assinatura,
-- com deeplink pra /saas/usuarios.
-- =============================================================================
BEGIN;
-- 1) Donos por tenant (dev-only) ---------------------------------------------
CREATE OR REPLACE FUNCTION public.saas_list_account_owners()
RETURNS TABLE (
tenant_id uuid,
slug text,
tenant_name text,
kind text,
owner_id uuid,
owner_name text,
owner_email text,
plan_key text,
created_at timestamptz,
is_new boolean
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501';
END IF;
RETURN QUERY
SELECT t.id, t.slug::text, t.name::text, t.kind::text,
owner.user_id, pr.full_name::text, au.email::text,
COALESCE(vas.plan_key, ps.plan_key)::text,
t.created_at,
(t.created_at > now() - interval '24 hours')
FROM public.tenants t
LEFT JOIN LATERAL (
SELECT tm.user_id
FROM public.tenant_members tm
WHERE tm.tenant_id = t.id AND tm.role = 'tenant_admin' AND tm.status = 'active'
ORDER BY tm.created_at ASC
LIMIT 1
) owner ON true
LEFT JOIN public.profiles pr ON pr.id = owner.user_id
LEFT JOIN auth.users au ON au.id = owner.user_id
LEFT JOIN public.v_tenant_active_subscription vas ON vas.tenant_id = t.id
LEFT JOIN LATERAL (
SELECT s.plan_key FROM public.subscriptions s
WHERE s.user_id = owner.user_id AND s.status = 'active' AND s.tenant_id IS NULL
ORDER BY s.created_at DESC LIMIT 1
) ps ON true
ORDER BY t.created_at DESC;
END $$;
ALTER FUNCTION public.saas_list_account_owners() OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.saas_list_account_owners() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.saas_list_account_owners() TO authenticated, service_role;
-- 2) notify_all_devs ----------------------------------------------------------
CREATE OR REPLACE FUNCTION public.notify_all_devs(
p_type text, p_payload jsonb, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL
)
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE r record; n int := 0;
BEGIN
FOR r IN SELECT user_id FROM public.saas_admins LOOP
PERFORM public.notify_user_sistema(r.user_id, p_type, p_payload, NULL, p_ref_id, p_ref_table);
n := n + 1;
END LOOP;
RETURN n;
END $$;
ALTER FUNCTION public.notify_all_devs(text, jsonb, uuid, text) OWNER TO supabase_admin;
-- 3) trigger em subscriptions -------------------------------------------------
CREATE OR REPLACE FUNCTION public.trg_notify_devs_subscription()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_slug text; v_title text;
BEGIN
-- só em INSERT ou quando o status muda
IF TG_OP = 'UPDATE' AND NEW.status IS NOT DISTINCT FROM OLD.status THEN
RETURN NEW;
END IF;
SELECT t.slug INTO v_slug FROM public.tenants t WHERE t.id = NEW.tenant_id;
v_title := CASE WHEN TG_OP = 'INSERT' THEN 'Nova assinatura' ELSE 'Assinatura atualizada' END;
PERFORM public.notify_all_devs(
'subscription_' || lower(TG_OP),
jsonb_build_object(
'title', v_title,
'detail', NEW.plan_key || ' · ' || NEW.status || COALESCE(' · ' || v_slug, ''),
'deeplink', '/saas/usuarios',
'plan_key', NEW.plan_key,
'status', NEW.status
),
NEW.id, 'subscriptions'
);
RETURN NEW;
END $$;
ALTER FUNCTION public.trg_notify_devs_subscription() OWNER TO supabase_admin;
DROP TRIGGER IF EXISTS trg_subscriptions_notify_devs ON public.subscriptions;
CREATE TRIGGER trg_subscriptions_notify_devs
AFTER INSERT OR UPDATE OF status ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.trg_notify_devs_subscription();
COMMIT;
@@ -0,0 +1,43 @@
-- =============================================================================
-- Freemium F3c — root_redirect (pra onde o visitante não-logado vai na raiz "/")
--
-- ⚠️ APLICAR COMO supabase_admin (RLS por is_saas_admin).
--
-- Config singleton saas_app_config + RPC pública get_root_redirect() (anon lê o
-- alvo: 'landing' | 'login'). O guard do front usa pra rotear "/". Só saas_admin
-- altera (via UPDATE direto, gated por RLS).
-- =============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS public.saas_app_config (
id boolean PRIMARY KEY DEFAULT true, -- singleton: sempre id=true
root_redirect text NOT NULL DEFAULT 'landing' CHECK (root_redirect IN ('landing','login')),
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_app_config_singleton CHECK (id)
);
INSERT INTO public.saas_app_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_app_config ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS saas_app_config_read ON public.saas_app_config;
CREATE POLICY saas_app_config_read ON public.saas_app_config FOR SELECT USING (true);
DROP POLICY IF EXISTS saas_app_config_write ON public.saas_app_config;
CREATE POLICY saas_app_config_write ON public.saas_app_config
FOR UPDATE USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
GRANT SELECT ON public.saas_app_config TO anon, authenticated;
GRANT UPDATE ON public.saas_app_config TO authenticated;
-- RPC pública: alvo do "/" pra visitante não-logado
CREATE OR REPLACE FUNCTION public.get_root_redirect()
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
SELECT COALESCE((SELECT root_redirect FROM public.saas_app_config WHERE id), 'landing');
$$;
ALTER FUNCTION public.get_root_redirect() OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.get_root_redirect() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_root_redirect() TO anon, authenticated, service_role;
COMMIT;
@@ -0,0 +1,33 @@
-- =============================================================================
-- F3 — my_tenants() passa a devolver slug (e nome) do tenant
--
-- O frontend resolve o schema físico do tenant ativo no cliente:
-- tenantStore guarda memberships de my_tenants(); slug -> 'tenant_<slug>'.
-- Campo extra é inofensivo pro frontend atual (main) que ignora colunas novas.
-- (mudança de RETURNS TABLE exige DROP + CREATE)
-- =============================================================================
BEGIN;
DROP FUNCTION IF EXISTS public.my_tenants();
CREATE FUNCTION public.my_tenants()
RETURNS TABLE(tenant_id uuid, role text, status text, kind text, slug text, tenant_name text)
LANGUAGE sql
STABLE
AS $function$
select
tm.tenant_id,
tm.role,
tm.status,
t.kind,
t.slug,
t.name
from public.tenant_members tm
join public.tenants t on t.id = tm.tenant_id
where tm.user_id = auth.uid();
$function$;
GRANT EXECUTE ON FUNCTION public.my_tenants() TO authenticated;
COMMIT;
@@ -0,0 +1,49 @@
-- =============================================================================
-- F1b — Decisão de roteamento anon: 6 tabelas anon-facing FICAM em public
--
-- Fluxos anônimos identificam o tenant por TOKEN/SLUG (não por login), então
-- não conseguem resolver o schema físico. Decisão (2026-06-13, opção C):
-- manter essas tabelas em public com tenant_id + RLS por token, como hoje.
--
-- patient_intake_requests — intake de paciente por convite (token)
-- patient_invites — tokens de convite
-- patient_invite_attempts — rate-limit anon dos convites
-- document_share_links — assinatura pública de documento (token)
-- agendador_configuracoes — agendador público (link_slug)
-- agendador_solicitacoes — solicitação criada por visitante anon
--
-- Logo, REMOVE essas 6 do _tenant_template (não viram schema do tenant).
-- O clone_tenant_template itera as tabelas do template dinamicamente, então
-- novos clones nascem sem elas automaticamente. Classificação final:
-- 78 tenant-scoped + 59 globais (era 84 + 53).
--
-- Nota F6: public.document_share_links.documento_id tem FK -> documents, que
-- VAI pro schema do tenant. No drop de public.documents (F6), essa FK precisa
-- virar coluna solta (uuid sem constraint) — o RPC valida via token. Idem
-- qualquer FK public->tenant dessas 6 (registrar no lote de FKs da F6).
-- =============================================================================
BEGIN;
DO $$
DECLARE
anon_tabs text[] := ARRAY[
'patient_intake_requests','patient_invites','patient_invite_attempts',
'document_share_links','agendador_configuracoes','agendador_solicitacoes'
];
t text;
BEGIN
FOREACH t IN ARRAY anon_tabs LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = '_tenant_template' AND table_name = t) THEN
EXECUTE format('DROP TABLE _tenant_template.%I CASCADE', t);
RAISE NOTICE 'F1b: _tenant_template.% removida (fica em public)', t;
END IF;
-- defensivo: tira do registro de realtime do template, se estiver lá
DELETE FROM _tenant_template._realtime_tables WHERE table_name = t;
END LOOP;
END $$;
UPDATE _tenant_template._meta SET value = '2'::jsonb WHERE key = 'template_version';
COMMIT;
@@ -0,0 +1,45 @@
-- =============================================================================
-- F5 — Trigger que re-expõe schemas tenant no PostgREST a cada clone/drop
--
-- public.refresh_pgrst_schemas() (criada em manual/f5_pgrst_refresh_schemas.
-- supabase_admin.sql, owned por supabase_admin) seta pgrst.db_schemas + NOTIFY.
-- Este trigger em tenant_schemas a dispara automaticamente — clone_tenant_template
-- e drop_tenant_schema NÃO precisam ser tocados (eles inserem/removem em
-- tenant_schemas, o que aciona o refresh no COMMIT).
--
-- PRÉ-REQUISITO: aplicar f5_pgrst_refresh_schemas.supabase_admin.sql ANTES desta
-- migration (a função precisa existir e estar owned por supabase_admin).
-- =============================================================================
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'refresh_pgrst_schemas'
) THEN
RAISE EXCEPTION 'F5: public.refresh_pgrst_schemas() não existe — aplique manual/f5_pgrst_refresh_schemas.supabase_admin.sql primeiro';
END IF;
END $$;
-- Trigger function (owned por postgres) só delega pro refresh (SECDEF supabase_admin)
CREATE OR REPLACE FUNCTION public.trg_refresh_pgrst_schemas()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
BEGIN
PERFORM public.refresh_pgrst_schemas();
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS trg_tenant_schemas_pgrst_refresh ON public.tenant_schemas;
CREATE TRIGGER trg_tenant_schemas_pgrst_refresh
AFTER INSERT OR DELETE OR UPDATE OF schema_name ON public.tenant_schemas
FOR EACH STATEMENT
EXECUTE FUNCTION public.trg_refresh_pgrst_schemas();
COMMIT;
@@ -0,0 +1,29 @@
-- =============================================================================
-- F6.0 — Clona os schemas dos tenants JÁ EXISTENTES (cutover)
--
-- Até aqui só tenants criados PÓS-F2 ganhavam schema. Os 9 tenants que já
-- existiam precisam dos seus schemas (ainda vazios — dados migram na F6.1).
-- Idempotente: só clona quem não está em tenant_schemas. Cada clone dispara
-- o trigger da F5 (expõe no PostgREST).
-- =============================================================================
BEGIN;
DO $$
DECLARE
r record;
v_schema text;
BEGIN
FOR r IN
SELECT t.id, t.slug
FROM public.tenants t
LEFT JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
WHERE ts.tenant_id IS NULL
ORDER BY t.created_at, t.id
LOOP
v_schema := public.clone_tenant_template(r.id);
RAISE NOTICE 'F6.0: tenant % (%) -> %', r.id, r.slug, v_schema;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,76 @@
-- =============================================================================
-- F6.2 Lote A — anexa triggers schema-agnósticos aos schemas tenant
--
-- O clone (LIKE INCLUDING ALL) NÃO copia triggers. As tabelas tenant nos
-- schemas precisam dos triggers de negócio. Lote A: os PROVADAMENTE
-- schema-agnósticos (só mexem em NEW/OLD, não referenciam outras tabelas) —
-- seguros pra anexar sem reescrever a função:
-- família updated_at (8) + prevent_promoting_to_system +
-- prevent_system_group_changes
-- Os schema-aware (financeiro/audit/notif/timeline/sync) vêm no Lote B.
--
-- attach_agnostic_triggers(schema) recria, no schema dado, os triggers de
-- public cuja função está na whitelist agnóstica. A função do trigger continua
-- sendo a de public (agnóstica → funciona em qualquer schema). Backfill dos 9;
-- o wiring no clone_tenant_template acontece no fim da F6.2 (com todos prontos).
-- =============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
RETURNS int
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
agnostic text[] := ARRAY[
'set_updated_at','fn_clinical_notes_updated_at','set_insurance_plans_updated_at',
'set_medicos_updated_at','set_services_updated_at','set_updated_at_recurrence',
'update_payment_settings_updated_at','update_professional_pricing_updated_at',
'prevent_promoting_to_system','prevent_system_group_changes'
];
r record;
v_def text;
v_count int := 0;
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN
RAISE EXCEPTION 'attach_agnostic_triggers: schema inválido %', p_schema;
END IF;
FOR r IN
SELECT c.relname AS tab, t.tgname, pg_get_triggerdef(t.oid) AS def
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_proc p ON p.oid = t.tgfoid
WHERE n.nspname = 'public' AND NOT t.tgisinternal
AND p.proname = ANY(agnostic)
AND EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = p_schema AND table_name = c.relname)
LOOP
-- redireciona o ON public.<tab> pro schema do tenant (a função fica em public)
v_def := replace(r.def, 'ON public.' || r.tab || ' ', 'ON ' || p_schema || '.' || r.tab || ' ');
IF v_def = r.def THEN
RAISE EXCEPTION 'attach_agnostic_triggers: não consegui redirecionar % (%.%)', r.tgname, p_schema, r.tab;
END IF;
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', r.tgname, p_schema, r.tab);
EXECUTE v_def;
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END;
$$;
-- Backfill dos 9 schemas existentes
DO $$
DECLARE r record; v int;
BEGIN
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
v := public.attach_agnostic_triggers(r.schema_name);
RAISE NOTICE 'F6.2A %: % triggers agnósticos', r.schema_name, v;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,34 @@
-- =============================================================================
-- Freemium F1 — limite de pacientes do plano therapist_free
--
-- clinic_free já traz max_patients=30 (em plan_features.limits da feature
-- clinic_calendar, semeado). O therapist_free não tinha limite de pacientes.
-- Pendura max_patients=20 na feature 'patients.manage' (a que o therapist_free
-- já possui, enabled).
--
-- REGRA DE OURO: referenciar plano/feature POR KEY via subquery, nunca por uuid
-- hardcoded (uuids divergem entre ambientes). Idempotente (merge no jsonb).
-- O enforcement em runtime (trigger) está em manual/freemium_f1_plan_limits.
-- =============================================================================
BEGIN;
UPDATE public.plan_features pf
SET limits = COALESCE(pf.limits, '{}'::jsonb) || jsonb_build_object('max_patients', 20)
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
-- Sanidade: garante que o limite ficou gravado (1 linha afetada esperada).
DO $$
DECLARE v int;
BEGIN
SELECT (pf.limits->>'max_patients')::int INTO v
FROM public.plan_features pf
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
IF v IS DISTINCT FROM 20 THEN
RAISE EXCEPTION 'therapist_free max_patients esperado 20, obtido %', v;
END IF;
END $$;
COMMIT;
@@ -0,0 +1,53 @@
-- =============================================================================
-- Fix (regressão schema-per-tenant): log_audit_change quebra INSERT em tabelas
-- GLOBAIS auditadas.
--
-- log_audit_change deriva o tenant via tenant_id_for_schema(TG_TABLE_SCHEMA).
-- Para tabelas em tenant_<slug> isso resolve certo. Mas o trigger também está
-- em public.tenant_members (tabela global) — e tenant_id_for_schema('public')
-- retorna NULL, violando audit_logs.tenant_id (NOT NULL). Resultado: QUALQUER
-- INSERT em tenant_members falhava (provisionamento, aceite de convite).
--
-- Fix: quando o schema não resolve um tenant (tabela global), usa o tenant_id
-- da própria linha (tenant_members.tenant_id). Se ainda assim for NULL, não
-- audita — mas NUNCA quebra a operação de negócio.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $function$
DECLARE
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
BEGIN
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
-- tabela global (public.*): cai no tenant_id da própria linha, se existir
IF v_tenant_id IS NULL THEN
v_tenant_id := NULLIF(to_jsonb(COALESCE(NEW, OLD)) ->> 'tenant_id', '')::uuid;
END IF;
-- sem tenant resolvível → não audita, mas não quebra a operação
IF v_tenant_id IS NULL THEN
RETURN COALESCE(NEW, OLD);
END IF;
IF TG_OP = 'DELETE' THEN
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
ELSE
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
IF v_changed IS NULL THEN RETURN NEW; END IF;
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
END IF;
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
RETURN COALESCE(NEW, OLD);
END $function$;
@@ -0,0 +1,66 @@
-- =============================================================================
-- Freemium F2 (polish) — apresentação do plano gratuito na vitrine pública
--
-- Os planos free já eram is_visible em v_public_pricing, mas sem plan_public
-- (nome/descrição/bullets) e sem preço — renderizavam sem nome/valor. Este seed
-- dá um cartão "Grátis" decente. Referência por KEY (subquery), idempotente.
-- O preço "Grátis" é tratado no front (Landingpage isFreePlan).
-- =============================================================================
BEGIN;
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
SELECT id, 'Grátis',
'Comece sem custo: o essencial pra organizar sua agenda, pacientes e prontuário.',
'Grátis', false, true, 0
FROM public.plans WHERE key = 'clinic_free'
ON CONFLICT (plan_id) DO UPDATE
SET public_name = EXCLUDED.public_name,
public_description = EXCLUDED.public_description,
badge = EXCLUDED.badge,
is_visible = true,
sort_order = EXCLUDED.sort_order,
updated_at = now();
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
SELECT id, 'Grátis',
'Pra terapeutas individuais: agenda, pacientes e prontuário sem custo.',
'Grátis', false, true, 0
FROM public.plans WHERE key = 'therapist_free'
ON CONFLICT (plan_id) DO UPDATE
SET public_name = EXCLUDED.public_name,
public_description = EXCLUDED.public_description,
badge = EXCLUDED.badge,
is_visible = true,
sort_order = EXCLUDED.sort_order,
updated_at = now();
-- bullets (idempotente: limpa os dos free e re-insere)
DELETE FROM public.plan_public_bullets
WHERE plan_id IN (SELECT id FROM public.plans WHERE key IN ('clinic_free','therapist_free'));
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
SELECT p.id, b.text, b.highlight, b.sort_order
FROM public.plans p
CROSS JOIN LATERAL (
VALUES
('Agenda completa e prontuário', true, 1),
('Até 30 pacientes ativos', false, 2),
('Documentos e lembretes básicos', false, 3),
('Agendamento online', false, 4)
) AS b(text, highlight, sort_order)
WHERE p.key = 'clinic_free';
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
SELECT p.id, b.text, b.highlight, b.sort_order
FROM public.plans p
CROSS JOIN LATERAL (
VALUES
('Agenda completa e prontuário', true, 1),
('Até 20 pacientes ativos', false, 2),
('Documentos e lembretes básicos', false, 3),
('Agendamento online', false, 4)
) AS b(text, highlight, sort_order)
WHERE p.key = 'therapist_free';
COMMIT;
+176
View File
@@ -0,0 +1,176 @@
# Deploy F4 — Freemium / PLG (hosted)
> Runbook de produção do épico freemium/PLG (branch `feat/freemium-plg`).
> Gerado em 2026-06-13. Faça **um passo de cada vez** e valide antes de seguir.
---
## ⛔ PRÉ-REQUISITO #0 (bloqueante) — schema-per-tenant no hosted
O freemium foi construído **em cima** da migração schema-per-tenant. As RPCs
(`auto_provision_free_tenant`, `slug_disponivel`, enforcement de limite, etc.)
dependem de infra que **só existe se a schema-per-tenant já estiver no hosted**:
- `tenant_schemas`, `_tenant_template`, `clone_tenant_template`, `seed_*`
- helpers `tenant_id_for_schema`, `tenant_schema_name`, `is_saas_admin`, `is_tenant_member`
- exposição dinâmica de schemas no PostgREST (`pgrst.db_schemas`)
- `v_tenant_active_subscription`, `notifications_sistema`, `tenant_members.slug`
**Se o hosted ainda está no modelo RLS (branch `main`), NÃO aplique o freemium —
ele vai quebrar.** Ordem obrigatória:
1. Deployar e validar a **schema-per-tenant** no hosted (migrations `20260612*` +
`20260613000001..000004` + os `manual/f5*..f6_2h*` + `f6_4`), **sem** o F6.3
DROP num primeiro momento (dados espelhados em public — ver `database-novo/manual/f6_3_ROLLBACK.md`).
2. Só então seguir este runbook do freemium.
> Enquanto a schema-per-tenant não estiver no hosted + testada no browser
> (task #7 / DROP F6.3 pendente), este deploy fica **em espera**.
---
## Inventário do que vai pro hosted (freemium)
### Migrations (rodam como `postgres` via `supabase db push` ou SQL Editor)
| Ordem | Arquivo | O quê |
|---|---|---|
| 1 | `database-novo/migrations/20260613000005_freemium_f1_therapist_free_patient_limit.sql` | `max_patients=20` no therapist_free |
| 2 | `database-novo/migrations/20260613000006_fix_audit_global_tables.sql` | **fix regressão** audit em tenant_members (aplicar SEMPRE) |
| 3 | `database-novo/migrations/20260613000007_freemium_f2_vitrine_free.sql` | cartão "Grátis" na vitrine (plan_public + bullets) |
### Manual `supabase_admin` (rodam com role elevada — ver nota de permissões)
Aplicar **nesta ordem** (idempotentes; `BEGIN/COMMIT` internos):
1. `database-novo/manual/freemium_f1_plan_limits.supabase_admin.sql`
2. `database-novo/manual/freemium_f2_provisioning.supabase_admin.sql`
3. `database-novo/manual/freemium_f3a_blacklist.supabase_admin.sql`
4. `database-novo/manual/freemium_f3b_saas_owners_notify.supabase_admin.sql`
5. `database-novo/manual/freemium_f3c_app_config.supabase_admin.sql`
### Edge functions
- `supabase/functions/recover-access` (esqueci-email por slug → magic link)
- `supabase/functions/send-welcome-email` (boas-vindas ao dono — best-effort)
### Config
- Auth → **Confirm email = ON** + Site/Redirect URLs
- Secrets de SMTP do `send-welcome-email` (+ `APP_URL`)
---
## Passo a passo
### 1) Banco — migrations
Com a CLI apontando pro projeto hosted (`supabase link` já feito):
```bash
supabase db push # aplica as migrations pendentes (inclui as 3 do freemium)
```
Ou, se preferir manual, cole cada arquivo `migrations/2026061300000[567]_*.sql` no
**SQL Editor** do dashboard (roda como `postgres`), na ordem da tabela acima.
> ⚠️ A `20260613000006_fix_audit_global_tables.sql` é **obrigatória** — sem ela,
> qualquer novo `tenant_members` (provisionamento, convite) falha no hosted também.
### 2) Banco — manual `supabase_admin`
**Nota de permissões (hosted):** no Supabase hosted, o `postgres` da connection
string tem mais privilégio que o local, mas o schema `auth` é de `supabase_admin`.
A blacklist (`freemium_f3a`) cria **trigger em `auth.users`** e vários objetos são
`ALTER FUNCTION ... OWNER TO supabase_admin`. Caminhos:
- **SQL Editor do dashboard** roda como `postgres` (costuma conseguir criar trigger
em `auth.users` no hosted) — tente por aí primeiro.
- Se algum `OWNER TO supabase_admin` ou o trigger em `auth.users` falhar por permissão,
rode via a connection string de **serviço** (Settings → Database → Connection string),
ou abra ticket de acesso. Os `OWNER TO supabase_admin` podem ser trocados por
`OWNER TO postgres` no hosted se necessário (sem perda funcional).
Aplicar os 5 arquivos `manual/freemium_f*.supabase_admin.sql` **na ordem**, colando
no SQL Editor (cada um é uma transação). Verifique a saída sem erro a cada um.
**Smoke SQL pós-aplicação** (no SQL Editor):
```sql
select public.slug_disponivel('teste_slug_livre'); -- {ok:true}
select public.get_root_redirect(); -- 'landing'
-- como saas_admin (logado no dashboard você é postgres; teste a RPC existe):
select proname from pg_proc where proname in
('auto_provision_free_tenant','processar_pos_signup','slug_disponivel',
'saas_list_account_owners','notify_all_devs','is_email_blacklisted','get_root_redirect');
-- trigger de limite presente nos schemas:
select count(*) from pg_trigger where tgname='enforce_patient_plan_limit';
```
### 3) Auth — dashboard
Authentication → **Providers / Email**:
- **Confirm email = ON** (equivale ao `enable_confirmations=true` do config.toml local).
- **Site URL** = origem do app em produção (ex.: `https://app.seudominio.com`).
- **Redirect URLs** — adicionar (magic link + confirmação caem aqui):
- `https://app.seudominio.com/onboarding`
- `https://app.seudominio.com/auth/login`
- `https://app.seudominio.com/**` (se preferir curinga)
- SMTP do GoTrue (o que manda confirmação + magic link): garantir que está
configurado com um provedor real (não Mailpit) em Authentication → Emails → SMTP.
### 4) Edge functions — deploy + secrets
```bash
supabase functions deploy recover-access
supabase functions deploy send-welcome-email
```
`recover-access` usa só envs já injetadas (SUPABASE_URL / SERVICE_ROLE_KEY / ANON_KEY).
`send-welcome-email` usa um **SMTP de sistema** (defaults = Mailpit local). Em produção,
configure os secrets pra um provedor real (pode ser o mesmo do GoTrue):
```bash
supabase secrets set \
SMTP_HOST="smtp.seuprovedor.com" \
SMTP_PORT="587" \
SMTP_USER="..." \
SMTP_PASS="..." \
SMTP_FROM="no-reply@seudominio.com" \
SMTP_FROM_NAME="Agência PSI" \
APP_URL="https://app.seudominio.com"
```
> É best-effort: se faltar SMTP, o welcome só não envia — o onboarding/login segue.
### 5) Frontend — rebuild + deploy
Build apontando pras envs do hosted (Supabase URL + anon key de produção):
```bash
npm run build
```
Publique o `dist/` no hosting de sempre. (A confirmação de e-mail é resolvida
server-side; o front já trata o caso "sem sessão" → tela "confirme seu e-mail".)
### 6) Smoke test no hosted (fluxo completo)
1. `/lp` → o cartão **Grátis** aparece na vitrine.
2. **Criar conta grátis** → escolher slug (disponibilidade ao vivo) → enviar.
3. Cai na tela **"confirme seu e-mail"** (não loga ainda).
4. Abre o e-mail (provedor real) → clica no link → entra → `/onboarding` provisiona →
painel do tenant. **Welcome email** chega (se SMTP configurado).
5. Cadastrar pacientes até passar do limite → **toast PLAN_LIMIT_REACHED** + Upgrade PRO.
6. Logar como **dev (saas_admin)**`/saas/usuarios` lista o novo cliente com selo
"Novo"; o **sino** recebeu "Nova assinatura".
7. `/auth/login`**"Esqueci meu e-mail"** com o slug → recebe magic link, dica mascarada.
8. `/saas/app-config` → adicionar um e-mail na **blacklist** → tentar cadastrar com
ele → bloqueado. Trocar **root_redirect** e conferir o destino de `/`.
---
## Rollback / kill-switch (se algo der errado)
- **Confirmação de e-mail**: desligar "Confirm email" no dashboard volta ao signup
sem confirmação (mas o signup novo já espera confirmação — prefira corrigir a frente).
- **Enforcement de limite**: `DROP TRIGGER enforce_patient_plan_limit ON <schema>.patients`
(ou ajustar `plan_features.limits` pra um número alto — vale em runtime, sem deploy).
- **Blacklist**: `DROP TRIGGER trg_enforce_email_blacklist ON auth.users;`
- **notify devs**: `DROP TRIGGER trg_subscriptions_notify_devs ON public.subscriptions;`
- **root_redirect**: `UPDATE public.saas_app_config SET root_redirect='login';` (ou 'landing').
- Tudo é **aditivo** — nenhuma tabela/coluna existente foi removida pelo freemium.
---
## Checklist rápido
- [ ] schema-per-tenant já está no hosted e validada (PRÉ-REQUISITO #0)
- [ ] migrations 05/06/07 aplicadas (`supabase db push`)
- [ ] 5 manual/freemium_f*.supabase_admin.sql aplicados na ordem
- [ ] Confirm email = ON + Site/Redirect URLs + SMTP do GoTrue
- [ ] `recover-access` e `send-welcome-email` deployadas
- [ ] secrets SMTP do `send-welcome-email` + `APP_URL`
- [ ] frontend rebuildado e publicado
- [ ] smoke test (8 passos) ✅
+212
View File
@@ -0,0 +1,212 @@
# Deploy — Migração Schema-per-Tenant (hosted)
> Runbook de produção da migração RLS-only → schema físico por tenant
> (branch `feat/schema-per-tenant`). **Pré-requisito do freemium** (ver
> `docs/DEPLOY_FREEMIUM_F4.md`). Gerado em 2026-06-13.
>
> ⚠️ Esta é a migração **mais delicada do projeto**: envolve migração de DADOS,
> exposição dinâmica de schemas no PostgREST e um DROP **irreversível** no fim.
> Faça em **janela de manutenção**, com backup fresco, um passo de cada vez.
---
## Estratégia de cutover (por que é seguro)
O desenho **COPIA** os dados (não move) de `public` pros schemas `tenant_<slug>` e
só remove o espelho de `public` no **último** passo (F6.3 DROP). Durante a transição,
os dados existem nos **dois lugares** → o código antigo (lê `public`) e o novo
(lê `tenant_<slug>`) funcionam simultaneamente. Isso permite:
```
estrutura aditiva → migra dados (copia) → sobe código novo → valida → (só então) DROP
```
Se algo der errado **antes do DROP**, é só voltar o frontend/edge pra versão antiga
(que lê `public`, intacto). O DROP é o único ponto de não-retorno.
---
## ⚠️ Risco hosted #1 — exposição dinâmica de schemas no PostgREST
Local: `refresh_pgrst_schemas()` faz `ALTER ROLE authenticator SET pgrst.db_schemas=...`
+ `NOTIFY pgrst, 'reload config'` (config in-database, persiste em `pg_db_role_setting`).
Um trigger em `public.tenant_schemas` re-roda isso a cada clone/drop.
No **Supabase hosted** isso precisa ser confirmado:
- O hosted suporta a config in-DB do PostgREST, MAS a permissão de `ALTER ROLE
authenticator` pode estar restrita à role de serviço. **Teste cedo** (Fase C):
rode `select public.refresh_pgrst_schemas();` e cheque se os schemas tenant
passam a responder via REST.
- Fallback se o `ALTER ROLE` falhar no hosted: adicionar os schemas em
**Dashboard → Project Settings → API → Exposed schemas** (lista). Problema: é
**estática** — cada signup novo cria um schema que precisaria entrar na lista.
Mitigação: manter o trigger in-DB (se funcionar) OU automatizar via Management API.
**Decidir isso ANTES de abrir pra signup self-service.**
> Sem exposição dos schemas tenant, o app novo recebe 404/empty nas tabelas tenant.
---
## Inventário (branch `feat/schema-per-tenant`)
### Migrations (aditivas — rodam como `postgres` / `supabase db push`)
Ordem natural por timestamp:
```
20260612000001_f1_tenants_slug.sql # tenants.slug + generate_tenant_slug + trigger
20260612000002_f1_tenant_schema_helpers.sql # tenant_schema_name, tenant_id_for_schema, ...
20260612000003_f1_tenant_template.sql # _tenant_template (78 tabelas, views, seeds)
20260612000004_f1_clone_drop_functions.sql # clone_tenant_template, drop_tenant_schema, tenant_schemas, channel_routing
20260612000005_f1_template_seed_whitelist.sql # limpa seeds órfãos
20260612000006_f2_provision_clone.sql # provision_* chamam clone
20260612000007_f3_my_tenants_slug.sql # my_tenants() retorna slug
20260613000001_f1b_keep_anon_tables_public.sql# 6 tabelas anon ficam em public
20260613000002_f5_pgrst_schemas_trigger.sql # trigger pgrst refresh em tenant_schemas
20260613000003_f6_0_clone_existing_tenants.sql# clona os tenants já existentes
20260613000004_f6_2a_attach_agnostic_triggers.sql # Lote A (triggers agnósticos)
```
> As 3 migrations `*_freemium_*` / `*_fix_audit_*` (000005/06/07) são do **freemium** —
> aplicar só no deploy do freemium (depois). A `fix_audit` pode (e deve) vir já aqui se
> for testar provisionamento, mas é inócua antes.
### Manual `supabase_admin` (privilegiadas — ordem obrigatória)
```
f5_pgrst_refresh_schemas.supabase_admin.sql # refresh_pgrst_schemas (ALTER ROLE authenticator)
f6_2b_schema_aware_triggers.supabase_admin.sql# Lote B (14 trigger funcs schema-aware)
f6_2c_notifications_split.supabase_admin.sql # Lote C (notifications_sistema + triggers)
f6_2d_user_rpcs.supabase_admin.sql # Lote D (14 user RPCs + _tenant_route)
f6_2e_cron_rpcs.supabase_admin.sql # Lote E (cron RPCs + _tenant_schema_unchecked)
f6_2f_anon_token_rpcs.supabase_admin.sql # Lote F (anon/token RPCs)
f6_2g_sql_to_plpgsql.supabase_admin.sql # Lote G (5 SQL→plpgsql)
f6_2h_clone_wiring.supabase_admin.sql # wiring: tenants novos nascem com triggers
f6_4_saas_admin_rpcs.supabase_admin.sql # SaaS-admin RPCs (feriados/notif/whatsapp)
# DADOS:
f6_1_migrate_data.supabase_admin.sql # cutover: COPIA dados public→schemas
# DROP (último, gated):
f6_3_drop_public_tenant_tables.supabase_admin.sql # 🛑 ponto de não-retorno
```
Rollback do DROP documentado em `database-novo/manual/f6_3_ROLLBACK.md`.
### Frontend / Edge (vão no rebuild + deploy)
- `src/lib/supabase/tenantClient.js`, `src/composables/useTenantDb.js`, `tenantStore` (slug/schema getters), `notificationStore` (dual-source), e os `supabase.from(...)` → `tenantDb().from(...)` espalhados.
- `supabase/functions/_shared/tenant.ts` + os webhooks/crons que passaram a rotear por schema.
### Config
- `supabase/config.toml [api] schemas` permanece `["public","graphql_public"]` — os
tenant são expostos **dinamicamente** (não na lista). Confirmar no hosted (Risco #1).
---
## Passo a passo
### Fase 0 — Pré-flight
- [ ] **Backup completo** do hosted (dashboard → Database → Backups, ou `pg_dump`).
- [ ] Confirmar que o hosted está no baseline (branch `main`/RLS) e estável.
- [ ] Janela de manutenção combinada (a Fase D é cutover de dados).
- [ ] Ter a connection string de **serviço** em mãos (algumas etapas exigem role elevada).
### Fase A — Estrutura aditiva (migrations)
Aplicar as 11 migrations `20260612*`/`20260613000001..000004` (e a `fix_audit` 000006).
Via `supabase db push` (com a branch linkada) ou colando no **SQL Editor** na ordem.
São **aditivas** — criam slug, helpers, `_tenant_template`, funções de clone, registry
`tenant_schemas`, e **clonam os tenants existentes** (000003 = f6_0). Não tocam dados.
**Verificar:**
```sql
select count(*) from public.tenant_schemas; -- = nº de tenants
select tenant_schema_name((select id from tenants limit 1)); -- 'tenant_<slug>'
select count(*) from information_schema.schemata where schema_name like 'tenant_%';
```
### Fase B — Funções/triggers privilegiados (manual)
Aplicar, **na ordem**, via connection string de serviço (ou SQL Editor se permitir):
`f6_2b → f6_2c → f6_2d → f6_2e → f6_2f → f6_2g → f6_2h → f6_4`.
(São CREATE OR REPLACE / idempotentes.)
> Vários fazem `ALTER FUNCTION ... OWNER TO supabase_admin`. Se a role disponível no
> hosted não permitir, troque pra `OWNER TO postgres` (sem perda funcional) — mesma
> nota do runbook do freemium.
### Fase C — PostgREST dinâmico (CRÍTICO — testar cedo)
Aplicar `f5_pgrst_refresh_schemas.supabase_admin.sql` e disparar:
```sql
select public.refresh_pgrst_schemas(); -- seta pgrst.db_schemas + NOTIFY reload
```
**Teste real:** via REST (anon/auth key do hosted), bater numa tabela de um schema tenant
(ex.: `GET /rest/v1/patients` com header `Accept-Profile: tenant_<slug>`). Deve responder
(200/empty), não 404 "schema not exposed".
- ✅ funcionou → seguir.
- ❌ falhou (`ALTER ROLE authenticator` negado) → aplicar o **fallback** do Risco #1
(Exposed schemas no dashboard) antes de prosseguir, e planejar a automação por signup.
### Fase D — Migração de DADOS (cutover, janela de manutenção)
Aplicar `f6_1_migrate_data.supabase_admin.sql` (precisa `session_replication_role=replica`
→ role de serviço). **COPIA** os dados public→schemas (idempotente, ON CONFLICT DO NOTHING).
**Verificar paridade** (por tabela/tenant — exemplo com `patients`):
```sql
-- public (origem) vs schema (destino) devem bater por tenant
select t.slug,
(select count(*) from public.patients p where p.tenant_id=t.id) as em_public,
-- ajuste o schema dinamicamente / rode por tenant:
null as em_schema
from public.tenants t order by t.slug;
-- e por schema: select count(*) from tenant_<slug>.patients;
```
Repetir o spot-check nas tabelas de maior volume (conversation_messages, financial_records, agenda_eventos).
### Fase E — Frontend + Edge (sobe o código novo)
- Deploy das **edge functions** alteradas (`supabase functions deploy <nome>` pras que
mudaram: webhooks twilio/evolution inbound, crons de fila, `_shared/tenant.ts` é embutido).
- **Rebuild + publish do frontend** da branch (agora `tenantDb().from(...)` lê os schemas).
- A partir daqui o app **lê/escreve nos schemas tenant**. Como os dados foram copiados na
Fase D e `public` ainda existe, nada quebra mesmo se algum ponto antigo escapar.
### Fase F — Smoke test (app no modelo novo)
- [ ] Login em 2-3 tenants distintos → agenda, pacientes, financeiro, conversas carregam.
- [ ] Criar/editar registros → conferir que gravam em `tenant_<slug>` (não em `public`).
- [ ] Notificações (sino) — dual-source (tenant + `notifications_sistema`).
- [ ] Webhook inbound (twilio/evolution) grava no schema certo (roteamento por canal).
- [ ] Crons (fila de notificação/email) varrem os tenants.
- [ ] Provisionar um tenant NOVO de teste → nasce com schema + triggers (wiring f6_2h).
- [ ] **Deixar rodando alguns dias** com os dados ainda espelhados em public (rede de segurança).
### Fase G — F6.3 DROP (🛑 PONTO DE NÃO-RETORNO)
**Só depois** de F validada por dias + sem incidentes. Sequência:
1. **Backup fresco obrigatório** (o header do f6_3 traz o `pg_dump --schema=public`).
2. Reler `database-novo/manual/f6_3_ROLLBACK.md`.
3. Aplicar `f6_3_drop_public_tenant_tables.supabase_admin.sql` (role de serviço):
pré-flight asserts → 2 FK→coluna solta → drop 9 views → DROP CASCADE 78 tabelas public.
4. Smoke test final. A partir daqui `public` não tem mais as tabelas tenant — só schemas.
---
## Rollback por fase
- **Fases AC** (estrutura/funções/pgrst): aditivas. Reverter = dropar os schemas/funções
novos; `public` intacto, app antigo segue. Sem perda.
- **Fase D** (dados): só copiou; reverter = ignorar/limpar schemas. `public` é a verdade.
- **Fase E** (código): **rollback = redeploy do frontend/edge da versão antiga** (lê public).
Esse é o botão de pânico até o DROP.
- **Fase G** (DROP): irreversível sem restore. Rollback = restaurar do backup (ver
`f6_3_ROLLBACK.md`). Por isso só após dias de validação.
---
## Ordem geral dos dois épicos
```
schema-per-tenant Fases AF → (rodar dias) → schema-per-tenant Fase G (DROP)
└─ freemium (DEPLOY_FREEMIUM_F4.md) pode entrar
logo após as Fases AF (não depende do DROP)
```
> O freemium **não** depende do DROP (F6.3) — depende da infra (Fases AF). Dá pra subir
> o freemium assim que o schema-per-tenant estiver validado no hosted, mantendo o espelho
> em public como rede de segurança, e fazer o DROP com calma depois.
## Checklist
- [ ] Fase 0: backup + janela + baseline confirmado
- [ ] Fase A: 11 migrations aplicadas + verificação
- [ ] Fase B: 9 manual (b→4) na ordem
- [ ] Fase C: pgrst dinâmico testado via REST (ou fallback decidido)
- [ ] Fase D: f6_1 + paridade de contagens conferida
- [ ] Fase E: edges + frontend novos publicados
- [ ] Fase F: smoke test + dias de soak
- [ ] Fase G: backup fresco → DROP → smoke final
+4 -2
View File
@@ -9,8 +9,10 @@
| Item | Quantidade | | Item | Quantidade |
|---|---| |---|---|
| Tabelas em `public` (BASE TABLE) | 137 | | Tabelas em `public` (BASE TABLE) | 137 |
| **Tenant-scoped** (vão pra `tenant_<x>`) — decidido Q3 | **84** | | **Tenant-scoped** (vão pra `tenant_<x>`) — Q3 + Q5 | **78** |
| **Globais** (ficam em `public`) | **53** | | **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) | | Funções que referenciam tabelas-tenant | **66** (não 29 — o aviso do blueprint se confirmou) |
| Views que referenciam tabelas-tenant | 6 | | Views que referenciam tabelas-tenant | 6 |
| FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) | | FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) |
+166
View File
@@ -0,0 +1,166 @@
# Handoff — Onde paramos, Riscos e Passo a passo de teste
> Estado consolidado dos dois épicos grandes (schema-per-tenant + freemium/PLG).
> Última atualização: 2026-06-13. Branch de trabalho: **`feat/schema-per-tenant`** (base)
> e **`feat/freemium-plg`** (empilhada — contém tudo). `main` segue no modelo RLS antigo.
---
## 1. Onde paramos (estado atual)
### Branches
- `main` — modelo RLS-only (produção atual). Recebeu só F0/F1/F2 aditivos da schema-per-tenant.
- `feat/schema-per-tenant` — migração completa F0→F6.2 + wiring + F6.4. **F6.3 DROP NÃO aplicado.**
- `feat/freemium-plg`**ramificada da schema-per-tenant**, contém TODO o freemium (F1/F2/F3) +
os dois runbooks de deploy + este handoff. **É a branch a deployar** (tem os dois épicos).
### Banco LOCAL (Docker `supabase_db_agenciapsi-primesakai`)
Está no estado **schema-per-tenant + freemium aplicado**:
- Schemas `tenant_<slug>` existem (9 tenants clonados) + dados COPIADOS (espelho ainda em `public`).
- Todas as migrations + todos os `manual/*.supabase_admin.sql` aplicados, **EXCETO o F6.3 DROP**.
- `enable_confirmations` está `true` no `config.toml` mas **só ativa após reiniciar o stack**.
### Schema-per-tenant — ✅ feito / ⏳ pendente
- ✅ Estrutura, helpers, template, clone/drop, provisionamento, 66 funções migradas, dados dos 9 tenants copiados+verificados, PostgREST dinâmico (local), frontend/edge roteando por schema.
-**F6.3 DROP** (remove o espelho em `public`) — preparado, NÃO aplicado. Aguarda teste no browser + OK + backup fresco. (task #7)
- 📄 Deploy: `docs/DEPLOY_SCHEMA_PER_TENANT.md`.
### Freemium/PLG — ✅ feito / ⏳ pendente
-**F1** limite de pacientes (trigger runtime + toast + Upgrade PRO).
-**F2** self-service (confirmação de e-mail, RPCs idempotentes, signup reescrito, /onboarding,
welcome email, vitrine "Grátis") + **fix de regressão** do audit em `tenant_members`.
-**F3** 4 extras (blacklist, /saas/usuarios + notify devs, esqueci-email, root_redirect).
-**F4 deploy** (hosted) — runbook em `docs/DEPLOY_FREEMIUM_F4.md`. Não deployado.
-**Teste local ponta-a-ponta** — exige reiniciar o stack (seção 3).
### Tudo commitado e pushado em `feat/freemium-plg`. Nada pendente no working tree
(só `.env`/dashboard/`.claude` locais, intencionalmente fora).
---
## 2. Riscos (todos)
### 🔴 Críticos
1. **PostgREST dinâmico no hosted** — a exposição de schemas tenant usa `ALTER ROLE
authenticator SET pgrst.db_schemas`. Pode ser restrito no hosted. Se falhar, o app novo
recebe 404 nas tabelas tenant. **Testar cedo** (Fase C do runbook); fallback = Exposed
schemas no dashboard (estático → problema com signup self-service). **Decidir antes de
abrir signup.**
2. **F6.3 DROP é irreversível** — remove as tabelas tenant de `public`. Só após dias de soak
no modelo novo + backup fresco. Rollback = restore (`f6_3_ROLLBACK.md`).
3. **Confirmação de e-mail + SMTP do GoTrue (hosted)** — com `Confirm email = ON`, se o SMTP
do GoTrue não estiver configurado com provedor real, **ninguém consegue logar** (o link de
confirmação não chega). Configurar SMTP no dashboard ANTES de ligar a confirmação.
### 🟠 Importantes
4. **Manual files fora do fluxo do `db.cjs`** — os `manual/*.supabase_admin.sql` NÃO são
aplicados pelo `db.cjs migrate`. São aplicados à mão (psql como `supabase_admin`). Fácil
esquecer um → função/trigger ausente. Os runbooks listam a ordem.
5. **`postgres` não é superuser no stack local** — por isso vários objetos são `supabase_admin`.
No hosted o `postgres` é mais privilegiado, mas o schema `auth` é de `supabase_admin`:
o trigger da blacklist em `auth.users` e os `OWNER TO supabase_admin` podem precisar de
SQL Editor ou troca pra `OWNER TO postgres`.
6. **`config.toml` é gitignored** — `enable_confirmations=true` está só no arquivo local
(não versionado). No hosted a confirmação vai pelo **dashboard** (Auth → Confirm email).
7. **Migração de dados (cutover)** — `f6_1` COPIA; conferir **paridade de contagens** por
tenant/tabela antes de confiar (e antes do DROP).
8. **Edge functions novas precisam deploy** — `recover-access` e `send-welcome-email` (freemium)
+ as edges de roteamento por schema (schema-per-tenant). Esquecer = esqueci-email/welcome/
webhooks quebram.
9. **Slug é IMUTÁVEL** — = nome do schema físico. Uma vez escolhido, não muda (trava em 3
camadas). UX do signup deixa claro, mas é definitivo.
### 🟡 Menores / a observar
10. **Enforcement de limite é por-linha** (BEFORE INSERT) — um bulk insert de pacientes numa
única statement pode passar marginalmente do limite (cada linha não vê as anteriores da
mesma statement). Na prática o cadastro é 1 a 1; ok.
11. **notify_all_devs dispara a cada subscription** (inclui a free do auto_provision) — em
escala, muitos avisos no sino do dev. Intencional; reavaliar se incomodar.
12. **send-welcome-email usa SMTP de sistema** (separado do canal do tenant) — precisa secrets
no hosted; é best-effort (falha não bloqueia login).
13. **auto_provision idempotente** retorna o 1º tenant ativo se o user já tem algum — usuário
multi-tenant que se cadastra de novo não ganha tenant novo (esperado).
14. **Local vs main inconsistente** — o banco local está no modelo novo; o código da `main` é
RLS. Se fizer `git checkout main`, o app antigo ainda funciona porque `public` tem as tabelas
(até o DROP). Não rodar `main` esperando o modelo novo (e vice-versa).
---
## 3. Passo a passo — como testar TUDO (local)
> Pré: Docker do Supabase rodando (portas 643xx). Frontend via `npm run dev`.
### Passo 0 — Ativar a confirmação de e-mail
A confirmação só vale após reiniciar o stack (o volume do banco **persiste** — nada se perde):
```bash
supabase stop && supabase start # se falhar com containers unhealthy, rode start de novo (transiente)
```
Conferir no Studio/Mailpit que está de pé. (Se preferir NÃO testar confirmação agora, pule —
o front trata os dois casos; mas o fluxo "confirme e-mail" só aparece com isto ligado.)
> 🔴 **GOTCHA OBRIGATÓRIO pós-restart** — a GUC `pgrst.db_schemas` (exposição dos schemas
> tenant no PostgREST) **NÃO sobrevive ao `supabase stop/start`** (o `start` reseta a role
> `authenticator`). Sem isso o app dá **404** em todas as tabelas tenant. Rodar SEMPRE após start:
> ```bash
> docker exec -i supabase_db_agenciapsi-primesakai psql -U supabase_admin -h 127.0.0.1 -d postgres \
> -c "select public.refresh_pgrst_schemas();"
> ```
> (Confirma exposição: `curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:64321/rest/v1/patients?limit=1" -H "Accept-Profile: tenant_<slug>"` deve dar 200.)
### Passo 1 — Schema-per-tenant: tenants EXISTENTES ainda funcionam
1. `npm run dev`, logar num tenant existente (ex.: clínica Bem-Estar / um terapeuta).
2. Abrir **Agenda, Pacientes, Financeiro, Conversas** → tudo carrega (lendo de `tenant_<slug>`).
3. Criar/editar um registro (ex.: um bloqueio na agenda, editar um paciente) → salva sem erro.
4. Sino de notificações abre (dual-source tenant + sistema).
> Se algo não carregar, é sinal de roteamento de schema — anotar a tela/erro.
### Passo 2 — Freemium: signup self-service NOVO (o fluxo principal)
1. Deslogar. Ir em **`/lp`** → conferir o cartão **"Grátis"** na vitrine.
2. **Criar conta grátis** → escolher tipo (terapeuta/clínica) + seu nome + nome do negócio +
**slug** (ver a checagem de disponibilidade ao vivo) + e-mail + senha.
3. Submeter → cai na tela **"Confirme seu e-mail"** (NÃO loga ainda).
4. Abrir o **Mailpit** (caixa de e-mail local) → achar o e-mail de confirmação → clicar no link.
5. Voltar/entrar em **`/auth/login`** → logar → cai em **`/onboarding`** → "Preparando seu
ambiente…" → provisiona → entra no painel do tenant novo.
6. Conferir no Mailpit o **e-mail de boas-vindas** (welcome — best-effort).
7. Conferir que o schema `tenant_<slug-escolhido>` foi criado (Studio) e que você é master.
### Passo 3 — Limite do plano gratuito
1. No tenant gratuito recém-criado (ou num clinic_free existente), cadastrar pacientes.
2. Ao passar do limite (clínica=30, terapeuta=20) → aparece o **toast "Limite do plano
gratuito"** com botão **"Fazer upgrade"** (não o erro cru).
3. Conferir o botão **"Upgrade PRO"** dourado no topbar (visível porque o plano é free).
### Passo 4 — SaaS / dev (logar como saas_admin)
1. **`/saas/usuarios`** → o cliente novo aparece com selo **"Novo"** (verde, 24h), com slug/e-mail/plano.
2. **Sino do dev** → recebeu **"Nova assinatura"** (do provisionamento).
3. **`/saas/app-config`**:
- Adicionar um **e-mail na blacklist** (ex.: `bloqueado@x.com`). Depois, deslogar e tentar
**criar conta** com ele → bloqueado de verdade.
- Testar **`@dominio.com`** (domínio inteiro).
- Trocar **root_redirect** (landing↔login) e abrir **`/`** deslogado → confere o destino.
### Passo 5 — Esqueci meu e-mail
1. **`/auth/login`** → **"Esqueci meu e-mail"** → digitar o **slug** do tenant criado no Passo 2.
2. Recebe a confirmação com a **dica mascarada** (jo****@gm****.com) e um **magic link** no Mailpit.
3. Clicar no magic link → entra. (O e-mail real nunca aparece na tela.)
4. ⚠️ Edge functions locais: precisam estar servidas (`supabase functions serve` ou o runtime
do stack). Se o esqueci-email/welcome não responder, é a edge não estar de pé localmente.
### Passo 6 — Pegadinha #4 (sino ao trocar de usuário)
1. Logado como user A, com notificações no sino → **logout**.
2. Logar como user B → o sino **não** mostra notificações do A (foi resetado no logout).
### Passo 7 (opcional, destrutivo, só quando confiante) — preparar o DROP
NÃO aplicar agora. Quando tudo acima estiver validado por dias: seguir a **Fase G** do
`docs/DEPLOY_SCHEMA_PER_TENANT.md` (backup fresco → `f6_3_drop_public_tenant_tables`).
---
## 4. Atalhos / referências
- Runbooks: `docs/DEPLOY_SCHEMA_PER_TENANT.md`, `docs/DEPLOY_FREEMIUM_F4.md`.
- Rollback do DROP: `database-novo/manual/f6_3_ROLLBACK.md`.
- Migrations: `database-novo/migrations/` (aplicar via `node database-novo/db.cjs migrate`).
- Manual privilegiados: `database-novo/manual/*.supabase_admin.sql` (aplicar como `supabase_admin`).
- Wiki: `Obsidian/Brain/wiki/Migracao Schema-per-Tenant.md` e `Obsidian/Brain/wiki/Freemium PLG.md`.
- Portas locais: API 64321 · DB 64322 · Studio 64323 (stack shiftada +10000).
+150 -338
View File
@@ -1,338 +1,150 @@
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase ---
Contexto e objetivo
# TAREFA: Implementar modelo freemium/PLG (plano gratuito self-service + Upgrade PRO)
Estou migrando meu sistema multi-tenant de RLS-only com tenant_id em cada tabela para schema-per-tenant (tenant_<slug>
com clones físicos da estrutura). Quero isolamento físico das tabelas que pertencem a um tenant, mantendo em public Você vai transformar o caminho de aquisição de assinatura deste SaaS multi-tenant
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.). em um modelo freemium/PLG, igual ao que já fiz num sistema irmão. O objetivo:
qualquer visitante cria uma conta gratuita sozinho, confirma o e-mail, e o ambiente
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui, do tenant é provisionado automaticamente — sem dev no meio. Plano gratuito limitado
considerando as lições que aprendi. + botão "Upgrade PRO" no topo.
Antes de começar — varredura obrigatória IMPORTANTE: este sistema é PARECIDO mas NÃO idêntico ao de referência. NÃO assuma
nomes de tabelas/funções/rotas. Antes de QUALQUER código, faça a fase de descoberta
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo: e me apresente o mapa + as decisões pra eu confirmar. Trabalhe em fases, commitando
por assunto, e validando cada migration no banco local em transação com ROLLBACK
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem antes de seguir. Rode o build a cada bloco de frontend.
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. ## FASE 0 — DESCOBERTA (não codar ainda; me devolva um mapa com file:line)
SELECT table_name, Mapeie e me explique como funciona hoje:
EXISTS(SELECT 1 FROM information_schema.columns c 1. Landing page / vitrine de planos e como o signup é acionado (query params? rota?).
WHERE c.table_schema='public' AND c.table_name=t.table_name 2. Fluxo de signup: componente, se usa supabase.auth.signUp direto ou um wrapper,
AND c.column_name='tenant_id') AS has_tenant_id o que cria (auth user, profile, tenant, subscription). Existe trigger
FROM information_schema.tables t handle_new_user em auth.users? Onde o profile nasce e com qual role default?
WHERE table_schema='public' AND table_type='BASE TABLE' 3. Modelo de planos SaaS: tabelas (plans, plan_prices, plan_features, plan_limits,
ORDER BY table_name; subscriptions, subscription_intents...), e o catálogo de features atual (LEIA
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi DO BANCO, não de seeds antigos — o catálogo costuma divergir do seed inicial).
"29 funções" e eram na verdade 52. Use: 4. Feature gating: como uma feature é checada (composable hasFeature? guard de
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab) rota com meta.feature? filtro de menu?).
SELECT DISTINCT p.proname, p.prokind, l.lanname 5. Enforcement de limites por plano: existe? (na maioria das vezes plan_limits
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace está semeado mas NINGUÉM lê — confirme).
JOIN pg_language l ON l.oid = p.prolang 6. Provisionamento de tenant: como um tenant nasce hoje (função provision_*?),
CROSS JOIN tenant_tabs t é manual (dev) ou automático? É multi-tenant por RLS ou schema-per-tenant?
WHERE n.nspname='public' Se schema-per-tenant: existe clone_tenant_schema/tenant_schema_name? O clone
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M') copia triggers do template?
ORDER BY 1; 7. Fluxo de auth: onde o profile é carregado no login (carregarPerfil?), onde o
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje guard decide pra onde mandar o usuário (roleHomePath), e o que acontece com um
cuidado especial. usuário logado SEM tenant.
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>'). 8. Infra de e-mail: como e-mails transacionais são enviados (Resend? SMTP? edge
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas. function?). Existe tabela de templates + algum render de {{var}}? O e-mail do
GoTrue (confirmação) funciona? Existe pg_net?
Plano de execução em fases 9. Infra de billing/pagamento (AsaaS/Stripe?): existe checkout de assinatura
RECORRENTE em nível de plano, ou só cobrança avulsa? Onde está o webhook?
F0 — Categorização (não codar nada ainda)
## FASE 0.5 — DECISÕES (me apresente como perguntas; estes são os defaults que
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge ## funcionaram bem, com o porquê):
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir. - Provisionamento: AUTO, mas só DEPOIS de confirmar o e-mail (anti-spam: cada
signup pode clonar dezenas de tabelas).
F1 — Template + helpers - Funil: manter os dois caminhos (free self-service + pago via intent/comercial).
- Upgrade PRO: checkout self-service (reusar infra de pagamento existente) — mas
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique isso é FASE 3, deferida; no início o botão abre o canal comercial.
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints. - Trial: o "free para sempre" substitui o trial.
- Crie helpers em public: - No limite: BLOQUEIA a inserção no banco (trigger) + toast amigável com CTA.
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado. - Slug do sindicato: a pessoa escolhe (sugestão automática a partir do nome,
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema. sanitizado), com checagem de disponibilidade ao vivo, e é IMUTÁVEL (se for
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o schema-per-tenant, o slug É o nome do schema → trocar órfã tudo; trave em 3
tenant_id (porque a coluna não existe mais nas tabelas tenant). camadas: sem UI, guard no banco rejeitando UPDATE, validação na criação).
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
dele. ## FASE 1 — Fundação do plano gratuito
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo. 1. Migration: criar plano `gratuito` (preço 0) + plan_features (tudo ON menos o
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE. módulo premium, ex: ordem_de_servico) + plan_limits (ex: 50 associados).
REGRA DE OURO: referencie features POR KEY via subquery, NUNCA por uuid
F2 — Provisionamento hardcoded (uuids de features geradas em runtime divergem entre ambientes).
Deixe o plano OCULTO na vitrine nesta fase (self-service ainda não existe).
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar 2. Enforcement de limite GENÉRICO: uma função trigger que resolve o tenant pelo
tenant novo. contexto (no schema-per-tenant: pelo nome do schema = TG_TABLE_SCHEMA; no
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando RLS: pelo tenant_id), lê o plano ativo + plan_limits EM RUNTIME (pra mudar o
por profiles.tenant_id = '<id-do-tenant>'). número no painel valer sem deploy), conta linhas vivas e dá RAISE com um código
parseável tipo 'PLAN_LIMIT_REACHED|<feature>|<limite>'. Trigger BEFORE INSERT
F3 — Frontend: composable de acesso tenant na tabela limitada. Se schema-per-tenant: coloque no template E faça backfill
nos schemas já existentes. Teste: 50 passam, 51º bloqueia; tenant pago intacto.
- Crie useTenantDb.js: 3. Frontend: helper que traduz o erro PLAN_LIMIT_REACHED em toast amigável com
export function useTenantDb() { CTA de upgrade, usado em TODOS os pontos de insert da tabela limitada. Botão
const { perfil } = useAuth(); "Upgrade PRO" no topbar quando o plano do tenant for 'gratuito'.
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
const isReady = computed(() => Boolean(schemaName.value)); ## FASE 2 — Self-service com confirmação de e-mail
function db() { 1. LIGUE a confirmação de e-mail (enable_confirmations=true no config.toml E no
if (!schemaName.value) throw new Error('tenant não disponível'); dashboard do hosted).
return supabase.schema(schemaName.value); 2. ⚠️ PEGADINHA CRÍTICA #1: com confirmação ligada, o signup NÃO tem sessão. Então
} TUDO que dependia de auth.uid()/JWT no signup QUEBRA em silêncio:
return { db, schemaName, isReady }; - inserir subscription_intents (RLS exige jwt email = email da linha) → erro.
} - registrar aceite legal (LGPD) → não grava.
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as SOLUÇÃO: NÃO faça esses efeitos no signup. Grave a escolha (plan_key, interval,
views/components/composables que tocam tabelas tenant. nome/slug do sindicato, ids das versões legais aceitas) no raw_user_meta_data
do signUp, e processe TUDO no 1º login pós-confirmação, via RPCs idempotentes:
F4 — Edge functions - auto_provision_free_tenant() (lê metadata, cria tenant, provisiona, vira
master, cria subscription gratuita ativa) — chamada em carregarPerfil quando
Padrão pra qualquer edge function que precisa acessar tabela tenant: o usuário não tem tenant. Gratuito não gera intenção.
const userClient = createClient(SUPABASE_URL, ANON_KEY, { - processar_pos_signup() (aceite legal + cria a intenção SÓ pro caminho pago).
global: { headers: { Authorization: authHeader } } 3. ⚠️ PEGADINHA CRÍTICA #2 (segurança): após o signUp, se NÃO veio sessão
}); (confirmação pendente), ENCERRE qualquer sessão local (signOut scope:'local')
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema'); e mostre uma tela "confirme seu e-mail". Senão, uma sessão anterior (ex: dev
const tenantDb = userClient.schema(tenantSchema as string); testando) vaza e o push pra /login joga o usuário pro painel da sessão antiga.
await tenantDb.from('oficios').update(...).eq(...); A pessoa só pode logar APÓS clicar no link do e-mail.
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto. 4. ⚠️ PEGADINHA CRÍTICA #3 (blindagem): um usuário logado SEM tenant nunca pode
cair num painel quebrado. No guard, redirecione todo logado-sem-tenant (não-dev)
F5 — Expor schemas no PostgREST pra uma tela /onboarding que resolve os estados: provisionando, slug colidiu
(deixa escolher outro slug e finalizar — faça o auto_provision aceitar um
Edite supabase/config.toml: p_slug_override), conta paga aguardando ativação, sem acesso, erro (retry).
[api] 5. Signup coleta nome do sindicato + slug (sugestão + sanitização + disponibilidade
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...] ao vivo via RPC slug_disponivel que retorna {ok, motivo}) + "seu nome".
extra_search_path = ["public", "extensions"] Torne o plano gratuito visível na vitrine agora.
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via 6. E-mail de boas-vindas: edge function (Resend) que renderiza o template, disparada
migration que regenera config.toml, ou aceite gerenciamento manual. no provisionamento. Best-effort (não bloqueia o login). Destinatário derivado
do JWT, não do body.
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
## SAAS / EXTRAS (faça os que fizerem sentido)
Divida em lotes pequenos e teste cada um: - Página /saas/usuarios: 1 linha por tenant com o DONO (master) — nome, slug,
e-mail principal — via uma RPC dev-only que cruza tenants+profiles+subscriptions
Lote 1 — split de notifications (SECURITY DEFINER). Realce em verde + selo "Novo" pra cliente criado nas últimas
24h (rowClass baseado em created_at). Reaproveite essa RPC pra mostrar o e-mail
Caso especial crítico. Antes do split, identifique: principal também nas listagens de assinaturas e tenants.
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant, - Notificação aos devs quando nasce/muda uma assinatura (incl. trial): trigger em
system_alert global). subscriptions chamando a função notify_all_devs com deeplink. ⚠️ PEGADINHA #4:
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos). se o sino de notificações é um singleton com flag "initialized", garanta que ele
RE-BUSCA ao trocar de usuário (logout+login), senão fica stale e ainda vaza
Decisão estrutural: notifications precisa virar duas tabelas: notificações entre usuários. A notificação só aparece pós-provisionamento e no
- tenant_<slug>.notifications — locais do tenant. sino do DEV (não do novo usuário).
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev). - "Esqueci meu e-mail": tela onde a pessoa informa o IDENTIFICADOR do sindicato
(slug, que ela escolheu e foi avisada ser definitivo) → o servidor acha o e-mail
Migration faz: do dono → mostra só uma DICA MASCARADA (jo****@gm****.com) → envia magic link
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime). (signInWithOtp, que usa o mesmo pipeline de e-mail do GoTrue, sem depender de
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por Resend) → a pessoa clica e entra. O e-mail real NUNCA volta pro cliente.
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types). - root_redirect: coluna em config + RPC pública + guard, pra escolher pra onde o
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs, visitante não logado vai na raiz "/" (landing ou login).
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant). - Lista de bloqueio (blacklist) de e-mails e slugs, gerida em Configurações:
4. DROP TABLE public.notifications. tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo trigger BEFORE INSERT em auth.users (não só no front); suporte a domínio inteiro
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem. com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
Lote 2-4 — refator das demais funções ## MÉTODO DE TRABALHO
- Tudo numa branch nova. Commits pequenos por assunto, mensagem clara.
Padrão pra TRIGGER em tabela tenant: - Cada migration: aplique no banco local e TESTE em transação com ROLLBACK (crie
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger auth.users fake + impersone via set_config('request.jwt.claims',...)) antes de
LANGUAGE plpgsql SECURITY DEFINER seguir. RPCs idempotentes.
SET search_path TO 'public', 'pg_temp' - Rode o build do frontend a cada bloco pra pegar erro cedo.
AS $$ - NUMERE as migrations com cuidado pra não colidir versão (quebra o db push).
DECLARE v_tenant_id uuid; - Me mostre o mapa da Fase 0 e as decisões da Fase 0.5 ANTES de codar.
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true); ## DEPLOY (no fim)
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar Migrations no hosted (db push) → dashboard Auth "Confirm email" ON + Site/Redirect
-- ... lógica com tabelas tenant SEM prefixo `public.` ... URLs corretas → deploy das edge functions + secret do provedor de e-mail → rebuild
END $$; do frontend → smoke test do fluxo: /lp → grátis → confirma e-mail → entra
provisionado → limite bloqueia → sino do dev → esqueci-email.
Padrão pra RPC chamada por user logado em um tenant:
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ... ---
LANGUAGE plpgsql SECURITY DEFINER Esse prompt é "diretor": ele força a IA a mapear o teu outro sistema primeiro (porque as tabelas/nomes vão diferir) e
SET search_path TO 'public', 'pg_temp' te apresentar decisões antes de codar — do jeito que fizemos aqui. As 4 pegadinhas marcadas com ⚠️ são as que mais
AS $$ custaram tempo; com elas escritas, a IA evita de cara.
DECLARE v_schema text := public.current_tenant_schema();
BEGIN Quer que eu gere também uma versão curta (1 parágrafo) pra um primeiro disparo, ou uma variante específica caso o
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF; outro sistema seja RLS puro (sem schema-per-tenant)? Aí eu ajusto os trechos de provisionamento/enforcement.
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_<slug>', 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('<lista_tabelas_tenant>') → db().from(...). Importe useTenantDb.
2. Caça por .eq('tenant_id': remova nos from('<tenant_table>'), mantenha nos from('<public_table>').
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_<projeto> pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl >
backups/pre-loteN/public.sql
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=tenant_<slug> --no-owner --no-acl >
backups/pre-loteN/tenant_<slug>.sql
Pra recarregar cache do PostgREST após mudanças:
docker exec supabase_db_<projeto> psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'"
Se mudou config.toml (schemas expostos), restart obrigatório:
docker restart supabase_rest_<projeto>
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.
+184
View File
@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""
F3 schema-per-tenant: codemod do frontend.
1. supabase.from('<tabela/view tenant>') -> tenantDb().from('...') (84 tabelas + 6 views)
2. injeta import { tenantDb } from '@/lib/supabase/tenantClient'
3. remove .eq('tenant_id', <expr>) APENAS dentro de cadeias tenantDb().from(...)
4. relatorio de sobras pra passada manual:
- tenant_id em payloads dentro de cadeias tenantDb (insert/upsert/update)
- onConflict com tenant_id em cadeias tenantDb
- supabase.from(<nao-literal>) pra auditoria
Uso: python scripts/codemod-tenant-db.py [--apply] (default: dry-run)
"""
import io, os, re, sys
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
APPLY = '--apply' in sys.argv
TENANT_RELS = [
# 84 tabelas (docs/F0_categorizacao.md §1.1 + §1.2)
'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',
# 6 views clonadas por schema
'conversation_threads','audit_log_unified','v_cashflow_projection','v_commitment_totals',
'v_patient_groups_with_counts','v_tag_patient_counts',
]
SKIP_FILES = {'tenantClient.js', 'useTenantDb.js', 'client.js'}
names = '|'.join(sorted(TENANT_RELS, key=len, reverse=True))
FROM_RE = re.compile(r"supabase\s*\.\s*from\(\s*(['\"])(" + names + r")\1\s*\)")
IMPORT_LINE = "import { tenantDb } from '@/lib/supabase/tenantClient';"
def skip_string(s, p):
q = s[p]; p += 1
while p < len(s):
if s[p] == '\\': p += 2; continue
if s[p] == q: return p + 1
p += 1
return p
def balanced_end(s, open_paren):
"""indice logo apos o ')' que fecha o '(' em open_paren"""
depth = 0; p = open_paren
while p < len(s):
c = s[p]
if c in '\'"`':
p = skip_string(s, p); continue
if c == '(': depth += 1
elif c == ')':
depth -= 1
if depth == 0: return p + 1
p += 1
return p
def chain_end(s, start):
"""fim da cadeia de metodos iniciada logo apos from(...)"""
i = start
while True:
j = i
while j < len(s) and s[j] in ' \t\r\n': j += 1
if j < len(s) and s[j] == '.':
m = re.match(r'[A-Za-z_$][\w$]*', s[j+1:])
if not m: return i
k = j + 1 + m.end()
while k < len(s) and s[k] in ' \t\r\n': k += 1
if k < len(s) and s[k] == '(':
i = balanced_end(s, k)
elif k < len(s) and s[k] == ';':
return k
else:
# acesso a propriedade sem chamada (ex.: .then? sempre tem parens) — para
return i
else:
return i
EQ_RE = re.compile(r"\.\s*eq\(\s*(['\"])tenant_id\1\s*,")
report = {'files': 0, 'from': 0, 'eq': 0, 'payload': [], 'onconflict': [], 'dynamic_from': []}
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in ('node_modules', '__tests__')]
for fn in filenames:
if not fn.endswith(('.js', '.vue', '.ts')) or fn in SKIP_FILES:
continue
path = os.path.join(dirpath, fn)
text = io.open(path, encoding='utf-8').read()
orig = text
# 1. from() replacement
text, n_from = FROM_RE.subn(lambda m: "tenantDb().from(%s%s%s)" % (m.group(1), m.group(2), m.group(1)), text)
report['from'] += n_from
# 3. eq removal dentro de cadeias tenantDb
n_eq = 0
while True:
removed = False
for m in re.finditer(r"tenantDb\(\)\s*\.\s*from\(", text):
fstart = m.end() - 1
fend = balanced_end(text, fstart)
cend = chain_end(text, fend)
span = text[fend:cend]
em = EQ_RE.search(span)
if em:
eq_open = fend + span.index('(', em.start() + 1)
# acha o '(' do .eq
eq_paren = fend + em.end() - len(em.group(0)) + span[em.start():].index('(')
eq_paren = fend + em.start() + text[fend + em.start():].index('(')
eq_close = balanced_end(text, eq_paren)
eq_dot = fend + em.start()
text = text[:eq_dot] + text[eq_close:]
n_eq += 1
removed = True
break
if not removed:
break
report['eq'] += n_eq
# 2. import injection
if 'tenantDb(' in text and "from '@/lib/supabase/tenantClient'" not in text:
anchor = re.search(r"^import .*from '@/lib/supabase/client';?\s*$", text, re.M)
if anchor:
text = text[:anchor.end()] + '\n' + IMPORT_LINE + text[anchor.end():]
else:
first_import = re.search(r"^import .*$", text, re.M)
if first_import:
text = text[:first_import.end()] + '\n' + IMPORT_LINE + text[first_import.end():]
else:
report['payload'].append((path, 0, 'SEM PONTO DE IMPORT — inserir manualmente'))
# 4. relatorios de sobras dentro de cadeias tenantDb
for m in re.finditer(r"tenantDb\(\)\s*\.\s*from\(", text):
fstart = m.end() - 1
fend = balanced_end(text, fstart)
cend = chain_end(text, fend)
span = text[fend:cend]
line = text[:m.start()].count('\n') + 1
if re.search(r"\btenant_id\b", span):
if 'onConflict' in span and 'tenant_id' in span:
report['onconflict'].append((path, line))
else:
report['payload'].append((path, line, 'tenant_id na cadeia'))
# from() dinamico com supabase (auditoria)
for m in re.finditer(r"supabase\s*\.\s*from\(\s*[^'\")]", text):
line = text[:m.start()].count('\n') + 1
report['dynamic_from'].append((path, line))
if text != orig:
report['files'] += 1
if APPLY:
io.open(path, 'w', encoding='utf-8', newline='').write(text)
mode = 'APPLY' if APPLY else 'DRY-RUN'
print('[%s] arquivos alterados: %d | from substituidos: %d | eq removidos: %d' %
(mode, report['files'], report['from'], report['eq']))
print('\n-- tenant_id sobrando em cadeias tenantDb (payloads, passada manual): %d' % len(report['payload']))
for p, l, why in report['payload'][:80]:
print(' %s:%s (%s)' % (os.path.relpath(p, ROOT), l, why))
print('\n-- onConflict com tenant_id em cadeias tenantDb: %d' % len(report['onconflict']))
for p, l in report['onconflict']:
print(' %s:%s' % (os.path.relpath(p, ROOT), l))
print('\n-- supabase.from(nao-literal) pra auditoria: %d' % len(report['dynamic_from']))
for p, l in report['dynamic_from'][:40]:
print(' %s:%s' % (os.path.relpath(p, ROOT), l))
+2 -2
View File
@@ -18,6 +18,7 @@
import { onMounted, watch } from 'vue'; import { onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { fetchDocsForPath } from '@/composables/useAjuda'; import { fetchDocsForPath } from '@/composables/useAjuda';
@@ -51,8 +52,7 @@ async function checkSetupWizard() {
// Se já confirmamos que este uid passou o setup, não verifica de novo // Se já confirmamos que este uid passou o setup, não verifica de novo
if (_setupClearedUid === uid && _setupClearedIsClinic === isClinic) return; if (_setupClearedUid === uid && _setupClearedIsClinic === isClinic) return;
const { data } = await supabase const { data } = await tenantDb().from('agenda_configuracoes')
.from('agenda_configuracoes')
.select('setup_concluido, setup_clinica_concluido, atendimento_mode') .select('setup_concluido, setup_clinica_concluido, atendimento_mode')
.eq('owner_id', uid) .eq('owner_id', uid)
.maybeSingle(); .maybeSingle();
+9 -6
View File
@@ -21,6 +21,7 @@ import { useRoleGuard } from '@/composables/useRoleGuard';
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators'; import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
import InputMask from 'primevue/inputmask'; import InputMask from 'primevue/inputmask';
import Message from 'primevue/message'; import Message from 'primevue/message';
@@ -269,12 +270,14 @@ async function submit(mode = 'only') {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.'; const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
errorMsg.value = msg; errorMsg.value = msg;
toast.add({ if (!maybeShowPlanLimitToast(toast, err, route.fullPath)) {
severity: 'error', toast.add({
summary: 'Erro ao salvar', severity: 'error',
detail: msg, summary: 'Erro ao salvar',
life: 4500 detail: msg,
}); life: 4500
});
}
console.error('[ComponentCadastroRapido] insert error:', err); console.error('[ComponentCadastroRapido] insert error:', err);
} finally { } finally {
@@ -35,6 +35,8 @@ import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro'; import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service'; import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service';
@@ -51,6 +53,7 @@ const emit = defineEmits(['cobranca-atualizada']);
// ── external ────────────────────────────────────────────────────────────────── // ── external ──────────────────────────────────────────────────────────────────
const toast = useToast(); const toast = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const tenantStore = useTenantStore();
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro(); const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro();
// ── estado local ────────────────────────────────────────────────────────────── // ── estado local ──────────────────────────────────────────────────────────────
@@ -126,8 +129,7 @@ async function fetchRecord() {
// após cancelar (caso comum: cancelou sem querer ou quer recobrar). // após cancelar (caso comum: cancelou sem querer ou quer recobrar).
// Sem esse filtro, o scenario ficava em 'com-cobranca' mostrando // Sem esse filtro, o scenario ficava em 'com-cobranca' mostrando
// o cancelado, e o botão "Gerar cobrança" sumia. // o cancelado, e o botão "Gerar cobrança" sumia.
const { data, error } = await supabase const { data, error } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method') .select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id) .eq('agenda_evento_id', props.evento.id)
.neq('status', 'cancelled') .neq('status', 'cancelled')
@@ -186,6 +188,7 @@ async function confirmPayment() {
payDlgLoading.value = true; payDlgLoading.value = true;
try { try {
const { data, error } = await supabase.rpc('mark_as_paid', { const { data, error } = await supabase.rpc('mark_as_paid', {
p_tenant_id: tenantStore.activeTenantId,
p_financial_record_id: record.value.id, p_financial_record_id: record.value.id,
p_payment_method: payDlgMethod.value p_payment_method: payDlgMethod.value
}); });
@@ -213,7 +216,7 @@ function requestCancel() {
acceptSeverity: 'danger', acceptSeverity: 'danger',
accept: async () => { accept: async () => {
try { try {
const { error } = await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', record.value.id); const { error } = await tenantDb().from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', record.value.id);
if (error) throw error; if (error) throw error;
@@ -27,6 +27,7 @@ import { gerarSlotsDoDia } from '@/utils/slotsGenerator';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
const toast = useToast(); const toast = useToast();
const props = defineProps({ const props = defineProps({
@@ -51,7 +52,7 @@ const regrasSemanais = ref([]); // agenda_regras_semanais
const bloqueadosByDia = ref({}); // {dia: Set('09:00'...)} const bloqueadosByDia = ref({}); // {dia: Set('09:00'...)}
async function loadRegrasSemanais() { async function loadRegrasSemanais() {
const { data, error } = await supabase.from('agenda_regras_semanais').select('*').eq('owner_id', props.ownerId).order('dia_semana', { ascending: true }).order('hora_inicio', { ascending: true }); const { data, error } = await tenantDb().from('agenda_regras_semanais').select('*').eq('owner_id', props.ownerId).order('dia_semana', { ascending: true }).order('hora_inicio', { ascending: true });
if (error) throw error; if (error) throw error;
regrasSemanais.value = data || []; regrasSemanais.value = data || [];
@@ -17,6 +17,7 @@ import { useConversationNotes } from '@/composables/useConversationNotes';
import { useConversationTags } from '@/composables/useConversationTags'; import { useConversationTags } from '@/composables/useConversationTags';
import { useConversationAssignment } from '@/composables/useConversationAssignment'; import { useConversationAssignment } from '@/composables/useConversationAssignment';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'; import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
const toast = useToast(); const toast = useToast();
@@ -69,10 +70,8 @@ async function loadPatients() {
linkPatientLoading.value = true; linkPatientLoading.value = true;
try { try {
// Carrega todos os pacientes do tenant (até 500) — filter é client-side // Carrega todos os pacientes do tenant (até 500) — filter é client-side
const { data, error } = await supabase const { data, error } = await tenantDb().from('patients')
.from('patients')
.select('id, nome_completo, telefone, email_principal, status') .select('id, nome_completo, telefone, email_principal, status')
.eq('tenant_id', tenantId)
.order('nome_completo', { ascending: true }) .order('nome_completo', { ascending: true })
.limit(500); .limit(500);
if (error) throw error; if (error) throw error;
@@ -99,8 +98,7 @@ async function confirmLinkPatient() {
const tenantId = store.thread.tenant_id; const tenantId = store.thread.tenant_id;
// 1) Vincula conversation_messages // 1) Vincula conversation_messages
const { error } = await supabase const { error } = await tenantDb().from('conversation_messages')
.from('conversation_messages')
.update({ patient_id: patient.id }) .update({ patient_id: patient.id })
.or(`from_number.eq.${phone},to_number.eq.${phone}`) .or(`from_number.eq.${phone},to_number.eq.${phone}`)
.is('patient_id', null); .is('patient_id', null);
@@ -137,8 +135,7 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) {
const phoneDigits = String(threadPhone).replace(/\D/g, ''); const phoneDigits = String(threadPhone).replace(/\D/g, '');
// Busca se já tem esse número cadastrado // Busca se já tem esse número cadastrado
const { data: existing } = await supabase const { data: existing } = await tenantDb().from('contact_phones')
.from('contact_phones')
.select('id, contact_type_id, whatsapp_linked_at') .select('id, contact_type_id, whatsapp_linked_at')
.eq('entity_type', 'patient') .eq('entity_type', 'patient')
.eq('entity_id', patientId) .eq('entity_id', patientId)
@@ -149,8 +146,7 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) {
if (existing) { if (existing) {
// Atualiza vinculado_at se ainda não tinha // Atualiza vinculado_at se ainda não tinha
if (!existing.whatsapp_linked_at) { if (!existing.whatsapp_linked_at) {
await supabase await tenantDb().from('contact_phones')
.from('contact_phones')
.update({ whatsapp_linked_at: new Date().toISOString() }) .update({ whatsapp_linked_at: new Date().toISOString() })
.eq('id', existing.id); .eq('id', existing.id);
} }
@@ -158,17 +154,15 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) {
} }
// Não tem — cria novo com type='whatsapp' // Não tem — cria novo com type='whatsapp'
const { data: types } = await supabase const { data: types } = await tenantDb().from('contact_types')
.from('contact_types')
.select('id, slug') .select('id, slug')
.is('tenant_id', null) .eq('is_system', true)
.eq('slug', 'whatsapp') .eq('slug', 'whatsapp')
.maybeSingle(); .maybeSingle();
const whatsappTypeId = types?.id; const whatsappTypeId = types?.id;
if (!whatsappTypeId) return; if (!whatsappTypeId) return;
await supabase.from('contact_phones').insert({ await tenantDb().from('contact_phones').insert({
tenant_id: tenantId,
entity_type: 'patient', entity_type: 'patient',
entity_id: patientId, entity_id: patientId,
contact_type_id: whatsappTypeId, contact_type_id: whatsappTypeId,
@@ -201,8 +195,7 @@ async function onPatientCreated(row) {
} }
try { try {
// 1) Vincula TODAS as mensagens do thread (anon) a esse patient_id // 1) Vincula TODAS as mensagens do thread (anon) a esse patient_id
const { error: msgErr } = await supabase const { error: msgErr } = await tenantDb().from('conversation_messages')
.from('conversation_messages')
.update({ patient_id: newPatientId }) .update({ patient_id: newPatientId })
.or(`from_number.eq.${phone},to_number.eq.${phone}`) .or(`from_number.eq.${phone},to_number.eq.${phone}`)
.is('patient_id', null); .is('patient_id', null);
@@ -238,10 +231,9 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form
if (!tenantId || !patientId || !threadPhone) return; if (!tenantId || !patientId || !threadPhone) return;
try { try {
// Busca tipos system // Busca tipos system
const { data: types } = await supabase const { data: types } = await tenantDb().from('contact_types')
.from('contact_types')
.select('id, slug') .select('id, slug')
.is('tenant_id', null); .eq('is_system', true);
const celularType = types?.find((t) => t.slug === 'celular'); const celularType = types?.find((t) => t.slug === 'celular');
const whatsappType = types?.find((t) => t.slug === 'whatsapp'); const whatsappType = types?.find((t) => t.slug === 'whatsapp');
@@ -257,7 +249,6 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form
// Celular primary (from form — o que o user digitou no cadastro rápido) // Celular primary (from form — o que o user digitou no cadastro rápido)
if (celularType && formDigits && formDigits.length >= 8) { if (celularType && formDigits && formDigits.length >= 8) {
rows.push({ rows.push({
tenant_id: tenantId,
entity_type: 'patient', entity_type: 'patient',
entity_id: patientId, entity_id: patientId,
contact_type_id: celularType.id, contact_type_id: celularType.id,
@@ -270,7 +261,6 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form
// WhatsApp linked (from thread) — só se diferente do celular // WhatsApp linked (from thread) — só se diferente do celular
if (whatsappType && phoneDigits && formNoDdi !== threadNoDdi) { if (whatsappType && phoneDigits && formNoDdi !== threadNoDdi) {
rows.push({ rows.push({
tenant_id: tenantId,
entity_type: 'patient', entity_type: 'patient',
entity_id: patientId, entity_id: patientId,
contact_type_id: whatsappType.id, contact_type_id: whatsappType.id,
@@ -285,7 +275,7 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form
} }
if (rows.length > 0) { if (rows.length > 0) {
await supabase.from('contact_phones').insert(rows); await tenantDb().from('contact_phones').insert(rows);
} }
} catch (e) { } catch (e) {
console.warn('[ConversationDrawer] insert whatsapp contact_phones:', e?.message); console.warn('[ConversationDrawer] insert whatsapp contact_phones:', e?.message);
@@ -13,6 +13,7 @@
<script setup> <script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'; import { ref, watch, onMounted, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'; import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { logEvent, logError } from '@/support/supportLogger'; import { logEvent, logError } from '@/support/supportLogger';
@@ -90,7 +91,7 @@ async function showNotif(msg) {
let name = msg.from_number || 'Desconhecido'; let name = msg.from_number || 'Desconhecido';
if (msg.patient_id) { if (msg.patient_id) {
const { data } = await supabase.from('patients').select('nome_completo').eq('id', msg.patient_id).maybeSingle(); const { data } = await tenantDb().from('patients').select('nome_completo').eq('id', msg.patient_id).maybeSingle();
if (data?.nome_completo) name = data.nome_completo; if (data?.nome_completo) name = data.nome_completo;
} }
@@ -142,7 +143,8 @@ function channelIcon(ch) {
function subscribe() { function subscribe() {
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
if (!tenantId) { const tenantSchema = tenantStore.activeTenantSchema;
if (!tenantId || !tenantSchema) {
logEvent(LOG_SRC, 'subscribe skipped — sem tenant'); logEvent(LOG_SRC, 'subscribe skipped — sem tenant');
return; return;
} }
@@ -154,9 +156,8 @@ function subscribe() {
'postgres_changes', 'postgres_changes',
{ {
event: 'INSERT', event: 'INSERT',
schema: 'public', schema: tenantSchema,
table: 'conversation_messages', table: 'conversation_messages'
filter: `tenant_id=eq.${tenantId}`
}, },
(payload) => { (payload) => {
const m = payload.new; const m = payload.new;
@@ -16,6 +16,7 @@
--> -->
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
@@ -121,10 +122,9 @@ async function openConversationByThreadKey(threadKey) {
try { try {
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
const { supabase } = await import('@/lib/supabase/client'); const { supabase } = await import('@/lib/supabase/client');
const { data } = await supabase const { data } = await tenantDb().from('conversation_threads')
.from('conversation_threads')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.maybeSingle(); .maybeSingle();
if (!data) return false; if (!data) return false;
+1 -1
View File
@@ -123,7 +123,7 @@ watch(query, (v) => {
const mySeq = ++searchSeq; const mySeq = ++searchSeq;
debounceT = setTimeout(async () => { debounceT = setTimeout(async () => {
try { try {
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 }); const { data, error } = await supabase.rpc('search_global', { p_tenant_id: tenantStore.activeTenantId, p_q: q, p_limit: 6 });
if (mySeq !== searchSeq) return; // resposta antiga, descarta if (mySeq !== searchSeq) return; // resposta antiga, descarta
if (error) { if (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
+7 -8
View File
@@ -31,6 +31,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// ─── Cache de exceções financeiras (vive enquanto o módulo estiver carregado) ─ // ─── Cache de exceções financeiras (vive enquanto o módulo estiver carregado) ─
@@ -84,10 +85,9 @@ export function useAgendaFinanceiro() {
const uid = await getUid(); const uid = await getUid();
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('financial_exceptions')
.from('financial_exceptions')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('exception_type', exceptionType) .eq('exception_type', exceptionType)
.or(`owner_id.eq.${uid},owner_id.is.null`) .or(`owner_id.eq.${uid},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true }) // owner próprio tem prioridade .order('owner_id', { ascending: false, nullsLast: true }) // owner próprio tem prioridade
@@ -188,10 +188,10 @@ export function useAgendaFinanceiro() {
if (!rule || rule.charge_mode === 'none') { if (!rule || rule.charge_mode === 'none') {
// Cancelar cobrança existente, se houver // Cancelar cobrança existente, se houver
if (evento.billed) { if (evento.billed) {
const { data: existingRec } = await supabase.from('financial_records').select('id, status').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle(); const { data: existingRec } = await tenantDb().from('financial_records').select('id, status').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
if (existingRec) { if (existingRec) {
await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', existingRec.id); await tenantDb().from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', existingRec.id);
} }
} }
return { ok: true }; return { ok: true };
@@ -202,11 +202,10 @@ export function useAgendaFinanceiro() {
if (evento.billed) { if (evento.billed) {
// Atualiza o valor da cobrança existente // Atualiza o valor da cobrança existente
const { data: existingRec } = await supabase.from('financial_records').select('id').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle(); const { data: existingRec } = await tenantDb().from('financial_records').select('id').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
if (existingRec) { if (existingRec) {
await supabase await tenantDb().from('financial_records')
.from('financial_records')
.update({ .update({
amount: chargeAmount, amount: chargeAmount,
final_amount: chargeAmount, final_amount: chargeAmount,
+3 -4
View File
@@ -17,6 +17,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers ──────────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────────
@@ -102,10 +103,8 @@ export function useAuditoria() {
try { try {
const { from, to } = dateRange.value; const { from, to } = dateRange.value;
let query = supabase let query = tenantDb().from('audit_log_unified')
.from('audit_log_unified') .select('uid, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
.select('uid, tenant_id, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
.eq('tenant_id', tenantId)
.gte('occurred_at', from.toISOString()) .gte('occurred_at', from.toISOString())
.lte('occurred_at', to.toISOString()) .lte('occurred_at', to.toISOString())
.order('occurred_at', { ascending: false }) .order('occurred_at', { ascending: false })
+5 -9
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
@@ -37,10 +38,8 @@ export function useAutoReplySettings() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('conversation_autoreply_settings')
.from('conversation_autoreply_settings')
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window') .select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
.eq('tenant_id', tenantId)
.maybeSingle(); .maybeSingle();
if (err) throw err; if (err) throw err;
if (data) { if (data) {
@@ -80,9 +79,8 @@ export function useAutoReplySettings() {
saving.value = true; saving.value = true;
try { try {
const { error: err } = await supabase const { error: err } = await tenantDb().from('conversation_autoreply_settings')
.from('conversation_autoreply_settings') .upsert({ ...payload }, { onConflict: 'singleton' });
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
if (err) throw err; if (err) throw err;
settings.value = payload; settings.value = payload;
return { ok: true }; return { ok: true };
@@ -98,10 +96,8 @@ export function useAutoReplySettings() {
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
if (!tenantId) return []; if (!tenantId) return [];
try { try {
const { data } = await supabase const { data } = await tenantDb().from('agenda_regras_semanais')
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo') .select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('tenant_id', tenantId)
.eq('ativo', true) .eq('ativo', true)
.order('dia_semana'); .order('dia_semana');
return (data || []).map((r) => ({ return (data || []).map((r) => ({
+11 -15
View File
@@ -17,6 +17,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
function startOfMonth(d = new Date()) { function startOfMonth(d = new Date()) {
@@ -82,41 +83,36 @@ export function useClinicKPIs() {
try { try {
const [finRes, pendRes, patRes, eventRes, finSeriesRes] = await Promise.all([ const [finRes, pendRes, patRes, eventRes, finSeriesRes] = await Promise.all([
// 1) financial_records PAGO no mês (para MRR) // 1) financial_records PAGO no mês (para MRR)
supabase tenantDb().from('financial_records')
.from('financial_records')
.select('final_amount, patient_id') .select('final_amount, patient_id')
.eq('tenant_id', tenantId)
.eq('status', 'paid') .eq('status', 'paid')
.gte('paid_at', monthStart) .gte('paid_at', monthStart)
.lte('paid_at', monthEnd), .lte('paid_at', monthEnd),
// 2) financial_records pending/overdue (qualquer data) // 2) financial_records pending/overdue (qualquer data)
supabase tenantDb().from('financial_records')
.from('financial_records')
.select('status, final_amount') .select('status, final_amount')
.eq('tenant_id', tenantId)
.in('status', ['pending', 'overdue']), .in('status', ['pending', 'overdue']),
// 3) patients por status // 3) patients por status
supabase tenantDb().from('patients')
.from('patients')
.select('status') .select('status')
.eq('tenant_id', tenantId), ,
// 4) eventos de agenda no mês (para realizado/cancelado/faltou) // 4) eventos de agenda no mês (para realizado/cancelado/faltou)
supabase tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('status, tipo') .select('status, tipo')
.eq('tenant_id', tenantId)
.gte('inicio_em', monthStart) .gte('inicio_em', monthStart)
.lte('inicio_em', monthEnd) .lte('inicio_em', monthEnd)
.neq('tipo', 'bloqueio'), .neq('tipo', 'bloqueio'),
// 5) financial_records pagos últimos 6 meses (série + top pacientes) // 5) financial_records pagos últimos 6 meses (série + top pacientes)
supabase tenantDb().from('financial_records')
.from('financial_records')
.select('final_amount, paid_at, patient_id, patients!patient_id(nome_completo)') .select('final_amount, paid_at, patient_id, patients!patient_id(nome_completo)')
.eq('tenant_id', tenantId)
.eq('status', 'paid') .eq('status', 'paid')
.gte('paid_at', sixMonthsAgo) .gte('paid_at', sixMonthsAgo)
.lte('paid_at', monthEnd) .lte('paid_at', monthEnd)
+10 -15
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i; const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
@@ -37,9 +38,8 @@ export function useContactEmails() {
async function loadTypes() { async function loadTypes() {
try { try {
const { data } = await supabase const { data } = await tenantDb().from('contact_email_types')
.from('contact_email_types') .select('id, name, slug, icon, is_system, position')
.select('id, tenant_id, name, slug, icon, is_system, position')
.order('position', { ascending: true }) .order('position', { ascending: true })
.order('name', { ascending: true }); .order('name', { ascending: true });
types.value = data || []; types.value = data || [];
@@ -56,8 +56,7 @@ export function useContactEmails() {
} }
loading.value = true; loading.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('contact_emails')
.from('contact_emails')
.select('id, contact_email_type_id, email, is_primary, notes, position, created_at') .select('id, contact_email_type_id, email, is_primary, notes, position, created_at')
.eq('entity_type', entityType) .eq('entity_type', entityType)
.eq('entity_id', entityId) .eq('entity_id', entityId)
@@ -74,8 +73,7 @@ export function useContactEmails() {
} }
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) { async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase const q = tenantDb().from('contact_emails')
.from('contact_emails')
.update({ is_primary: false }) .update({ is_primary: false })
.eq('entity_type', entityType) .eq('entity_type', entityType)
.eq('entity_id', entityId) .eq('entity_id', entityId)
@@ -122,10 +120,8 @@ export function useContactEmails() {
} }
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0); const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
const { data, error } = await supabase const { data, error } = await tenantDb().from('contact_emails')
.from('contact_emails')
.insert({ .insert({
tenant_id: tenantId,
entity_type: entityType, entity_type: entityType,
entity_id: entityId, entity_id: entityId,
contact_email_type_id, contact_email_type_id,
@@ -172,7 +168,7 @@ export function useContactEmails() {
if (sanitized.is_primary === true) { if (sanitized.is_primary === true) {
await unsetOtherPrimaries(entityType, entityId, id); await unsetOtherPrimaries(entityType, entityId, id);
} }
const { error } = await supabase.from('contact_emails').update(sanitized).eq('id', id); const { error } = await tenantDb().from('contact_emails').update(sanitized).eq('id', id);
if (error) throw error; if (error) throw error;
await loadEmails(entityType, entityId); await loadEmails(entityType, entityId);
return { ok: true }; return { ok: true };
@@ -200,12 +196,12 @@ export function useContactEmails() {
saving.value = true; saving.value = true;
try { try {
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary; const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
const { error } = await supabase.from('contact_emails').delete().eq('id', id); const { error } = await tenantDb().from('contact_emails').delete().eq('id', id);
if (error) throw error; if (error) throw error;
if (wasPrimary) { if (wasPrimary) {
const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0)); const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
if (remaining.length > 0) { if (remaining.length > 0) {
await supabase.from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id); await tenantDb().from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id);
} }
} }
await loadEmails(entityType, entityId); await loadEmails(entityType, entityId);
@@ -227,7 +223,6 @@ export function useContactEmails() {
saving.value = true; saving.value = true;
try { try {
const rows = pendingItems.map((e) => ({ const rows = pendingItems.map((e) => ({
tenant_id: tenantId,
entity_type: entityType, entity_type: entityType,
entity_id: entityId, entity_id: entityId,
contact_email_type_id: e.contact_email_type_id, contact_email_type_id: e.contact_email_type_id,
@@ -236,7 +231,7 @@ export function useContactEmails() {
notes: e.notes || null, notes: e.notes || null,
position: e.position position: e.position
})); }));
const { error } = await supabase.from('contact_emails').insert(rows); const { error } = await tenantDb().from('contact_emails').insert(rows);
if (error) throw error; if (error) throw error;
await loadEmails(entityType, entityId); await loadEmails(entityType, entityId);
return { ok: true, count: rows.length }; return { ok: true, count: rows.length };
+10 -17
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
function normalizeDigits(raw) { function normalizeDigits(raw) {
@@ -36,9 +37,8 @@ export function useContactPhones() {
async function loadTypes() { async function loadTypes() {
try { try {
const { data } = await supabase const { data } = await tenantDb().from('contact_types')
.from('contact_types') .select('id, name, slug, icon, is_mobile, is_system, position')
.select('id, tenant_id, name, slug, icon, is_mobile, is_system, position')
.order('position', { ascending: true }) .order('position', { ascending: true })
.order('name', { ascending: true }); .order('name', { ascending: true });
types.value = data || []; types.value = data || [];
@@ -55,8 +55,7 @@ export function useContactPhones() {
} }
loading.value = true; loading.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('contact_phones')
.from('contact_phones')
.select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at') .select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at')
.eq('entity_type', entityType) .eq('entity_type', entityType)
.eq('entity_id', entityId) .eq('entity_id', entityId)
@@ -74,8 +73,7 @@ export function useContactPhones() {
// Ensure só 1 primary por entidade — seta outros pra false antes de inserir/atualizar // Ensure só 1 primary por entidade — seta outros pra false antes de inserir/atualizar
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) { async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase const q = tenantDb().from('contact_phones')
.from('contact_phones')
.update({ is_primary: false }) .update({ is_primary: false })
.eq('entity_type', entityType) .eq('entity_type', entityType)
.eq('entity_id', entityId) .eq('entity_id', entityId)
@@ -127,10 +125,8 @@ export function useContactPhones() {
} }
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0); const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
const { data, error } = await supabase const { data, error } = await tenantDb().from('contact_phones')
.from('contact_phones')
.insert({ .insert({
tenant_id: tenantId,
entity_type: entityType, entity_type: entityType,
entity_id: entityId, entity_id: entityId,
contact_type_id, contact_type_id,
@@ -177,8 +173,7 @@ export function useContactPhones() {
await unsetOtherPrimaries(entityType, entityId, id); await unsetOtherPrimaries(entityType, entityId, id);
} }
const { error } = await supabase const { error } = await tenantDb().from('contact_phones')
.from('contact_phones')
.update(sanitized) .update(sanitized)
.eq('id', id); .eq('id', id);
if (error) throw error; if (error) throw error;
@@ -208,15 +203,14 @@ export function useContactPhones() {
saving.value = true; saving.value = true;
try { try {
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary; const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
const { error } = await supabase.from('contact_phones').delete().eq('id', id); const { error } = await tenantDb().from('contact_phones').delete().eq('id', id);
if (error) throw error; if (error) throw error;
// Se removeu o primary, promove o próximo pra primary // Se removeu o primary, promove o próximo pra primary
if (wasPrimary) { if (wasPrimary) {
const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0)); const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
if (remaining.length > 0) { if (remaining.length > 0) {
await supabase await tenantDb().from('contact_phones')
.from('contact_phones')
.update({ is_primary: true }) .update({ is_primary: true })
.eq('id', remaining[0].id); .eq('id', remaining[0].id);
} }
@@ -242,7 +236,6 @@ export function useContactPhones() {
saving.value = true; saving.value = true;
try { try {
const rows = pendingItems.map((p) => ({ const rows = pendingItems.map((p) => ({
tenant_id: tenantId,
entity_type: entityType, entity_type: entityType,
entity_id: entityId, entity_id: entityId,
contact_type_id: p.contact_type_id, contact_type_id: p.contact_type_id,
@@ -252,7 +245,7 @@ export function useContactPhones() {
notes: p.notes || null, notes: p.notes || null,
position: p.position position: p.position
})); }));
const { error } = await supabase.from('contact_phones').insert(rows); const { error } = await tenantDb().from('contact_phones').insert(rows);
if (error) throw error; if (error) throw error;
// Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids. // Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids.
await loadPhones(entityType, entityId); await loadPhones(entityType, entityId);
+6 -9
View File
@@ -12,6 +12,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
export function useConversationAssignment() { export function useConversationAssignment() {
@@ -55,10 +56,8 @@ export function useConversationAssignment() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('conversation_assignments')
.from('conversation_assignments') .select('thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.maybeSingle(); .maybeSingle();
if (err) throw err; if (err) throw err;
@@ -96,7 +95,6 @@ export function useConversationAssignment() {
if (!userId) return { ok: false, error: 'not_authenticated' }; if (!userId) return { ok: false, error: 'not_authenticated' };
const payload = { const payload = {
tenant_id: tenantId,
thread_key: threadKey, thread_key: threadKey,
patient_id: patientId || null, patient_id: patientId || null,
contact_number: contactNumber || null, contact_number: contactNumber || null,
@@ -105,10 +103,9 @@ export function useConversationAssignment() {
assigned_at: new Date().toISOString() assigned_at: new Date().toISOString()
}; };
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('conversation_assignments')
.from('conversation_assignments') .upsert(payload, { onConflict: 'thread_key' })
.upsert(payload, { onConflict: 'tenant_id,thread_key' }) .select('thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.single(); .single();
if (err) throw err; if (err) throw err;
+5 -10
View File
@@ -12,6 +12,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
function sanitizeBody(raw) { function sanitizeBody(raw) {
@@ -42,10 +43,8 @@ export function useConversationNotes() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('conversation_notes')
.from('conversation_notes')
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at') .select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.is('deleted_at', null) .is('deleted_at', null)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
@@ -82,10 +81,8 @@ export function useConversationNotes() {
const userId = authData?.user?.id; const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' }; if (!userId) return { ok: false, error: 'not_authenticated' };
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('conversation_notes')
.from('conversation_notes')
.insert({ .insert({
tenant_id: tenantId,
thread_key: threadKey, thread_key: threadKey,
patient_id: patientId, patient_id: patientId,
contact_number: contactNumber, contact_number: contactNumber,
@@ -122,8 +119,7 @@ export function useConversationNotes() {
if (!id || !clean) return { ok: false, error: 'invalid_params' }; if (!id || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true; saving.value = true;
try { try {
const { error: err } = await supabase const { error: err } = await tenantDb().from('conversation_notes')
.from('conversation_notes')
.update({ body: clean }) .update({ body: clean })
.eq('id', id); .eq('id', id);
if (err) throw err; if (err) throw err;
@@ -144,8 +140,7 @@ export function useConversationNotes() {
if (!id) return { ok: false, error: 'invalid_id' }; if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true; saving.value = true;
try { try {
const { error: err } = await supabase const { error: err } = await tenantDb().from('conversation_notes')
.from('conversation_notes')
.update({ deleted_at: new Date().toISOString() }) .update({ deleted_at: new Date().toISOString() })
.eq('id', id); .eq('id', id);
if (err) throw err; if (err) throw err;
+13 -24
View File
@@ -11,6 +11,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
function normalizePhoneBR(raw) { function normalizePhoneBR(raw) {
@@ -38,15 +39,11 @@ export function useConversationOptouts() {
loading.value = true; loading.value = true;
try { try {
const [optsRes, kwsRes] = await Promise.all([ const [optsRes, kwsRes] = await Promise.all([
supabase tenantDb().from('conversation_optouts')
.from('conversation_optouts')
.select('id, phone, patient_id, source, keyword_matched, original_message, notes, opted_out_at, opted_back_in_at, blocked_by') .select('id, phone, patient_id, source, keyword_matched, original_message, notes, opted_out_at, opted_back_in_at, blocked_by')
.eq('tenant_id', tenantId)
.order('opted_out_at', { ascending: false }), .order('opted_out_at', { ascending: false }),
supabase tenantDb().from('conversation_optout_keywords')
.from('conversation_optout_keywords') .select('id, keyword, enabled, is_system')
.select('id, tenant_id, keyword, enabled, is_system')
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
.order('is_system', { ascending: false }) .order('is_system', { ascending: false })
.order('keyword', { ascending: true }) .order('keyword', { ascending: true })
]); ]);
@@ -56,7 +53,7 @@ export function useConversationOptouts() {
// Enriquece com nome do paciente // Enriquece com nome do paciente
const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))]; const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))];
if (patIds.length) { if (patIds.length) {
const { data: pats } = await supabase.from('patients').select('id, nome_completo').in('id', patIds); const { data: pats } = await tenantDb().from('patients').select('id, nome_completo').in('id', patIds);
const patMap = Object.fromEntries((pats || []).map((p) => [p.id, p.nome_completo])); const patMap = Object.fromEntries((pats || []).map((p) => [p.id, p.nome_completo]));
optouts.value = optouts.value.map((o) => ({ ...o, _patient_name: patMap[o.patient_id] || null })); optouts.value = optouts.value.map((o) => ({ ...o, _patient_name: patMap[o.patient_id] || null }));
} }
@@ -79,19 +76,15 @@ export function useConversationOptouts() {
const userId = authData?.user?.id; const userId = authData?.user?.id;
// Verifica se já existe ativo // Verifica se já existe ativo
const { data: existing } = await supabase const { data: existing } = await tenantDb().from('conversation_optouts')
.from('conversation_optouts')
.select('id') .select('id')
.eq('tenant_id', tenantId)
.eq('phone', cleanPhone) .eq('phone', cleanPhone)
.is('opted_back_in_at', null) .is('opted_back_in_at', null)
.maybeSingle(); .maybeSingle();
if (existing) return { ok: false, error: 'already_opted_out' }; if (existing) return { ok: false, error: 'already_opted_out' };
const { data, error } = await supabase const { data, error } = await tenantDb().from('conversation_optouts')
.from('conversation_optouts')
.insert({ .insert({
tenant_id: tenantId,
phone: cleanPhone, phone: cleanPhone,
patient_id: patientId, patient_id: patientId,
source: 'manual', source: 'manual',
@@ -115,8 +108,7 @@ export function useConversationOptouts() {
saving.value = true; saving.value = true;
try { try {
const now = new Date().toISOString(); const now = new Date().toISOString();
const { error } = await supabase const { error } = await tenantDb().from('conversation_optouts')
.from('conversation_optouts')
.update({ opted_back_in_at: now }) .update({ opted_back_in_at: now })
.eq('id', id); .eq('id', id);
if (error) throw error; if (error) throw error;
@@ -136,10 +128,9 @@ export function useConversationOptouts() {
if (!tenantId || !clean) return { ok: false, error: 'invalid_params' }; if (!tenantId || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true; saving.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('conversation_optout_keywords')
.from('conversation_optout_keywords') .insert({ keyword: clean, is_system: false, enabled: true })
.insert({ tenant_id: tenantId, keyword: clean, is_system: false, enabled: true }) .select('id, keyword, enabled, is_system')
.select('id, tenant_id, keyword, enabled, is_system')
.single(); .single();
if (error) throw error; if (error) throw error;
keywords.value = [...keywords.value, data]; keywords.value = [...keywords.value, data];
@@ -154,8 +145,7 @@ export function useConversationOptouts() {
async function toggleKeyword(id, enabled) { async function toggleKeyword(id, enabled) {
saving.value = true; saving.value = true;
try { try {
const { error } = await supabase const { error } = await tenantDb().from('conversation_optout_keywords')
.from('conversation_optout_keywords')
.update({ enabled }) .update({ enabled })
.eq('id', id); .eq('id', id);
if (error) throw error; if (error) throw error;
@@ -172,8 +162,7 @@ export function useConversationOptouts() {
async function deleteKeyword(id) { async function deleteKeyword(id) {
saving.value = true; saving.value = true;
try { try {
const { error } = await supabase const { error } = await tenantDb().from('conversation_optout_keywords')
.from('conversation_optout_keywords')
.delete() .delete()
.eq('id', id); .eq('id', id);
if (error) throw error; if (error) throw error;
+12 -23
View File
@@ -12,6 +12,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
function sanitizeName(raw) { function sanitizeName(raw) {
@@ -46,9 +47,8 @@ export function useConversationTags() {
loading.value = true; loading.value = true;
try { try {
// RLS filtra automaticamente: system (tenant_id IS NULL) + custom do tenant ativo // RLS filtra automaticamente: system (tenant_id IS NULL) + custom do tenant ativo
const { data, error } = await supabase const { data, error } = await tenantDb().from('conversation_tags')
.from('conversation_tags') .select('id, name, slug, color, icon, position, is_system')
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.order('position', { ascending: true }) .order('position', { ascending: true })
.order('name', { ascending: true }); .order('name', { ascending: true });
if (error) throw error; if (error) throw error;
@@ -67,10 +67,8 @@ export function useConversationTags() {
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
if (!tenantId || !Array.isArray(threadKeys) || !threadKeys.length) return new Map(); if (!tenantId || !Array.isArray(threadKeys) || !threadKeys.length) return new Map();
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('conversation_thread_tags')
.from('conversation_thread_tags')
.select('thread_key, tag_id') .select('thread_key, tag_id')
.eq('tenant_id', tenantId)
.in('thread_key', threadKeys); .in('thread_key', threadKeys);
if (error) throw error; if (error) throw error;
const map = new Map(); const map = new Map();
@@ -93,10 +91,8 @@ export function useConversationTags() {
return; return;
} }
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('conversation_thread_tags')
.from('conversation_thread_tags')
.select('tag_id') .select('tag_id')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey); .eq('thread_key', threadKey);
if (error) throw error; if (error) throw error;
threadTagIds.value = new Set((data || []).map((r) => r.tag_id)); threadTagIds.value = new Set((data || []).map((r) => r.tag_id));
@@ -116,10 +112,8 @@ export function useConversationTags() {
try { try {
if (hasTag) { if (hasTag) {
const { error } = await supabase const { error } = await tenantDb().from('conversation_thread_tags')
.from('conversation_thread_tags')
.delete() .delete()
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.eq('tag_id', tagId); .eq('tag_id', tagId);
if (error) throw error; if (error) throw error;
@@ -130,10 +124,8 @@ export function useConversationTags() {
const { data: authData } = await supabase.auth.getUser(); const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id; const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' }; if (!userId) return { ok: false, error: 'not_authenticated' };
const { error } = await supabase const { error } = await tenantDb().from('conversation_thread_tags')
.from('conversation_thread_tags')
.insert({ .insert({
tenant_id: tenantId,
thread_key: threadKey, thread_key: threadKey,
tag_id: tagId, tag_id: tagId,
tagged_by: userId tagged_by: userId
@@ -162,17 +154,15 @@ export function useConversationTags() {
saving.value = true; saving.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('conversation_tags')
.from('conversation_tags')
.insert({ .insert({
tenant_id: tenantId,
name: cleanName, name: cleanName,
slug, slug,
color, color,
icon, icon,
is_system: false is_system: false
}) })
.select('id, tenant_id, name, slug, color, icon, position, is_system') .select('id, name, slug, color, icon, position, is_system')
.single(); .single();
if (error) throw error; if (error) throw error;
allTags.value = [...allTags.value, data].sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name)); allTags.value = [...allTags.value, data].sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
@@ -201,11 +191,10 @@ export function useConversationTags() {
saving.value = true; saving.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('conversation_tags')
.from('conversation_tags')
.update(patch) .update(patch)
.eq('id', id) .eq('id', id)
.select('id, tenant_id, name, slug, color, icon, position, is_system') .select('id, name, slug, color, icon, position, is_system')
.single(); .single();
if (error) throw error; if (error) throw error;
allTags.value = allTags.value allTags.value = allTags.value
@@ -224,7 +213,7 @@ export function useConversationTags() {
if (!id) return { ok: false, error: 'invalid_id' }; if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true; saving.value = true;
try { try {
const { error } = await supabase.from('conversation_tags').delete().eq('id', id); const { error } = await tenantDb().from('conversation_tags').delete().eq('id', id);
if (error) throw error; if (error) throw error;
allTags.value = allTags.value.filter((t) => t.id !== id); allTags.value = allTags.value.filter((t) => t.id !== id);
const next = new Set(threadTagIds.value); const next = new Set(threadTagIds.value);
+11 -17
View File
@@ -17,6 +17,7 @@
import { ref, computed, onUnmounted } from 'vue'; import { ref, computed, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// Metadata canonica das colunas do kanban — fonte unica consumida pelo // Metadata canonica das colunas do kanban — fonte unica consumida pelo
@@ -82,10 +83,8 @@ export function useConversations() {
error.value = null; error.value = null;
try { try {
const { data, error: qErr } = await supabase const { data, error: qErr } = await tenantDb().from('conversation_threads')
.from('conversation_threads')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.order('last_message_at', { ascending: false }) .order('last_message_at', { ascending: false })
.limit(500); .limit(500);
if (qErr) throw qErr; if (qErr) throw qErr;
@@ -100,7 +99,8 @@ export function useConversations() {
function subscribeRealtime() { function subscribeRealtime() {
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
if (!tenantId) return; const tenantSchema = tenantStore.activeTenantSchema;
if (!tenantId || !tenantSchema) return;
if (realtimeChannel) { if (realtimeChannel) {
supabase.removeChannel(realtimeChannel); supabase.removeChannel(realtimeChannel);
} }
@@ -110,9 +110,8 @@ export function useConversations() {
'postgres_changes', 'postgres_changes',
{ {
event: 'INSERT', event: 'INSERT',
schema: 'public', schema: tenantSchema,
table: 'conversation_messages', table: 'conversation_messages'
filter: `tenant_id=eq.${tenantId}`
}, },
(payload) => { (payload) => {
// refetch da lista (view agrega tudo) — debounced // refetch da lista (view agrega tudo) — debounced
@@ -129,9 +128,8 @@ export function useConversations() {
'postgres_changes', 'postgres_changes',
{ {
event: 'UPDATE', event: 'UPDATE',
schema: 'public', schema: tenantSchema,
table: 'conversation_messages', table: 'conversation_messages'
filter: `tenant_id=eq.${tenantId}`
}, },
(payload) => { (payload) => {
_scheduleLoad(); _scheduleLoad();
@@ -226,10 +224,8 @@ export function useConversations() {
} }
threadLoading.value = true; threadLoading.value = true;
try { try {
let q = supabase let q = tenantDb().from('conversation_messages')
.from('conversation_messages')
.select('*') .select('*')
.eq('tenant_id', tenantStore.activeTenantId)
.order('created_at', { ascending: true }) .order('created_at', { ascending: true })
.limit(500); .limit(500);
@@ -253,10 +249,8 @@ export function useConversations() {
// Marca unread do inbound como lido // Marca unread do inbound como lido
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
let q = supabase let q = tenantDb().from('conversation_messages')
.from('conversation_messages')
.update({ read_at: nowIso }) .update({ read_at: nowIso })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound') .eq('direction', 'inbound')
.is('read_at', null); .is('read_at', null);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id); if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
@@ -271,7 +265,7 @@ export function useConversations() {
const patch = { kanban_status: newStatus }; const patch = { kanban_status: newStatus };
if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString(); if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString();
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantId); let q = tenantDb().from('conversation_messages').update(patch);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id); if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
else q = q.eq('from_number', thread.contact_number).is('patient_id', null); else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
+5 -8
View File
@@ -17,6 +17,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { getFeriadosNacionais } from '@/utils/feriadosBR'; import { getFeriadosNacionais } from '@/utils/feriadosBR';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore'; import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
@@ -59,10 +60,8 @@ export function useFeriados(opts = {}) {
} }
async function _doFetch(tenantId, cacheKey) { async function _doFetch(tenantId, cacheKey) {
const { data, error } = await supabase const { data, error } = await tenantDb().from('feriados')
.from('feriados')
.select('*') .select('*')
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
.gte('data', `${ano.value}-01-01`) .gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`) .lte('data', `${ano.value}-12-31`)
.order('data'); .order('data');
@@ -98,10 +97,8 @@ export function useFeriados(opts = {}) {
// Comportamento legado (sem cache) — páginas de admin que editam. // Comportamento legado (sem cache) — páginas de admin que editam.
loading.value = true; loading.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('feriados')
.from('feriados')
.select('*') .select('*')
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
.gte('data', `${ano.value}-01-01`) .gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`) .lte('data', `${ano.value}-12-31`)
.order('data'); .order('data');
@@ -114,7 +111,7 @@ export function useFeriados(opts = {}) {
// ── Criar feriado municipal ─────────────────────────────── // ── Criar feriado municipal ───────────────────────────────
async function criar(payload) { async function criar(payload) {
const { data, error } = await supabase.from('feriados').insert(payload).select().single(); const { data, error } = await tenantDb().from('feriados').insert(payload).select().single();
if (error) throw error; if (error) throw error;
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data)); municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data));
if (cache) cache.invalidate('feriados'); if (cache) cache.invalidate('feriados');
@@ -123,7 +120,7 @@ export function useFeriados(opts = {}) {
// ── Remover feriado municipal ───────────────────────────── // ── Remover feriado municipal ─────────────────────────────
async function remover(id) { async function remover(id) {
const { error } = await supabase.from('feriados').delete().eq('id', id); const { error } = await tenantDb().from('feriados').delete().eq('id', id);
if (error) throw error; if (error) throw error;
municipais.value = municipais.value.filter((f) => f.id !== id); municipais.value = municipais.value.filter((f) => f.id !== id);
if (cache) cache.invalidate('feriados'); if (cache) cache.invalidate('feriados');
+11 -9
View File
@@ -17,6 +17,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers internos ──────────────────────────────────────────────────────── // ─── helpers internos ────────────────────────────────────────────────────────
@@ -38,7 +39,7 @@ async function getUid() {
// ─── select base com joins ─────────────────────────────────────────────────── // ─── select base com joins ───────────────────────────────────────────────────
const BASE_SELECT = ` const BASE_SELECT = `
id, tenant_id, owner_id, patient_id, agenda_evento_id, id, owner_id, patient_id, agenda_evento_id,
type, amount, discount_amount, final_amount, type, amount, discount_amount, final_amount,
status, due_date, paid_at, payment_method, payment_link, status, due_date, paid_at, payment_method, payment_link,
description, notes, created_at, updated_at, description, notes, created_at, updated_at,
@@ -117,10 +118,8 @@ export function useFinancialRecords() {
const offset = filters.offset ?? 0; const offset = filters.offset ?? 0;
try { try {
let query = supabase let query = tenantDb().from('financial_records')
.from('financial_records')
.select(BASE_SELECT, { count: 'exact' }) .select(BASE_SELECT, { count: 'exact' })
.eq('tenant_id', tenantId)
.is('deleted_at', null) .is('deleted_at', null)
.order('due_date', { ascending: false }) .order('due_date', { ascending: false })
.range(offset, offset + limit - 1); .range(offset, offset + limit - 1);
@@ -214,11 +213,9 @@ export function useFinancialRecords() {
const discount = payload.discount_amount ?? 0; const discount = payload.discount_amount ?? 0;
const amount = payload.amount ?? 0; const amount = payload.amount ?? 0;
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('financial_records')
.from('financial_records')
.insert([ .insert([
{ {
tenant_id: tenantId,
owner_id: ownerId, owner_id: ownerId,
patient_id: payload.patient_id ?? null, patient_id: payload.patient_id ?? null,
agenda_evento_id: null, agenda_evento_id: null,
@@ -257,14 +254,19 @@ export function useFinancialRecords() {
error.value = null; error.value = null;
try { try {
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
const { data, error: err } = await supabase.rpc('mark_as_paid', { const { data, error: err } = await supabase.rpc('mark_as_paid', {
p_tenant_id: tenantId,
p_financial_record_id: recordId, p_financial_record_id: recordId,
p_payment_method: paymentMethod p_payment_method: paymentMethod
}); });
if (err) throw err; if (err) throw err;
// RPC retorna SETOF (array) — patch local direto, sem depender do retorno // RPC retorna jsonb (objeto único) — patch local direto, sem depender do retorno
const idx = records.value.findIndex((r) => r.id === recordId); const idx = records.value.findIndex((r) => r.id === recordId);
if (idx !== -1) { if (idx !== -1) {
records.value[idx] = { records.value[idx] = {
@@ -291,7 +293,7 @@ export function useFinancialRecords() {
error.value = null; error.value = null;
try { try {
const { error: err } = await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', recordId); const { error: err } = await tenantDb().from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', recordId);
if (err) throw err; if (err) throw err;
+3 -1
View File
@@ -17,6 +17,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { downloadLgpdPDF } from '@/utils/lgpdExportFormats'; import { downloadLgpdPDF } from '@/utils/lgpdExportFormats';
function slugify(s) { function slugify(s) {
@@ -53,7 +54,8 @@ export function useLgpdExport() {
throw new Error('patientId obrigatório'); throw new Error('patientId obrigatório');
} }
const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_patient_id: patientId }); const tenantId = useTenantStore().activeTenantId;
const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_tenant_id: tenantId, p_patient_id: patientId });
if (rpcErr) throw rpcErr; if (rpcErr) throw rpcErr;
return data; return data;
} }
+8 -9
View File
@@ -17,6 +17,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// ─── estado compartilhado ────────────────────────────────── // ─── estado compartilhado ──────────────────────────────────
@@ -50,9 +51,8 @@ async function _refresh() {
// 1. Agenda hoje // 1. Agenda hoje
{ {
let q = supabase.from('agenda_eventos').select('id', { count: 'exact', head: true }).gte('inicio_em', startDay).lt('inicio_em', endDay); let q = tenantDb().from('agenda_eventos').select('id', { count: 'exact', head: true }).gte('inicio_em', startDay).lt('inicio_em', endDay);
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId); if (!(isClinic && tenantId)) q = q.eq('owner_id', ownerId);
else q = q.eq('owner_id', ownerId);
const { count } = await q; const { count } = await q;
agendaHoje.value = count || 0; agendaHoje.value = count || 0;
} }
@@ -74,10 +74,8 @@ async function _refresh() {
// 4. Conversas não lidas (mensagens inbound sem read_at) // 4. Conversas não lidas (mensagens inbound sem read_at)
if (tenantId) { if (tenantId) {
const { count } = await supabase const { count } = await tenantDb().from('conversation_messages')
.from('conversation_messages')
.select('id', { count: 'exact', head: true }) .select('id', { count: 'exact', head: true })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound') .eq('direction', 'inbound')
.is('read_at', null); .is('read_at', null);
conversasUnread.value = count || 0; conversasUnread.value = count || 0;
@@ -92,7 +90,8 @@ function _subscribeRealtime() {
try { try {
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null; const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!tenantId) return; const tenantSchema = tenantStore.activeTenantSchema;
if (!tenantId || !tenantSchema) return;
if (_realtimeChannel) { if (_realtimeChannel) {
supabase.removeChannel(_realtimeChannel); supabase.removeChannel(_realtimeChannel);
} }
@@ -100,12 +99,12 @@ function _subscribeRealtime() {
.channel(`menu_badges_conv_${tenantId}`) .channel(`menu_badges_conv_${tenantId}`)
.on( .on(
'postgres_changes', 'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` }, { event: 'INSERT', schema: tenantSchema, table: 'conversation_messages' },
() => _refresh() () => _refresh()
) )
.on( .on(
'postgres_changes', 'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` }, { event: 'UPDATE', schema: tenantSchema, table: 'conversation_messages' },
() => _refresh() () => _refresh()
) )
.subscribe(); .subscribe();
+3 -3
View File
@@ -18,6 +18,7 @@ import { onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useNotificationStore, fireBrowserNotification } from '@/stores/notificationStore'; import { useNotificationStore, fireBrowserNotification } from '@/stores/notificationStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'; import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
@@ -91,10 +92,9 @@ export function useNotifications() {
if (payload.thread_key) { if (payload.thread_key) {
try { try {
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
const { data } = await supabase const { data } = await tenantDb().from('conversation_threads')
.from('conversation_threads')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('thread_key', payload.thread_key) .eq('thread_key', payload.thread_key)
.maybeSingle(); .maybeSingle();
if (data) { if (data) {
+9 -6
View File
@@ -16,6 +16,8 @@
*/ */
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
export function usePatientLifecycle() { export function usePatientLifecycle() {
async function canDelete(patientId) { async function canDelete(patientId) {
const { data, error } = await supabase.rpc('can_delete_patient', { p_patient_id: patientId }); const { data, error } = await supabase.rpc('can_delete_patient', { p_patient_id: patientId });
@@ -24,7 +26,8 @@ export function usePatientLifecycle() {
} }
async function deletePatient(patientId) { async function deletePatient(patientId) {
const { data, error } = await supabase.rpc('safe_delete_patient', { p_patient_id: patientId }); const tenantId = useTenantStore().activeTenantId;
const { data, error } = await supabase.rpc('safe_delete_patient', { p_tenant_id: tenantId, p_patient_id: patientId });
if (error) return { ok: false, error: 'rpc_error', message: error.message }; if (error) return { ok: false, error: 'rpc_error', message: error.message };
return data; // { ok, error?, message? } return data; // { ok, error?, message? }
} }
@@ -32,8 +35,8 @@ export function usePatientLifecycle() {
async function checkActiveSchedule(patientId) { async function checkActiveSchedule(patientId) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const [evts, recs] = await Promise.all([ const [evts, recs] = await Promise.all([
supabase.from('agenda_eventos').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'agendado').gt('inicio_em', now), tenantDb().from('agenda_eventos').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'agendado').gt('inicio_em', now),
supabase.from('recurrence_rules').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'ativo') tenantDb().from('recurrence_rules').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'ativo')
]); ]);
return { return {
hasFutureSessions: (evts.count ?? 0) > 0, hasFutureSessions: (evts.count ?? 0) > 0,
@@ -42,17 +45,17 @@ export function usePatientLifecycle() {
} }
async function deactivatePatient(patientId) { async function deactivatePatient(patientId) {
const { error } = await supabase.from('patients').update({ status: 'Inativo', updated_at: new Date().toISOString() }).eq('id', patientId); const { error } = await tenantDb().from('patients').update({ status: 'Inativo', updated_at: new Date().toISOString() }).eq('id', patientId);
return error ? { ok: false, error } : { ok: true }; return error ? { ok: false, error } : { ok: true };
} }
async function archivePatient(patientId) { async function archivePatient(patientId) {
const { error } = await supabase.from('patients').update({ status: 'Arquivado', updated_at: new Date().toISOString() }).eq('id', patientId); const { error } = await tenantDb().from('patients').update({ status: 'Arquivado', updated_at: new Date().toISOString() }).eq('id', patientId);
return error ? { ok: false, error } : { ok: true }; return error ? { ok: false, error } : { ok: true };
} }
async function reactivatePatient(patientId) { async function reactivatePatient(patientId) {
const { error } = await supabase.from('patients').update({ status: 'Ativo', updated_at: new Date().toISOString() }).eq('id', patientId); const { error } = await tenantDb().from('patients').update({ status: 'Ativo', updated_at: new Date().toISOString() }).eq('id', patientId);
return error ? { ok: false, error } : { ok: true }; return error ? { ok: false, error } : { ok: true };
} }
+5 -9
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
const DEFAULTS = { const DEFAULTS = {
@@ -38,15 +39,11 @@ export function useSessionReminders() {
loading.value = true; loading.value = true;
try { try {
const [settingsRes, logsRes] = await Promise.all([ const [settingsRes, logsRes] = await Promise.all([
supabase tenantDb().from('session_reminder_settings')
.from('session_reminder_settings')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.maybeSingle(), .maybeSingle(),
supabase tenantDb().from('session_reminder_logs')
.from('session_reminder_logs')
.select('id, event_id, reminder_type, sent_at, provider, skip_reason, to_phone') .select('id, event_id, reminder_type, sent_at, provider, skip_reason, to_phone')
.eq('tenant_id', tenantId)
.order('sent_at', { ascending: false }) .order('sent_at', { ascending: false })
.limit(30) .limit(30)
]); ]);
@@ -88,9 +85,8 @@ export function useSessionReminders() {
saving.value = true; saving.value = true;
try { try {
const { error } = await supabase const { error } = await tenantDb().from('session_reminder_settings')
.from('session_reminder_settings') .upsert({ ...payload }, { onConflict: 'singleton' });
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
if (error) throw error; if (error) throw error;
return { ok: true }; return { ok: true };
} catch (e) { } catch (e) {
+36
View File
@@ -0,0 +1,36 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useTenantDb.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
|
| Composable reativo sobre tenantClient: use em componentes que precisam
| aguardar o tenant ativo (isReady) ou reagir à troca de tenant.
| Em services/repositories, importe tenantDb direto de
| '@/lib/supabase/tenantClient'.
*/
import { computed } from 'vue';
import { useTenantStore } from '@/stores/tenantStore';
import { tenantDb, tenantSchemaName } from '@/lib/supabase/tenantClient';
export function useTenantDb() {
const tenantStore = useTenantStore();
const schemaName = computed(() => tenantSchemaName(tenantStore.activeTenantSlug));
const isReady = computed(() => Boolean(schemaName.value));
function db() {
return tenantDb();
}
return { db, schemaName, isReady };
}
@@ -27,6 +27,7 @@ import Message from 'primevue/message';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'; import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'; import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue'; import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue';
@@ -803,8 +804,7 @@ async function openSessionRecordsDialog() {
sessionRecordsDialogOpen.value = true; sessionRecordsDialogOpen.value = true;
sessionRecordsLoading.value = true; sessionRecordsLoading.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at') .select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
.eq('agenda_evento_id', eid) .eq('agenda_evento_id', eid)
.is('deleted_at', null) .is('deleted_at', null)
@@ -17,6 +17,7 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useFeriados } from '@/composables/useFeriados'; import { useFeriados } from '@/composables/useFeriados';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import DatePicker from 'primevue/datepicker'; import DatePicker from 'primevue/datepicker';
@@ -168,7 +169,6 @@ async function confirmar() {
try { try {
const base = { const base = {
owner_id: props.ownerId, owner_id: props.ownerId,
tenant_id: props.tenantId,
tipo: 'bloqueio', tipo: 'bloqueio',
recorrente: false recorrente: false
}; };
@@ -204,7 +204,7 @@ async function confirmar() {
return; return;
} }
const { error } = await supabase.from('agenda_bloqueios').insert(rows); const { error } = await tenantDb().from('agenda_bloqueios').insert(rows);
if (error) throw error; if (error) throw error;
// Marcar sessões existentes como "remarcado" // Marcar sessões existentes como "remarcado"
@@ -229,7 +229,7 @@ async function marcarSessoesParaRemarcar(bloqueios) {
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcado' // Para cada bloqueio, tenta marcar sessões existentes como 'remarcado'
for (const b of bloqueios) { for (const b of bloqueios) {
try { try {
let query = supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`); let query = tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`);
if (b.hora_inicio && b.hora_fim) { if (b.hora_inicio && b.hora_fim) {
// filtra pela hora aproximada comparação UTC simplificada // filtra pela hora aproximada comparação UTC simplificada
@@ -250,7 +250,6 @@ async function salvarFeriadoMunicipal() {
const iso = toISO(fform.value.data); const iso = toISO(fform.value.data);
try { try {
await criarFeriado({ await criarFeriado({
tenant_id: props.tenantId,
owner_id: props.ownerId, owner_id: props.ownerId,
tipo: 'municipal', tipo: 'municipal',
nome: fform.value.nome.trim(), nome: fform.value.nome.trim(),
@@ -20,6 +20,7 @@ import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
insurancePlanId: { type: String, default: '' }, insurancePlanId: { type: String, default: '' },
@@ -61,7 +62,7 @@ async function onSave() {
value: Number(form.value.value), value: Number(form.value.value),
active: true active: true
}; };
const { data, error } = await supabase.from('insurance_plan_services').insert(payload).select().single(); const { data, error } = await tenantDb().from('insurance_plan_services').insert(payload).select().single();
if (error) throw error; if (error) throw error;
toast.add({ severity: 'success', summary: 'Procedimento cadastrado', life: 2200 }); toast.add({ severity: 'success', summary: 'Procedimento cadastrado', life: 2200 });
emit('created', data); emit('created', data);
@@ -18,6 +18,7 @@
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useFeriados } from '@/composables/useFeriados'; import { useFeriados } from '@/composables/useFeriados';
@@ -109,7 +110,7 @@ async function loadBloqueiosMes() {
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const end = `${ano}-${String(mesAtual).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
loadingBloqueios.value = true; loadingBloqueios.value = true;
try { try {
const { data } = await supabase.from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end); const { data } = await tenantDb().from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end);
bloqueiosDatas.value = new Set((data || []).map((r) => r.data_inicio)); bloqueiosDatas.value = new Set((data || []).map((r) => r.data_inicio));
} catch { } catch {
/* silencioso */ /* silencioso */
@@ -152,7 +153,6 @@ async function confirmarBloqueio(feriado) {
try { try {
const row = { const row = {
owner_id: _ownerId.value, owner_id: _ownerId.value,
tenant_id: _tenantId.value,
tipo: 'bloqueio', tipo: 'bloqueio',
recorrente: false, recorrente: false,
titulo: `Feriado: ${feriado.nome}`, titulo: `Feriado: ${feriado.nome}`,
@@ -163,11 +163,11 @@ async function confirmarBloqueio(feriado) {
origem: 'agenda_feriado' origem: 'agenda_feriado'
}; };
const { error } = await supabase.from('agenda_bloqueios').insert([row]); const { error } = await tenantDb().from('agenda_bloqueios').insert([row]);
if (error) throw error; if (error) throw error;
// Marcar sessões existentes no dia como 'remarcado' // Marcar sessões existentes no dia como 'remarcado'
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`); await tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data]); bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data]);
toast.add({ toast.add({
@@ -212,7 +212,6 @@ async function salvar() {
saving.value = true; saving.value = true;
try { try {
await criar({ await criar({
tenant_id: _tenantId.value,
owner_id: _ownerId.value, owner_id: _ownerId.value,
tipo: 'municipal', tipo: 'municipal',
nome: form.value.nome.trim(), nome: form.value.nome.trim(),
@@ -11,7 +11,7 @@
| o id pra que o parent pré-selecione no select de serviços. | o id pra que o parent pré-selecione no select de serviços.
| |
| Campos mínimos (obrigatórios no schema): | Campos mínimos (obrigatórios no schema):
| name, price, owner_id, tenant_id | name, price, owner_id
| Opcionais úteis: | Opcionais úteis:
| duration_min, description | duration_min, description
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -20,6 +20,7 @@
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({ const props = defineProps({
@@ -72,7 +73,7 @@ async function onSave() {
// Nome unico por owner (case-insensitive) espelha a validacao // Nome unico por owner (case-insensitive) espelha a validacao
// do useServices.save() pra impedir duplicata tambem quando o // do useServices.save() pra impedir duplicata tambem quando o
// cadastro vem do quick-create dentro do AgendaEventDialog. // cadastro vem do quick-create dentro do AgendaEventDialog.
const { data: dups, error: dupErr } = await supabase.from('services').select('id').eq('owner_id', ownerId).ilike('name', name).limit(1); const { data: dups, error: dupErr } = await tenantDb().from('services').select('id').eq('owner_id', ownerId).ilike('name', name).limit(1);
if (dupErr) throw dupErr; if (dupErr) throw dupErr;
if (dups && dups.length > 0) { if (dups && dups.length > 0) {
toast.add({ severity: 'warn', summary: 'Nome em uso', detail: 'Já existe um serviço com este nome.', life: 3500 }); toast.add({ severity: 'warn', summary: 'Nome em uso', detail: 'Já existe um serviço com este nome.', life: 3500 });
@@ -82,14 +83,13 @@ async function onSave() {
const payload = { const payload = {
owner_id: ownerId, owner_id: ownerId,
tenant_id: tid,
name, name,
price: Number(form.value.price), price: Number(form.value.price),
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null, duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
description: form.value.description?.trim().slice(0, 500) || null, description: form.value.description?.trim().slice(0, 500) || null,
active: true active: true
}; };
const { data, error } = await supabase.from('services').insert(payload).select().single(); const { data, error } = await tenantDb().from('services').insert(payload).select().single();
if (error) throw error; if (error) throw error;
toast.add({ severity: 'success', summary: 'Serviço criado', life: 2200 }); toast.add({ severity: 'success', summary: 'Serviço criado', life: 2200 });
emit('created', data); emit('created', data);
@@ -18,6 +18,7 @@
Acessível via SupportDebugBanner botão "Docs". --> Acessível via SupportDebugBanner botão "Docs". -->
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { tenantDb } from '@/lib/supabase/tenantClient';
const props = defineProps({ const props = defineProps({
visible: { type: Boolean, default: false } visible: { type: Boolean, default: false }
@@ -141,7 +142,7 @@ const activeTab = ref(0);
<!-- Tab 1: Tabelas --> <!-- Tab 1: Tabelas -->
<TabPanel header="Tabelas"> <TabPanel header="Tabelas">
<div class="dd-section"> <div class="dd-section">
<p class="dd-p">Todas as tabelas usam <strong>Row Level Security (RLS)</strong> habilitada.</p> <p class="dd-p">Todas as tabelas usam <strong>Row Level Security (RLS)</strong> habilitada. As tabelas da agenda vivem no schema do tenant (<code>tenant_&lt;slug&gt;</code>, sem coluna <code>tenant_id</code>) e são acessadas via <code>tenantDb().from(...)</code>.</p>
<h3 class="dd-h3">Core</h3> <h3 class="dd-h3">Core</h3>
<table class="dd-table"> <table class="dd-table">
@@ -156,12 +157,12 @@ const activeTab = ref(0);
<tr> <tr>
<td><code>agenda_configuracoes</code></td> <td><code>agenda_configuracoes</code></td>
<td>Configurações da agenda por owner (terapeuta ou clínica)</td> <td>Configurações da agenda por owner (terapeuta ou clínica)</td>
<td>owner_id, tenant_id, slot_duration_minutes, start_time, end_time, days_of_week</td> <td>owner_id, slot_duration_minutes, start_time, end_time, days_of_week</td>
</tr> </tr>
<tr> <tr>
<td><code>agenda_eventos</code></td> <td><code>agenda_eventos</code></td>
<td>Eventos individuais (sessões, bloqueios avulsos)</td> <td>Eventos individuais (sessões, bloqueios avulsos)</td>
<td>id, owner_id, tenant_id, patient_id, starts_at, ends_at, status, recurrence_rule_id, tipo</td> <td>id, owner_id, patient_id, starts_at, ends_at, status, recurrence_rule_id, tipo</td>
</tr> </tr>
<tr> <tr>
<td><code>agenda_bloqueios</code></td> <td><code>agenda_bloqueios</code></td>
@@ -217,7 +218,7 @@ const activeTab = ref(0);
<tr> <tr>
<td><code>determined_commitments</code></td> <td><code>determined_commitments</code></td>
<td>Tipos de compromisso determinístico (ex: Avaliação, Supervisão)</td> <td>Tipos de compromisso determinístico (ex: Avaliação, Supervisão)</td>
<td>id, owner_id, tenant_id, name, color, duration_minutes</td> <td>id, owner_id, name, color, duration_minutes</td>
</tr> </tr>
<tr> <tr>
<td><code>determined_commitment_fields</code></td> <td><code>determined_commitment_fields</code></td>
@@ -232,7 +233,7 @@ const activeTab = ref(0);
<tr> <tr>
<td><code>services</code></td> <td><code>services</code></td>
<td>Catálogo de serviços do terapeuta/clínica</td> <td>Catálogo de serviços do terapeuta/clínica</td>
<td>id, owner_id, tenant_id, name, default_price, active</td> <td>id, owner_id, name, default_price, active</td>
</tr> </tr>
<tr> <tr>
<td><code>professional_pricing</code></td> <td><code>professional_pricing</code></td>
@@ -636,8 +637,7 @@ async function loadEvents (ownerId, range) &#123;
logAPI('useAgendaEvents', 'loadEvents start', &#123; ownerId, range &#125;) logAPI('useAgendaEvents', 'loadEvents start', &#123; ownerId, range &#125;)
try &#123; try &#123;
const &#123; data, error &#125; = await supabase const &#123; data, error &#125; = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('*') .select('*')
.eq('owner_id', ownerId) .eq('owner_id', ownerId)
@@ -476,7 +476,7 @@ describe('onSendManualReminder', () => {
_functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null }); _functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null });
const { onSendManualReminder, toast, sendingReminder } = setup({ composer }); const { onSendManualReminder, toast, sendingReminder } = setup({ composer });
await onSendManualReminder(); await onSendManualReminder();
expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1' } }); expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1', tenant_id: 'tenant-1' } });
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'success' })); expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'success' }));
expect(sendingReminder.value).toBe(false); expect(sendingReminder.value).toBe(false);
}); });
@@ -32,6 +32,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { buildBloqueioBackgroundEvents } from '@/features/agenda/services/agendaMappers'; import { buildBloqueioBackgroundEvents } from '@/features/agenda/services/agendaMappers';
export function useAgendaBloqueios() { export function useAgendaBloqueios() {
@@ -55,14 +56,12 @@ export function useAgendaBloqueios() {
// Query: recorrentes (qualquer data) OU não-recorrentes com // Query: recorrentes (qualquer data) OU não-recorrentes com
// data_inicio <= isoEnd e (data_fim ?? data_inicio) >= isoStart. // data_inicio <= isoEnd e (data_fim ?? data_inicio) >= isoStart.
// 2 queries simples + merge pra evitar string-building frágil. // 2 queries simples + merge pra evitar string-building frágil.
const baseNonRec = supabase const baseNonRec = tenantDb().from('agenda_bloqueios')
.from('agenda_bloqueios')
.select('*') .select('*')
.eq('recorrente', false) .eq('recorrente', false)
.lte('data_inicio', isoEnd) .lte('data_inicio', isoEnd)
.or(`data_fim.gte.${isoStart},and(data_fim.is.null,data_inicio.gte.${isoStart})`); .or(`data_fim.gte.${isoStart},and(data_fim.is.null,data_inicio.gte.${isoStart})`);
const baseRec = supabase const baseRec = tenantDb().from('agenda_bloqueios')
.from('agenda_bloqueios')
.select('*') .select('*')
.eq('recorrente', true); .eq('recorrente', true);
@@ -38,6 +38,7 @@ import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { labelStatusSessao } from './agendaEventHelpers'; import { labelStatusSessao } from './agendaEventHelpers';
const EVENTO_TIPO_SESSAO = 'sessao'; const EVENTO_TIPO_SESSAO = 'sessao';
@@ -157,7 +158,7 @@ export function useAgendaEventActions({
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 }); toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
return; return;
} }
const { data, error } = await supabase.from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single(); const { data, error } = await tenantDb().from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single();
if (error) throw error; if (error) throw error;
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 }); toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
emit('updated', data); emit('updated', data);
@@ -213,8 +214,7 @@ export function useAgendaEventActions({
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString(); const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString(); const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString();
let q = supabase let q = tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('id, inicio_em, fim_em, titulo') .select('id, inicio_em, fim_em, titulo')
.eq('patient_id', pid) .eq('patient_id', pid)
.gte('inicio_em', dayStart) .gte('inicio_em', dayStart)
@@ -41,6 +41,7 @@
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function generateRuleDates(rule) { export function generateRuleDates(rule) {
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {}; const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {};
if (!start_date || !weekdays?.length) return []; if (!start_date || !weekdays?.length) return [];
@@ -150,14 +151,13 @@ export function useAgendaEventLifecycle({
} }
serieLoading.value = true; serieLoading.value = true;
try { try {
const { data: rule, error: ruleErr } = await supabase.from('recurrence_rules').select('*').eq('id', rid).maybeSingle(); const { data: rule, error: ruleErr } = await tenantDb().from('recurrence_rules').select('*').eq('id', rid).maybeSingle();
if (ruleErr) throw ruleErr; if (ruleErr) throw ruleErr;
const { data: excData } = await supabase.from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid); const { data: excData } = await tenantDb().from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid);
const exMap = new Map((excData || []).map((e) => [e.original_date, e])); const exMap = new Map((excData || []).map((e) => [e.original_date, e]));
const { data: realData } = await supabase const { data: realData } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, recurrence_date') .select('id, inicio_em, fim_em, status, recurrence_date')
.eq('recurrence_id', rid) .eq('recurrence_id', rid)
.is('mirror_of_event_id', null) .is('mirror_of_event_id', null)
@@ -236,8 +236,7 @@ export function useAgendaEventLifecycle({
// 1) Record direto (materializada que tem agenda_evento_id real) // 1) Record direto (materializada que tem agenda_evento_id real)
const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::'); const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::');
if (evId && !isVirtualId) { if (evId && !isVirtualId) {
const { data, error } = await supabase const { data, error } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method') .select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId) .eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue']) .in('status', ['pending', 'paid', 'overdue'])
@@ -255,8 +254,7 @@ export function useAgendaEventLifecycle({
// materializadas sem cobrança individual) herdam status do // materializadas sem cobrança individual) herdam status do
// contrato pra UI mostrar "Cobrança paga" coerentemente. // contrato pra UI mostrar "Cobrança paga" coerentemente.
if (ruleId && patientId) { if (ruleId && patientId) {
const { data: contracts } = await supabase const { data: contracts } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.select('id, package_price, charging_style, status') .select('id, package_price, charging_style, status')
.eq('patient_id', patientId) .eq('patient_id', patientId)
.eq('type', 'package') .eq('type', 'package')
@@ -266,8 +264,7 @@ export function useAgendaEventLifecycle({
if (upfront) { if (upfront) {
// Confere se há record PAGO ligado a qualquer evento do // Confere se há record PAGO ligado a qualquer evento do
// mesmo recurrence_id (ou seja, contrato foi quitado). // mesmo recurrence_id (ou seja, contrato foi quitado).
const { data: siblingEvents } = await supabase const { data: siblingEvents } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('id') .select('id')
.eq('recurrence_id', ruleId); .eq('recurrence_id', ruleId);
const ids = (siblingEvents || []).map((e) => e.id); const ids = (siblingEvents || []).map((e) => e.id);
@@ -276,8 +273,7 @@ export function useAgendaEventLifecycle({
// pending OU overdue). Pacote upfront tem 1 record // pending OU overdue). Pacote upfront tem 1 record
// unico cobrindo toda a serie — qualquer status dele // unico cobrindo toda a serie — qualquer status dele
// trava as siblings (cobranca ja emitida, imutavel). // trava as siblings (cobranca ja emitida, imutavel).
const { data: anyRec } = await supabase const { data: anyRec } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method') .select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.in('agenda_evento_id', ids) .in('agenda_evento_id', ids)
.in('status', ['paid', 'pending', 'overdue']) .in('status', ['paid', 'pending', 'overdue'])
@@ -315,8 +311,7 @@ export function useAgendaEventLifecycle({
const evId = props.eventRow?.id; const evId = props.eventRow?.id;
if (!evId) return; if (!evId) return;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method') .select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId) .eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue']) .in('status', ['pending', 'paid', 'overdue'])
@@ -341,8 +336,7 @@ export function useAgendaEventLifecycle({
// Só faz sentido pra sessão de série // Só faz sentido pra sessão de série
if (!patientId || !ruleId) return; if (!patientId || !ruleId) return;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.select('id, type, total_sessions, sessions_used, package_price, charging_style, status, active_from') .select('id, type, total_sessions, sessions_used, package_price, charging_style, status, active_from')
.eq('patient_id', patientId) .eq('patient_id', patientId)
.eq('type', 'package') .eq('type', 'package')
@@ -477,7 +471,7 @@ export function useAgendaEventLifecycle({
sendingReminder.value = true; sendingReminder.value = true;
try { try {
const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', { const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', {
body: { event_id: composer.form.value.id } body: { event_id: composer.form.value.id, tenant_id: props.tenantId }
}); });
if (error || !data?.ok) { if (error || !data?.ok) {
const err = data?.error || error?.message || 'unknown_error'; const err = data?.error || error?.message || 'unknown_error';
@@ -522,8 +516,7 @@ export function useAgendaEventLifecycle({
if (serieValorMode) serieValorMode.value = 'multiplicar'; if (serieValorMode) serieValorMode.value = 'multiplicar';
if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) { if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) {
supabase tenantDb().from('patients')
.from('patients')
.select('id, nome_completo') .select('id, nome_completo')
.eq('id', composer.form.value.paciente_id) .eq('id', composer.form.value.paciente_id)
.maybeSingle() .maybeSingle()
@@ -602,8 +595,7 @@ export function useAgendaEventLifecycle({
const d = new Date(composer.form.value.dia); const d = new Date(composer.form.value.dia);
const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const { data } = await supabase const { data } = await supabase.from('agendador_solicitacoes')
.from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, paciente_email') .select('id, paciente_nome, paciente_sobrenome, paciente_email')
.eq('owner_id', props.ownerId) .eq('owner_id', props.ownerId)
.eq('status', 'pendente') .eq('status', 'pendente')
@@ -625,8 +617,7 @@ export function useAgendaEventLifecycle({
const dow = new Date(dia).getDay(); const dow = new Date(dia).getDay();
loadingOnlineSlots.value = true; loadingOnlineSlots.value = true;
try { try {
const { data } = await supabase const { data } = await tenantDb().from('agenda_online_slots')
.from('agenda_online_slots')
.select('time') .select('time')
.eq('owner_id', props.ownerId) .eq('owner_id', props.ownerId)
.eq('weekday', dow) .eq('weekday', dow)
@@ -38,6 +38,7 @@
*/ */
import { ref, watch, nextTick } from 'vue'; import { ref, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { calcFinalPrice } from './agendaEventHelpers'; import { calcFinalPrice } from './agendaEventHelpers';
export function useAgendaEventPickerBilling({ export function useAgendaEventPickerBilling({
@@ -254,13 +255,11 @@ export function useAgendaEventPickerBilling({
pacientesError.value = ''; pacientesError.value = '';
pacientesLoading.value = true; pacientesLoading.value = true;
let q = supabase let q = tenantDb().from('patients')
.from('patients') .select('id,nome_completo,email_principal,telefone,status,avatar_url,responsible_member_id,created_at')
.select('id,nome_completo,email_principal,telefone,status,avatar_url,tenant_id,responsible_member_id,created_at')
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(500); .limit(500);
if (props.tenantId) q = q.eq('tenant_id', props.tenantId);
if (props.restrictPatientsToOwner && props.patientScopeOwnerId) { if (props.restrictPatientsToOwner && props.patientScopeOwnerId) {
q = q.eq('responsible_member_id', props.patientScopeOwnerId); q = q.eq('responsible_member_id', props.patientScopeOwnerId);
} }
@@ -27,6 +27,7 @@
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
// Shape interno de CommitmentItem: // Shape interno de CommitmentItem:
// { // {
// service_id: uuid, // service_id: uuid,
@@ -56,7 +57,7 @@ export function useCommitmentServices() {
async function loadItems(eventId) { async function loadItems(eventId) {
if (!eventId) return []; if (!eventId) return [];
const { data, error } = await supabase.from('commitment_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('commitment_id', eventId).order('created_at', { ascending: true }); const { data, error } = await tenantDb().from('commitment_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('commitment_id', eventId).order('created_at', { ascending: true });
if (error) throw error; if (error) throw error;
return (data || []).map(_mapRow); return (data || []).map(_mapRow);
@@ -73,7 +74,7 @@ export function useCommitmentServices() {
if (!eventId) throw new Error('eventId é obrigatório para salvar commitment_services.'); if (!eventId) throw new Error('eventId é obrigatório para salvar commitment_services.');
// 1. Remove itens existentes deste evento // 1. Remove itens existentes deste evento
const { error: deleteError } = await supabase.from('commitment_services').delete().eq('commitment_id', eventId); const { error: deleteError } = await tenantDb().from('commitment_services').delete().eq('commitment_id', eventId);
if (deleteError) throw deleteError; if (deleteError) throw deleteError;
@@ -89,14 +90,14 @@ export function useCommitmentServices() {
final_price: item.final_price final_price: item.final_price
})); }));
const { error: insertError } = await supabase.from('commitment_services').insert(rows); const { error: insertError } = await tenantDb().from('commitment_services').insert(rows);
if (insertError) throw insertError; if (insertError) throw insertError;
} }
// 3. Marca a ocorrência como customizada (impede sobrescrita por edições do raiz) // 3. Marca a ocorrência como customizada (impede sobrescrita por edições do raiz)
if (markCustomized) { if (markCustomized) {
const { error: updateError } = await supabase.from('agenda_eventos').update({ services_customized: true }).eq('id', eventId); const { error: updateError } = await tenantDb().from('agenda_eventos').update({ services_customized: true }).eq('id', eventId);
if (updateError) throw updateError; if (updateError) throw updateError;
} }
@@ -107,7 +108,7 @@ export function useCommitmentServices() {
async function loadRuleItems(ruleId) { async function loadRuleItems(ruleId) {
if (!ruleId) return []; if (!ruleId) return [];
const { data, error } = await supabase.from('recurrence_rule_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('rule_id', ruleId).order('created_at', { ascending: true }); const { data, error } = await tenantDb().from('recurrence_rule_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('rule_id', ruleId).order('created_at', { ascending: true });
if (error) throw error; if (error) throw error;
return (data || []).map(_mapRow); return (data || []).map(_mapRow);
@@ -120,7 +121,7 @@ export function useCommitmentServices() {
async function saveRuleItems(ruleId, items) { async function saveRuleItems(ruleId, items) {
if (!ruleId) throw new Error('ruleId é obrigatório para salvar recurrence_rule_services.'); if (!ruleId) throw new Error('ruleId é obrigatório para salvar recurrence_rule_services.');
const { error: deleteError } = await supabase.from('recurrence_rule_services').delete().eq('rule_id', ruleId); const { error: deleteError } = await tenantDb().from('recurrence_rule_services').delete().eq('rule_id', ruleId);
if (deleteError) throw deleteError; if (deleteError) throw deleteError;
@@ -136,7 +137,7 @@ export function useCommitmentServices() {
final_price: item.final_price final_price: item.final_price
})); }));
const { error: insertError } = await supabase.from('recurrence_rule_services').insert(rows); const { error: insertError } = await tenantDb().from('recurrence_rule_services').insert(rows);
if (insertError) throw insertError; if (insertError) throw insertError;
} }
@@ -171,7 +172,7 @@ export function useCommitmentServices() {
if (!ruleId) return; if (!ruleId) return;
// Busca IDs das ocorrências materializadas elegíveis // Busca IDs das ocorrências materializadas elegíveis
let q = supabase.from('agenda_eventos').select('id').eq('recurrence_id', ruleId); let q = tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', ruleId);
if (!ignoreCustomized) { if (!ignoreCustomized) {
q = q.eq('services_customized', false); q = q.eq('services_customized', false);
@@ -189,8 +190,7 @@ export function useCommitmentServices() {
// em batch evita N round-trips. Status considerados imutáveis: pending, // em batch evita N round-trips. Status considerados imutáveis: pending,
// paid, overdue. cancelled é ok propagar (record foi descartado). // paid, overdue. cancelled é ok propagar (record foi descartado).
const eventIds = events.map((e) => e.id); const eventIds = events.map((e) => e.id);
const { data: lockedEvents, error: frErr } = await supabase const { data: lockedEvents, error: frErr } = await tenantDb().from('financial_records')
.from('financial_records')
.select('agenda_evento_id') .select('agenda_evento_id')
.in('agenda_evento_id', eventIds) .in('agenda_evento_id', eventIds)
.in('status', ['pending', 'paid', 'overdue']); .in('status', ['pending', 'paid', 'overdue']);
@@ -202,7 +202,7 @@ export function useCommitmentServices() {
// Para cada evento elegível: delete + insert (padrão idempotente) // Para cada evento elegível: delete + insert (padrão idempotente)
for (const ev of eligibleEvents) { for (const ev of eligibleEvents) {
const { error: delErr } = await supabase.from('commitment_services').delete().eq('commitment_id', ev.id); const { error: delErr } = await tenantDb().from('commitment_services').delete().eq('commitment_id', ev.id);
if (delErr) throw delErr; if (delErr) throw delErr;
if (items?.length) { if (items?.length) {
@@ -215,7 +215,7 @@ export function useCommitmentServices() {
discount_flat: item.discount_flat ?? 0, discount_flat: item.discount_flat ?? 0,
final_price: item.final_price final_price: item.final_price
})); }));
const { error: insErr } = await supabase.from('commitment_services').insert(rows); const { error: insErr } = await tenantDb().from('commitment_services').insert(rows);
if (insErr) throw insErr; if (insErr) throw insErr;
} }
} }
@@ -17,6 +17,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useDeterminedCommitments(tenantIdRef) { export function useDeterminedCommitments(tenantIdRef) {
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
@@ -39,10 +40,9 @@ export function useDeterminedCommitments(tenantIdRef) {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('determined_commitments')
.from('determined_commitments') .select('id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)') // ✅ SOMENTE tenant corrente
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
.eq('active', true) .eq('active', true)
.order('is_native', { ascending: false }) .order('is_native', { ascending: false })
.order('name', { ascending: true }); .order('name', { ascending: true });
@@ -28,6 +28,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useFinancialExceptions() { export function useFinancialExceptions() {
const exceptions = ref([]); const exceptions = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -39,7 +40,7 @@ export function useFinancialExceptions() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const { data, error: err } = await supabase.from('financial_exceptions').select('*').or(`owner_id.eq.${ownerId},owner_id.is.null`).order('exception_type', { ascending: true }).order('created_at', { ascending: true }); const { data, error: err } = await tenantDb().from('financial_exceptions').select('*').or(`owner_id.eq.${ownerId},owner_id.is.null`).order('exception_type', { ascending: true }).order('created_at', { ascending: true });
if (err) throw err; if (err) throw err;
exceptions.value = data || []; exceptions.value = data || [];
@@ -60,8 +61,7 @@ export function useFinancialExceptions() {
error.value = ''; error.value = '';
try { try {
if (payload.id) { if (payload.id) {
const { error: err } = await supabase const { error: err } = await tenantDb().from('financial_exceptions')
.from('financial_exceptions')
.update({ .update({
charge_mode: payload.charge_mode, charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null, charge_value: payload.charge_value ?? null,
@@ -72,9 +72,8 @@ export function useFinancialExceptions() {
.eq('id', payload.id); .eq('id', payload.id);
if (err) throw err; if (err) throw err;
} else { } else {
const { error: err } = await supabase.from('financial_exceptions').insert({ const { error: err } = await tenantDb().from('financial_exceptions').insert({
owner_id: payload.owner_id, owner_id: payload.owner_id,
tenant_id: payload.tenant_id ?? null,
exception_type: payload.exception_type, exception_type: payload.exception_type,
charge_mode: payload.charge_mode, charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null, charge_value: payload.charge_value ?? null,
@@ -96,7 +95,7 @@ export function useFinancialExceptions() {
async function remove(id) { async function remove(id) {
error.value = ''; error.value = '';
try { try {
const { error: err } = await supabase.from('financial_exceptions').delete().eq('id', id); const { error: err } = await tenantDb().from('financial_exceptions').delete().eq('id', id);
if (err) throw err; if (err) throw err;
exceptions.value = exceptions.value.filter((e) => e.id !== id); exceptions.value = exceptions.value.filter((e) => e.id !== id);
} catch (e) { } catch (e) {
@@ -30,6 +30,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useInsurancePlans() { export function useInsurancePlans() {
const plans = ref([]); const plans = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -40,8 +41,7 @@ export function useInsurancePlans() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('insurance_plans')
.from('insurance_plans')
.select( .select(
` `
*, *,
@@ -66,8 +66,7 @@ export function useInsurancePlans() {
error.value = null; error.value = null;
try { try {
if (payload.id) { if (payload.id) {
const { error: err } = await supabase const { error: err } = await tenantDb().from('insurance_plans')
.from('insurance_plans')
.update({ .update({
name: payload.name, name: payload.name,
notes: payload.notes || null, notes: payload.notes || null,
@@ -76,9 +75,8 @@ export function useInsurancePlans() {
.eq('id', payload.id); .eq('id', payload.id);
if (err) throw err; if (err) throw err;
} else { } else {
const { error: err } = await supabase.from('insurance_plans').insert({ const { error: err } = await tenantDb().from('insurance_plans').insert({
owner_id: payload.owner_id, owner_id: payload.owner_id,
tenant_id: payload.tenant_id,
name: payload.name, name: payload.name,
notes: payload.notes || null notes: payload.notes || null
}); });
@@ -93,7 +91,7 @@ export function useInsurancePlans() {
async function toggle(id, active) { async function toggle(id, active) {
error.value = null; error.value = null;
try { try {
const { error: err } = await supabase.from('insurance_plans').update({ active }).eq('id', id); const { error: err } = await tenantDb().from('insurance_plans').update({ active }).eq('id', id);
if (err) throw err; if (err) throw err;
const plan = plans.value.find((p) => p.id === id); const plan = plans.value.find((p) => p.id === id);
if (plan) plan.active = active; if (plan) plan.active = active;
@@ -106,7 +104,7 @@ export function useInsurancePlans() {
async function remove(id) { async function remove(id) {
error.value = null; error.value = null;
try { try {
const { error: err } = await supabase.from('insurance_plans').update({ active: false }).eq('id', id); const { error: err } = await tenantDb().from('insurance_plans').update({ active: false }).eq('id', id);
if (err) throw err; if (err) throw err;
const plan = plans.value.find((p) => p.id === id); const plan = plans.value.find((p) => p.id === id);
if (plan) plan.active = false; if (plan) plan.active = false;
@@ -120,8 +118,7 @@ export function useInsurancePlans() {
error.value = null; error.value = null;
try { try {
if (payload.id) { if (payload.id) {
const { error: err } = await supabase const { error: err } = await tenantDb().from('insurance_plan_services')
.from('insurance_plan_services')
.update({ .update({
name: payload.name, name: payload.name,
value: payload.value value: payload.value
@@ -129,7 +126,7 @@ export function useInsurancePlans() {
.eq('id', payload.id); .eq('id', payload.id);
if (err) throw err; if (err) throw err;
} else { } else {
const { error: err } = await supabase.from('insurance_plan_services').insert({ const { error: err } = await tenantDb().from('insurance_plan_services').insert({
insurance_plan_id: payload.insurance_plan_id, insurance_plan_id: payload.insurance_plan_id,
name: payload.name, name: payload.name,
value: payload.value value: payload.value
@@ -145,7 +142,7 @@ export function useInsurancePlans() {
async function togglePlanService(id, active) { async function togglePlanService(id, active) {
error.value = null; error.value = null;
try { try {
const { error: err } = await supabase.from('insurance_plan_services').update({ active }).eq('id', id); const { error: err } = await tenantDb().from('insurance_plan_services').update({ active }).eq('id', id);
if (err) throw err; if (err) throw err;
} catch (e) { } catch (e) {
error.value = e?.message || 'Erro ao atualizar procedimento'; error.value = e?.message || 'Erro ao atualizar procedimento';
@@ -156,7 +153,7 @@ export function useInsurancePlans() {
async function removeDefinitivo(id) { async function removeDefinitivo(id) {
error.value = null; error.value = null;
try { try {
const { error: err } = await supabase.from('insurance_plans').delete().eq('id', id); const { error: err } = await tenantDb().from('insurance_plans').delete().eq('id', id);
if (err) throw err; if (err) throw err;
plans.value = plans.value.filter((p) => p.id !== id); plans.value = plans.value.filter((p) => p.id !== id);
} catch (e) { } catch (e) {
@@ -168,7 +165,7 @@ export function useInsurancePlans() {
async function removePlanService(id) { async function removePlanService(id) {
error.value = null; error.value = null;
try { try {
const { error: err } = await supabase.from('insurance_plan_services').delete().eq('id', id); const { error: err } = await tenantDb().from('insurance_plan_services').delete().eq('id', id);
if (err) throw err; if (err) throw err;
} catch (e) { } catch (e) {
error.value = e?.message || 'Erro ao remover procedimento'; error.value = e?.message || 'Erro ao remover procedimento';
@@ -29,6 +29,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function usePatientDiscounts() { export function usePatientDiscounts() {
const discounts = ref([]); const discounts = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -40,7 +41,7 @@ export function usePatientDiscounts() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const { data, error: err } = await supabase.from('patient_discounts').select('*').eq('owner_id', ownerId).order('created_at', { ascending: false }); const { data, error: err } = await tenantDb().from('patient_discounts').select('*').eq('owner_id', ownerId).order('created_at', { ascending: false });
if (err) throw err; if (err) throw err;
discounts.value = data || []; discounts.value = data || [];
@@ -53,17 +54,19 @@ export function usePatientDiscounts() {
} }
// ── Criar ou atualizar um desconto ─────────────────────────────────── // ── Criar ou atualizar um desconto ───────────────────────────────────
// payload deve conter: { owner_id, tenant_id, patient_id, discount_pct, discount_flat, ... } // payload deve conter: { owner_id, patient_id, discount_pct, discount_flat, ... }
// Se payload.id estiver presente, faz UPDATE; caso contrário, INSERT. // Se payload.id estiver presente, faz UPDATE; caso contrário, INSERT.
async function save(payload) { async function save(payload) {
error.value = ''; error.value = '';
try { try {
if (payload.id) { if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload; const { id, owner_id, tenant_id, ...fields } = payload;
const { error: err } = await supabase.from('patient_discounts').update(fields).eq('id', id).eq('owner_id', owner_id); const { error: err } = await tenantDb().from('patient_discounts').update(fields).eq('id', id).eq('owner_id', owner_id);
if (err) throw err; if (err) throw err;
} else { } else {
const { error: err } = await supabase.from('patient_discounts').insert(payload); // eslint-disable-next-line no-unused-vars
const { tenant_id: _dropTenantId, ...insertFields } = payload;
const { error: err } = await tenantDb().from('patient_discounts').insert(insertFields);
if (err) throw err; if (err) throw err;
} }
} catch (e) { } catch (e) {
@@ -76,7 +79,7 @@ export function usePatientDiscounts() {
async function remove(id) { async function remove(id) {
error.value = ''; error.value = '';
try { try {
const { error: err } = await supabase.from('patient_discounts').update({ active: false }).eq('id', id); const { error: err } = await tenantDb().from('patient_discounts').update({ active: false }).eq('id', id);
if (err) throw err; if (err) throw err;
discounts.value = discounts.value.filter((d) => d.id !== id); discounts.value = discounts.value.filter((d) => d.id !== id);
} catch (e) { } catch (e) {
@@ -95,8 +98,7 @@ export function usePatientDiscounts() {
if (!ownerId || !patientId) return null; if (!ownerId || !patientId) return null;
try { try {
const now = new Date().toISOString(); const now = new Date().toISOString();
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('patient_discounts')
.from('patient_discounts')
.select('*') .select('*')
.eq('owner_id', ownerId) .eq('owner_id', ownerId)
.eq('patient_id', patientId) .eq('patient_id', patientId)
@@ -23,6 +23,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useProfessionalPricing() { export function useProfessionalPricing() {
const rows = ref([]); // professional_pricing rows const rows = ref([]); // professional_pricing rows
const loading = ref(false); const loading = ref(false);
@@ -34,7 +35,7 @@ export function useProfessionalPricing() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const { data, error: err } = await supabase.from('professional_pricing').select('id, determined_commitment_id, price, notes').eq('owner_id', ownerId); const { data, error: err } = await tenantDb().from('professional_pricing').select('id, determined_commitment_id, price, notes').eq('owner_id', ownerId);
if (err) throw err; if (err) throw err;
rows.value = data || []; rows.value = data || [];
@@ -31,6 +31,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from '@/features/agenda/services/_tenantGuards'; import { assertTenantId } from '@/features/agenda/services/_tenantGuards';
import { logRecurrence, logError, logPerf } from '@/support/supportLogger'; import { logRecurrence, logError, logPerf } from '@/support/supportLogger';
@@ -326,7 +327,6 @@ function buildOccurrence(rule, date, originalIso, exception) {
owner_id: rule.owner_id, owner_id: rule.owner_id,
therapist_id: rule.therapist_id, therapist_id: rule.therapist_id,
terapeuta_id: rule.therapist_id, terapeuta_id: rule.therapist_id,
tenant_id: rule.tenant_id,
// nome do paciente — injetado pelo loadAndExpand via _patient // nome do paciente — injetado pelo loadAndExpand via _patient
paciente_nome: rule._patient?.nome_completo ?? null, paciente_nome: rule._patient?.nome_completo ?? null,
@@ -452,12 +452,7 @@ export function useRecurrence() {
// Busca regras sem end_date (abertas) + regras com end_date >= rangeStart // Busca regras sem end_date (abertas) + regras com end_date >= rangeStart
// Dois selects separados evitam problemas com .or() + .is.null no Supabase JS // Dois selects separados evitam problemas com .or() + .is.null no Supabase JS
const baseQuery = () => { const baseQuery = () => {
let q = supabase.from('recurrence_rules').select('*').eq('owner_id', ownerId).eq('status', 'ativo').lte('start_date', endISO).order('start_date', { ascending: true }); return tenantDb().from('recurrence_rules').select('*').eq('owner_id', ownerId).eq('status', 'ativo').lte('start_date', endISO).order('start_date', { ascending: true });
// Filtra por tenant quando disponível — defesa em profundidade
if (tenantId && tenantId !== 'null' && tenantId !== 'undefined') {
q = q.eq('tenant_id', tenantId);
}
return q;
}; };
const [resOpen, resWithEnd] = await Promise.all([baseQuery().is('end_date', null), baseQuery().gte('end_date', startISO).not('end_date', 'is', null)]); const [resOpen, resWithEnd] = await Promise.all([baseQuery().is('end_date', null), baseQuery().gte('end_date', startISO).not('end_date', 'is', null)]);
@@ -504,11 +499,11 @@ export function useRecurrence() {
const endISO = toISO(rangeEnd); const endISO = toISO(rangeEnd);
// Query 1 — comportamento original: exceções cujo original_date está no range // Query 1 — comportamento original: exceções cujo original_date está no range
const q1 = supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ids).gte('original_date', startISO).lte('original_date', endISO); const q1 = tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ids).gte('original_date', startISO).lte('original_date', endISO);
// Query 2 — bug fix: remarcações cujo new_date cai neste range // Query 2 — bug fix: remarcações cujo new_date cai neste range
// (original_date pode estar antes ou depois do range) // (original_date pode estar antes ou depois do range)
const q2 = supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ids).eq('type', 'reschedule_session').not('new_date', 'is', null).gte('new_date', startISO).lte('new_date', endISO); const q2 = tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ids).eq('type', 'reschedule_session').not('new_date', 'is', null).gte('new_date', startISO).lte('new_date', endISO);
const [res1, res2] = await Promise.all([q1, q2]); const [res1, res2] = await Promise.all([q1, q2]);
@@ -550,7 +545,7 @@ export function useRecurrence() {
// Busca nomes dos pacientes das regras carregadas // Busca nomes dos pacientes das regras carregadas
const patientIds = [...new Set(rules.value.map((r) => r.patient_id).filter(Boolean))]; const patientIds = [...new Set(rules.value.map((r) => r.patient_id).filter(Boolean))];
if (patientIds.length) { if (patientIds.length) {
const { data: patients } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds); const { data: patients } = await tenantDb().from('patients').select('id, nome_completo, avatar_url').in('id', patientIds);
// injeta nome diretamente na regra para o buildOccurrence usar // injeta nome diretamente na regra para o buildOccurrence usar
const pMap = new Map((patients || []).map((p) => [p.id, p])); const pMap = new Map((patients || []).map((p) => [p.id, p]));
for (const rule of rules.value) { for (const rule of rules.value) {
@@ -579,15 +574,14 @@ export function useRecurrence() {
/** /**
* Cria uma nova regra de recorrência. * Cria uma nova regra de recorrência.
* tenant_id é injetado do tenantStore se não vier no payload (defesa em profundidade). * tenant_id é dropado defensivamente schema-per-tenant não tem essa coluna.
* @param {Object} rule - campos da tabela recurrence_rules * @param {Object} rule - campos da tabela recurrence_rules
* @returns {Object} regra criada * @returns {Object} regra criada
*/ */
async function createRule(rule) { async function createRule(rule) {
const tenantId = currentTenantId();
logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type }); logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type });
const safeRule = { ...rule, tenant_id: rule?.tenant_id || tenantId }; const { tenant_id: _dropTenantId, ...safeRule } = rule || {};
const { data, error: err } = await supabase.from('recurrence_rules').insert([safeRule]).select('*').single(); const { data, error: err } = await tenantDb().from('recurrence_rules').insert([safeRule]).select('*').single();
if (err) { if (err) {
logError('useRecurrence', 'createRule ERRO', err); logError('useRecurrence', 'createRule ERRO', err);
throw err; throw err;
@@ -598,15 +592,14 @@ export function useRecurrence() {
/** /**
* Atualiza a regra toda (editar todos). * Atualiza a regra toda (editar todos).
* Filtro adicional por tenant_id defesa em profundidade (RLS cobre, mas reforçamos). * Isolamento multi-tenant garantido pelo schema do tenant (tenantDb).
*/ */
async function updateRule(id, patch) { async function updateRule(id, patch) {
const tenantId = currentTenantId(); const tenantId = currentTenantId();
const { data, error: err } = await supabase const { data, error: err } = await tenantDb().from('recurrence_rules')
.from('recurrence_rules')
.update({ ...patch, updated_at: new Date().toISOString() }) .update({ ...patch, updated_at: new Date().toISOString() })
.eq('id', id) .eq('id', id)
.eq('tenant_id', tenantId)
.select('*') .select('*')
.single(); .single();
if (err) throw err; if (err) throw err;
@@ -614,15 +607,14 @@ export function useRecurrence() {
} }
/** /**
* Cancela a série inteira (filtro por tenant_id defesa em profundidade). * Cancela a série inteira.
*/ */
async function cancelRule(id) { async function cancelRule(id) {
const tenantId = currentTenantId(); const tenantId = currentTenantId();
const { error: err } = await supabase const { error: err } = await tenantDb().from('recurrence_rules')
.from('recurrence_rules')
.update({ status: 'cancelado', updated_at: new Date().toISOString() }) .update({ status: 'cancelado', updated_at: new Date().toISOString() })
.eq('id', id) .eq('id', id)
.eq('tenant_id', tenantId); ;
if (err) throw err; if (err) throw err;
} }
@@ -631,7 +623,9 @@ export function useRecurrence() {
* Retorna o id da nova regra criada * Retorna o id da nova regra criada
*/ */
async function splitRuleAt(id, fromDateISO) { async function splitRuleAt(id, fromDateISO) {
const tenantId = currentTenantId();
const { data, error: err } = await supabase.rpc('split_recurrence_at', { const { data, error: err } = await supabase.rpc('split_recurrence_at', {
p_tenant_id: tenantId,
p_recurrence_id: id, p_recurrence_id: id,
p_from_date: fromDateISO p_from_date: fromDateISO
}); });
@@ -643,7 +637,9 @@ export function useRecurrence() {
* Cancela a série a partir de uma data * Cancela a série a partir de uma data
*/ */
async function cancelRuleFrom(id, fromDateISO) { async function cancelRuleFrom(id, fromDateISO) {
const tenantId = currentTenantId();
const { error: err } = await supabase.rpc('cancel_recurrence_from', { const { error: err } = await supabase.rpc('cancel_recurrence_from', {
p_tenant_id: tenantId,
p_recurrence_id: id, p_recurrence_id: id,
p_from_date: fromDateISO p_from_date: fromDateISO
}); });
@@ -654,13 +650,11 @@ export function useRecurrence() {
/** /**
* Cria ou atualiza uma exceção para uma ocorrência específica. * Cria ou atualiza uma exceção para uma ocorrência específica.
* tenant_id é injetado do tenantStore se não vier no payload. * tenant_id é dropado defensivamente schema-per-tenant não tem essa coluna.
*/ */
async function upsertException(ex) { async function upsertException(ex) {
const tenantId = currentTenantId(); const { tenant_id: _dropTenantId, ...safeEx } = ex || {};
const safeEx = { ...ex, tenant_id: ex?.tenant_id || tenantId }; const { data, error: err } = await tenantDb().from('recurrence_exceptions')
const { data, error: err } = await supabase
.from('recurrence_exceptions')
.upsert([safeEx], { onConflict: 'recurrence_id,original_date' }) .upsert([safeEx], { onConflict: 'recurrence_id,original_date' })
.select('*') .select('*')
.single(); .single();
@@ -670,16 +664,14 @@ export function useRecurrence() {
/** /**
* Remove uma exceção (restaura a ocorrência ao normal). * Remove uma exceção (restaura a ocorrência ao normal).
* Filtro por tenant_id defesa em profundidade.
*/ */
async function deleteException(recurrenceId, originalDate) { async function deleteException(recurrenceId, originalDate) {
const tenantId = currentTenantId(); const tenantId = currentTenantId();
const { error: err } = await supabase const { error: err } = await tenantDb().from('recurrence_exceptions')
.from('recurrence_exceptions')
.delete() .delete()
.eq('recurrence_id', recurrenceId) .eq('recurrence_id', recurrenceId)
.eq('original_date', originalDate) .eq('original_date', originalDate)
.eq('tenant_id', tenantId); ;
if (err) throw err; if (err) throw err;
} }
@@ -29,6 +29,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useServices() { export function useServices() {
const services = ref([]); const services = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -39,7 +40,7 @@ export function useServices() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const { data, error: err } = await supabase.from('services').select('id, name, description, price, duration_min, active').eq('owner_id', ownerId).order('created_at', { ascending: true }); const { data, error: err } = await tenantDb().from('services').select('id, name, description, price, duration_min, active').eq('owner_id', ownerId).order('created_at', { ascending: true });
if (err) throw err; if (err) throw err;
services.value = data || []; services.value = data || [];
@@ -61,7 +62,7 @@ export function useServices() {
// Nome unico por owner (case-insensitive). No update, // Nome unico por owner (case-insensitive). No update,
// ignora o proprio id pra nao conflitar consigo mesmo // ignora o proprio id pra nao conflitar consigo mesmo
// quando o usuario salva sem mudar o nome. // quando o usuario salva sem mudar o nome.
let dupQuery = supabase.from('services').select('id').eq('owner_id', payload.owner_id).ilike('name', name).limit(1); let dupQuery = tenantDb().from('services').select('id').eq('owner_id', payload.owner_id).ilike('name', name).limit(1);
if (payload.id) dupQuery = dupQuery.neq('id', payload.id); if (payload.id) dupQuery = dupQuery.neq('id', payload.id);
const { data: dups, error: dupErr } = await dupQuery; const { data: dups, error: dupErr } = await dupQuery;
if (dupErr) throw dupErr; if (dupErr) throw dupErr;
@@ -71,10 +72,12 @@ export function useServices() {
if (payload.id) { if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload; const { id, owner_id, tenant_id, ...fields } = payload;
const { error: err } = await supabase.from('services').update(fields).eq('id', id).eq('owner_id', owner_id); const { error: err } = await tenantDb().from('services').update(fields).eq('id', id).eq('owner_id', owner_id);
if (err) throw err; if (err) throw err;
} else { } else {
const { error: err } = await supabase.from('services').insert(payload); // eslint-disable-next-line no-unused-vars
const { tenant_id: _dropTenantId, ...insertFields } = payload;
const { error: err } = await tenantDb().from('services').insert(insertFields);
if (err) throw err; if (err) throw err;
} }
} catch (e) { } catch (e) {
@@ -86,7 +89,7 @@ export function useServices() {
async function toggle(id, active) { async function toggle(id, active) {
error.value = ''; error.value = '';
try { try {
const { error: err } = await supabase.from('services').update({ active }).eq('id', id); const { error: err } = await tenantDb().from('services').update({ active }).eq('id', id);
if (err) throw err; if (err) throw err;
const svc = services.value.find((s) => s.id === id); const svc = services.value.find((s) => s.id === id);
if (svc) svc.active = active; if (svc) svc.active = active;
@@ -99,7 +102,7 @@ export function useServices() {
async function remove(id) { async function remove(id) {
error.value = ''; error.value = '';
try { try {
const { error: err } = await supabase.from('services').delete().eq('id', id); const { error: err } = await tenantDb().from('services').delete().eq('id', id);
if (err) throw err; if (err) throw err;
services.value = services.value.filter((s) => s.id !== id); services.value = services.value.filter((s) => s.id !== id);
} catch (e) { } catch (e) {
+19 -26
View File
@@ -47,6 +47,7 @@ import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettin
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const toast = useToast(); const toast = useToast();
@@ -677,12 +678,11 @@ async function loadMonthSearchRows() {
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString(); const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString();
monthSearchLoading.value = true; monthSearchLoading.value = true;
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select( .select(
'id, owner_id, tenant_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, patients!agenda_eventos_patient_id_fkey(nome_completo)' 'id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, patients!agenda_eventos_patient_id_fkey(nome_completo)'
) )
.eq('tenant_id', tid)
.in('owner_id', ids) .in('owner_id', ids)
.is('mirror_of_event_id', null) .is('mirror_of_event_id', null)
.gte('inicio_em', start) .gte('inicio_em', start)
@@ -915,7 +915,7 @@ async function debugPatientsForColumn(staffUserId) {
console.log('tenant_member_id (mapeado):', memberId); console.log('tenant_member_id (mapeado):', memberId);
try { try {
const { count, error } = await supabase.from('patients').select('id', { count: 'exact', head: true }).eq('tenant_id', tid); const { count, error } = await tenantDb().from('patients').select('id', { count: 'exact', head: true });
if (error) throw error; if (error) throw error;
console.log('patients total no tenant:', count); console.log('patients total no tenant:', count);
} catch (e) { } catch (e) {
@@ -924,7 +924,7 @@ async function debugPatientsForColumn(staffUserId) {
if (memberId && isUuid(memberId)) { if (memberId && isUuid(memberId)) {
try { try {
const { count, error } = await supabase.from('patients').select('id', { count: 'exact', head: true }).eq('tenant_id', tid).eq('responsible_member_id', memberId); const { count, error } = await tenantDb().from('patients').select('id', { count: 'exact', head: true }).eq('responsible_member_id', memberId);
if (error) throw error; if (error) throw error;
console.log('patients por responsible_member_id:', count); console.log('patients por responsible_member_id:', count);
} catch (e) { } catch (e) {
@@ -932,10 +932,9 @@ async function debugPatientsForColumn(staffUserId) {
} }
try { try {
const { data, error } = await supabase const { data, error } = await tenantDb().from('patients')
.from('patients')
.select('id,nome_completo,email_principal,telefone,responsible_member_id,created_at') .select('id,nome_completo,email_principal,telefone,responsible_member_id,created_at')
.eq('tenant_id', tid)
.eq('responsible_member_id', memberId) .eq('responsible_member_id', memberId)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(5); .limit(5);
@@ -1212,8 +1211,7 @@ async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim
if (!is_virtual || !inicio_em) return; if (!is_virtual || !inicio_em) return;
const rid = row.recurrence_id ?? row.serie_id ?? null; const rid = row.recurrence_id ?? row.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10); const rDate = recurrence_date || inicio_em?.slice(0, 10);
const { data: existing } = await supabase const { data: existing } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('id') .select('id')
.eq('recurrence_id', rid) .eq('recurrence_id', rid)
.eq('recurrence_date', rDate) .eq('recurrence_date', rDate)
@@ -1281,9 +1279,8 @@ async function _offerBillingContract(basePayload, recorrencia, tenantId) {
rejectLabel: 'Agora não', rejectLabel: 'Agora não',
accept: async () => { accept: async () => {
try { try {
const { error } = await supabase.from('billing_contracts').insert({ const { error } = await tenantDb().from('billing_contracts').insert({
owner_id: basePayload.owner_id, owner_id: basePayload.owner_id,
tenant_id: tenantId,
patient_id: basePayload.paciente_id, patient_id: basePayload.paciente_id,
type: 'package', type: 'package',
total_sessions: n, total_sessions: n,
@@ -1454,7 +1451,7 @@ async function onDialogSave(arg) {
extra_fields: basePayload.extra_fields ?? null extra_fields: basePayload.extra_fields ?? null
}); });
if (arg.onSaved) { if (arg.onSaved) {
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle(); const { data: existing } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
if (existing?.id) { if (existing?.id) {
eventId = existing.id; eventId = existing.id;
} else { } else {
@@ -1550,8 +1547,7 @@ async function onDialogSave(arg) {
}); });
// Propaga campos não-serviço para sessões já materializadas da série // Propaga campos não-serviço para sessões já materializadas da série
await supabase await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.update({ .update({
modalidade: basePayload.modalidade ?? 'presencial', modalidade: basePayload.modalidade ?? 'presencial',
titulo_custom: basePayload.titulo_custom ?? null, titulo_custom: basePayload.titulo_custom ?? null,
@@ -1599,8 +1595,7 @@ async function onDialogSave(arg) {
}); });
// Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção) // Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção)
await supabase await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.update({ .update({
modalidade: basePayload.modalidade ?? 'presencial', modalidade: basePayload.modalidade ?? 'presencial',
titulo_custom: basePayload.titulo_custom ?? null, titulo_custom: basePayload.titulo_custom ?? null,
@@ -1708,7 +1703,7 @@ async function onDialogDelete(arg) {
if (isVirtual) { if (isVirtual) {
const rDate = row.original_date || row.inicio_em?.slice(0, 10); const rDate = row.original_date || row.inicio_em?.slice(0, 10);
const existing = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle(); const existing = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
if (existing.data?.id) { if (existing.data?.id) {
await updateClinic(existing.data.id, { recurrence_id: null, recurrence_date: null }, { tenantId: tid }); await updateClinic(existing.data.id, { recurrence_id: null, recurrence_date: null }, { tenantId: tid });
@@ -1926,8 +1921,7 @@ async function loadMiniMonthEvents(refDate) {
try { try {
const tid = tenantId.value; const tid = tenantId.value;
// 1. Eventos normais (bolinhas) // 1. Eventos normais (bolinhas)
let evQ = supabase.from('agenda_eventos').select('inicio_em').gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString()); let evQ = tenantDb().from('agenda_eventos').select('inicio_em').gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
if (tid) evQ = evQ.eq('tenant_id', tid);
const { data: evData } = await evQ; const { data: evData } = await evQ;
const evSet = new Set(); const evSet = new Set();
@@ -1961,7 +1955,7 @@ async function loadMiniMonthEvents(refDate) {
const isoStart = `${year}-${pad(month + 1)}-01`; const isoStart = `${year}-${pad(month + 1)}-01`;
const lastDay = new Date(year, month + 1, 0).getDate(); const lastDay = new Date(year, month + 1, 0).getDate();
const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`; const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`;
let blkQ = supabase.from('agenda_bloqueios').select('data_inicio').is('hora_inicio', null).gte('data_inicio', isoStart).lte('data_inicio', isoEnd); let blkQ = tenantDb().from('agenda_bloqueios').select('data_inicio').is('hora_inicio', null).gte('data_inicio', isoStart).lte('data_inicio', isoEnd);
if (clinicOwnerId.value) blkQ = blkQ.eq('owner_id', clinicOwnerId.value); if (clinicOwnerId.value) blkQ = blkQ.eq('owner_id', clinicOwnerId.value);
const { data: blkData } = await blkQ; const { data: blkData } = await blkQ;
miniBlockedDaySet.value = new Set((blkData || []).map((r) => r.data_inicio)); miniBlockedDaySet.value = new Set((blkData || []).map((r) => r.data_inicio));
@@ -2050,10 +2044,9 @@ async function bloquearFeriadoDoAlerta(feriado) {
if (!clinicOwnerId.value || !tenantId.value) return; if (!clinicOwnerId.value || !tenantId.value) return;
feriadosAlertaSalvando.value = feriado.data; feriadosAlertaSalvando.value = feriado.data;
try { try {
const { error } = await supabase.from('agenda_bloqueios').insert([ const { error } = await tenantDb().from('agenda_bloqueios').insert([
{ {
owner_id: clinicOwnerId.value, owner_id: clinicOwnerId.value,
tenant_id: tenantId.value,
tipo: 'bloqueio', tipo: 'bloqueio',
recorrente: false, recorrente: false,
titulo: `Feriado: ${feriado.nome}`, titulo: `Feriado: ${feriado.nome}`,
@@ -2065,7 +2058,7 @@ async function bloquearFeriadoDoAlerta(feriado) {
} }
]); ]);
if (error) throw error; if (error) throw error;
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`); await tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]); feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]); miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
@@ -2088,7 +2081,7 @@ async function desbloquearFeriadoDoAlerta(feriado) {
if (!clinicOwnerId.value) return; if (!clinicOwnerId.value) return;
feriadosAlertaSalvando.value = `unblock_${feriado.data}`; feriadosAlertaSalvando.value = `unblock_${feriado.data}`;
try { try {
const { error } = await supabase.from('agenda_bloqueios').delete().eq('owner_id', clinicOwnerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']); const { error } = await tenantDb().from('agenda_bloqueios').delete().eq('owner_id', clinicOwnerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
if (error) throw error; if (error) throw error;
@@ -18,6 +18,7 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff'; import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
@@ -78,11 +79,10 @@ async function load() {
if (!userId.value) return; if (!userId.value) return;
loading.value = true; loading.value = true;
try { try {
let q = supabase.from('recurrence_rules').select('*').order('start_date', { ascending: false }); let q = tenantDb().from('recurrence_rules').select('*').order('start_date', { ascending: false });
if (isClinic.value) { if (isClinic.value) {
if (!tenantId.value) return; if (!tenantId.value) return;
q = q.eq('tenant_id', tenantId.value);
if (filterOwner.value) q = q.eq('owner_id', filterOwner.value); if (filterOwner.value) q = q.eq('owner_id', filterOwner.value);
} else { } else {
q = q.eq('owner_id', userId.value); q = q.eq('owner_id', userId.value);
@@ -97,7 +97,7 @@ async function load() {
const patientIds = [...new Set(rawRules.map((r) => r.patient_id).filter(Boolean))]; const patientIds = [...new Set(rawRules.map((r) => r.patient_id).filter(Boolean))];
const patientMap = {}; const patientMap = {};
if (patientIds.length) { if (patientIds.length) {
const { data: pts } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds); const { data: pts } = await tenantDb().from('patients').select('id, nome_completo, avatar_url').in('id', patientIds);
for (const p of pts || []) patientMap[p.id] = p; for (const p of pts || []) patientMap[p.id] = p;
} }
for (const r of rawRules) r._patient = patientMap[r.patient_id] || null; for (const r of rawRules) r._patient = patientMap[r.patient_id] || null;
@@ -115,8 +115,8 @@ async function load() {
async function reloadSessions(ruleIds) { async function reloadSessions(ruleIds) {
const [exRes, sessRes] = await Promise.all([ const [exRes, sessRes] = await Promise.all([
supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ruleIds).order('original_date'), tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ruleIds).order('original_date'),
supabase.from('agenda_eventos').select('id, recurrence_id, recurrence_date, status, inicio_em, fim_em').in('recurrence_id', ruleIds).order('inicio_em') tenantDb().from('agenda_eventos').select('id, recurrence_id, recurrence_date, status, inicio_em, fim_em').in('recurrence_id', ruleIds).order('inicio_em')
]); ]);
const exm = {}; const exm = {};
for (const ex of exRes.data || []) { for (const ex of exRes.data || []) {
@@ -254,17 +254,16 @@ const PILL_CLASS = {
async function onPillStatusChange(rule, s, newStatus) { async function onPillStatusChange(rule, s, newStatus) {
try { try {
if (s.real_id) { if (s.real_id) {
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', s.real_id); await tenantDb().from('agenda_eventos').update({ status: newStatus }).eq('id', s.real_id);
} else { } else {
const { data: ex } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rule.id).eq('recurrence_date', s.date).maybeSingle(); const { data: ex } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', rule.id).eq('recurrence_date', s.date).maybeSingle();
if (ex?.id) { if (ex?.id) {
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', ex.id); await tenantDb().from('agenda_eventos').update({ status: newStatus }).eq('id', ex.id);
} else { } else {
await supabase.from('agenda_eventos').insert({ await tenantDb().from('agenda_eventos').insert({
recurrence_id: rule.id, recurrence_id: rule.id,
recurrence_date: s.date, recurrence_date: s.date,
owner_id: rule.owner_id, owner_id: rule.owner_id,
tenant_id: rule.tenant_id,
tipo: 'sessao', tipo: 'sessao',
status: newStatus, status: newStatus,
inicio_em: s.date + 'T' + (rule.start_time || '00:00') + ':00', inicio_em: s.date + 'T' + (rule.start_time || '00:00') + ':00',
@@ -287,7 +286,7 @@ async function onCancelRule(rule) {
const name = rule._patient?.nome_completo || 'paciente'; const name = rule._patient?.nome_completo || 'paciente';
if (!confirm(`Encerrar a série de "${name}"?\n\nSessões futuras deixarão de ser geradas. Sessões passadas já registradas são mantidas.`)) return; if (!confirm(`Encerrar a série de "${name}"?\n\nSessões futuras deixarão de ser geradas. Sessões passadas já registradas são mantidas.`)) return;
try { try {
await supabase.from('recurrence_rules').update({ status: 'cancelado', updated_at: new Date().toISOString() }).eq('id', rule.id); await tenantDb().from('recurrence_rules').update({ status: 'cancelado', updated_at: new Date().toISOString() }).eq('id', rule.id);
toast.add({ severity: 'success', summary: 'Série encerrada', life: 2000 }); toast.add({ severity: 'success', summary: 'Série encerrada', life: 2000 });
await load(); await load();
} catch (e) { } catch (e) {
@@ -297,7 +296,7 @@ async function onCancelRule(rule) {
async function onReactivateRule(rule) { async function onReactivateRule(rule) {
try { try {
await supabase.from('recurrence_rules').update({ status: 'ativo', updated_at: new Date().toISOString() }).eq('id', rule.id); await tenantDb().from('recurrence_rules').update({ status: 'ativo', updated_at: new Date().toISOString() }).eq('id', rule.id);
toast.add({ severity: 'success', summary: 'Série reativada', life: 2000 }); toast.add({ severity: 'success', summary: 'Série reativada', life: 2000 });
await load(); await load();
} catch (e) { } catch (e) {
@@ -20,6 +20,7 @@ import { useRouter, useRoute } from 'vue-router';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
@@ -606,8 +607,7 @@ async function loadMonthSearchRows() {
try { try {
// 1. Eventos reais do banco inclui recurrence_id/recurrence_date para // 1. Eventos reais do banco inclui recurrence_id/recurrence_date para
// mergeWithStoredSessions deduplicar sessões materializadas de séries. // mergeWithStoredSessions deduplicar sessões materializadas de séries.
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select( .select(
'id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)' 'id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)'
) )
@@ -1031,7 +1031,7 @@ const desativadoFcRef = ref(null);
async function loadDesativados() { async function loadDesativados() {
if (!ownerId.value) return; if (!ownerId.value) return;
try { try {
const { data: pats, error: pErr } = await supabase.from('patients').select('id, nome_completo, status').eq('owner_id', ownerId.value).in('status', ['Inativo', 'Arquivado']); const { data: pats, error: pErr } = await tenantDb().from('patients').select('id, nome_completo, status').eq('owner_id', ownerId.value).in('status', ['Inativo', 'Arquivado']);
if (pErr) { if (pErr) {
console.warn('[loadDesativados] patients error:', pErr); console.warn('[loadDesativados] patients error:', pErr);
@@ -1044,9 +1044,8 @@ async function loadDesativados() {
} }
const patIds = pats.map((p) => p.id); const patIds = pats.map((p) => p.id);
const sessQ = supabase.from('agenda_eventos').select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id').in('patient_id', patIds).order('inicio_em', { ascending: true }); const sessQ = tenantDb().from('agenda_eventos').select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id').in('patient_id', patIds).order('inicio_em', { ascending: true });
if (ownerId.value) sessQ.eq('owner_id', ownerId.value); if (ownerId.value) sessQ.eq('owner_id', ownerId.value);
if (clinicTenantId.value) sessQ.eq('tenant_id', clinicTenantId.value);
const { data: sessions, error: sErr } = await sessQ; const { data: sessions, error: sErr } = await sessQ;
if (sErr) { if (sErr) {
@@ -1278,7 +1277,7 @@ async function loadMiniMonthEvents(refDate) {
try { try {
// 1. Eventos reais (agenda_eventos) // 1. Eventos reais (agenda_eventos)
const { data: evData } = await supabase.from('agenda_eventos').select('inicio_em').eq('owner_id', ownerId.value).gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString()); const { data: evData } = await tenantDb().from('agenda_eventos').select('inicio_em').eq('owner_id', ownerId.value).gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
const evSet = new Set(); const evSet = new Set();
for (const r of evData || []) { for (const r of evData || []) {
@@ -1303,8 +1302,7 @@ async function loadMiniMonthEvents(refDate) {
const isoStart = `${year}-${pad(month + 1)}-01`; const isoStart = `${year}-${pad(month + 1)}-01`;
const lastDay = new Date(year, month + 1, 0).getDate(); const lastDay = new Date(year, month + 1, 0).getDate();
const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`; const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`;
const { data: blkData } = await supabase const { data: blkData } = await tenantDb().from('agenda_bloqueios')
.from('agenda_bloqueios')
.select('data_inicio') .select('data_inicio')
.eq('owner_id', ownerId.value || '') .eq('owner_id', ownerId.value || '')
.is('hora_inicio', null) .is('hora_inicio', null)
@@ -1398,10 +1396,9 @@ async function bloquearFeriadoDoAlerta(feriado) {
if (!ownerId.value || !clinicTenantId.value) return; if (!ownerId.value || !clinicTenantId.value) return;
feriadosAlertaSalvando.value = feriado.data; feriadosAlertaSalvando.value = feriado.data;
try { try {
const { error } = await supabase.from('agenda_bloqueios').insert([ const { error } = await tenantDb().from('agenda_bloqueios').insert([
{ {
owner_id: ownerId.value, owner_id: ownerId.value,
tenant_id: clinicTenantId.value,
tipo: 'bloqueio', tipo: 'bloqueio',
recorrente: false, recorrente: false,
titulo: `Feriado: ${feriado.nome}`, titulo: `Feriado: ${feriado.nome}`,
@@ -1413,7 +1410,7 @@ async function bloquearFeriadoDoAlerta(feriado) {
} }
]); ]);
if (error) throw error; if (error) throw error;
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`); await tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]); feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]); miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
@@ -1438,7 +1435,7 @@ async function desbloquearFeriadoDoAlerta(feriado) {
if (!ownerId.value) return; if (!ownerId.value) return;
feriadosAlertaSalvando.value = `unblock_${feriado.data}`; feriadosAlertaSalvando.value = `unblock_${feriado.data}`;
try { try {
const { error } = await supabase.from('agenda_bloqueios').delete().eq('owner_id', ownerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']); const { error } = await tenantDb().from('agenda_bloqueios').delete().eq('owner_id', ownerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
if (error) throw error; if (error) throw error;
@@ -1736,8 +1733,7 @@ async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim
if (!is_virtual || !inicio_em) return; if (!is_virtual || !inicio_em) return;
const rid = row.recurrence_id ?? row.serie_id ?? null; const rid = row.recurrence_id ?? row.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10); const rDate = recurrence_date || inicio_em?.slice(0, 10);
const { data: existing } = await supabase const { data: existing } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('id') .select('id')
.eq('recurrence_id', rid) .eq('recurrence_id', rid)
.eq('recurrence_date', rDate) .eq('recurrence_date', rDate)
@@ -1807,9 +1803,8 @@ async function _offerBillingContract(normalized, recorrencia, tenantId) {
rejectLabel: 'Agora não', rejectLabel: 'Agora não',
accept: async () => { accept: async () => {
try { try {
const { error } = await supabase.from('billing_contracts').insert({ const { error } = await tenantDb().from('billing_contracts').insert({
owner_id: normalized.owner_id, owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id, patient_id: normalized.paciente_id,
type: 'package', type: 'package',
total_sessions: n, total_sessions: n,
@@ -1933,12 +1928,11 @@ async function onDialogSave(arg) {
if (recorrencia?.conflitos?.length && createdRule?.id) { if (recorrencia?.conflitos?.length && createdRule?.id) {
const exceptions = recorrencia.conflitos.map((c) => ({ const exceptions = recorrencia.conflitos.map((c) => ({
recurrence_id: createdRule.id, recurrence_id: createdRule.id,
tenant_id: clinicId,
original_date: c.date, original_date: c.date,
type: c.conflict.type === 'feriado' ? 'holiday_block' : c.conflict.type === 'bloqueado' ? 'cancel_session' : c.conflict.type === 'folga' ? 'cancel_session' : 'cancel_session', type: c.conflict.type === 'feriado' ? 'holiday_block' : c.conflict.type === 'bloqueado' ? 'cancel_session' : c.conflict.type === 'folga' ? 'cancel_session' : 'cancel_session',
reason: c.conflict.label reason: c.conflict.label
})); }));
const { error: exErr } = await supabase.from('recurrence_exceptions').insert(exceptions); const { error: exErr } = await tenantDb().from('recurrence_exceptions').insert(exceptions);
if (exErr) logError('AgendaTerapeutaPage', 'onDialogSave: erro ao inserir exceptions', exErr); if (exErr) logError('AgendaTerapeutaPage', 'onDialogSave: erro ao inserir exceptions', exErr);
} }
@@ -1998,7 +1992,7 @@ async function onDialogSave(arg) {
extra_fields: normalized.extra_fields ?? null extra_fields: normalized.extra_fields ?? null
}); });
if (arg.onSaved) { if (arg.onSaved) {
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle(); const { data: existing } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
if (existing?.id) { if (existing?.id) {
eventId = existing.id; eventId = existing.id;
} else { } else {
@@ -2091,8 +2085,7 @@ async function onDialogSave(arg) {
}); });
// Propaga campos não-serviço para sessões já materializadas da série // Propaga campos não-serviço para sessões já materializadas da série
await supabase await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.update({ .update({
modalidade: normalized.modalidade ?? 'presencial', modalidade: normalized.modalidade ?? 'presencial',
titulo_custom: normalized.titulo_custom ?? null, titulo_custom: normalized.titulo_custom ?? null,
@@ -2140,8 +2133,7 @@ async function onDialogSave(arg) {
}); });
// Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção) // Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção)
await supabase await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.update({ .update({
modalidade: normalized.modalidade ?? 'presencial', modalidade: normalized.modalidade ?? 'presencial',
titulo_custom: normalized.titulo_custom ?? null, titulo_custom: normalized.titulo_custom ?? null,
@@ -2205,8 +2197,7 @@ async function onDialogSave(arg) {
let detail = 'Já existe um compromisso nesse horário. Verifique a agenda e escolha outro horário.'; let detail = 'Já existe um compromisso nesse horário. Verifique a agenda e escolha outro horário.';
try { try {
if (normalized?.inicio_em && normalized?.fim_em && normalized?.owner_id) { if (normalized?.inicio_em && normalized?.fim_em && normalized?.owner_id) {
const { data: conflicting } = await supabase const { data: conflicting } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('titulo, inicio_em, fim_em') .select('titulo, inicio_em, fim_em')
.eq('owner_id', normalized.owner_id) .eq('owner_id', normalized.owner_id)
.lt('inicio_em', normalized.fim_em) .lt('inicio_em', normalized.fim_em)
@@ -2276,7 +2267,7 @@ async function onDialogDelete(arg) {
if (isVirtual) { if (isVirtual) {
// Ocorrência virtual: materializa como evento avulso (sem recurrence_id) // Ocorrência virtual: materializa como evento avulso (sem recurrence_id)
const rDate = row.original_date || row.inicio_em?.slice(0, 10); const rDate = row.original_date || row.inicio_em?.slice(0, 10);
const existing = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle(); const existing = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
if (existing.data?.id) { if (existing.data?.id) {
await update(existing.data.id, { recurrence_id: null, recurrence_date: null }); await update(existing.data.id, { recurrence_id: null, recurrence_date: null });
@@ -19,6 +19,7 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'; import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
@@ -241,17 +242,15 @@ async function converterEmSessao(s) {
async function encontrarOuCriarPaciente(s) { async function encontrarOuCriarPaciente(s) {
const email = s.paciente_email?.toLowerCase().trim(); const email = s.paciente_email?.toLowerCase().trim();
if (email) { if (email) {
const { data: found } = await supabase.from('patients').select('id').eq('tenant_id', tenantId.value).ilike('email_principal', email).maybeSingle(); const { data: found } = await tenantDb().from('patients').select('id').ilike('email_principal', email).maybeSingle();
if (found?.id) return found.id; if (found?.id) return found.id;
} }
const { data: memberData, error: memberErr } = await supabase.from('tenant_members').select('id').eq('tenant_id', tenantId.value).eq('user_id', ownerId.value).eq('status', 'active').maybeSingle(); const { data: memberData, error: memberErr } = await supabase.from('tenant_members').select('id').eq('tenant_id', tenantId.value).eq('user_id', ownerId.value).eq('status', 'active').maybeSingle();
if (memberErr || !memberData?.id) throw new Error('Membro ativo não encontrado para criação do paciente.'); if (memberErr || !memberData?.id) throw new Error('Membro ativo não encontrado para criação do paciente.');
const scope = isClinic.value ? 'clinic' : 'therapist'; const scope = isClinic.value ? 'clinic' : 'therapist';
const nomeCompleto_ = [s.paciente_nome, s.paciente_sobrenome].filter(Boolean).join(' '); const nomeCompleto_ = [s.paciente_nome, s.paciente_sobrenome].filter(Boolean).join(' ');
const { data: novo, error: criErr } = await supabase const { data: novo, error: criErr } = await tenantDb().from('patients')
.from('patients')
.insert({ .insert({
tenant_id: tenantId.value,
responsible_member_id: memberData.id, responsible_member_id: memberData.id,
owner_id: ownerId.value, owner_id: ownerId.value,
nome_completo: nomeCompleto_, nome_completo: nomeCompleto_,
@@ -26,6 +26,7 @@ import Menu from 'primevue/menu';
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue'; import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast(); const toast = useToast();
@@ -161,10 +162,9 @@ async function fetchAll() {
} }
loading.value = true; loading.value = true;
try { try {
const { data: cData, error: cErr } = await supabase const { data: cData, error: cErr } = await tenantDb().from('determined_commitments')
.from('determined_commitments') .select('id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.eq('tenant_id', tenantId)
.order('is_native', { ascending: false }) .order('is_native', { ascending: false })
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (cErr) throw cErr; if (cErr) throw cErr;
@@ -172,10 +172,9 @@ async function fetchAll() {
const ids = (cData || []).map((x) => x.id); const ids = (cData || []).map((x) => x.id);
let fieldsByCommitmentId = {}; let fieldsByCommitmentId = {};
if (ids.length > 0) { if (ids.length > 0) {
const { data: fData, error: fErr } = await supabase const { data: fData, error: fErr } = await tenantDb().from('determined_commitment_fields')
.from('determined_commitment_fields') .select('id, commitment_id, key, label, field_type, required, sort_order')
.select('id, tenant_id, commitment_id, key, label, field_type, required, sort_order')
.eq('tenant_id', tenantId)
.in('commitment_id', ids) .in('commitment_id', ids)
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true });
if (fErr) throw fErr; if (fErr) throw fErr;
@@ -193,7 +192,7 @@ async function fetchAll() {
}, {}); }, {});
} }
const { data: lData, error: lErr } = await supabase.from('commitment_time_logs').select('commitment_id, minutes').eq('tenant_id', tenantId); const { data: lData, error: lErr } = await tenantDb().from('commitment_time_logs').select('commitment_id, minutes');
if (lErr) throw lErr; if (lErr) throw lErr;
const totals = {}; const totals = {};
for (const row of lData || []) { for (const row of lData || []) {
@@ -253,7 +252,7 @@ async function onToggleActive(c) {
if (!tenantId) return; if (!tenantId) return;
saving.value = true; saving.value = true;
try { try {
const { error } = await supabase.from('determined_commitments').update({ active: !!c.active }).eq('tenant_id', tenantId).eq('id', c.id); const { error } = await tenantDb().from('determined_commitments').update({ active: !!c.active }).eq('id', c.id);
if (error) throw error; if (error) throw error;
toast.add({ severity: 'success', summary: 'Atualizado', detail: `"${c.name}" ${c.active ? 'ativo' : 'inativo'}.`, life: 2500 }); toast.add({ severity: 'success', summary: 'Atualizado', detail: `"${c.name}" ${c.active ? 'ativo' : 'inativo'}.`, life: 2500 });
} catch (e) { } catch (e) {
@@ -271,10 +270,8 @@ async function onSave(payload) {
try { try {
await supabase.auth.getUser(); await supabase.auth.getUser();
if (dlgMode.value === 'create') { if (dlgMode.value === 'create') {
const { data: newC, error: cErr } = await supabase const { data: newC, error: cErr } = await tenantDb().from('determined_commitments')
.from('determined_commitments')
.insert({ .insert({
tenant_id: tenantId,
is_native: false, is_native: false,
native_key: null, native_key: null,
is_locked: false, is_locked: false,
@@ -284,14 +281,13 @@ async function onSave(payload) {
bg_color: payload.bg_color || null, bg_color: payload.bg_color || null,
text_color: payload.text_color || null text_color: payload.text_color || null
}) })
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at') .select('id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.single(); .single();
if (cErr) throw cErr; if (cErr) throw cErr;
const fields = Array.isArray(payload.fields) ? payload.fields : []; const fields = Array.isArray(payload.fields) ? payload.fields : [];
if (fields.length > 0) { if (fields.length > 0) {
const { error: fErr } = await supabase.from('determined_commitment_fields').insert( const { error: fErr } = await tenantDb().from('determined_commitment_fields').insert(
fields.map((f, idx) => ({ fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: newC.id, commitment_id: newC.id,
key: f.key, key: f.key,
label: f.label, label: f.label,
@@ -304,8 +300,7 @@ async function onSave(payload) {
} }
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 }); toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 });
} else { } else {
const { error: upErr } = await supabase const { error: upErr } = await tenantDb().from('determined_commitments')
.from('determined_commitments')
.update({ .update({
name: payload.name, name: payload.name,
description: payload.description, description: payload.description,
@@ -313,16 +308,15 @@ async function onSave(payload) {
bg_color: payload.bg_color || null, bg_color: payload.bg_color || null,
text_color: payload.text_color || null text_color: payload.text_color || null
}) })
.eq('tenant_id', tenantId)
.eq('id', payload.id); .eq('id', payload.id);
if (upErr) throw upErr; if (upErr) throw upErr;
const { error: delErr } = await supabase.from('determined_commitment_fields').delete().eq('tenant_id', tenantId).eq('commitment_id', payload.id); const { error: delErr } = await tenantDb().from('determined_commitment_fields').delete().eq('commitment_id', payload.id);
if (delErr) throw delErr; if (delErr) throw delErr;
const fields = Array.isArray(payload.fields) ? payload.fields : []; const fields = Array.isArray(payload.fields) ? payload.fields : [];
if (fields.length > 0) { if (fields.length > 0) {
const { error: insErr } = await supabase.from('determined_commitment_fields').insert( const { error: insErr } = await tenantDb().from('determined_commitment_fields').insert(
fields.map((f, idx) => ({ fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: payload.id, commitment_id: payload.id,
key: f.key, key: f.key,
label: f.label, label: f.label,
@@ -358,11 +352,11 @@ async function onDelete(c) {
if (!tenantId) return; if (!tenantId) return;
saving.value = true; saving.value = true;
try { try {
const { error: fErr } = await supabase.from('determined_commitment_fields').delete().eq('tenant_id', tenantId).eq('commitment_id', c.id); const { error: fErr } = await tenantDb().from('determined_commitment_fields').delete().eq('commitment_id', c.id);
if (fErr) throw fErr; if (fErr) throw fErr;
const { error: lErr } = await supabase.from('commitment_time_logs').delete().eq('tenant_id', tenantId).eq('commitment_id', c.id); const { error: lErr } = await tenantDb().from('commitment_time_logs').delete().eq('commitment_id', c.id);
if (lErr) throw lErr; if (lErr) throw lErr;
const { data: delRows, error: dErr } = await supabase.from('determined_commitments').delete().eq('tenant_id', tenantId).eq('id', c.id).eq('is_native', false).select('id'); const { data: delRows, error: dErr } = await tenantDb().from('determined_commitments').delete().eq('id', c.id).eq('is_native', false).select('id');
if (dErr) throw dErr; if (dErr) throw dErr;
if (!delRows?.length) throw new Error('DELETE bloqueado por RLS.'); if (!delRows?.length) throw new Error('DELETE bloqueado por RLS.');
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 }); toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 });
@@ -21,6 +21,7 @@
*/ */
import { dateToISO } from '@/features/agenda/utils/timeHelpers'; import { dateToISO } from '@/features/agenda/utils/timeHelpers';
import { tenantDb } from '@/lib/supabase/tenantClient';
// ── Helpers puros ───────────────────────────────────────────────────────── // ── Helpers puros ─────────────────────────────────────────────────────────
@@ -155,10 +156,9 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
const excType = exceptionTypeMap[status]; const excType = exceptionTypeMap[status];
if (excType && tenantId) { if (excType && tenantId) {
try { try {
const { data } = await supabase const { data } = await tenantDb().from('financial_exceptions')
.from('financial_exceptions')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('exception_type', excType) .eq('exception_type', excType)
.or(`owner_id.eq.${ownerId},owner_id.is.null`) .or(`owner_id.eq.${ownerId},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true }) .order('owner_id', { ascending: false, nullsLast: true })
@@ -177,8 +177,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
const contractId = row?.billing_contract_id ?? null; const contractId = row?.billing_contract_id ?? null;
if (contractId) { if (contractId) {
try { try {
const { data } = await supabase const { data } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.select('*') .select('*')
.eq('id', contractId) .eq('id', contractId)
.maybeSingle(); .maybeSingle();
@@ -189,14 +188,12 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
} }
if (!ctx.billingContract && eventoId) { if (!ctx.billingContract && eventoId) {
try { try {
const { data: ev } = await supabase const { data: ev } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('billing_contract_id') .select('billing_contract_id')
.eq('id', eventoId) .eq('id', eventoId)
.maybeSingle(); .maybeSingle();
if (ev?.billing_contract_id) { if (ev?.billing_contract_id) {
const { data: c } = await supabase const { data: c } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.select('*') .select('*')
.eq('id', ev.billing_contract_id) .eq('id', ev.billing_contract_id)
.maybeSingle(); .maybeSingle();
@@ -208,10 +205,9 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
} }
if (!ctx.billingContract && patientId && tenantId) { if (!ctx.billingContract && patientId && tenantId) {
try { try {
const { data: c } = await supabase const { data: c } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('patient_id', patientId) .eq('patient_id', patientId)
.eq('status', 'active') .eq('status', 'active')
.eq('type', 'package') .eq('type', 'package')
@@ -227,8 +223,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
// 3) Pending record // 3) Pending record
if (eventoId) { if (eventoId) {
try { try {
const { data } = await supabase const { data } = await tenantDb().from('financial_records')
.from('financial_records')
.select('*') .select('*')
.eq('agenda_evento_id', eventoId) .eq('agenda_evento_id', eventoId)
.in('status', ['pending', 'overdue']) .in('status', ['pending', 'overdue'])
@@ -244,8 +239,7 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
// 3b) Paid record pré-existente (caso C12: antecipar pagamento). // 3b) Paid record pré-existente (caso C12: antecipar pagamento).
if (eventoId) { if (eventoId) {
try { try {
const { data } = await supabase const { data } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id, status, amount, final_amount, paid_at, payment_method') .select('id, status, amount, final_amount, paid_at, payment_method')
.eq('agenda_evento_id', eventoId) .eq('agenda_evento_id', eventoId)
.eq('status', 'paid') .eq('status', 'paid')
@@ -266,16 +260,14 @@ export async function loadStatusChangeContext({ supabase, row, eventoId, status,
saldoConsumed: false saldoConsumed: false
}; };
try { try {
const { data: evRow } = await supabase const { data: evRow } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select('status, billing_contract_id') .select('status, billing_contract_id')
.eq('id', eventoId) .eq('id', eventoId)
.maybeSingle(); .maybeSingle();
if (evRow) { if (evRow) {
ctx.reverseArtifacts.previousStatus = evRow.status; ctx.reverseArtifacts.previousStatus = evRow.status;
} }
const { data: recs } = await supabase const { data: recs } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id, status, amount, final_amount, description, paid_at, payment_method') .select('id, status, amount, final_amount, description, paid_at, payment_method')
.eq('agenda_evento_id', eventoId) .eq('agenda_evento_id', eventoId)
.neq('status', 'cancelled') .neq('status', 'cancelled')
@@ -336,8 +328,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`; const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
for (const id of pendingIds) { for (const id of pendingIds) {
const { error: cErr } = await supabase const { error: cErr } = await tenantDb().from('financial_records')
.from('financial_records')
.update({ .update({
status: 'cancelled', status: 'cancelled',
notes: `[${today}] ${reason}`, notes: `[${today}] ${reason}`,
@@ -356,8 +347,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 2) Devolver saldo // 2) Devolver saldo
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) { if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
try { try {
const { data: freshContract, error: fetchErr } = await supabase const { data: freshContract, error: fetchErr } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.select('sessions_used, total_sessions, status') .select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id) .eq('id', ctx.billingContract.id)
.maybeSingle(); .maybeSingle();
@@ -369,7 +359,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
if (currentUsed >= totalSessions) { if (currentUsed >= totalSessions) {
patch.status = 'active'; patch.status = 'active';
} }
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id); const { error: dErr } = await tenantDb().from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (dErr) throw dErr; if (dErr) throw dErr;
} catch (e) { } catch (e) {
console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message); console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message);
@@ -380,7 +370,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 3) Desamarrar billing_contract_id (só se devolveu saldo) // 3) Desamarrar billing_contract_id (só se devolveu saldo)
if (decision.reverseRestoreSaldo && r.saldoConsumed) { if (decision.reverseRestoreSaldo && r.saldoConsumed) {
try { try {
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId); await tenantDb().from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
} catch (e) { } catch (e) {
console.warn('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message); console.warn('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message);
} }
@@ -393,8 +383,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 1) Consumir saldo // 1) Consumir saldo
if (decision.consumeSaldo && ctx.billingContract?.id) { if (decision.consumeSaldo && ctx.billingContract?.id) {
tasks.push( tasks.push(
supabase tenantDb().from('billing_contracts')
.from('billing_contracts')
.update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 }) .update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 })
.eq('id', ctx.billingContract.id) .eq('id', ctx.billingContract.id)
); );
@@ -404,8 +393,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado'; const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isForwardStatus && ctx.billingContract?.id && eventoId) { if (isForwardStatus && ctx.billingContract?.id && eventoId) {
tasks.push( tasks.push(
supabase tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }) .update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
.eq('id', eventoId) .eq('id', eventoId)
); );
@@ -418,7 +406,6 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`; const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
const finePayload = { const finePayload = {
owner_id: uid, owner_id: uid,
tenant_id: tenantId,
patient_id: patientId, patient_id: patientId,
agenda_evento_id: eventoId, agenda_evento_id: eventoId,
amount: decision.fineAmount, amount: decision.fineAmount,
@@ -429,8 +416,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
type: 'receita' type: 'receita'
}; };
tasks.push( tasks.push(
supabase tenantDb().from('financial_records')
.from('financial_records')
.insert(finePayload) .insert(finePayload)
.then(({ error }) => { .then(({ error }) => {
if (error) { if (error) {
@@ -455,8 +441,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const noteEntry = `[${today}] ${reasonText}`; const noteEntry = `[${today}] ${reasonText}`;
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry; const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
tasks.push( tasks.push(
supabase tenantDb().from('financial_records')
.from('financial_records')
.update({ .update({
status: 'cancelled', status: 'cancelled',
notes: noteText, notes: noteText,
@@ -469,8 +454,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status) // 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
if (decision.markPaid && ctx.pendingRecord?.id) { if (decision.markPaid && ctx.pendingRecord?.id) {
tasks.push( tasks.push(
supabase tenantDb().from('financial_records')
.from('financial_records')
.update({ .update({
status: 'paid', status: 'paid',
paid_at: new Date().toISOString(), paid_at: new Date().toISOString(),
@@ -492,8 +476,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
} }
} }
try { try {
const { data: freshContract, error: fetchErr } = await supabase const { data: freshContract, error: fetchErr } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.select('sessions_used, total_sessions, status') .select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id) .eq('id', ctx.billingContract.id)
.maybeSingle(); .maybeSingle();
@@ -504,7 +487,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
if (newUsed >= (freshContract?.total_sessions ?? 0)) { if (newUsed >= (freshContract?.total_sessions ?? 0)) {
patch.status = 'completed'; patch.status = 'completed';
} }
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id); const { error: incErr } = await tenantDb().from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (incErr) throw incErr; if (incErr) throw incErr;
tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 }); tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
} catch (e) { } catch (e) {
@@ -520,7 +503,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
try { try {
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId); const { error: linkErr } = await tenantDb().from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
if (linkErr) throw linkErr; if (linkErr) throw linkErr;
} catch (e) { } catch (e) {
console.error('[agendaBilling] erro amarrando billing_contract_id:', e?.message); console.error('[agendaBilling] erro amarrando billing_contract_id:', e?.message);
@@ -548,7 +531,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) { if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
patchContract.status = 'completed'; patchContract.status = 'completed';
} }
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id); const { error: incErr } = await tenantDb().from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
if (incErr) throw incErr; if (incErr) throw incErr;
} catch (e) { } catch (e) {
console.error('[agendaBilling] erro incrementando sessions_used:', e?.message); console.error('[agendaBilling] erro incrementando sessions_used:', e?.message);
@@ -570,8 +553,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
// Pós-processamento do record gerado pelo pacote saldo // Pós-processamento do record gerado pelo pacote saldo
if (decision.generatePackageCharge && eventoId) { if (decision.generatePackageCharge && eventoId) {
try { try {
const { data: newRec } = await supabase const { data: newRec } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id') .select('id')
.eq('agenda_evento_id', eventoId) .eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -579,8 +561,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
.single(); .single();
if (newRec?.id) { if (newRec?.id) {
if (decision.markPaid) { if (decision.markPaid) {
await supabase await tenantDb().from('financial_records')
.from('financial_records')
.update({ .update({
status: 'paid', status: 'paid',
paid_at: new Date().toISOString(), paid_at: new Date().toISOString(),
@@ -589,8 +570,7 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
}) })
.eq('id', newRec.id); .eq('id', newRec.id);
} else if (decision.paymentMethod === 'link') { } else if (decision.paymentMethod === 'link') {
await supabase await tenantDb().from('financial_records')
.from('financial_records')
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() }) .update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
.eq('id', newRec.id); .eq('id', newRec.id);
} }
@@ -609,11 +589,9 @@ export async function applyStatusDecisions({ supabase, toast, eventoId, row, nov
export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) { export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
const { n, packagePrice } = computeSeriePrice(recorrencia); const { n, packagePrice } = computeSeriePrice(recorrencia);
try { try {
const { data: createdContract, error: contractErr } = await supabase const { data: createdContract, error: contractErr } = await tenantDb().from('billing_contracts')
.from('billing_contracts')
.insert({ .insert({
owner_id: normalized.owner_id, owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id, patient_id: normalized.paciente_id,
type: 'package', type: 'package',
total_sessions: n, total_sessions: n,
@@ -645,11 +623,9 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
startDt.setHours(hh, mm, 0, 0); startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000); const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
const { data: createdEvent, error: evErr } = await supabase const { data: createdEvent, error: evErr } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.insert({ .insert({
owner_id: rule.owner_id, owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null, terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id, recurrence_id: rule.id,
recurrence_date: firstISO, recurrence_date: firstISO,
@@ -680,8 +656,7 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
if (cobErr) throw cobErr; if (cobErr) throw cobErr;
const paidNow = markPaidNow === true && paymentMethod !== 'link'; const paidNow = markPaidNow === true && paymentMethod !== 'link';
const { data: recRow } = await supabase const { data: recRow } = await tenantDb().from('financial_records')
.from('financial_records')
.select('id') .select('id')
.eq('agenda_evento_id', createdEvent.id) .eq('agenda_evento_id', createdEvent.id)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -696,7 +671,7 @@ export async function createPackageContract({ supabase, rule, normalized, recorr
patch.status = 'paid'; patch.status = 'paid';
patch.paid_at = new Date().toISOString(); patch.paid_at = new Date().toISOString();
} }
await supabase.from('financial_records').update(patch).eq('id', recRow.id); await tenantDb().from('financial_records').update(patch).eq('id', recRow.id);
} }
const methodLabel = { const methodLabel = {
@@ -745,7 +720,6 @@ export async function materializeAndChargePerSession({ supabase, rule, normalize
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000); const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
return { return {
owner_id: rule.owner_id, owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null, terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id, recurrence_id: rule.id,
recurrence_date: iso, recurrence_date: iso,
@@ -762,7 +736,7 @@ export async function materializeAndChargePerSession({ supabase, rule, normalize
}; };
}); });
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em'); const { data: createdEvents, error: evErr } = await tenantDb().from('agenda_eventos').insert(rows).select('id, inicio_em');
if (evErr) throw evErr; if (evErr) throw evErr;
let okCount = 0; let okCount = 0;
@@ -15,6 +15,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { import {
assertTenantId as assertValidTenantId, assertTenantId as assertValidTenantId,
assertIsoRange as assertValidIsoRange, assertIsoRange as assertValidIsoRange,
@@ -24,7 +25,7 @@ import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
/** /**
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico. * Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
* IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant. * Isolamento multi-tenant garantido pelo schema do tenant (tenantDb).
*/ */
export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO } = {}) { export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO } = {}) {
assertValidTenantId(tenantId); assertValidTenantId(tenantId);
@@ -34,10 +35,9 @@ export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO }
const safeOwnerIds = sanitizeOwnerIds(ownerIds); const safeOwnerIds = sanitizeOwnerIds(ownerIds);
if (!safeOwnerIds.length) return []; if (!safeOwnerIds.length) return [];
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select(AGENDA_EVENT_SELECT) .select(AGENDA_EVENT_SELECT)
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds) .in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO) .gte('inicio_em', startISO)
.lt('inicio_em', endISO) .lt('inicio_em', endISO)
@@ -78,13 +78,11 @@ export async function createClinicAgendaEvento(payload, { tenantId } = {}) {
throw new Error('owner_id é obrigatório para criação pela clínica.'); throw new Error('owner_id é obrigatório para criação pela clínica.');
} }
const insertPayload = { // dropa tenant_id se vier no payload (schema-per-tenant não tem a coluna)
...payload, // eslint-disable-next-line no-unused-vars
tenant_id: tenantId const { tenant_id: _dropTenantId, ...insertPayload } = payload;
};
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.insert(insertPayload) .insert(insertPayload)
.select(AGENDA_EVENT_SELECT) .select(AGENDA_EVENT_SELECT)
.single(); .single();
@@ -95,7 +93,7 @@ export async function createClinicAgendaEvento(payload, { tenantId } = {}) {
/** /**
* Atualização segura para clínica: * Atualização segura para clínica:
* - filtra por id + tenant_id (evita update cruzado) * - filtra por id (isolamento via schema do tenant)
* - permite editar owner_id (caso você mova evento para outro profissional) * - permite editar owner_id (caso você mova evento para outro profissional)
*/ */
export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) { export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
@@ -103,11 +101,13 @@ export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
if (!patch) throw new Error('Patch vazio.'); if (!patch) throw new Error('Patch vazio.');
assertValidTenantId(tenantId); assertValidTenantId(tenantId);
const { data, error } = await supabase // eslint-disable-next-line no-unused-vars
.from('agenda_eventos') const { tenant_id: _dropTenantId, ...safePatch } = patch;
.update(patch)
const { data, error } = await tenantDb().from('agenda_eventos')
.update(safePatch)
.eq('id', id) .eq('id', id)
.eq('tenant_id', tenantId)
.select(AGENDA_EVENT_SELECT) .select(AGENDA_EVENT_SELECT)
.single(); .single();
@@ -117,13 +117,13 @@ export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
/** /**
* Delete seguro para clínica: * Delete seguro para clínica:
* - filtra por id + tenant_id * - filtra por id (isolamento via schema do tenant)
*/ */
export async function deleteClinicAgendaEvento(id, { tenantId } = {}) { export async function deleteClinicAgendaEvento(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.'); if (!id) throw new Error('ID inválido.');
assertValidTenantId(tenantId); assertValidTenantId(tenantId);
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId); const { error } = await tenantDb().from('agenda_eventos').delete().eq('id', id);
if (error) throw error; if (error) throw error;
return true; return true;
@@ -143,8 +143,7 @@ function _mapRow(r) {
// timestamps // timestamps
inicio_em: r.inicio_em, inicio_em: r.inicio_em,
fim_em: r.fim_em, fim_em: r.fim_em
tenant_id: r.tenant_id ?? null
} }
}; };
} }
@@ -15,6 +15,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId as assertValidTenantId, assertIsoRange, getUid } from './_tenantGuards'; import { assertTenantId as assertValidTenantId, assertIsoRange, getUid } from './_tenantGuards';
import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects'; import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
@@ -23,8 +24,7 @@ import { AGENDA_EVENT_SELECT, flattenAgendaRow } from './agendaSelects';
export async function getMyAgendaSettings() { export async function getMyAgendaSettings() {
const uid = await getUid(); const uid = await getUid();
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_configuracoes')
.from('agenda_configuracoes')
.select('*') .select('*')
.eq('owner_id', uid) .eq('owner_id', uid)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -36,8 +36,7 @@ export async function getMyAgendaSettings() {
export async function getMyWorkSchedule() { export async function getMyWorkSchedule() {
const uid = await getUid(); const uid = await getUid();
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_regras_semanais')
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo') .select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('owner_id', uid) .eq('owner_id', uid)
.eq('ativo', true) .eq('ativo', true)
@@ -78,10 +77,9 @@ export async function listMyAgendaEvents({ startISO, endISO, ownerId, tenantId,
const uid = ownerId || (await getUid()); const uid = ownerId || (await getUid());
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
let q = supabase let q = tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.select(AGENDA_EVENT_SELECT) .select(AGENDA_EVENT_SELECT)
.eq('tenant_id', tid)
.eq('owner_id', uid) .eq('owner_id', uid)
.gte('inicio_em', startISO) .gte('inicio_em', startISO)
.lt('inicio_em', endISO) .lt('inicio_em', endISO)
@@ -96,9 +94,8 @@ export async function listMyAgendaEvents({ startISO, endISO, ownerId, tenantId,
/** /**
* Criação segura: * Criação segura:
* - injeta tenant_id do tenantStore
* - injeta owner_id do usuário logado (ignora owner_id vindo de fora) * - injeta owner_id do usuário logado (ignora owner_id vindo de fora)
* - dropa paciente_id (campo legado) se vier no payload * - dropa paciente_id (campo legado) e tenant_id (schema-per-tenant não tem a coluna) se vierem no payload
*/ */
export async function createAgendaEvento(payload) { export async function createAgendaEvento(payload) {
if (!payload) throw new Error('Payload vazio.'); if (!payload) throw new Error('Payload vazio.');
@@ -106,11 +103,10 @@ export async function createAgendaEvento(payload) {
const tid = resolveTenantId(); const tid = resolveTenantId();
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...rest } = payload; const { paciente_id: _dropped, tenant_id: _dropTenantId, ...rest } = payload;
const insertPayload = { ...rest, tenant_id: tid, owner_id: uid }; const insertPayload = { ...rest, owner_id: uid };
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.insert([insertPayload]) .insert([insertPayload])
.select(AGENDA_EVENT_SELECT) .select(AGENDA_EVENT_SELECT)
.single(); .single();
@@ -120,7 +116,7 @@ export async function createAgendaEvento(payload) {
} }
/** /**
* Atualização segura: filtra por id + tenant_id (RLS reforça no banco). * Atualização segura: filtra por id (isolamento via schema do tenant; RLS reforça no banco).
*/ */
export async function updateAgendaEvento(id, patch, { tenantId } = {}) { export async function updateAgendaEvento(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.'); if (!id) throw new Error('ID inválido.');
@@ -128,13 +124,12 @@ export async function updateAgendaEvento(id, patch, { tenantId } = {}) {
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...safePatch } = patch; const { paciente_id: _dropped, tenant_id: _dropTenantId, ...safePatch } = patch;
const { data, error } = await supabase const { data, error } = await tenantDb().from('agenda_eventos')
.from('agenda_eventos')
.update(safePatch) .update(safePatch)
.eq('id', id) .eq('id', id)
.eq('tenant_id', tid)
.select(AGENDA_EVENT_SELECT) .select(AGENDA_EVENT_SELECT)
.single(); .single();
@@ -143,13 +138,13 @@ export async function updateAgendaEvento(id, patch, { tenantId } = {}) {
} }
/** /**
* Delete seguro: filtra por id + tenant_id. * Delete seguro: filtra por id (isolamento via schema do tenant).
*/ */
export async function deleteAgendaEvento(id, { tenantId } = {}) { export async function deleteAgendaEvento(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.'); if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tid); const { error } = await tenantDb().from('agenda_eventos').delete().eq('id', id);
if (error) throw error; if (error) throw error;
return true; return true;
} }
@@ -21,7 +21,7 @@
export const AGENDA_EVENT_SELECT = ` export const AGENDA_EVENT_SELECT = `
id, owner_id, patient_id, tipo, status, id, owner_id, patient_id, tipo, status,
titulo, titulo_custom, observacoes, inicio_em, fim_em, titulo, titulo_custom, observacoes, inicio_em, fim_em,
terapeuta_id, tenant_id, visibility_scope, terapeuta_id, visibility_scope,
determined_commitment_id, link_online, extra_fields, modalidade, determined_commitment_id, link_online, extra_fields, modalidade,
recurrence_id, recurrence_date, recurrence_id, recurrence_date,
mirror_of_event_id, price, mirror_of_event_id, price,
+1 -1
View File
@@ -13,7 +13,7 @@
*/ */
const ALLOWED_FIELDS = [ const ALLOWED_FIELDS = [
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id', 'owner_id', 'terapeuta_id', 'patient_id',
'tipo', 'status', 'titulo', 'observacoes', 'modalidade', 'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
'inicio_em', 'fim_em', 'visibility_scope', 'inicio_em', 'fim_em', 'visibility_scope',
'mirror_of_event_id', 'mirror_source', 'mirror_of_event_id', 'mirror_source',
@@ -17,6 +17,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from './_tenantGuards'; import { assertTenantId } from './_tenantGuards';
import { import {
@@ -41,7 +42,7 @@ function resolveTenantId(tenantIdArg) {
export async function listThreads({ tenantId, limit = 500 } = {}) { export async function listThreads({ tenantId, limit = 500 } = {}) {
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('tenant_id', tid).order('last_message_at', { ascending: false }).limit(limit); const { data, error } = await tenantDb().from('conversation_threads').select(CONVERSATION_THREAD_SELECT).order('last_message_at', { ascending: false }).limit(limit);
if (error) throw error; if (error) throw error;
return data || []; return data || [];
@@ -54,7 +55,7 @@ export async function getThreadById(threadId, { tenantId } = {}) {
if (!threadId) throw new Error('threadId obrigatório.'); if (!threadId) throw new Error('threadId obrigatório.');
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('id', threadId).eq('tenant_id', tid).maybeSingle(); const { data, error } = await tenantDb().from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('id', threadId).maybeSingle();
if (error) throw error; if (error) throw error;
return data || null; return data || null;
@@ -67,7 +68,7 @@ export async function updateThread(threadId, patch, { tenantId } = {}) {
if (!threadId) throw new Error('threadId obrigatório.'); if (!threadId) throw new Error('threadId obrigatório.');
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_threads').update({ ...patch, updated_at: new Date().toISOString() }).eq('id', threadId).eq('tenant_id', tid).select(CONVERSATION_THREAD_SELECT).single(); const { data, error } = await tenantDb().from('conversation_threads').update({ ...patch, updated_at: new Date().toISOString() }).eq('id', threadId).select(CONVERSATION_THREAD_SELECT).single();
if (error) throw error; if (error) throw error;
return data; return data;
@@ -82,7 +83,7 @@ export async function listMessagesByThread(threadId, { tenantId, limit = 500 } =
if (!threadId) return []; if (!threadId) return [];
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT).eq('tenant_id', tid).eq('thread_id', threadId).order('created_at', { ascending: true }).limit(limit); const { data, error } = await tenantDb().from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT).eq('thread_id', threadId).order('created_at', { ascending: true }).limit(limit);
if (error) throw error; if (error) throw error;
return data || []; return data || [];
@@ -96,7 +97,7 @@ export async function listMessagesByPatient(patientId, { tenantId, limit = 200 }
if (!patientId) return []; if (!patientId) return [];
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT_BRIEF).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false }).limit(limit); const { data, error } = await tenantDb().from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT_BRIEF).eq('patient_id', patientId).order('created_at', { ascending: false }).limit(limit);
if (error) throw error; if (error) throw error;
return data || []; return data || [];
@@ -109,7 +110,7 @@ export async function updateMessageKanban(messageId, kanbanStatus, { tenantId }
if (!messageId) throw new Error('messageId obrigatório.'); if (!messageId) throw new Error('messageId obrigatório.');
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('conversation_messages').update({ kanban_status: kanbanStatus, updated_at: new Date().toISOString() }).eq('id', messageId).eq('tenant_id', tid); const { error } = await tenantDb().from('conversation_messages').update({ kanban_status: kanbanStatus, updated_at: new Date().toISOString() }).eq('id', messageId);
if (error) throw error; if (error) throw error;
} }
@@ -12,6 +12,7 @@
import { ref, reactive, watch, computed } from 'vue' import { ref, reactive, watch, computed } from 'vue'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { tenantDb } from '@/lib/supabase/tenantClient';
import { import {
createSignatureRequests, createSignatureRequests,
listSignatures, listSignatures,
@@ -61,8 +62,7 @@ function removeSignatario(idx) {
async function fetchPatientEmails(patientId) { async function fetchPatientEmails(patientId) {
if (!patientId) { patientEmails.value = []; return } if (!patientId) { patientEmails.value = []; return }
try { try {
const { data } = await supabase const { data } = await tenantDb().from('patients')
.from('patients')
.select('email_principal, email_alternativo') .select('email_principal, email_alternativo')
.eq('id', patientId) .eq('id', patientId)
.single() .single()
@@ -66,12 +66,17 @@ export function useDocumentSignatures() {
} }
} }
async function sign(signatureId, { hashDocumento = null } = {}) { async function sign(signatureId, { hashDocumento = null, tenantId = null } = {}) {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const updated = await signByPortal(signatureId, hashDocumento); // schema-per-tenant: a assinatura vive no schema do tenant. Resolve o
const idx = signatures.value.findIndex(s => s.id === signatureId); // tenant_id da própria linha (list_my_signatures retorna tenant_id) ou
// do parâmetro explícito.
const row = signatures.value.find(s => (s.signature_id ?? s.id) === signatureId);
const tid = tenantId || row?.tenant_id;
const updated = await signByPortal(tid, signatureId, hashDocumento);
const idx = signatures.value.findIndex(s => (s.signature_id ?? s.id) === signatureId);
if (idx >= 0) signatures.value.splice(idx, 1, updated); if (idx >= 0) signatures.value.splice(idx, 1, updated);
return updated; return updated;
} catch (e) { } catch (e) {
@@ -20,8 +20,11 @@ import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// helpers // helpers
const router = useRouter(); const router = useRouter();
const tenantStore = useTenantStore();
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }); const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
function fmtBRL(v) { function fmtBRL(v) {
@@ -66,6 +69,7 @@ async function loadSummary(uid) {
try { try {
// Receitas e despesas pagas no mês via RPC // Receitas e despesas pagas no mês via RPC
const { data: rpc } = await supabase.rpc('get_financial_summary', { const { data: rpc } = await supabase.rpc('get_financial_summary', {
p_tenant_id: tenantStore.activeTenantId,
p_owner_id: uid, p_owner_id: uid,
p_year: year, p_year: year,
p_month: month p_month: month
@@ -75,7 +79,7 @@ async function loadSummary(uid) {
totalDespesas.value = Number(s?.total_despesas ?? 0); totalDespesas.value = Number(s?.total_despesas ?? 0);
// Pending e overdue separados (sem filtro de mês) // Pending e overdue separados (sem filtro de mês)
const { data: pendRows } = await supabase.from('financial_records').select('status, final_amount').eq('owner_id', uid).is('deleted_at', null).in('status', ['pending', 'overdue']); const { data: pendRows } = await tenantDb().from('financial_records').select('status, final_amount').eq('owner_id', uid).is('deleted_at', null).in('status', ['pending', 'overdue']);
let pen = 0, let pen = 0,
ove = 0; ove = 0;
@@ -116,7 +120,7 @@ async function loadChart(uid) {
chartLoading.value = true; chartLoading.value = true;
const months = getLast6Months(); const months = getLast6Months();
try { try {
const results = await Promise.all(months.map((m) => supabase.rpc('get_financial_summary', { p_owner_id: uid, p_year: m.year, p_month: m.month }))); const results = await Promise.all(months.map((m) => supabase.rpc('get_financial_summary', { p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_year: m.year, p_month: m.month })));
const receitas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0)); const receitas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0));
const despesas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_despesas ?? 0)); const despesas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_despesas ?? 0));
@@ -141,7 +145,7 @@ async function loadCashflow() {
cashflowLoading.value = true; cashflowLoading.value = true;
cashflowError.value = false; cashflowError.value = false;
try { try {
const { data, error } = await supabase.from('v_cashflow_projection').select('mes_label, receitas_projetadas, despesas_projetadas, saldo_projetado, count_registros').order('mes', { ascending: true }); const { data, error } = await tenantDb().from('v_cashflow_projection').select('mes_label, receitas_projetadas, despesas_projetadas, saldo_projetado, count_registros').order('mes', { ascending: true });
if (error) throw error; if (error) throw error;
cashflowRows.value = data ?? []; cashflowRows.value = data ?? [];
} catch { } catch {
@@ -168,6 +172,7 @@ async function loadRecent(uid) {
recentLoading.value = true; recentLoading.value = true;
try { try {
const { data } = await supabase.rpc('list_financial_records', { const { data } = await supabase.rpc('list_financial_records', {
p_tenant_id: tenantStore.activeTenantId,
p_owner_id: uid, p_owner_id: uid,
p_limit: 5, p_limit: 5,
p_offset: 0 p_offset: 0
@@ -20,6 +20,7 @@ import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { useFinancialRecords } from '@/composables/useFinancialRecords'; import { useFinancialRecords } from '@/composables/useFinancialRecords';
@@ -47,7 +48,7 @@ async function loadPatients() {
const tenantId = tenantStore.activeTenantId; const tenantId = tenantStore.activeTenantId;
if (!tenantId) return; if (!tenantId) return;
const { data } = await supabase.from('patients').select('id, nome_completo, identification_color').eq('tenant_id', tenantId).order('nome_completo'); const { data } = await tenantDb().from('patients').select('id, nome_completo, identification_color').order('nome_completo');
patients.value = data ?? []; patients.value = data ?? [];
} }
@@ -21,6 +21,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
// ─── Status mapping Asaas → financial_records.status ──────────────────────── // ─── Status mapping Asaas → financial_records.status ────────────────────────
@@ -128,10 +129,9 @@ export async function getPaymentForRecord(financialRecordId) {
if (!financialRecordId) return null; if (!financialRecordId) return null;
const tenantId = resolveTenantId(); const tenantId = resolveTenantId();
const { data, error } = await supabase const { data, error } = await tenantDb().from('asaas_payments')
.from('asaas_payments')
.select('id, asaas_payment_id, billing_type, status, value, due_date, payment_date, invoice_url, payment_url, bank_slip_url, pix_qr_code, pix_copy_paste, cancelled_at') .select('id, asaas_payment_id, billing_type, status, value, due_date, payment_date, invoice_url, payment_url, bank_slip_url, pix_qr_code, pix_copy_paste, cancelled_at')
.eq('tenant_id', tenantId)
.eq('financial_record_id', financialRecordId) .eq('financial_record_id', financialRecordId)
.is('cancelled_at', null) .is('cancelled_at', null)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -167,7 +167,7 @@ export async function syncPayment(asaasPaymentId) {
*/ */
export async function isGatewayEnabled() { export async function isGatewayEnabled() {
const tenantId = resolveTenantId(); const tenantId = resolveTenantId();
const { data, error } = await supabase.from('payment_settings').select('asaas_enabled, asaas_environment').eq('tenant_id', tenantId).maybeSingle(); const { data, error } = await tenantDb().from('payment_settings').select('asaas_enabled, asaas_environment').maybeSingle();
if (error) return false; if (error) return false;
return !!data?.asaas_enabled; return !!data?.asaas_enabled;
} }
@@ -13,6 +13,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards'; import { assertTenantId, getUid } from './_tenantGuards';
import { BILLING_CONTRACT_SELECT } from './financialSelects'; import { BILLING_CONTRACT_SELECT } from './financialSelects';
@@ -31,7 +32,7 @@ export async function listForPatient(patientId, { tenantId, includeDeleted = fal
if (!patientId) return []; if (!patientId) return [];
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
let q = supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false }); let q = tenantDb().from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('patient_id', patientId).order('created_at', { ascending: false });
if (!includeDeleted) q = q.is('deleted_at', null); if (!includeDeleted) q = q.is('deleted_at', null);
@@ -48,7 +49,7 @@ export async function getById(contractId, { tenantId } = {}) {
if (!contractId) throw new Error('contractId obrigatório.'); if (!contractId) throw new Error('contractId obrigatório.');
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).eq('tenant_id', tid).maybeSingle(); const { data, error } = await tenantDb().from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).maybeSingle();
if (error) throw error; if (error) throw error;
return data || null; return data || null;
@@ -65,7 +66,6 @@ export async function create(payload) {
const tid = resolveTenantId(payload.tenantId); const tid = resolveTenantId(payload.tenantId);
const row = { const row = {
tenant_id: tid,
owner_id: uid, owner_id: uid,
patient_id: payload.patient_id, patient_id: payload.patient_id,
charging_style: payload.charging_style, charging_style: payload.charging_style,
@@ -77,7 +77,7 @@ export async function create(payload) {
end_date: payload.end_date || null end_date: payload.end_date || null
}; };
const { data, error } = await supabase.from('billing_contracts').insert([row]).select(BILLING_CONTRACT_SELECT).single(); const { data, error } = await tenantDb().from('billing_contracts').insert([row]).select(BILLING_CONTRACT_SELECT).single();
if (error) throw error; if (error) throw error;
return data; return data;
@@ -93,7 +93,7 @@ export async function update(contractId, patch, { tenantId } = {}) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { updated_at: _dropped, ...safePatch } = patch || {}; const { updated_at: _dropped, ...safePatch } = patch || {};
const { data, error } = await supabase.from('billing_contracts').update(safePatch).eq('id', contractId).eq('tenant_id', tid).select(BILLING_CONTRACT_SELECT).single(); const { data, error } = await tenantDb().from('billing_contracts').update(safePatch).eq('id', contractId).select(BILLING_CONTRACT_SELECT).single();
if (error) throw error; if (error) throw error;
return data; return data;
@@ -128,7 +128,7 @@ export async function findRecordsByRecurrence(recurrenceId, { tenantId } = {}) {
if (!recurrenceId) return []; if (!recurrenceId) return [];
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').eq('tenant_id', tid).is('deleted_at', null).not('agenda_evento_id', 'is', null); const { data, error } = await tenantDb().from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').is('deleted_at', null).not('agenda_evento_id', 'is', null);
// NOTE: filter por recurrence_id requer join — fica como TODO no orchestrator // NOTE: filter por recurrence_id requer join — fica como TODO no orchestrator
// (memória project_cross_week_propagation: query records cross-week por recurrence_id). // (memória project_cross_week_propagation: query records cross-week por recurrence_id).
@@ -11,6 +11,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards'; import { assertTenantId, getUid } from './_tenantGuards';
import { FINANCIAL_EXCEPTION_SELECT } from './financialSelects'; import { FINANCIAL_EXCEPTION_SELECT } from './financialSelects';
@@ -35,10 +36,9 @@ export async function getRule(exceptionType, { tenantId } = {}) {
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const uid = await getUid(); const uid = await getUid();
const { data, error } = await supabase const { data, error } = await tenantDb().from('financial_exceptions')
.from('financial_exceptions')
.select(FINANCIAL_EXCEPTION_SELECT) .select(FINANCIAL_EXCEPTION_SELECT)
.eq('tenant_id', tid)
.eq('exception_type', exceptionType) .eq('exception_type', exceptionType)
.or(`owner_id.eq.${uid},owner_id.is.null`) .or(`owner_id.eq.${uid},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true }) .order('owner_id', { ascending: false, nullsLast: true })
@@ -54,7 +54,7 @@ export async function getRule(exceptionType, { tenantId } = {}) {
*/ */
export async function listAll({ tenantId } = {}) { export async function listAll({ tenantId } = {}) {
const tid = resolveTenantId(tenantId); const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('financial_exceptions').select(FINANCIAL_EXCEPTION_SELECT).eq('tenant_id', tid).order('exception_type', { ascending: true }); const { data, error } = await tenantDb().from('financial_exceptions').select(FINANCIAL_EXCEPTION_SELECT).order('exception_type', { ascending: true });
if (error) throw error; if (error) throw error;
return data || []; return data || [];
} }
@@ -72,7 +72,6 @@ export async function upsertRule(payload) {
const tid = resolveTenantId(payload.tenantId); const tid = resolveTenantId(payload.tenantId);
const row = { const row = {
tenant_id: tid,
owner_id: payload.ownerScoped ? uid : null, owner_id: payload.ownerScoped ? uid : null,
exception_type: payload.exception_type, exception_type: payload.exception_type,
charge_mode: payload.charge_mode || 'none', charge_mode: payload.charge_mode || 'none',
@@ -83,7 +82,8 @@ export async function upsertRule(payload) {
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}; };
const { data, error } = await supabase.from('financial_exceptions').upsert(row, { onConflict: 'tenant_id,owner_id,exception_type' }).select(FINANCIAL_EXCEPTION_SELECT).single(); // TODO(schema-per-tenant): conferir unique (PK é só id; antes era tenant_id,owner_id,exception_type)
const { data, error } = await tenantDb().from('financial_exceptions').upsert(row, { onConflict: 'owner_id,exception_type' }).select(FINANCIAL_EXCEPTION_SELECT).single();
if (error) throw error; if (error) throw error;
return data; return data;

Some files were not shown because too many files have changed in this diff Show More