94 Commits

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:58:46 -03:00
Leonardo b0b636c660 log: sessao 22/05 - Melissa UX overhaul + 5 saas-docs (Fases 2-5)
Sessao completa de ~14 commits. 2 grandes blocos:

BLOCO 1 — Melissa UI overhaul: tray bottom-right (substitui topbar
band), mobile collapse parcial em <md, busca global unificada
(MelissaBusca ganha "Ir para [data]", popover da agenda deletado),
dock com 4 builtins, hero resumo com cancelado/remarcado, settings+
ajuda click-outside, cronometro evento-aware (botao ⏱ na timeline +
sessionPlan + confirm fechar), documents edit in-place via
document_generated.documento_id, wire-up dos 5 botoes do preview.

BLOCO 2 — 5 docs saas novas (03-07 em development/saas-docs/) +
SQL imports + 60 FAQs total. Cobertura: aba Documentos paciente,
pagina Templates, Assinatura eletronica, Emissao de recibo
profissional, Relatorios + 3 formatos de export.

Memorias adicionadas:
- feedback_tailwind_utility_load_order (hidden perde pra CSS base
  do componente por ordem de carga Vite)
- project_documents_reedit_in_place (linkage documento_id + editingDocId)

PROXIMA SESSAO (23/05): Fase 6 restante (C12 antecipar UX iter —
unico item de codigo da lista de ontem), Fase 7 restante (regressao
Agenda C7-C13, validacao manual). Antes/depois: panorama MVP no
ROADMAP canonico — ainda restam #12 papel timbrado, #15 NFS-e,
§1.5 Sentry, Asaas Fase B, M4 cutover, validacao centralizada
de forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:37:10 -03:00
Leonardo 701d9f4fcc saas-docs: doc Relatorios e exportacao (Fase 5 #13)
Doc 07 cobrindo a pagina de Relatorios + 3 formatos de exportacao:
- Layout 2-col (sidebar stats+filtros, main grafico+tabela)
- 4 periodos (semana/mes/3m/6m), agrupamento auto (dia vs week ISO
  vs month ISO)
- 5 KPIs clicaveis como filtros (total/realizadas/faltas/canc/remarc)
- Grafico Chart.js com cores por status
- DataTable paginada + status com tag colorida
- Export PDF (HTML->PDF A4, KPIs + tabela)
- Export Excel XLSX (exceljs dinamico, frozen header, alternating
  rows, branded, formatos data+currency)
- Export CSV (vanilla, BOM UTF-8, separador ; pt-BR)
- Filtros aplicados na tela respeitados na exportacao
- Nome do arquivo com timestamp pra evitar overwrite
- Notas dev: reportExport.service.js, pdf.service, exceljs lazy load

12 FAQs: como ver, periodos disponiveis, exportar PDF/Excel/CSV,
quando usar qual formato, filtros respeitados, formulas, agrupamento
do grafico, filtrar por paciente, ver outro terapeuta, nome do
arquivo, exportacao agendada (pendente).

categoria='Relatórios', pagina_path='/melissa/relatorios', ordem=7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:59:12 -03:00
Leonardo 34412c6883 saas-docs: doc da Emissao de recibo profissional (Fase 4 #14)
Doc 06 cobrindo o quick path de emissao de recibo:
- Quando o botao aparece (AgendaEventoFinanceiroPanel com record
  status=paid)
- O que vem auto-preenchido (paciente, sessao, valor, forma pgto,
  terapeuta+registro formatado, clinica+CNPJ, data)
- Registro profissional generico — CRP/CRM/CRFa/CREFITO/CRESS/CRN/
  Outro (variavel terapeuta_registro auto-formata)
- Valor por extenso (helper valorExtenso.js, ate 999 milhoes)
- Onde fica salvo (download + aba Documentos categoria 'Recibo')
- Quick path emitirReciboParaSessao() vs flow manual de Gerar
- Notas dev: service, helper, mapping, migration do template,
  localizacao do botao

12 FAQs cobrindo casos comuns: emitir recibo de sessao paga, por
que botao nao aparece, valor por extenso correto, suporte multi-
conselho, onde salva, recibo avulso, CRP vazio, CNPJ formatado,
corrigir valor, enviar pra assinar, data sessao vs emissao,
reemitir.

categoria='Financeiro', pagina_path='/melissa/agenda', ordem=6.
SQL import em database-novo/tmp/import-doc-recibo-profissional.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:31:55 -03:00
Leonardo 3a42b0696d saas-docs: doc da Assinatura eletronica de documentos (Fase 3 #7)
Doc 05 cobrindo o fluxo end-to-end de assinatura eletronica:
- Visao geral (terapeuta cria solicitacao -> link publico -> paciente
  abre sem login -> aceite LGPD -> assinatura registrada server-side)
- Lado terapeuta: DocumentSignatureDialog (signatarios, toggle link
  publico, validade 24h/3d/7d/30d, URL copyavel)
- Lado paciente publico: SharedDocumentPage com /shared/document/:token
  (preview + painel LGPD + checkbox aceite + assinar/recusar + SHA-256
  computado client-side)
- Audit trail: hash + timestamp server + IP/UA via inet_client_addr()
  e current_setting (anti-spoof)
- Portal logado: PortalDocumentos lista pendencias com KPIs + filtro
- Expiracao de link, multiplos signatarios, validade legal LGPD/CFP/
  MP2200-2 (limite ICP-Brasil)
- Notas dev: RPCs, service, composable, components, pendencia notificacao
  automatica via Modulo 6

12 FAQs cobrindo: como pedir assinatura, paciente sem login, audit
registrado, terapeuta assinar tambem, validade do link, recusar, portal
do paciente, envio manual hoje, validade legal, integridade do conteudo,
cancelar solicitacao, multiplos signatarios.

categoria='Documentos', pagina_path='/melissa/paciente', ordem=5.
SQL import em database-novo/tmp/import-doc-assinatura-eletronica.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:26:32 -03:00
Leonardo 7dd8cde8b4 saas-docs: doc da pagina de Templates de documentos (Fase 2)
Doc 04 cobrindo a pagina de gestao de templates:
- Globais (sistema, read-only) vs Tenant (seus, editaveis)
- Lista em grid com cards + badge "padrao" pros globais
- Preview de template global (iframe sandbox A4) + botao Duplicar
- Criar novo template (nome/tipo/desc/cabecalho/corpo/rodape)
- Editor rich-text com menu de variaveis (insere {{nome_var}})
- Lista de variaveis disponiveis (paciente/terapeuta/clinica/sessao/geral)
- Mobile drawer pros templates
- Duplicar (cria copia em "Seus templates" com sufixo "(copia)")
- Desativar (soft-delete, docs antigos continuam acessiveis)
- Mapeamento tipo template -> categoria do doc gerado

12 FAQs: pra que serve, por que nao edita padroes, como usar variavel,
quais variaveis, recuperar desativado, duplicar pra personalizar,
global vs tenant, imagens (logo/assinatura), cabecalho/rodape em todas
as paginas, variaveis obrigatorias, limites, compartilhamento entre
terapeutas do mesmo tenant.

categoria='Documentos', pagina_path='/melissa/documentos-templates',
ordem=4. SQL import em database-novo/tmp/import-doc-documentos-
templates.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:04:17 -03:00
Leonardo ec56f9429b saas-docs: doc da aba Documentos do paciente (Fase 2)
Doc 03 cobrindo o fluxo completo da aba Documentos no prontuario:
- Layout 2-col (sidebar de tipos + main grid)
- Toolbar: Atualizar/Gerar/Upload
- Upload (drag-drop, metadados, visibilidade)
- 11 tipos de documento + classificacao automatica
- Gerar a partir de template (fluxo 3 steps com auto-fill de vars)
- Edicao in-place (re-editar doc gerado, preserva ID+audit) — feature
  nova de 22/05
- Preview com 5 acoes (Baixar/Editar/Compartilhar/Assinar/Excluir)
- Share dialog (link publico temporario)
- Assinatura eletronica
- Soft-delete + retencao 5 anos (LGPD/CFP)
- Mobile drawer pros tipos

12 FAQs cobrindo casos comuns: upload, geracao, auto-fill, edicao
de gerado, edicao de uploaded, share, sign, recuperar excluido,
formatos aceitos, visibilidade, fix do bug 22/05 dos botoes do preview.

categoria='Documentos', pagina_path='/melissa/paciente', ordem=3.
SQL import em database-novo/tmp/import-doc-documentos-paciente.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:04:05 -03:00
Leonardo 89bf181742 melissa/paciente-docs: wire-up preview actions + Editar abre dialog em modo edicao
DocumentPreviewDialog emitia @download/@edit/@share/@sign/@delete que
o MelissaPatientDocuments nao ouvia — os 5 botoes da sidebar do preview
caiam no vazio. Adicionado wire-up roteando pros mesmos handlers do
card (onDownload, onEdit, onShare, onSign, onDelete). Share/sign/delete
fecham o preview antes de abrir o proprio dialog pra UX limpa; download
mantem preview aberto (acao instantanea).

DocumentGenerateDialog ganha prop editing-doc-id. Quando setado:
- Busca template_id + dados_preenchidos via loadGeneratedFromDocId
- Pre-seleciona template, popula vars (sobrescreve auto-loaded vars
  com dados_preenchidos pra preservar customizacao anterior)
- Pula direto pra step 'edit'
- Save vira UPDATE in-place (preserva documents.id e audit trail)
- Header muda pra "Editar documento" + icone pi-pencil amber
- Botao final vira "Substituir documento"
- Doc sem registro generated (legado): toast info + flow normal de
  select template; ao salvar, cria o registro generated linkado.

MelissaPatientDocuments:
- onEdit substituido (era shortcut pra onPreview): abre generate dialog
  com editing-doc-id setado.
- Novo ref editingDoc dedicado (separado do selectedDoc que serve
  preview/share/sign/delete) pra evitar vazar "edit state" pro botao
  "Gerar" do header quando user so abre preview e fecha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:42:39 -03:00
Leonardo 342defecde documents/generate: suporte a edicao in-place + linkage documento_id
document_generated.documento_id (FK pra documents) estava sempre NULL
no INSERT — sem isso nao da pra rastrear qual generated belongs to
qual documents row, impossibilitando re-edicao.

DocumentGenerate.service saveGeneratedDocument:
- Modo create (default): INSERT em documents PRIMEIRO pra capturar
  doc.id, depois INSERT em document_generated com documento_id setado.
- Modo edit (editingDocId param novo): UPDATE in-place — substitui
  PDF no Storage (novo path), atualiza bucket_path/tamanho/nome em
  documents (preserva id+audit), atualiza dados_preenchidos+pdf_path
  em document_generated. Se nao houver registro generated (doc legado),
  INSERT vinculando ao documents.id. Cleanup best-effort do PDF antigo.
- Nova fn loadGeneratedFromDocId(documentoId): busca template_id +
  dados_preenchidos pra pre-popular o dialog de edicao.

useDocumentGenerate.generateAndSave: ganha 2o param editingDocId que
passa pro service.

Backfill SQL pra docs antigos: match dg.pdf_path = d.bucket_path +
tenant/patient guard. 3 docs linkados no DB local, 5 ficaram orfaos
(paths que nao existem mais em documents — cleanup antigo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:42:24 -03:00
Leonardo fff70e4a71 saas-docs/tmp: SQL de import direto pra doc Cronometro
Script usado pra importar a doc 02-cronometro-melissa.json
diretamente no banco via psql (mesmo padrao da doc Busca global).
DO block com dollar quoting ($HTML$ e $FAQ$) pra evitar escape hell
no HTML conteudo + nos FAQs.

Importacao executada em 2026-05-22. Doc id=e87d4d33-7f5c-454e-a2ff-
0f92505b7c3c + 12 FAQ itens vinculados.

Path: database-novo/tmp/import-doc-cronometro.sql — pasta tmp pra
artefatos de operacao (nao parte do schema canonico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:49:10 -03:00
Leonardo 550c4ade44 saas-docs: doc do Cronometro de sessao (Melissa)
Doc JSON com 10 secoes cobrindo: 3 jeitos de abrir (hero, timeline,
card proximo paciente), pre-selecao + autostart via evento, exibicao
de programado/atraso (sessionPlan), anatomia do dialog, minimizar
(chip no dock), parar (salva DB) vs fechar (descarta com confirm),
toque no fim, persistencia localStorage, regra "um cronometro por
vez", atividade livre sem paciente, mobile (chip sem nome).

12 FAQs incluindo o caso de uso central (Larissa chegou agora),
comportamento do X com sessao rodando, cronometro multi-aba,
significado do badge 'atrasada Xmin', etc.

categoria='Sessão', pagina_path='/melissa', ordem=2 (busca era 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:49:02 -03:00
Leonardo 473e0f026e melissa/layout: topbar->tray bottom-right + dock 4 builtins + mobile collapse
Tray no canto inferior direito (substitui o topbar band do topo):
busca + plan-DEV + bell + ajuda + cog. Sibling de .melissa-dock
(fora de .win11-summary) pra ficar sempre interativo mesmo com
secao aberta (que aplica blur+pointer-none). z-index 66 (acima
do dock=65). Em <md (768px) collapse parcial — bell/help/cog/
plan-DEV somem e viram popup vertical no botao ⋮; dot vermelho
no ⋮ quando ha notificacoes nao lidas. Search sempre visivel.

Dock: 4 builtins na ordem Agenda · Pacientes · WhatsApp · Financeiro
(antes so Agenda+WhatsApp). MRU (max 3) ganha @media (max-width:
767px) display:none — utility 'hidden' do Tailwind perdia pro
.dock-pin{display:grid} por ordem de carga. Divisor entre builtins
e pins user some em mobile se so houver MRU (que ja esta oculto).

Wire-ups das commits anteriores:
- ref melissaBuscaRef + provide('openMelissaBusca') pra acoes
  contextuais futuras (botao tray chama direto via ref)
- @goto-date no <MelissaBusca> -> onBuscaGotoDate via _callOnAgenda
- @iniciar-cronometro no <MelissaTimelineHoje> -> handler que abre
  o cronometro com sessionPlan + autostart; opcao (b) "ja ativo"
  mostra toast warn sem trocar paciente
- Card "Proximo paciente" troca CTA pra "Iniciar cronometro" quando
  emCurso E tem patient_id; @open chama o mesmo handler do timeline

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:45 -03:00
Leonardo 9f3a047d6d melissa/cronometro: pre-selecionar paciente + sessionPlan + confirm fechar
MelissaCronometro.abrir() agora aceita opts { pacienteId, autostart,
sessionPlan }. Retorna { opened, alreadyRunning, samePaciente, ... }
pra caller decidir o feedback. Estado sessionPlan { startH, endH }
exibe "Programado: HH:MM – HH:MM" sob o select + badge laranja
"atrasada Xmin" quando hNow > startH. Cronometro NAO auto-ajusta —
analista decide quando comecar/parar. Tick a 30s atualiza atraso.
sessionPlan persiste no localStorage junto com o snapshot.

X agora dispara confirmarFechar(): pede ConfirmDialog quando ha
sessao em andamento OU tempo decorrido nao salvo; fecha direto se
clean. Tooltip mudou pra "Encerrar sem salvar".

Chip minimizado: nome do paciente fica display:none em <md (mobile)
pra nao estourar largura do dock — icone + timer cobrem o essencial.

MelissaTimelineHoje: botao ⏱ overlay no canto sup. direito das pills
(horizontal + vertical) quando ev esta em curso E tem patient_id.
Pulso emerald sutil pra chamar atencao; @click.stop pra nao abrir
o evento. Novo emit iniciar-cronometro(ev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:27 -03:00
Leonardo 8bf992910d melissa/busca-global: 'Ir para [data]' + Ctrl+K unificado
MelissaBusca ganha parser de data ('hoje', 'amanha', 'ontem',
DD/MM/YYYY) e card destacado azul "Ir para [data]" como primeiro
item do flatList. Quando query parseia como data, pula a RPC
search_global (nao busca paciente com nome '20/06'). Enter sem
selecao explicita pega o primeiro item — UX spotlight padrao.

Novo emit goto-date(date) capturado em MelissaLayout via helper
_callOnAgenda que abre a agenda se fechada e chama gotoDate exposto
pela MelissaAgenda (alias pro onBuscaGotoDate existente).

MelissaAgenda perde o popover proprio (MelissaAgendaSearchPopover
deletado), o ref searchPopover, o hotkey Ctrl+K local e
onBuscaSelectEvento. Ctrl+K agora vive so na MelissaBusca — evita
dois listeners no mesmo atalho. MelissaBusca expoe openDialog via
defineExpose pra a lupa do tray chamar.

MelissaPacientes: comment update mencionando o tray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:12 -03:00
Leonardo fa2b431a56 melissa/hero: contagem 'cancelado/remarcado' no resumo do dia
Acrescenta sufixo "(x foi cancelado, x foi remarcado)" depois do chip
de atendimentos quando ha sessoes nesses status em eventosHojeReais.
Sufixo nao-clicavel, peso menor pra nao competir com o link do total.
Pluralizacao gramatical (1 foi / 2+ foram).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:00 -03:00
Leonardo eb42759979 melissa/settings+ajuda: fechar ao clicar fora
Popover Personalizar (cog) e drawer de Ajuda agora fecham quando o
user clica em qualquer lugar fora do panel. Listener mousedown em
capture, watch em open pra anexar/desanexar; ignora o proprio botao
trigger (data-ajuda-toggle pro ajuda; cogBtnEl ref pro settings) pra
nao fazer close+reopen. Tambem flipa o panel do settings de top-12
pra bottom-12 (cog agora vive no bottom da .melissa-tray).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:40:48 -03:00
Leonardo c17c547ed2 log: sessao 21/05 noite - Melissa Fase 2 UX iter + bug isFinite(null)
5 commits em paciente.documentos e documents/generate. Bug raiz dos
"campos vem vazios": isFinite(null) global retorna true, null.toFixed
crashava em loadAllVariables. Trocado por Number.isFinite (strict).

Proxima sessao retoma de Fase 2 (2.7-2.9 gerar PDF dentro da aba
Documentos do paciente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:10:48 -03:00
Leonardo 4f05c2cf1b documents/generate: fix null.toFixed em loadAllVariables (isFinite global e enganoso)
loadVariables falhava com TypeError quando nao havia sessao
vinculada (agendaEventoId=null) E o user nao passava extras.valor.
Stack: 'Cannot read properties of null (reading toFixed)'.

Causa: usei isFinite() global em vez de Number.isFinite():
  isFinite(null) => true    (coerce: Number(null) === 0)
  Number.isFinite(null) => false

Como isFinite(null) retorna true, o codigo entrava no branch
`valorNum.toFixed(2)` e crashava. Com isso, loadAllVariables
inteiro estourava e variables.value zerava — explicando os
inputs todos vazios mesmo com paciente/perfil/clinica preenchidos.

Fix: trocar isFinite por Number.isFinite (versao strict, nao
coerce null/undefined/string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:04:58 -03:00
Leonardo 512bcc979c documents/generate: debug log em loadAllVariables
User reportou que mesmo com profile/paciente/clinica preenchidos
os campos do dialog continuam vazios. Pra diagnosticar:

- Promise.all -> Promise.allSettled: nao mascara falha individual
- console.error por source que falhou (patient/session/therapist/clinic)
- console.log com payload completo em dev mode (ownerId, tenantId,
  patientId, agendaEventoId, valores carregados, errors)

Depois de identificar a causa esses logs ficam ou viram telemetria
estruturada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:02:26 -03:00
Leonardo 61bb0d9c26 documents/generate: FloatLabel + map de origem nos inputs
Dois problemas reportados no dialog "Gerar documento":
1. Inputs usavam <label> + <InputText> simples, fora do padrao
   FloatLabel adotado no resto do app.
2. Quando o auto-preenchimento vinha vazio o user nao tinha onde
   ir cadastrar o dado.

Mudancas:
- TEMPLATE_VARIABLES ganha campo `source` em cada entrada com a
  descricao de onde o dado eh cadastrado (ex: "Perfil -> Registro
  Profissional"). Map canonico no DocumentTemplates.service.js.
- DocumentGenerateDialog refatorado:
  * FloatLabel variant="on" em todos os inputs
  * Banner no topo com contagem "X de Y preenchidos" (verde se 100%,
    amber se faltam dados)
  * Hint (`pi pi-link` + texto source) embaixo de cada campo vazio
    apontando onde cadastrar
  * Erro de carregamento renderizado dentro do step edit
  * Input ganha `invalid` quando vazio (borda destaque)
- useDocumentGenerate.loadVariables:
  * console.error em caso de excecao (era engolido em silencio)
  * mensagem amigavel quando loadAllVariables retorna tudo vazio
    (caso comum quando paciente/perfil/clinica estao incompletos)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:59:32 -03:00
Leonardo 6c39c58dc8 melissa/paciente-docs: drawer mobile herda tema (win11-root no portal)
Drawer teleportado pro body perdia as vars --m-* (definidas em
.win11-root no MelissaLayout), caia nos fallbacks hardcoded (#1a1d2e)
e ficava mais escuro que o resto do tema.

Fix:
- Wrapper .mpd-drawer-portal recebe class win11-root pra trazer as
  vars --m-* pro escopo teleportado.
- Vars locais --mpd-bg/--mpd-border/--mpd-text com cascata:
  --m-* (win11-root) -> --p-* (PrimeVue global) -> hardcoded.
  Respeita dark/light automaticamente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:54:37 -03:00
Leonardo 4e1ebeba13 melissa/paciente-docs: fix drawer mobile (teleport body + style global)
Bug: drawer abria mas travava — sidebar interna nao aparecia.
Duas causas combinadas:

1. position:fixed preso em stacking context: o MelissaPaciente
   tem transform/filter num ancestral, fazendo o fixed virar
   relativo ao pai em vez da viewport.
2. Styles scoped: ate corrigir o stacking context, ao teleportar
   pro body os data-v scoped attrs sumiriam e o CSS nao aplicaria.

Fix:
- <Teleport to="body"> wrap nos elementos drawer + backdrop. Saem
  da arvore do componente e ficam no body raiz.
- Styles do drawer movidos pra um <style> NAO-scoped no fim do
  arquivo. Classes globais .mpd-mobile-drawer* garantem que
  aplique nos elementos teleportados (que perdem data-v).
- Fallbacks adicionados nas vars CSS (--m-bg-medium, --m-border,
  --m-text) caso o body nao tenha o tema melissa carregado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:48:50 -03:00
Leonardo 51c33e73b9 melissa/paciente: aba Documentos vira pagina nativa 2-col
Antes: <DocumentsListPage embedded /> reusava o componente do
Rail/Classic em modo embed — visual conflitava com o padrao
Melissa, sem agrupamento por tipo, scroll inconsistente.

Novo: MelissaPatientDocuments.vue (componente nativo 2-col
seguindo MelissaDocumentosTemplates):
- Sidebar esquerda: tipos de documento com contadores
  (Todos, Laudo, Receita, Exame, Termo assinado, Relatorio
  externo, Identidade, Convenio, Declaracao, Atestado,
  Recibo, Outro). Item ativo destaca primary; vazios em
  opacity 50%.
- Main direita: header com titulo do tipo + count, DataView
  com cards (DocumentCard reusado), paginacao automatica >12,
  empty states distintos (global vs filtrado).
- Header da pagina: botoes Refresh / Gerar / Upload (primary
  outlined no dark-friendly).
- Mobile <1024px: sidebar vira drawer com botao "Tipos" no
  header (espelha padrao MelissaBloqueios/Templates).

Reaproveita do features/documents:
- useDocuments composable
- DocumentCard, DocumentUploadDialog, DocumentPreviewDialog,
  DocumentGenerateDialog, DocumentSignatureDialog,
  DocumentShareDialog

MelissaPaciente.vue: import DocumentsListPage -> Melissa
PatientDocuments + uso na aba.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:44:44 -03:00
Leonardo 682840f355 patient cadastro: fix nav pra view individual + rename pra singular
Bug: no melissa, salvar paciente -> "Salvar e ver pacientes" caia
em /pages/access. Causa: patientsListRoute() so tinha branches
/therapist e /admin, jogava na rota errada que o guard rejeita
no contexto melissa.

Fix:
1. PatientCadastroDialog + ComponentCadastroRapido — funcao
   renomeada pra patientViewRoute(patientId). Branch /melissa
   redireciona pra /melissa/paciente?id=<id> (prontuario individual)
   quando ha id, ou /melissa/pacientes (lista) sem id.
2. Botao "Salvar e ver pacientes" -> "Salvar e ver paciente"
   (singular). Reflete a navegacao real: vai pro proprio paciente
   que acabou de salvar, nao pra lista.
3. onCreated pega data?.id || props.patientId pra montar a rota.

Comportamento melissa: salvar paciente -> abre /melissa/paciente
?id=<id> (prontuario). Therapist/admin segue indo pra lista
(comportamento pre-existente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:29:46 -03:00
Leonardo c6105df98a melissa/templates: count badges usam .mdt-page__count (consistencia + fix dark)
Badges .mdt-section__count.is-info e .is-accent tinham o mesmo
problema do botao primary: bg solido com texto branco/cor primary
quebrava o contraste no modo escuro (texto sumia).

Trocados pelo .mdt-page__count (mesmo estilo do badge no header
da pagina) — usa var(--m-accent-soft) que adapta ao tema.

Tambem removido o CSS .mdt-section__count (e .is-info / .is-accent)
que ficou orfao.

Visual: numero do contador (17 globais, N tenant) com o mesmo
estilo do "17" no header — consistencia visual + dark mode safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:11:28 -03:00
Leonardo 402def7539 melissa/templates: botoes primary viram outlined (fix dark mode)
Bug: no modo escuro o bg primary do botao --primary tornava o
texto branco ilegivel — cor primary clara contra fundo claro.

Fix: estilo outlined em vez de filled:
- background transparente
- border-color: var(--p-primary-color)
- color: var(--p-primary-color)
- hover: bg sutil 10% mix com primary

Mantem hierarquia visual (a borda destacada sinaliza acao primaria)
mas sem o conflito de contraste. Funciona em ambos os temas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:09:49 -03:00
Leonardo 5dc91614ad melissa/templates: titulo do card quebra em ate 3 linhas
.mdt-card__name: -webkit-line-clamp 1 -> 3 + word-break:break-word
+ line-height 1.3. Nomes longos (ex: "Termo de Consentimento Livre
e Esclarecido para Atendimento Online") cabem inteiros em ate 3
linhas, com elipses no final se passar.

.mdt-card max-height: 200px -> 240px pra acomodar o titulo mais
alto + tipo + descricao (2 linhas) + footer com variaveis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:07:43 -03:00
Leonardo 597f8c05d5 melissa/templates: remove ConfirmDialog duplicado (toast 2x bug)
Bug: duplicar template disparava toast 2x e criava 2 copias.
Causa: MelissaLayout ja monta <ConfirmDialog /> global. Quando
MelissaDocumentosTemplates tambem montava, o confirm.require()
do PrimeVue dispara em TODOS os ConfirmDialog ativos -> callback
do accept executa 2x.

Fix: remove o <ConfirmDialog /> local de MelissaDocumentosTemplates.
O global do MelissaLayout cobre tudo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:02:25 -03:00
Leonardo 79425a3c9a templates/editor: variavel mobile insere na posicao do cursor
Antes: variavel inseria sempre no fim do texto no mobile.
Causa: usavamos append direto no form (form[field] += tag) porque
o foco estava no drawer e Jodit.insertHTML travava.

Fix: capturar selection ANTES do drawer abrir, restaurar antes de
inserir.

JoditEmailEditor expose API estendida:
- saveSelection() -> retorna markers (jodit.selection.save())
- restoreSelection(markers) -> re-foca editor + restaura markers
- focus() -> foca o editor

DocumentTemplateEditor:
- ref savedSelection capturada em openDrawer('vars'): snapshot dos
  markers do Jodit no momento (cursor original)
- insertVariable mobile: setTimeout 280ms apos fechar drawer ->
  restaura markers -> insertHTML (cursor volta pra onde estava ->
  variavel aparece no ponto exato)
- Fallback append no form se restore falhar
- savedSelection limpa em fecharDrawer + apos insert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:47:38 -03:00
Leonardo 87a1ac1358 templates/editor: defer insercao pos-transicao + pointer-events na saida
Trava persistia mesmo apos fix anterior. Causa raiz: append no form
ocorria durante a transicao CSS do drawer (250ms slide out). Vue
reagia ao v-model -> Jodit re-renderia HTML com nova variavel ->
concorrencia entre repaint do drawer saindo + reflow do Jodit
mobile = trava.

Fix:
1. setTimeout(280ms) — append no form so executa DEPOIS que a
   transicao do drawer terminou. Drawer sai limpo, depois Jodit
   re-renderiza isolado.
2. CSS: .dte-mobile-drawer:not(.is-open) ganha pointer-events:none
   durante saida. Evita captura de touch/click "perdidos" que
   tentavam triggerar handlers no drawer ja saindo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:42:40 -03:00
Leonardo 6860628087 templates/editor: fix trava do drawer ao inserir variavel no mobile
Bug: ao clicar numa variavel no drawer mobile, a variavel era
inserida mas o drawer travava/bugava. Causa: editor.insertHTML(tag)
do Jodit tenta resolver selection/cursor — no mobile, foco esta nos
botoes do drawer, nao no editor, entao Jodit fica em loop tentando
encontrar posicao.

Fix:
- Detecta isMobile e usa append direto via v-model
  (form.value[field] += tag) em vez de editor.insertHTML
- Fecha o drawer ANTES da insercao pra Jodit reconciliar com
  v-model na proxima tick
- No desktop, comportamento original (insertHTML mantem posicao
  do cursor) permanece

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:25:41 -03:00
Leonardo 134f562a1f templates/editor: drawer mobile com form + variaveis (tabs)
Mobile (<1024px): so o editor (col 2) fica visivel. Form de
metadados (col 1) e variaveis (col 3) viram tabs dentro de um
drawer fixed que abre pela esquerda.

Padrao espelhado de MelissaBloqueios/MelissaDocumentosTemplates,
com adaptacoes pra ser autocontido (sem dependencia do componente
pai).

Script:
- drawerOpen + drawerTab ('form' | 'vars') + isMobile refs
- _mqMobile matchMedia listener (onMounted setup +
  onBeforeUnmount cleanup)
- openDrawer(tab) / fecharDrawer helpers
- insertVariable agora fecha o drawer no mobile apos inserir

Template:
- Drawer wrap no inicio: tabs (Identificacao / Variaveis) +
  botao close + 2 panes (#dte-mobile-drawer-form e
  #dte-mobile-drawer-vars)
- Backdrop overlay com blur fecha o drawer
- Toolbar do editor ganha 2 botoes mobile-only (Identificacao /
  Variaveis) com classe dte-toolbar__mobile-actions
- <Teleport to="#dte-mobile-drawer-form" :disabled="!isMobile">
  envolvendo a <aside class="dte-side">
- <Teleport to="#dte-mobile-drawer-vars" :disabled="!isMobile">
  envolvendo a <aside class="dte-vars">

CSS:
- .dte-mobile-drawer fixed left, transform translateX, 250ms
- 2 panes scroll interno separado
- @media (max-width:1023px): cols vira 1-col, side/vars inline
  somem, botoes mobile aparecem, titulo canonico some

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:23:13 -03:00
Leonardo bbbb08ba9d melissa/templates: drawer mobile com templates do sistema
Mobile (<1024px) agora segue padrao MelissaBloqueios:
- Coluna esquerda (Templates do sistema) eh teleportada pra um
  drawer fixed que abre via botao "Templates do sistema" no header.
- Botao .mdt-menu-btn--mobile-only substitui o titulo no mobile
  (mais legivel + acao clara).
- Backdrop escuro com blur fecha o drawer ao clicar fora.
- Auto-fecha quando o user seleciona um template (libera viewport
  pra ver o preview no main).

Script:
- drawerOpen + isMobile refs + matchMedia listener
- toggleDrawer/fecharDrawer helpers
- onMounted setup + onBeforeUnmount cleanup

Template:
- <Transition name="mdt-drawer-fade"> wrap (slide horizontal +
  fade do backdrop)
- <Teleport to="#mdt-mobile-drawer-target" :disabled="!isMobile">
  envolvendo a <aside class="mdt-side">
- Botao "Menu" no header com class mdt-menu-btn--mobile-only

CSS:
- .mdt-mobile-drawer fixed left, transform translateX, 250ms cubic
- .mdt-mobile-drawer__backdrop overlay com blur
- @media (max-width: 1023px): cols vira 1-col, sidebar inline some,
  botao menu aparece, titulo canonico some, acções viram icone-only

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:19:17 -03:00
Leonardo 17f114f32f melissa/templates: usa DataView pros templates do tenant
Substitui o <div class="mdt-grid"> v-for simples por <DataView>
do PrimeVue na coluna "Seus documentos".

Beneficios:
- Paginacao automatica quando passa de 12 templates (era scroll
  infinito virando lento)
- Slot #grid permite manter o layout de cards atual
- Footer com paginator integrado ao design (border-top + bg
  transparente)

CSS:
- .mdt-dataview flex column ocupando o main
- :deep(.p-dataview-content) flex 1 + overflow auto = scroll
  interno dos cards
- :deep(.p-dataview-paginator-bottom) flex-shrink 0 = paginator
  sempre visivel no fundo
- .mdt-main .mdt-grid passa a ter padding 12 e gap 10 (era
  herdado do .mdt-grid global)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:11:10 -03:00
Leonardo c9afe8f009 templates/preview: background do papel cobre 100% da altura
Bug: container .dte-preview era display:flex + justify-content:center.
Em alguns navegadores, o flex limitava a altura intrinseca do
.dte-preview__doc (papel) quando o conteudo crescia — background
branco ficava com a altura do menor item e o conteudo "vazava".

Fix: container vira block normal com overflow-y:auto. Doc
centralizado via margin:0 auto (em vez de justify-content). Adiciona
box-sizing:border-box + height:auto + overflow:visible no doc pra
garantir que o background cresce com o conteudo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:04:21 -03:00
Leonardo c7e311b851 jodit: remove botoes hr, eraser e source (nao funcionavam)
Botoes da toolbar do corpo que nao tinham comportamento esperado:
- hr (linha horizontal)
- eraser (apagar formatacao)
- source (alternar HTML)

Removidos do array bodyButtons. layoutButtons (header/footer) ja
nao tinha esses 3 (era enxuta).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:03:24 -03:00
Leonardo 0aabea7753 templates/editor: corpo do editor ocupa 100% da altura disponivel
Antes: minHeight 450 em pixel fixo do Jodit limitava o corpo —
sobrava area vazia abaixo do editor.

Fix CSS-only (sem mexer no JoditEmailEditor compartilhado):

- .dte-main__editor: overflow hidden + flex column (era overflow-y
  auto). O scroll passa pra dentro do Jodit (workplace).
- .dte-editor-wrap: flex 1 + min-height 450 (preserva minimo).
- :deep(.jodit-container/workplace/wysiwyg) force flex + height
  100% + min-height 0/100% pra anular o height: 450px que o Jodit
  seta inline.

Resultado: editor sempre preenche toda area disponivel da COL 2,
expande/contrai com a janela, e o scroll do conteudo fica dentro
do proprio editor (jodit-wysiwyg).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:02:04 -03:00
Leonardo 80cce772db templates/editor: layout 3-col + tabs cabecalho/corpo/rodape
Refatora DocumentTemplateEditor em 3 colunas seguindo padrao
MelissaAgendaConfig:

- COL 1 (esquerda, 240-280px): form de metadados — nome, tipo,
  descricao (Textarea com autoResize), URL do logo
- COL 2 (centro, flex 1): sub-tabs Cabecalho/Corpo/Rodape, 1
  editor visivel por vez. Cada editor com minHeight: 450px (era
  120/350/120). Tab ativa destacada com border-bottom primary +
  background sutil.
- COL 3 (direita, 220-260px): variaveis agrupadas por categoria,
  hint dinamico mostrando qual sub-tab esta ativa ("Clique para
  inserir no Cabecalho/Corpo/Rodape"). Botoes com {{ }} braces
  em monospace + cor primary.

Scroll interno:
- .dte-page flex column, gap 12, min-height 0, padding 12
- Cada coluna eh card (border + radius) com header sticky + body
  scrollable interno (overflow-y: auto, scrollbar-width: thin)
- Variaveis com max-height proprio + scroll interno

Mobile (<1024px):
- 3-col vira 1-col stacked
- Container do .dte-cols ganha overflow-y auto (scroll da pagina
  inteira em vez de scroll interno em cada coluna)
- Variaveis ganha max-height 320px pra nao ocupar a tela toda

Preview (toggle no topo):
- Documento A4-like centralizado (max-width 794px ≈ 96dpi)
- Padding 48/56px, shadow sutil
- Mobile: padding reduzido pra max width disponivel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:59:21 -03:00
Leonardo f1c24242e0 melissa/templates: ajustes UI dos cards e icones (4 fixes)
1. Icone "eye" do header de Preview -> cor primary (classe
   mdt-main__title-icon-eye).

2. Icone "ellipsis-v" (3 pontos) dos cards do tenant -> cor
   primary via :deep(.p-button-icon) selector.

3. Variaveis do card: formato "< 12 variaveis >" (entities
   HTML &lt;/&gt;) em font monospace + cor primary + bold.
   Removido o icone pi-code (a propria notacao < > sinaliza).

4. .mdt-card max-height: 200px + overflow hidden. Foot agora
   tem justify-content: center + margin-top: auto pra grudar
   no fundo do card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:53:08 -03:00
Leonardo b821db6438 melissa/templates: fix parse error em variaveis do preview
Erro: \"Unexpected token, expected }\" em \`{{ array.map(v => \`{{...${v}}}...\`) }}\`.
Vue parser confunde os \`{{\` da template string aninhada com os
delimitadores de interpolacao Vue, abortando parse.

Fix: extrai pra helper externo formatVarsPreview(vars, max) que
monta as chaves via concatenacao de strings (open + open + v +
close + close) — sem template literal com \`{{\` literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:47:00 -03:00
Leonardo 0fafc28581 melissa/templates: layout 2-col + preview antes de duplicar
Refatora MelissaDocumentosTemplates seguindo padrao do
MelissaAgendaConfig (2-col com sidebar). Dois ajustes pedidos:

1. Layout 2-col (mdt-cols grid 360px + 1fr):
   - COL 1 (sidebar): "Templates do sistema" — lista vertical
     compacta com nome/tipo/descricao. Click abre preview.
   - COL 2 (main): "Seus documentos" + subtitulo + grid de cards
     dos templates do tenant.
   - Empty states distintos por coluna.
   - Mobile (<900px): empilha 1-col.

2. Preview antes de duplicar:
   - View 'preview' nova (alem de list/create/edit).
   - Click num template do sistema -> view='preview' (substitui
     "Seus documentos" no main, sidebar permanece pra navegar).
   - Header da main muda: nome do template + tipo/desc + 2 botoes
     (Voltar / Duplicar).
   - Iframe sandbox=allow-same-origin renderiza HTML completo
     (cabecalho+corpo+rodape com CSS basico A4-like).
   - Footer com lista de variaveis {{...}} do template (5 +N).
   - Item ativo na sidebar destaca borda primary + opacity 1 no
     icone de visualizar.
   - Pos-duplicar: volta pra view='list' pra mostrar o novo
     template no main.

UX result: user le antes de copiar (evita lixo em "Seus documentos"
de copias que nao queria).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:42:05 -03:00
Leonardo 75e67eae5d docs/templates: move pra Configurações (3 layouts)
Templates de documentos sao "setup", nao operacao diaria — deveriam
viver em Configuracoes, nao no menu de Documentos do paciente.

Mudancas:

1. Melissa — melissaConfigGrupos.js ganha grupo "Documentos" com
   1 item "Modelos de documentos" -> slug `documentos-templates`
   (pagina nativa MelissaDocumentosTemplates ja existe + ja esta
   wired no MelissaLayout linha 2896).

2. Rail/Classic — routes.configs.js ganha rota
   /configuracoes/documentos/templates (name=ConfiguracoesDocumentos
   Templates) apontando pro mesmo DocumentTemplatesPage.vue.

3. Rotas antigas removidas — routes.therapist.js e routes.clinic.js
   nao tem mais /documents/templates nem nomes de rota
   therapist-documents-templates / admin-documents-templates.
   URLs antigas dao 404 (decisao do user — limpa).

4. ConfiguracoesPage (sidebar Rail/Classic) ganha grupo
   "Documentos" antes do "Empresa & Plataforma" com item "Modelos
   de documentos".

5. Menus de pacientes (therapist.menu + clinic.menu) NAO tem mais
   "Templates" — caminho de acesso e Configuracoes.

6. pagesIndex.js (busca global) atualizado: novo path, novos
   keywords (recibo, atestado, laudo, tcle, lgpd, consent), roles
   ['therapist','admin'].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:11:33 -03:00
Leonardo 9a6eb56827 saas-docs/tmp: SQL de import direto pra doc Busca global
Script usado pra importar a doc 01-busca-global-melissa.json
diretamente no banco via psql (sem passar pelo botao "Importar
JSON" da UI). DO block com dollar quoting pra evitar escape hell
no HTML conteudo + nos FAQs (que contem aspas, kbd, etc).

Importacao executada. Doc id=d9d2e431-0bd7-4883-9cfa-3a1a3228c295
+ 12 FAQ itens vinculados.

Path: database-novo/tmp/import-doc-busca.sql — pasta tmp pra
artefatos de operacao (nao parte do schema canonico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:51:09 -03:00
Leonardo 652571da69 saas-docs: doc da Busca global + Recently viewed (Fase 1 melissa)
Primeira doc gerada do plano de testes layout Melissa. Segue o
template do prompt em SaasDocsPage.vue:
- titulo, conteudo HTML rico (cards reproduzidos visualmente),
  categoria=Navegacao, pagina_path=/melissa
- 12 FAQs cobrindo: atalho Ctrl+K, busca por telefone/CPF, lista
  acessados recentemente, privacidade local-only, threshold 2
  chars, cores semanticas por categoria, navegacao por teclado,
  documentos por nome paciente, limpar localStorage, sessoes
  passadas/futuras
- Nota pro dev: componente MelissaBusca.vue nao tem id= em nenhum
  elemento — sugestoes de IDs pra adicionar quando ativar
  data-highlight links.

Path: development/saas-docs/01-busca-global-melissa.json
Pronto pra importar via /saas/docs "Importar JSON".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:14:02 -03:00
Leonardo 30367392ff busca melissa: troca popover por Dialog Spotlight (CMD+K pattern)
Refatora MelissaBusca pra usar PrimeVue Dialog em vez de popover
absolute. Resolve definitivamente o bug do panel estourar viewport
quando ha muitos resultados.

Mudancas:

1. Trigger no dock: input -> <button> com aparencia de input. Clica
   ou Ctrl+K abre Dialog. Mantem placeholder + Ctrl+K kbd hint.

2. Dialog Spotlight: 640px max-width, posicionado 10vh do topo
   (estilo Spotlight macOS / Linear / GitHub). Backdrop blur escuro,
   dismissable mask, sem header, sem closable button (Esc cobre).

3. Input REAL dentro do Dialog: autofocus on open via nextTick.
   Mantem v-model="query" + @keydown="onKeydown" (Arrow/Enter).

4. Panel de resultados: era position:absolute com max-height:60vh
   (estourava em layouts com input perto do bottom). Agora vive
   DENTRO do Dialog (flex:1, max-height:70vh no content), scroll
   interno garantido por design — conteudo NUNCA passa do bottom
   da pagina.

5. Remove: onClickOutside (dismissableMask cobre), Transition
   mb-fade (Dialog tem sua animacao).

Comportamento end-user identico (Ctrl+K, navegacao com setas, Enter
seleciona, Esc fecha) mas visual + manutencao muito melhor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:52:19 -03:00
Leonardo b40116fe5d busca melissa: panel usa surface do tema (resolve branco-em-branco)
Texto branco hardcoded ficava ilegivel no tema claro do PrimeVue
(branco em branco). Tema escuro funcionava ok pq fundo era escuro.

Fix: troca cores hardcoded por CSS tokens do tema:
- mb-panel background: var(--surface-card)
- mb-panel border: var(--surface-border)
- mb-item color: var(--text-color)
- mb-item__sub color: var(--text-color-secondary)
- mb-group__title color: var(--text-color-secondary) com opacity
- mb-item hover: color-mix com p-primary-color 8%

Icones semanticos (patient pink, sessao indigo, doc sky, intake
orange) ficam mais saturados no tema claro e suavizados no escuro
via :root.app-dark selectors.

Input field do search bar mantem fallback `white` — ele fica no
shell escuro do Melissa (lockscreen-style), nao depende do tema
PrimeVue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:30:09 -03:00
Leonardo ffd8eab72d busca melissa: shape RPC + cores legíveis + cor de sessão
3 fixes pedidos no teste manual:

1. Shape errado da RPC: search_global retorna { id, label, sublabel,
   deeplink } pra TODOS os tipos, mas o codigo lia campos diretos
   (nome_completo, paciente_nome, inicio_em, nome_original etc) que
   nao existem -> resultados saiam "(sem nome)", sem datas.
   Fix: filteredPacientes + rpcAppointments + rpcDocuments + rpcIntakes
   agora usam label/sublabel direto. selectEntry extrai patient_id da
   deeplink quando precisa.

2. Cores ilegiveis: fundo do panel transparente demais (var(--m-bg-medium)
   nao tinha contraste em alguns temas). Fix: fundo solido rgba(20,22,32,
   0.92), border 14% white, text 96% white pra label, 65% pra sub
   (sobe pra 78% no hover/active). Group title 50% + bold pra hierarquia
   clara.

3. Cor das sessoes: grupo "Sessoes" tinha icone cinza generico. Fix:
   classes .mb-item__icon--{patient,sessao,doc,intake} com paleta
   espelhando a agenda — sessao = indigo-500 (#a5b4fc texto +
   rgba(99,102,241,0.20) bg, mesma cor do pickColor() padrao);
   patient = pink-400; doc = sky-500; intake = orange-400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:26:24 -03:00
Leonardo dee89ccd84 registro profissional: campo livre quando tipo='outro'
Quando o profissional seleciona "Outro" no Tipo de registro, agora
aparece um campo adicional pra informar o nome do conselho/instituicao
livre (ex: APM, ABRAP, conselhos nao-listados).

Migration 20260521000009 adiciona profiles.professional_registration_
type_other (text livre). Aplicada e marcada no _db_migrations.

ProfilePage e MelissaPerfil:
- form.professional_registration_type_other no reactive
- SELECT/UPDATE inclui a nova coluna
- UI condicional: campo aparece SOMENTE quando type === 'outro'
- Preview ao vivo usa type_other no lugar de 'outro' quando aplicavel
- Save limpa type_other automaticamente quando troca pra outro tipo

DocumentGenerate.service.loadTherapistData puxa type_other da query.
Quando profile.type='outro', terapeuta_registro_tipo recebe o valor
livre (ex: 'APM 12345/SP' em vez de 'outro 12345/SP'). terapeuta_crp
(legacy compat) continua so preenchido quando type RAW = 'CRP'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:26:21 -03:00
Leonardo 6a8ee52ad8 offline-overlay: trava falso positivo em dev/HMR + rede instavel
Problema: overlay "Sem conexao" aparecia toda hora em dev. Causa:
fetch('/favicon.ico') com timeout 4s + poll a cada 10s + sem retry.
Qualquer slow request (vite HMR rebuild, DNS, network blip) marcava
offline imediato.

Fixes:

1. Confia em navigator.onLine PRIMEIRO. Se browser ja sinaliza
   offline (wifi caiu, modo aviao), pula o fetch — fonte 100%
   autoritativa.

2. Threshold de 2 falhas consecutivas. Antes 1 falha = offline.
   Agora precisa 2 consecutivas, descarta blips esporadicos.
   Reset pra 0 a cada success.

3. Timeout fetch 4s -> 8s. Mais tolerante a slow requests.

4. Poll 10s -> 30s (prod) ou 60s (dev). Reduz pressao no Vite HMR
   sem perder detectividade. Eventos offline/online do browser
   continuam capturando mudancas reais instantaneamente.

5. Em DEV, polling 60s (vs 30s prod). HMR rebuilds podem demorar;
   queremos minimizar fetch concorrente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:17:27 -03:00
Leonardo 7516468f78 melissa/perfil: ajustes UX nos cards novos
4 fixes pedidos no teste manual:

1. Card "Registro profissional" movido pra apos Identidade (em vez
   de antes do Layout). Faz sentido contextual — dados pessoais
   profissionais ficam juntos.

2. Inputs do Registro convertidos pra FloatLabel variant="on"
   (padrao Melissa do resto da tela). Tres campos: tipo, numero, uf
   + preview box.

3. Card "Preferencias" tema agora em 1 linha (grid 2-col fixo,
   classe .mpr-theme-row). Antes podia quebrar em 2 linhas via
   flex-wrap.

4. "Trocar senha" navega pra /melissa/seguranca (rota nativa
   Melissa, MelissaSeguranca.vue ja existente) em vez de
   /account/security (que sairia do shell Melissa). Nao vaza mais
   pro layout classico.

Styles novos extraidos do inline pro <style scoped>: mpr-preview-box,
mpr-theme-row, mpr-theme-card, mpr-info-row, mpr-action-card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:14:29 -03:00
Leonardo 20d2b3aee4 melissa/perfil: 3 cards novos — registro CFP + preferencias + seguranca
Espelha as melhorias do ProfilePage no perfil nativo Melissa
(/melissa/perfil), com 4 changes:

1. Card "Registro Profissional" (id=mpr-sec-registro, antes do
   card Layout): Select tipo + Number + Select UF + preview ao vivo
   "Aparecera nos documentos como: CRP 06/12345/SP". 3 colunas de
   migration 20260521000003 wire-up no load + save.

2. Card "Layout" — sub do Rail atualizado pra mensagem solicitada:
   "Icones no canto esquerdo + painel expansivel. Disponivel apenas
   no desktop."

3. Card "Preferencias" (id=mpr-sec-preferencias, depois do Layout):
   toggle Tema Claro vs Escuro com cards visuais + sun/moon icons.
   Usa isDarkTheme + toggleDarkMode do useLayout.

4. Card "Seguranca" (id=mpr-sec-seguranca, ultimo): mostra e-mail
   atual readonly + botao "Trocar senha" que navega pra
   /account/security.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:04:32 -03:00
Leonardo ae1e1388b9 profile: UI dos campos de registro profissional (CFP #5)
Gap detectado em teste manual: migration 20260521000003 adicionou
as 3 colunas (professional_registration_type/_number/_uf) e o
DocumentGenerate.service.loadTherapistData ja le delas, mas a UI
de edicao nao foi criada.

ProfilePage.vue ganha novo card "Registro Profissional" (id=
registro-profissional, cor #0ea5e9 ciano, antes do card de Redes
Sociais):
- Select tipo (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro — mesmas
  opcoes do CHECK constraint)
- InputText numero
- Select UF (27 estados, filterable)
- Preview: "Aparecera nos documentos como: CRP 06/12345/SP"
- Numero e UF disabled enquanto tipo nao escolhido

Wire-up: SELECT/UPDATE do profile agora incluem as 3 colunas.
form.* tem defaults vazios.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:54:51 -03:00
Leonardo 4024469952 db: aplica 13 migrations + 3 seeds + ext config + gotcha doc
Aplica no banco local todas as migrations pendentes do dia (clinical
notes, accept_invite RPC, asaas tables/rls, profiles registration,
specialties, document_templates consent types, sign_document RPCs,
list_my_signatures, recibo amend) e os 3 seeds novos (clinical note
templates, specialties, consent forms LGPD/Gravacao).

db.config.json estendido com os 3 seeds novos (system group) pra
setup do zero rodar tudo.

Gotcha re-validado: migration 20260521000005 (CHECK constraint
em document_templates) silenciosamente falhou via db.cjs porque
postgres nao e owner da tabela (owned por supabase_admin). Detectado
quando seed_060 falhou com violates check constraint. Re-rodada
via TCP 127.0.0.1 trust com `psql -U supabase_admin`. Memoria
project_supabase_admin_gotcha atualizada com o metodo correto.

Sanity check pos-aplicacao:
- 5 RPCs novas + 8 tabelas novas
- 17 document_templates global (15 + 2 LGPD/Gravacao)
- 34 specialties + 6 clinical_note_templates
- Backup automatico em backups/2026-05-21/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:19:45 -03:00
Leonardo 661790d577 wiki + padronizacao: agenda Fase 4 residual 70% fechada
Atualiza PADRONIZACAO.md marcando Fase 4 da agenda em sua maior
parte fechada: popover snapshot + reverse transition (ja feitos
em C11) + decomposicao A+B1+B2 (-991L useMelissaAgenda) + Fases
C+D (Rail/Clinica adotam billing core via useAgendaStatusChange) +
C12 UX iter.

Pendente: indicadores visuais 3 canais em Rail/Clinica + popover
Rail antecipar/revogar/trocar metodo + doc de ajuda.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:49:14 -03:00
Leonardo 6807b447cb agenda Fase D: adapter Clinica usa agendaBilling.service
AgendaClinicaPage espelha Fase C: useAgendaStatusChange composable
+ AgendaStatusChangeConfirmDialog plugado.

onUpdateSeriesEvent reescrito:
- Materializa virtual se preciso (via createClinic com status='agendado'
  + tenantId)
- updateClinic({ status }) no DB
- applyStatusChange(eventoId, row, novoStatus) ramifica via dialog
  quando preciso
- loadClinicRange() refetch apos applied

Mesma feature parity de Melissa pra status change na Clinica:
multa, taxa cancelamento tardio, consumir saldo, gerar cobranca
pacote saldo, reverse transition trava — tudo via agendaBilling.service.

Fase C (Rail) + Fase D (Clinica) fechadas. Os 3 layouts (Melissa/
Rail/Clinica) agora compartilham o billing core do agendaBilling.
service via composable useAgendaStatusChange.

Pendente (residual incremental):
- Indicadores visuais (3 canais) nos 3 layouts
- Antecipar/Revogar pagamento no popover de Rail (Rail nao tem
  popover separado — usa AgendaEventDialog direto; precisa
  refactor maior)
- Doc de ajuda

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:48:18 -03:00
Leonardo 034c2c0f3d agenda Fase C: adapter Rail usa agendaBilling.service
AgendaTerapeutaPage (Rail) ganha o fluxo de status change do
Melissa via novo composable useAgendaStatusChange (reusable
wrapper sobre agendaBilling.service).

src/features/agenda/composables/useAgendaStatusChange.js (novo):
- Composable Tipo A pra qualquer page que precise do flow
  load context -> dialog se necessario -> apply decisoes
- Mantem state do dialog + resolver promise
- Expoe applyStatusChange(eventoId, row, novoStatus)
- Resolve ownerId via supabase.auth + tenantId via tenantStore

AgendaTerapeutaPage:
- onUpdateSeriesEvent refatorado: materializa virtual se preciso ->
  update status -> applyStatusChange (load ctx + dialog + apply)
- AgendaStatusChangeConfirmDialog plugado no template

Antes: Rail fazia so update(id, { status }) cru — sem multa,
sem pacote, sem reverse, sem nada de C7-C13. Era a versao
primitiva do status change.

Depois: Rail tem feature parity com Melissa pra status change.
Multa por falta, taxa de cancelamento tardio, consumir saldo do
pacote, gerar cobranca de pacote saldo, reverse transition trava
— tudo via mesmo agendaBilling.service.

Pendente Fase C: indicadores visuais (3 canais) + antecipar
pagamento (popover-specific, depende refactor maior do
AgendaEventDialog ou criar Rail popover). Fica pra iter
incremental.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:46:35 -03:00
Leonardo 87833d4ec6 wiki log: agenda Fase B (B1+B2) — agendaBilling.service extraido
Registra a decomposicao end-to-end (A+B1+B2) totalizando -991L
no useMelissaAgenda. 3 layouts podem agora compartilhar o billing
core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:37:32 -03:00
Leonardo 049dd91b9b agenda Fase B2: extrai mutations pro agendaBilling.service
Continua decomposicao da agenda. Extrai 3 mutations:
- applyStatusDecisions          (~330L — reverse, consume saldo,
                                  multa, mark paid, generate package
                                  charge, antecipated payment)
- createPackageContract         (~140L — upfront ou saldo)
- materializeAndChargePerSession (~90L — N events + N records)

Padrao das assinaturas:
- supabase como dep explicita (em vez de closure)
- toast OPCIONAL (callsite fora de UI pode passar null;
  applyStatusDecisions ramifica via `if (toast?.add)`)
- ownerId/tenantId como args (em vez de capturar refs)

createPackageContract + materializeAndChargePerSession ja retornavam
{ toast: {...} } pra caller mostrar — pattern preservado.

useMelissaAgenda.js: 2593L -> 2042L (-551L). 3 wrappers finos
injetam supabase/toast/refs do escopo do composable. Comportamento
identico — codigo movido linha-a-linha, so refactor de signature.

TOTAL nas fases A+B1+B2: -1525L extraidas do useMelissaAgenda
(de 3033L original pra 2042L atual). Tres pages (Melissa/Rail/
Clinica) agora podem reusar mesmo billing core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:37:09 -03:00
Leonardo e7e3d1beb1 agenda Fase B1: agendaBilling.service (read-only + helpers puros)
Continua decomposicao da agenda (apos Fase A utils). Extrai pro
service os componentes read-only / pure:

- computeSeriePrice          (puro)
- generateOccurrenceDates    (puro)
- loadStatusChangeContext    (read-only DB — assina supabase,
                              ownerId, tenantId, row, eventoId,
                              status)
- needsStatusConfirmDialog   (puro — depende so do ctx)

useMelissaAgenda.js: 2792L -> 2593L (-199L). _loadStatusChangeContext
agora e wrapper fino que injeta supabase/ownerId/tenantId do
composable scope. _needsConfirmDialog vira alias direto.
_computeSeriePrice/_generateOccurrenceDates importados direto.

Fase B1 deixa Rail/Clínica capazes de reusar TODA a logica
read-only de status change. Mutations (applyStatusDecisions,
createPackageContract, materializeAndChargePerSession) ficam pra
Fase B2.

Risco: zero comportamental — toda chamada produz o mesmo ctx
de antes. Codigo movido sem mudancas de logica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:30:06 -03:00
Leonardo aa587e849c wiki log: C12 UX iterado + agenda Fase A utils extract
Registra os 3 commits da sessao (C12 trocar metodo, C12 filtro
cancelled, Fase A utils extract). Memoria
project_c12_antecipar_iterar atualizada pra refletir patterns
prontos pra Rail/Clinica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:22:10 -03:00
Leonardo ee117eafe6 agenda Fase A: extrai utils puros pra features/agenda/utils
Decomposicao da agenda em prep pra replicar Rail/Clinica.

4 arquivos novos em src/features/agenda/utils/:
- eventoTipo.js  -> EVENTO_TIPO + normalize/derive + MAX_SESSION_MINUTES
- dbFields.js    -> pickDbFields whitelist (memoria pickdbfields_whitelist)
- timeHelpers.js -> isUuid + addMinutesToTime + isoToDecimalHour + dateToISO
- colors.js      -> pickColor (status+tipo+isOccurrence)

useMelissaAgenda.js (2863L -> 2792L): removeu definicoes locais
(83 linhas), passou a importar dos utils. Aliases _addMinutesToTime
e _dateToISO mantidos no escopo via import "as" pra nao mexer
em 70+ callsites internos.

Fase A = baseline zero-comportamental pra Rail/Clinica adotarem
os mesmos helpers. Fase B (service de billing — applyStatusDecisions,
createPackageContract, materializeAndCharge) vem em seguida.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:21:12 -03:00
Leonardo b7f3c23ad6 agenda C12 UX: filtrar cancelled do dialog Lancamentos da sessao
Iteracao UX #2 do C12: records cancelled (do ciclo Revogar+Antecipar
e tambem das multas) poluiam o dialog "Lancamentos da sessao",
escondendo o que importa (ativos).

lancamentosShowHistory ref (default false) + lancamentosFiltered
computed filtra status !== 'cancelled'. lancamentosCancelledCount
computa contagem pra feedback.

UI:
- Dialog abre limpo (sempre lancamentosShowHistory=false em
  onVerLancamentos)
- Quando ha cancelled e existe ativo: linha acima da lista com
  "{N} cancelado(s) ocultos" + botao toggle "Mostrar/Ocultar
  historico"
- Quando todos sao cancelled: empty state especial "Sem
  lancamentos ativos. {N} cancelado(s) no historico" + botao
  pra expandir
- Cards cancelled atenuados (opacity 0.55, border-dashed,
  background sutil, description com line-through) — claramente
  audit trail, nao-ativo

Combina com "Trocar metodo" (commit anterior) — agora o caso 99%
do tempo ele ve so o record ativo, nao precisa nem expandir
historico.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:31:01 -03:00
Leonardo 9c518a2b44 agenda C12 UX: "Trocar metodo" em vez de Revogar+Antecipar
Iteracao UX do C12 (antecipar pagamento) — antes user que queria
trocar PIX por dinheiro precisava Revogar (cancela record) +
Antecipar de novo (cria record novo), acumulando lixo no audit
trail (memoria project_c12_antecipar_iterar: ciclos longos chegaram
a 5+ records cancelled num mesmo evento).

MelissaEventoPanel ganha 3 botoes quando isAntecipacaoAtiva:
  - "Trocar metodo"   (default, icone pi-sync)
  - "Revogar pagamento" (danger, icone pi-times-circle)
Antes mostrava so "Revogar".

MelissaLayout:
- anteciparMode ref ('create' | 'update') + onTrocarMetodoAntecipacao
  pre-seleciona o metodo atual lendo o paid record antes de abrir
  o dialog
- confirmAnteciparPagamento ramifica: mode='update' faz UPDATE no
  paid existente (payment_method + paid_at + notes audit "metodo
  trocado: X -> Y"). Sem cancel cycle, sem record novo.
- Dialog header/labels/CTA dinamicos por mode

Result: ciclo trocar metodo agora gera 0 records cancelled (so
update + nota auditoria). Revogar continua disponivel pra quando
realmente precisar cancelar o pagamento.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:29:02 -03:00
250 changed files with 15043 additions and 4245 deletions
+338
View File
@@ -14,6 +14,86 @@ Chronological, append-only record of everything that's happened in this wiki.
---
## [2026-05-22 dia] session | Melissa UX overhaul + 5 docs saas (Fases 2-5)
Touched: none codigo durable; 5 docs saas novas em development/saas-docs/
Sessao longa (~12 commits codigo + 5 docs). 2 grandes blocos:
BLOCO 1 — Melissa UI overhaul (manha):
- Tray no canto inf. direito (substitui topbar band do topo): busca +
plan-DEV + bell + ajuda + cog. Sibling de .melissa-dock (fora de
.win11-summary) pra ficar interativa com secao aberta. Em <md (768px)
collapse parcial — bell/help/cog/plan-DEV viram popup vertical no
botao ⋮; dot vermelho no ⋮ quando ha notificacoes nao lidas.
- Busca global unificada: MelissaBusca ganha parser de data (hoje/
amanha/ontem/DD/MM/YYYY) + card azul "Ir para [data]" + emit
goto-date. Popover da agenda (MelissaAgendaSearchPopover) deletado;
Ctrl+K so vive na MelissaBusca. Lupa unica fica so na .melissa-tray
(removida das toolbars de secoes pra evitar pollution mobile).
- Dock: 4 builtins (Agenda · Pacientes · WhatsApp · Financeiro). MRU
oculto em <md via @media (utility 'hidden' do tailwind perdia pro
.dock-pin{display:grid} por carga).
- Hero resumo: contagem "(x foi cancelado, x foi remarcado)" depois
do chip atendimentos com gramatica plural.
- Settings + Ajuda fecham ao clicar fora (mousedown capture + watch
open). Cog ref + data-ajuda-toggle ignoram trigger pra evitar
close+reopen.
- Cronometro: pre-selecao paciente + autostart quando aberto via
botao ⏱ na timeline (sessao em curso) ou card "Proximo paciente".
abrir(opts) com { pacienteId, autostart, sessionPlan }. sessionPlan
exibe "Programado: HH:MM HH:MM" + badge "atrasada X min"; NAO
desconta atraso auto. Confirm fechar quando ha sessao rodando/
decorrido sem salvar. Chip minimizado oculta nome do paciente em
<md (so icone + tempo).
- Documents: linkage document_generated.documento_id agora preenchido
no INSERT (era sempre NULL). Modo edit in-place via editingDocId:
busca template+dados_preenchidos via loadGeneratedFromDocId, popula
vars, pula pra step 'edit'; save substitui PDF no Storage e
atualiza documents (preserva id+audit). Header amber "Editar
documento" + botao "Substituir documento". Backfill SQL pra docs
antigos (3 linkados, 5 orfaos no DB local).
- DocumentPreviewDialog: wire-up dos 5 botoes da sidebar (download/
editar/share/sign/delete) que estavam caindo no vazio.
BLOCO 2 — saas-docs (tarde):
Padrao igual da 01-busca-global-melissa.json — JSON-fonte +
SQL de import direto via $HTML$/$FAQ$ dollar quoting. 5 docs novas
(03 a 07), cada uma com 12 FAQ itens:
- 03 Documentos do paciente — pagina_path /melissa/paciente,
categoria Documentos
- 04 Templates de documentos — pagina_path /melissa/documentos-
templates, categoria Documentos
- 05 Assinatura eletronica — pagina_path /melissa/paciente,
categoria Documentos
- 06 Recibo profissional — pagina_path /melissa/agenda, categoria
Financeiro (cobre fluxo do AgendaEventoFinanceiroPanel)
- 07 Relatorios e exportacao — pagina_path /melissa/relatorios,
categoria Relatorios
Todas importadas no DB local via docker exec psql. Total acumulado:
7 docs ativas em saas_docs (busca + cronometro + os 5 novos).
PROXIMA SESSAO (retomar 23/05):
- Fase 6 RESTANTE: C12 UX iter (cronometro/sessao antecipar pgto —
flow DB ja ok, UX obscura adiada em 20/05). Unico item de codigo
da lista de ontem.
- Fase 7 RESTANTE: Regressao Agenda C7-C13 (validacao manual; eu
nao executo, so listo plano de teste se quiser).
- Antes/depois: olhada no ROADMAP.md canonico pra panorama MVP
real. Itens visiveis ainda no horizonte: #12 papel timbrado
(bloqueado, codigo no UniaoApp), #15 NFS-e (esforco L), §1.5
Sentry+qualidade, Asaas Fase B (bloqueado), M4 cutover billing
(depende decisoes #2/#3/#6), validacao centralizada CPF/CNPJ/tel.
ITENS TESTADOS HOJE (✅): tray + busca unificada + cronometro
evento-aware + edicao in-place de docs gerados + Fase 2.7-2.9
(gerar PDF, vars CRP/UF, tipo_documento='outro').
PUSH: 12 commits pushados (c17c547..701d9f4) usando workaround SSL
(git -c http.sslVerify=false push). Credenciais pediram 1x, depois
cacheou pra sessao toda.
## [2026-05-20 18:30] session | C12 deferred + C13 prep (lock ja existia em Fase 6)
Touched: none (codigo + HANDOFF; memoria project_c12_antecipar_iterar)
Detalhes:
@@ -1355,3 +1435,261 @@ document_templates ou setting tenants.letterhead_html.
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
sweep residual (M4 cutover billing — bloqueado decisoes #2/#3/#6),
ou agenda Fase 4 residual.
## [2026-05-21 night] session | agenda Fase 4: C12 UX iter + utils extract
Touched: none (durable em memoria project_c12_antecipar_iterar atualizada)
Iniciou agenda Fase 4 residual. Auditoria revelou: popover snapshot
e reverse transition trava JA estavam done de fato (commits f83315b
+ 5684297 durante C11). Pendentes reais: C12 UX, replicacao Rail/
Clinica, doc ajuda.
3 commits:
1) agenda C12 UX: "Trocar metodo" em vez de Revogar+Antecipar
MelissaEventoPanel ganha 2 botoes quando isAntecipacaoAtiva
(antes era so "Revogar"). MelissaLayout: anteciparMode ref +
onTrocarMetodoAntecipacao pre-seleciona metodo atual. confirm
Antecipar Pagamento ramifica: mode='update' faz UPDATE no paid
existente (sem cancel cycle). Result: trocar metodo gera 0
records cancelled.
2) agenda C12 UX: filtrar cancelled do dialog Lancamentos
lancamentosShowHistory ref (default false) + lancamentosFiltered
computed. UI: badge "{N} cancelado(s) ocultos" + toggle
Mostrar/Ocultar historico. Cards cancelled atenuados (opacity
0.55, border-dashed, line-through na desc) quando expandidos.
Combina com Trocar metodo — caso 99% so ve ativos.
3) agenda Fase A: extrai utils puros pra features/agenda/utils
Decomposicao em prep pra Rail/Clinica adotarem. 4 arquivos novos:
eventoTipo.js + dbFields.js + timeHelpers.js + colors.js.
useMelissaAgenda.js: 2863L -> 2792L (-71L), imports via aliases
pra nao mexer em 70+ callsites internos. Zero impacto comportamental.
C12 UX iter 3 (validar antecipar->Realizada nao duplica record) JA
estava implementado em commits 00c4168 + f83315b — comentario no
codigo de _loadStatusChangeContext confirma "ctx.existingPaidRecord"
pra evitar oferecer "Gerar cobranca nova".
PENDENTE replicacao Rail/Clinica:
- Fase B (service de billing): extrair _loadStatusChangeContext,
_applyStatusDecisions, _createPackageContract, _materializeAndCharge
PerSession num service reusavel. ~2-3h, risco medio (precisa nao
quebrar 7 ciclos da agenda C7-C13).
- Fase C/D: adapter em AgendaTerapeutaPage/AgendaClinicaPage.
ATUAL: decidir entre Fase B agora ou pausar replicacao + atacar
outro residual (NFS-e, sweep, etc).
## [2026-05-21 late night] session | agenda Fase B (B1+B2) — agendaBilling.service
Touched: none
Continua decomposicao da agenda pra Rail/Clinica. 2 commits cobrindo
Fase B inteira (read-only + mutations):
Fase B1 (e7e3d1b): agendaBilling.service nasce com
- computeSeriePrice (puro)
- generateOccurrenceDates (puro)
- needsStatusConfirmDialog (puro)
- loadStatusChangeContext (read-only, 5 deps)
useMelissaAgenda: 2792L -> 2593L (-199L)
Fase B2 (049dd91): adiciona mutations
- applyStatusDecisions (~330L — todas as decisoes do dialog)
- createPackageContract (~140L — upfront/saldo)
- materializeAndChargePerSession (~90L — per_session)
useMelissaAgenda: 2593L -> 2042L (-551L)
TOTAL fases A+B1+B2: 3033L -> 2042L (-991L extraidas, ~33% reducao).
3 pages (Melissa/Rail/Clinica) agora podem reusar mesmo billing
core. Comportamento Melissa identico — codigo movido linha-a-linha,
so refactor de signature pra receber deps explicitas em vez de
closure.
Pendente: Fase C (adapter Rail) + Fase D (adapter Clinica) +
doc ajuda.
## [2026-05-21 dawn] session | migrations + seeds aplicados no banco local
Touched: none
Aplicou todas as 13 migrations pendentes do dia (clinical_notes
tables/rls/versioning + documents link + accept_invite RPC + asaas
tables/rls + profiles registration + specialties + document_templates
consent types + sign_document RPCs + list_my_signatures + recibo
amend) + 3 seeds novos (seed_040 clinical_note_templates 6 entries +
seed_050 specialties 34 entries + seed_060 consent forms 2 templates
LGPD/Gravacao + amend tcle_online).
Gotcha re-validado (memoria atualizada): migration 20260521000005
estendendo CHECK dt_tipo_check foi marcada aplicada pelo db.cjs mas
silenciosamente ROLLBACK (postgres nao e owner de document_templates).
Detectado quando seed_060 falhou com violates check constraint.
Re-rodada via `docker exec -i ... sh -c 'psql -U supabase_admin -h
127.0.0.1 -d postgres'` (trust pra 127.0.0.1/32 em pg_hba.conf).
db.config.json estendido com os 3 seeds novos (system group, ordem
seed_040 -> seed_050 -> seed_060) pra setup do zero rodar tudo.
Sanity check pos-aplicacao:
- 5 RPCs novas (accept_tenant_invite + 3 sign + list_my_signatures)
- 8 tabelas novas (clinical_notes + versions + templates + asaas
customers/payments/webhook + profile_specialties + specialties)
- 17 document_templates global (15 existentes + 2 LGPD/Gravacao)
- 34 specialties seedadas
- 6 clinical_note_templates seedados
- 3 colunas professional_registration_* em profiles
- Backup automatico criado em backups/2026-05-21/
## [2026-05-21 deep night] session | agenda Fases C + D — Rail+Clinica adotam billing core
Touched: none
Replicacao Rail/Clinica fechada via composable reusavel
useAgendaStatusChange (Tipo A wrapper sobre agendaBilling.service).
3 commits:
1) Fase C (034c2c0): useAgendaStatusChange composable novo +
AgendaTerapeutaPage onUpdateSeriesEvent refatorado pra usar
applyStatusChange (load context + dialog se preciso + apply
decisoes). AgendaStatusChangeConfirmDialog plugado no template.
Antes: Rail fazia so update(id, { status }) cru. Zero das
features C7-C13.
Depois: Rail tem feature parity com Melissa pra status change.
Multa por falta, taxa cancelamento tardio, consumir saldo,
gerar cobranca pacote saldo, reverse transition trava.
2) Fase D (6807b44): AgendaClinicaPage espelha Fase C usando o
mesmo composable. Diferencas adaptadas (updateClinic + createClinic
recebem tenantId arg explicito).
3) Pendente residual:
- Indicadores visuais (3 canais: barra esquerda verde / badge $
amber / neutro) ainda nao replicados no Rail/Clinica — sao
custom event classNames do FullCalendar, requerem _payment
StateMap.
- Antecipar/Revogar/Trocar metodo no popover do Rail — Rail
nao tem popover separado, usa AgendaEventDialog direto;
precisa refactor maior pra acomodar.
- Doc ajuda completa.
ESTADO: agenda Fase 4 residual 70% fechada. C7-C13 core flow
(status change com billing) agora cobre os 3 layouts. UI fina
(popover antecipar, indicadores visuais) fica pra iter incremental
sob demanda.
TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
- CFP #6/#7 (Compliance Fase 1.2 ✅)
- #14 Recibo profissional PDF
- §1.3 UX 3/4 (#10 #11 #13)
- C12 UX iter (Trocar metodo + filtro cancelled)
- Agenda decomposicao A+B1+B2: -991L em useMelissaAgenda (~33%)
- Agenda Fases C+D: Rail+Clinica adotam billing core
- useAgendaStatusChange composable novo
## [2026-05-21 23:00] session | Melissa Fase 2 UX iter + bug isFinite(null)
Touched: feedback_isfinite_strict, feedback_teleport_body_styles
Detalhes:
Sessao de testes manuais Fase 2 (templates + paciente.documentos).
4 ajustes UX + 1 bug funcional resolvido. 5 commits, 0 push (SSL
self-signed Gitea — user faz manual amanha).
1) MelissaPatientDocuments (4e1ebeb, 6c39c58):
Aba Documentos no /melissa/paciente?id=X foi convertida de embed
<DocumentsListPage> pra pagina nativa 2-col Melissa. Drawer mobile
bugava (transform/filter em ancestrais trapando position:fixed).
Fix:
- <Teleport to="body"> no drawer + backdrop pra escapar stacking
- styles do drawer movidos pra <style> nao-scoped (teleport perde
data-v attrs do scoped)
- wrapper teleportado recebe class "win11-root" pra herdar vars
--m-* (definidas nesse escopo no MelissaLayout)
- cascata --mpd-bg/border/text: --m-* -> --p-* -> hardcoded
2) DocumentGenerateDialog (61bb0d9, 512bcc9):
Inputs trocados pra FloatLabel variant="on". Adicionado map de
ORIGEM dos campos (TEMPLATE_VARIABLES.source) — hint embaixo de
cada campo vazio explica onde cadastrar (ex: "Perfil -> Registro
Profissional"). Banner verde/amber no topo conta preenchidos.
3) Bug critico (4f05c2c) — RAIZ do "campos vem vazio mesmo com
profile preenchido":
loadAllVariables crashava com TypeError "Cannot read properties
of null (reading toFixed)" quando NAO havia sessao vinculada
(agendaEventoId=null) E sem extras.valor. Toda a Promise
estourava, variables zerava.
Causa: isFinite(null) global retorna TRUE (Number(null)===0),
entrava no branch valorNum.toFixed e crashava.
Fix: trocar por Number.isFinite (strict, nao coerce).
Salvo como memoria feedback_isfinite_strict.
PROXIMA SESSAO (retomar amanha 22/05):
- Continuar Fase 2: 2.7-2.9 (gerar PDF dentro da aba Documentos
do paciente, conferir vars CRP/UF preenchem, doc aparece como
tipo_documento='outro')
- Gerar JSON docs Fase 2 (#6 + templates page)
- Fase 3: Portal assinatura #7
- Fase 4: Recibo profissional #14 testes
- Fase 5: Relatorios export #13
- Fase 6: C12 UX iter (deferred 20/05)
- Fase 7: Regressao Agenda C7-C13
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
do Gitea exige `git -c http.sslVerify=false push origin main`
+ credenciais (user faz manual).
## [2026-06-12 10:47] session | F0 schema-per-tenant: varredura e categorizacao
Touched: Migracao Schema-per-Tenant, index
## [2026-06-12 11:49] session | F1 schema-per-tenant: template + helpers + clone
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
@@ -0,0 +1,145 @@
# Migração Schema-per-Tenant
**Status:** F6.0 + F6.1 concluídas e verificadas (2026-06-13). Dados dos 9 tenants migrados pros schemas, AINDA espelhados em public (nada dropado), backup em `database-novo/backups/pre-F6/`. Próximo: **F6.2 (rewrite das funções — push dedicado)**, depois checkpoint de teste do app, depois F6.3 (DROP, só com OK do Leonardo). F0-F2 em `main`; F3+/F1b/F5/F6.0-1 no branch.
## F6.0 + F6.1 — entregue (commit 003f2eb)
- F6.0 (migration 20260613000003): clone dos 9 tenants reais (schemas vazios, expostos no PostgREST via trigger F5).
- F6.1 (manual `database-novo/manual/f6_1_migrate_data.supabase_admin.sql`, rodar como supabase_admin): copia dados public→schemas com `session_replication_role=replica`. Tabelas com tenant_id por filtro; 3 filhas sem tenant_id (commitment_services, insurance_plan_services, recurrence_rule_services) por JOIN no pai; exclui colunas GENERATED (`net_amount`, `margin_brl`); reset de 4 sequences; ON CONFLICT DO NOTHING.
- **Verificado**: contagens public vs schemas batem (35 patients, 37 eventos, 355 mensagens, 54 financeiro, 13 commitment_services…). notification_templates "146" = 144 seeds (16×9) + 2 tenant — esperado.
- Gotcha: colunas GENERATED não aceitam INSERT → excluir via `is_generated='NEVER'`. DO block é atômico → erro no meio dá rollback total (re-rodar é seguro com ON CONFLICT).
## F6.2 — PLANO (rewrite de 66 funções + split notifications) — próximo push
Decisões já tomadas: parar antes do DROP pra testar app; split notifications JUNTO na F6.
Inventário de triggers (81 attachments nas tabelas tenant em public):
- **Schema-agnósticos** (só NEW/OLD, sem refs a tabela): família `set_updated_at`/`set_*_updated_at`, `fn_clinical_notes_updated_at`, `prevent_promoting_to_system`, `prevent_system_group_changes`, `patients_validate_member_consistency`, `fn_agenda_regras_semanais_no_overlap` → anexar nos schemas como estão.
- **Schema-aware** (~10, escrevem em OUTRA tabela tenant / audit / notifications) → reescrever com `set_config('search_path', TG_TABLE_SCHEMA||',public,pg_temp', true)` e `tenant_id_for_schema(TG_TABLE_SCHEMA)` onde precisam do tenant_id (audit_logs/notifications_sistema são globais): `auto_create_financial_record_from_session`, `sync_busy_mirror_agenda_eventos`, `notify_on_session_status`, `fanout_inbound_message_to_notifications`, `log_audit_change`, `fn_sla_resolve_on_outbound`, `fn_clinical_note_version`, `fn_document_signature_timeline`, `fn_documents_timeline_insert`, `trg_fn_patient_status_history/timeline/risco`, `sync_legacy_email/phone_fields`, `agenda_cfg_sync`, `cancel_notifications_*`, `fn_notify_agenda_status_change`, `trg_fn_financial_records_auto_overdue`.
- **`financial_records_inject_tenant` → OBSOLETO** no schema (não há coluna tenant_id) — NÃO anexar.
Sub-lotes propostos (cada um com smoke test + commit):
- **A** ✅ DONE (commit d58b939, migration 20260613000004): `attach_agnostic_triggers(schema)` recria os triggers agnósticos (8 setters updated_at + 2 prevent_*) nos 9 schemas (54 triggers/schema). Smoke: set_updated_at dispara. Wiring no clone fica pro fim da F6.2.
- **B** ✅ DONE (commit 5741e10, manual/f6_2b_schema_aware_triggers.supabase_admin.sql — roda como supabase_admin pois trigger fns são owned por supabase_admin). 14 funcs reescritas (set_config search_path dinâmico + tenant_id_for_schema p/ audit_logs global); sync_busy_mirror cross-tenant via tenant_schema_for+EXECUTE format; financial_records_inject_tenant obsoleto (não anexado). Detach dos 14 de public + attach 22 triggers/schema (defs reais, tenant_id removido de WHEN/UPDATE OF). Smoke: sessão→realizado cria financial_record no schema + audit roteia tenant_id certo + timeline OK.
- **Gotchas Lote B**: (1) trigger functions owned por supabase_admin → CREATE OR REPLACE só como supabase_admin (vira manual, não db.cjs). (2) Triggers reais tinham `WHEN (new.tenant_id=new.owner_id)` e `UPDATE OF tenant_id,...` → quebram no schema; remover tenant_id dos WHEN/colunas ao re-anexar. (3) Estratégia hybrid: detach de public pra função reescrita não rodar errada lá.
- **C** ✅ DONE (commit bedbb9b, manual/f6_2c_notifications_split.supabase_admin.sql). DESCOBERTA: neste projeto TODAS as notifs atuais (inbound_message, session_status, system_alert, new_patient) são tenant-LOCAIS — avisos cross-tenant do SaaS vivem em `global_notices`, não em notifications. Então: notifications fica tenant-local (já nos schemas); `public.notifications_sistema` criado como canal SaaS→tenant FUTURO (vazio hoje) + RLS + realtime + notify_user_sistema(). 4 notif-triggers tenant reescritos schema-aware + detach public + attach (5/schema); notify_on_intake/scheduling disparam em tabelas PUBLIC (F1b) → roteiam pro schema via tenant_schema_for+EXECUTE format; cancel_patient_pending herda search_path do chamador. Smoke: msg inbound → notif no schema, destinatário certo. Frontend notificationStore.js: load 2 fontes + merge por created_at + `_origem`; realtime 2 canais; markRead/archive roteiam por _origem. conversation_messages.id é bigint (gotcha no teste).
- **D** ✅ 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)
Os 3 únicos pontos de criação de tenant (`provision_account_tenant`, `create_clinic_tenant`, `ensure_personal_tenant_for_user` — este último também acionado pelo trigger de signup `handle_new_user_create_personal_tenant`) agora chamam `clone_tenant_template()` na mesma transação: clone falhou → tenant não nasce. Smoke: ensure_personal criou tenant pessoal `tenant_terapeuta_pessoal` com 84 tabelas + registro, 2ª chamada idempotente, drop limpou tudo. Não há fluxo de exclusão de tenant no sistema (drop_tenant_schema fica pra uso admin/manual).
## F1 — entregue (migrations 2026061200000105 em database-novo/migrations/)
- `tenants.slug` criado + backfill dos 9 + trigger auto-gera/imutável
- Helpers: `tenant_schema_name/for`, `tenant_id_for_schema`, `tenant_schema_checked(p_tenant_id)` (valida `is_tenant_member` — substitui current_tenant_schema do blueprint)
- `_tenant_template`: 84 tabelas sem tenant_id, 6 singletons (`singleton boolean PK/UQ` nas configs 1-linha: company_profiles, email_layout_config, conversation_autoreply_settings/bots/sla_rules, session_reminder_settings), 4 sequences locais, 94 FKs (62 intra + 32 pra public/auth), 6 views com placeholders `__SCHEMA__`/`__TENANT_ID__` em `_views`, seeds de sistema (whitelist 8 lookups)
- `clone_tenant_template(uuid)` → tabelas+seqs+seeds+FKs+views+RLS (policies com tenant_id EMBUTIDO: `is_tenant_member('<uuid>')` + saas_admin_full)+realtime+grants+trigger routing+registro em `tenant_schemas`
- `drop_tenant_schema(uuid)` protegido; `public.channel_routing` (webhook inbound acha tenant do canal) sincronizada por trigger
- Smoke: clone tenant_smoke_f1 → 84 tabelas/168 policies/roundtrip/routing sync/singleton rejeitando 2ª linha → drop limpo
### Gotchas aprendidos na F1
- **`postgres` não é superuser no Supabase** → `session_replication_role` proibido; seeds usam retry-loop de FK (rounds). Vale pro F6 (migração de dados): rodar como `supabase_admin` ou retry-loop.
- **db.cjs aplicava migration sem `ON_ERROR_STOP`** → rollback silencioso reportado como sucesso. Corrigido (psqlFile agora usa `-v ON_ERROR_STOP=1`).
- Linhas operacionais órfãs com tenant_id NULL (intakes/convites/notifs) NÃO são seeds — whitelist explícita.
- Clones F1/F2 ainda SEM triggers de negócio (F6) e fora do PostgREST (F5) — `_meta.triggers_pending=true`.
Migração de multi-tenant RLS-only (tenant_id em cada tabela) para schema físico por tenant (`tenant_<slug>`), seguindo blueprint do projeto irmão (`novo-rumo.txt` na raiz), adaptado.
## Artefatos
- `docs/F0_categorizacao.md` — varredura completa: classificação das 137 tabelas, 66 funções, 6 views, FKs, edge functions, divergências.
- `novo-rumo.txt` (raiz) — blueprint original com lições do projeto irmão.
## Números-chave
- 137 tabelas public → 79 tenant-scoped + 5 em decisão (infra mensageria) + 53 globais
- 66 funções afetadas (blueprint avisava: listas pré-feitas subestimam — era "29" lá, 66 aqui)
- 1 única FK global→tenant problemática: `whatsapp_credits_transactions.conversation_message_id`
- 0 policies de tabelas globais usando funções a refatorar
- 9 tenants (3 clínicas + 6 therapists), volumetria minúscula (<400 linhas/tabela)
## Divergências vs blueprint (decisivas)
1. **Sem `tenants.slug`** — precisa criar coluna ou usar uuid no nome do schema.
2. **Multi-membership**: `profiles.tenant_id` 100% NULL; verdade vive em `tenant_members` (4 users multi-tenant). `current_tenant_schema()` do blueprint não funciona → frontend escolhe schema ([[tenantStore]] já tem `activeTenantId`), segurança via policy com tenant_id embutido por schema + RPCs recebem `p_tenant_id` validado com `is_tenant_member()`.
3. **6/9 tenants são terapeutas individuais** — schema por signup; custo operacional do config.toml do PostgREST cresce com tenants.
4. `email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` apontam pra **auth.users** (legado) — mapear na migração de dados.
5. View `current_tenant_id` é código morto (claim JWT nunca populado).
## Decisões (2026-06-12)
- Q1: **criar `tenants.slug`** → schemas `tenant_<slug>`
- Q2: **todo tenant ganha schema** (clínicas e therapists)
- Q3: **mensageria tenant-scoped** (isolamento máximo, contra rec. global) → crons varrem tenants em loop; webhooks inbound precisam de índice global `channel_routing` (channel_external_id → tenant_id) pra rotear antes de gravar
- Q4: **asaas tenant** (staging `asaas_webhook_events` global roteia)
Total final: **84 tabelas tenant-scoped, 53 globais.**
## Fases (tasks #1#7 na sessão)
F0 categorização ✅ · F1 template+helpers · F2 provisionamento · F3 frontend useTenantDb · F4 edge functions · F5 PostgREST config · F6 rewrite funções + migração dados + drops (lotes, backup antes de cada um)
Relacionados: [[Decisões de Billing da Agenda]], [[Supabase Local]], [[index]]
+1
View File
@@ -30,3 +30,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.*
- [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4)
+1 -1
View File
@@ -89,7 +89,7 @@ function psqlFile(filePath) {
const absPath = path.resolve(filePath);
const content = fs.readFileSync(absPath, 'utf8');
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q -v ON_ERROR_STOP=1`;
return execSync(cmd, {
input: utf8Content,
encoding: 'utf8',
+4 -1
View File
@@ -22,7 +22,10 @@
"seed_015_document_templates.sql",
"seed_030_dev_phases_items.sql",
"seed_031_dev_auditoria.sql",
"seed_032_dev_competitors.sql"
"seed_032_dev_competitors.sql",
"seed_040_clinical_note_templates.sql",
"seed_050_specialties.sql",
"seed_060_consent_forms_extra.sql"
],
"test_data": [
"seed_020_test_data.sql"
@@ -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,21 @@
-- ============================================================================
-- Compliance CFP #5 — campo livre quando tipo de registro = 'outro'
-- ----------------------------------------------------------------------------
-- Migration 20260521000003 adicionou professional_registration_type com CHECK
-- limitado a 8 valores (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro). Quando o
-- profissional escolhe 'outro', precisa informar qual conselho/instituição
-- (ex: associações privadas, conselhos não-listados).
--
-- Esta migration adiciona professional_registration_type_other (text livre),
-- que só é preenchido quando type = 'outro'.
-- ============================================================================
BEGIN;
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS professional_registration_type_other text;
COMMENT ON COLUMN public.profiles.professional_registration_type_other IS
'Nome livre do conselho/instituição quando professional_registration_type = ''outro''. Aparece em recibos/laudos no lugar do tipo padrão.';
COMMIT;
@@ -0,0 +1,87 @@
-- =============================================================================
-- F1.1 — Schema-per-tenant: coluna tenants.slug
-- Plano: novo-rumo.txt + docs/F0_categorizacao.md (decisão Q1)
-- Slug é a base do nome do schema físico: tenant_<slug>. Unico e imutável.
-- =============================================================================
BEGIN;
ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS slug text;
-- Geração de slug a partir do nome (sanitizado pra identificador Postgres)
CREATE OR REPLACE FUNCTION public.generate_tenant_slug(p_name text)
RETURNS text
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
base text;
cand text;
n int := 1;
BEGIN
base := lower(coalesce(nullif(trim(p_name), ''), 'tenant'));
base := translate(base,
'áàâãäåéèêëíìîïóòôõöúùûüçñýÿ',
'aaaaaaeeeeiiiiooooouuuucnyy');
base := regexp_replace(base, '[^a-z0-9]+', '_', 'g');
base := regexp_replace(base, '^_+|_+$', '', 'g');
base := left(base, 48);
IF base = '' OR base !~ '^[a-z]' THEN
base := 't_' || base;
base := left(base, 48);
END IF;
cand := base;
WHILE EXISTS (SELECT 1 FROM public.tenants WHERE slug = cand) LOOP
n := n + 1;
cand := left(base, 44) || '_' || n;
END LOOP;
RETURN cand;
END;
$$;
-- Backfill dos tenants existentes
DO $$
DECLARE r record;
BEGIN
FOR r IN SELECT id, name FROM public.tenants WHERE slug IS NULL ORDER BY created_at, id LOOP
UPDATE public.tenants SET slug = public.generate_tenant_slug(r.name) WHERE id = r.id;
RAISE NOTICE 'tenant % -> slug %', r.id, (SELECT slug FROM public.tenants WHERE id = r.id);
END LOOP;
END $$;
ALTER TABLE public.tenants ALTER COLUMN slug SET NOT NULL;
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_key UNIQUE (slug);
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z][a-z0-9_]{1,47}$');
-- Auto-gera no INSERT (provisionamento atual não conhece slug); imutável no UPDATE
CREATE OR REPLACE FUNCTION public.trg_tenants_slug()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.slug IS NULL OR trim(NEW.slug) = '' THEN
NEW.slug := public.generate_tenant_slug(NEW.name);
END IF;
RETURN NEW;
END IF;
IF NEW.slug IS DISTINCT FROM OLD.slug THEN
RAISE EXCEPTION 'tenants.slug é imutável (tenant %, % -> %)', OLD.id, OLD.slug, NEW.slug;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_tenants_slug_ins ON public.tenants;
CREATE TRIGGER trg_tenants_slug_ins BEFORE INSERT ON public.tenants
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
DROP TRIGGER IF EXISTS trg_tenants_slug_upd ON public.tenants;
CREATE TRIGGER trg_tenants_slug_upd BEFORE UPDATE OF slug ON public.tenants
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
COMMIT;
@@ -0,0 +1,73 @@
-- =============================================================================
-- F1.2 — Schema-per-tenant: helpers de resolução de schema
-- Adaptação ao modelo multi-membership deste projeto (docs/F0_categorizacao.md D2):
-- profiles.tenant_id é NULL; membership vive em tenant_members (multi-tenant).
-- Logo NÃO existe current_tenant_schema() — RPCs recebem p_tenant_id explícito
-- e validam via tenant_schema_checked(p_tenant_id).
-- =============================================================================
BEGIN;
-- slug -> nome de schema (validado). Retorna NULL se slug inválido.
CREATE OR REPLACE FUNCTION public.tenant_schema_name(p_slug text)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $$
SELECT CASE
WHEN p_slug ~ '^[a-z][a-z0-9_]{1,47}$' THEN 'tenant_' || p_slug
ELSE NULL
END;
$$;
-- tenant_id -> nome de schema
CREATE OR REPLACE FUNCTION public.tenant_schema_for(p_tenant_id uuid)
RETURNS text
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
SELECT public.tenant_schema_name(t.slug) FROM public.tenants t WHERE t.id = p_tenant_id;
$$;
-- nome de schema -> tenant_id (CRÍTICO pra triggers: a coluna tenant_id não
-- existe mais nas tabelas tenant; o schema é a identidade)
CREATE OR REPLACE FUNCTION public.tenant_id_for_schema(p_schema text)
RETURNS uuid
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
SELECT t.id FROM public.tenants t WHERE public.tenant_schema_name(t.slug) = p_schema;
$$;
-- Resolve schema de um tenant COM validação de acesso do usuário logado.
-- Substitui o current_tenant_schema() do blueprint (que assumia 1 tenant/usuário).
CREATE OR REPLACE FUNCTION public.tenant_schema_checked(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 'tenant_schema_checked: 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 'acesso negado ao 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 'tenant % não encontrado ou slug inválido', p_tenant_id;
END IF;
RETURN v_schema;
END;
$$;
COMMIT;
@@ -0,0 +1,513 @@
-- =============================================================================
-- F1.3 — Schema-per-tenant: construção do schema _tenant_template
--
-- Clona a ESTRUTURA das 84 tabelas tenant-scoped (docs/F0_categorizacao.md §1)
-- a partir de public, SEM a coluna tenant_id:
-- * PK/UNIQUE compostos perdem tenant_id; PK/UNIQUE que eram SÓ (tenant_id)
-- viram coluna `singleton boolean` (tabela de config 1-linha-por-tenant)
-- * índices parciais WHERE tenant_id IS [NOT] NULL são fundidos/deduplicados
-- * sequences bigserial são localizadas no template (não compartilham public)
-- * FKs locais apontam pro template; FKs pra tabelas globais ficam em public/auth
-- * linhas-default do sistema (tenant_id IS NULL) viram SEED do template e
-- são copiadas pra cada tenant no clone
-- * 6 views adaptadas ficam registradas em _tenant_template._views com
-- placeholders __SCHEMA__ / __TENANT_ID__ (instanciadas no clone)
--
-- O template NUNCA é exposto no PostgREST nem recebe dados de tenant.
-- =============================================================================
BEGIN;
DROP SCHEMA IF EXISTS _tenant_template CASCADE;
CREATE SCHEMA _tenant_template;
-- Helper interno: remove tenant_id de uma definição de índice e simplifica
-- predicados parciais que testavam tenant_id.
CREATE FUNCTION _tenant_template._adapt_indexdef(p_def text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE d text := p_def;
BEGIN
-- coluna no início/meio/fim da lista
d := regexp_replace(d, '\(tenant_id,\s*', '(', 'g');
d := regexp_replace(d, ',\s*tenant_id\)', ')', 'g');
d := regexp_replace(d, ',\s*tenant_id,', ',', 'g');
-- predicados parciais
d := replace(d, '(tenant_id IS NOT NULL) AND ', '');
d := replace(d, ' AND (tenant_id IS NOT NULL)', '');
d := replace(d, ' WHERE ((tenant_id IS NOT NULL))', '');
d := replace(d, ' WHERE (tenant_id IS NOT NULL)', '');
d := replace(d, '(tenant_id IS NULL) AND ', '');
d := replace(d, ' AND (tenant_id IS NULL)', '');
d := replace(d, ' WHERE ((tenant_id IS NULL))', '');
d := replace(d, ' WHERE (tenant_id IS NULL)', '');
RETURN d;
END;
$$;
DO $$
DECLARE
tabs text[] := ARRAY[
'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'
];
t text;
r record;
r2 record;
v_def text;
v_sig text;
v_seq text;
v_cols text;
v_remaining text;
v_n int;
seen_sigs text[];
pending text[] := ARRAY[]::text[];
failed text[];
BEGIN
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
IF array_length(tabs, 1) <> 84 THEN
RAISE EXCEPTION 'lista de tabelas tenant deveria ter 84, tem %', array_length(tabs, 1);
END IF;
---------------------------------------------------------------------------
-- PASS 1: clonar estrutura
---------------------------------------------------------------------------
FOREACH t IN ARRAY tabs LOOP
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = t AND table_type = 'BASE TABLE') THEN
RAISE EXCEPTION 'tabela public.% não existe — lista F0 desatualizada', t;
END IF;
EXECUTE format('CREATE TABLE _tenant_template.%I (LIKE public.%I INCLUDING ALL)', t, t);
END LOOP;
RAISE NOTICE 'PASS 1 ok: % tabelas clonadas', array_length(tabs, 1);
---------------------------------------------------------------------------
-- PASS 2: localizar sequences (defaults nextval apontando pra public)
---------------------------------------------------------------------------
FOR r IN
SELECT c.relname AS tab, a.attname AS col,
pg_get_expr(d.adbin, d.adrelid) AS def
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 = '_tenant_template'::regnamespace
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''public.%'
LOOP
v_seq := r.tab || '_' || r.col || '_seq';
EXECUTE format('CREATE SEQUENCE _tenant_template.%I', v_seq);
EXECUTE format('ALTER TABLE _tenant_template.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
r.tab, r.col, '_tenant_template.' || v_seq);
EXECUTE format('ALTER SEQUENCE _tenant_template.%I OWNED BY _tenant_template.%I.%I',
v_seq, r.tab, r.col);
RAISE NOTICE 'PASS 2: sequence local _tenant_template.% (%.%)', v_seq, r.tab, r.col;
END LOOP;
---------------------------------------------------------------------------
-- PASS 3: drop tenant_id + recriar constraints/índices sem a coluna
---------------------------------------------------------------------------
FOREACH t IN ARRAY tabs LOOP
IF NOT EXISTS (SELECT 1 FROM pg_attribute
WHERE attrelid = ('_tenant_template.' || quote_ident(t))::regclass
AND attname = 'tenant_id' AND NOT attisdropped) THEN
CONTINUE; -- joins/children sem tenant_id (commitment_services etc.)
END IF;
-- 3a. capturar PK/UNIQUE que contêm tenant_id (no template)
CREATE TEMP TABLE IF NOT EXISTS _f1_cons (tab text, conname text, contype char, remaining text) ON COMMIT DROP;
DELETE FROM _f1_cons WHERE tab = t;
INSERT INTO _f1_cons
SELECT t, con.conname, con.contype,
(SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY k.ord)
FROM unnest(con.conkey) WITH ORDINALITY k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
WHERE a.attname <> 'tenant_id')
FROM pg_constraint con
WHERE con.conrelid = ('_tenant_template.' || quote_ident(t))::regclass
AND con.contype IN ('p', 'u')
AND EXISTS (SELECT 1 FROM unnest(con.conkey) k
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
WHERE a.attname = 'tenant_id');
-- 3b. capturar índices "soltos" (não-constraint) que usam tenant_id
CREATE TEMP TABLE IF NOT EXISTS _f1_idx (tab text, idxname text, def text) ON COMMIT DROP;
DELETE FROM _f1_idx WHERE tab = t;
INSERT INTO _f1_idx
SELECT t, c2.relname, pg_get_indexdef(i.indexrelid)
FROM pg_index i
JOIN pg_class c2 ON c2.oid = i.indexrelid
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
AND NOT EXISTS (SELECT 1 FROM pg_constraint cc WHERE cc.conindid = i.indexrelid)
AND pg_get_indexdef(i.indexrelid) ~ '\mtenant_id\M';
-- 3c. drop da coluna (leva junto constraints/índices que a usam)
EXECUTE format('ALTER TABLE _tenant_template.%I DROP COLUMN tenant_id CASCADE', t);
-- 3d. recriar PK/UNIQUE
FOR r IN SELECT * FROM _f1_cons WHERE tab = t LOOP
IF r.remaining IS NULL OR r.remaining = '' THEN
-- era PK/UNIQUE exatamente (tenant_id): tabela 1-linha-por-tenant
EXECUTE format('ALTER TABLE _tenant_template.%I ADD COLUMN singleton boolean NOT NULL DEFAULT true', t);
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I CHECK (singleton = true)',
t, t || '_singleton_chk');
IF r.contype = 'p' THEN
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I PRIMARY KEY (singleton)',
t, r.conname);
ELSE
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I UNIQUE (singleton)',
t, r.conname);
END IF;
RAISE NOTICE 'PASS 3: %.% era (tenant_id) -> singleton (%)', t, r.conname,
CASE r.contype WHEN 'p' THEN 'PK' ELSE 'UNIQUE' END;
ELSE
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s (%s)',
t, r.conname,
CASE r.contype WHEN 'p' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END,
r.remaining);
RAISE NOTICE 'PASS 3: %.% recriado sem tenant_id -> (%)', t, r.conname, r.remaining;
END IF;
END LOOP;
-- 3e. recriar índices soltos transformados (com dedupe)
seen_sigs := ARRAY[]::text[];
-- assinaturas dos índices que já existem na tabela (pós-recriação de constraints)
FOR r2 IN
SELECT regexp_replace(pg_get_indexdef(i.indexrelid),
'^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '') AS sig
FROM pg_index i
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
LOOP
seen_sigs := seen_sigs || r2.sig;
END LOOP;
FOR r IN SELECT * FROM _f1_idx WHERE tab = t LOOP
-- índice cuja ÚNICA coluna era tenant_id: descartar
IF r.def ~ '\(tenant_id\)( WHERE .*)?$' THEN
RAISE NOTICE 'PASS 3: índice % descartado (era só tenant_id)', r.idxname;
CONTINUE;
END IF;
v_def := _tenant_template._adapt_indexdef(r.def);
v_sig := regexp_replace(v_def, '^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '');
IF v_sig = ANY (seen_sigs) THEN
RAISE NOTICE 'PASS 3: índice % deduplicado', r.idxname;
CONTINUE;
END IF;
EXECUTE v_def;
seen_sigs := seen_sigs || v_sig;
RAISE NOTICE 'PASS 3: índice % recriado: %', r.idxname, v_sig;
END LOOP;
END LOOP;
---------------------------------------------------------------------------
-- PASS 4: FKs (a partir das FKs reais de public)
---------------------------------------------------------------------------
FOR r IN
SELECT con.conname,
cl.relname AS tab,
ns2.nspname AS fschema,
cl2.relname AS ftab,
pg_get_constraintdef(con.oid) AS def,
EXISTS (SELECT 1 FROM unnest(con.conkey) k
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
WHERE a.attname = 'tenant_id') AS uses_tenant_id
FROM pg_constraint con
JOIN pg_class cl ON cl.oid = con.conrelid
JOIN pg_class cl2 ON cl2.oid = con.confrelid
JOIN pg_namespace ns2 ON ns2.oid = cl2.relnamespace
WHERE con.contype = 'f'
AND cl.relnamespace = 'public'::regnamespace
AND cl.relname = ANY (tabs)
ORDER BY cl.relname, con.conname
LOOP
IF r.uses_tenant_id THEN
RAISE NOTICE 'PASS 4: FK %.% descartada (coluna tenant_id removida)', r.tab, r.conname;
CONTINUE;
END IF;
v_def := r.def;
IF r.fschema = 'public' AND r.ftab = ANY (tabs) THEN
-- alvo também é tenant-scoped -> referência intra-template
v_def := regexp_replace(v_def,
' REFERENCES (public\.)?' || r.ftab || '\(',
' REFERENCES _tenant_template.' || r.ftab || '(');
END IF;
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s', r.tab, r.conname, v_def);
END LOOP;
RAISE NOTICE 'PASS 4 ok: FKs recriadas';
---------------------------------------------------------------------------
-- PASS 5: seeds — linhas-default do sistema (tenant_id IS NULL em public)
-- APENAS tabelas de lookup/template (whitelist): linhas operacionais órfãs
-- com tenant_id NULL (intakes, convites, notifs) NÃO são defaults.
-- Sem session_replication_role (postgres não é superuser no Supabase):
-- resolve ordem de FK por tentativa-e-repetição em rounds.
---------------------------------------------------------------------------
FOREACH t IN ARRAY ARRAY[
'clinical_note_templates','contact_email_types','contact_types',
'conversation_optout_keywords','conversation_tags','document_templates',
'notification_templates','feriados'
] LOOP
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = t AND column_name = 'tenant_id') THEN
CONTINUE;
END IF;
EXECUTE format('SELECT count(*) FROM public.%I WHERE tenant_id IS NULL', t) INTO v_n;
IF v_n = 0 THEN CONTINUE; END IF;
pending := pending || t;
END LOOP;
WHILE coalesce(array_length(pending, 1), 0) > 0 LOOP
failed := ARRAY[]::text[];
FOREACH t IN ARRAY pending LOOP
SELECT string_agg(quote_ident(c.column_name), ', ' ORDER BY c.ordinal_position)
INTO v_cols
FROM information_schema.columns c
WHERE c.table_schema = '_tenant_template' AND c.table_name = t
AND c.column_name <> 'singleton'
AND EXISTS (SELECT 1 FROM information_schema.columns p
WHERE p.table_schema = 'public' AND p.table_name = t
AND p.column_name = c.column_name);
BEGIN
EXECUTE format('INSERT INTO _tenant_template.%I (%s) SELECT %s FROM public.%I WHERE tenant_id IS NULL',
t, v_cols, v_cols, t);
RAISE NOTICE 'PASS 5: linhas-default semeadas em _tenant_template.%', t;
EXCEPTION WHEN foreign_key_violation THEN
failed := failed || t;
END;
END LOOP;
IF array_length(failed, 1) = array_length(pending, 1) THEN
RAISE EXCEPTION 'PASS 5: dependência circular/externa nos seeds: %', failed;
END IF;
pending := failed;
END LOOP;
END $$;
-- =============================================================================
-- Metadados do template
-- =============================================================================
CREATE TABLE _tenant_template._meta (key text PRIMARY KEY, value jsonb NOT NULL);
INSERT INTO _tenant_template._meta VALUES
('template_version', '1'::jsonb),
('built_from', '"docs/F0_categorizacao.md"'::jsonb),
('triggers_pending', 'true'::jsonb); -- triggers de negócio só na F6
-- Tabelas que entram na publication supabase_realtime a cada clone
-- (espelha o estado atual da publication em public: conversation_messages, notifications)
CREATE TABLE _tenant_template._realtime_tables (table_name text PRIMARY KEY);
INSERT INTO _tenant_template._realtime_tables
SELECT tablename FROM pg_publication_tables
WHERE pubname = 'supabase_realtime' AND schemaname = 'public'
AND tablename IN (
'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'
);
-- Views adaptadas (instanciadas pelo clone com __SCHEMA__ / __TENANT_ID__)
CREATE TABLE _tenant_template._views (
view_name text PRIMARY KEY,
position int NOT NULL,
definition text NOT NULL
);
INSERT INTO _tenant_template._views VALUES
('conversation_threads', 1, $vw$
CREATE VIEW __SCHEMA__.conversation_threads WITH (security_invoker = true) AS
WITH base AS (
SELECT cm.id, cm.patient_id, cm.channel, cm.body, cm.direction,
cm.kanban_status, cm.read_at, cm.created_at,
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
COALESCE(cm.patient_id::text,
'anon:' || COALESCE(CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
'unknown')) AS thread_key
FROM __SCHEMA__.conversation_messages cm
), latest AS (
SELECT DISTINCT ON (base.thread_key)
base.thread_key, base.patient_id, base.channel, base.contact_number,
base.body AS last_message_body, base.direction AS last_message_direction,
base.kanban_status, base.created_at AS last_message_at
FROM base
ORDER BY base.thread_key, base.created_at DESC
), counts AS (
SELECT base.thread_key, count(*) AS message_count,
count(*) FILTER (WHERE base.direction = 'inbound' AND base.read_at IS NULL) AS unread_count
FROM base
GROUP BY base.thread_key
)
SELECT '__TENANT_ID__'::uuid AS tenant_id,
l.thread_key, l.patient_id, p.nome_completo AS patient_name,
l.contact_number, l.channel, c.message_count, c.unread_count,
l.last_message_at, l.last_message_body, l.last_message_direction,
l.kanban_status, ca.assigned_to, ca.assigned_at
FROM latest l
JOIN counts c ON c.thread_key = l.thread_key
LEFT JOIN __SCHEMA__.patients p ON p.id = l.patient_id
LEFT JOIN __SCHEMA__.conversation_assignments ca ON ca.thread_key = l.thread_key
$vw$),
('audit_log_unified', 2, $vw$
CREATE VIEW __SCHEMA__.audit_log_unified WITH (security_invoker = true) AS
SELECT 'audit:' || al.id::text AS uid, al.tenant_id, al.user_id, al.entity_type, al.entity_id, al.action,
CASE al.action
WHEN 'insert' THEN 'Criou ' || al.entity_type
WHEN 'update' THEN ('Alterou ' || al.entity_type) || COALESCE((' (' || array_to_string(al.changed_fields, ', ')) || ')', '')
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
END AS description,
al.created_at AS occurred_at, 'audit_logs' AS source,
jsonb_build_object('old_values', al.old_values, 'new_values', al.new_values, 'changed_fields', al.changed_fields) AS details
FROM public.audit_logs al
WHERE al.tenant_id = '__TENANT_ID__'::uuid
UNION ALL
SELECT 'doc_access:' || dal.id::text, '__TENANT_ID__'::uuid, dal.user_id, 'document', dal.documento_id::text, dal.acao,
CASE dal.acao
WHEN 'visualizou' THEN 'Visualizou documento'
WHEN 'baixou' THEN 'Baixou documento'
WHEN 'imprimiu' THEN 'Imprimiu documento'
WHEN 'compartilhou' THEN 'Compartilhou documento'
WHEN 'assinou' THEN 'Assinou documento'
ELSE dal.acao
END,
dal.acessado_em, 'document_access_logs',
jsonb_build_object('ip', dal.ip::text, 'user_agent', dal.user_agent)
FROM __SCHEMA__.document_access_logs dal
UNION ALL
SELECT 'psh:' || psh.id::text, '__TENANT_ID__'::uuid, psh.alterado_por, 'patient_status', psh.patient_id::text, 'status_change',
((('Status do paciente: ' || COALESCE(psh.status_anterior, '')) || '') || psh.status_novo) || COALESCE((' (' || psh.motivo) || ')', ''),
psh.alterado_em, 'patient_status_history',
jsonb_build_object('status_anterior', psh.status_anterior, 'status_novo', psh.status_novo, 'motivo', psh.motivo,
'encaminhado_para', psh.encaminhado_para, 'data_saida', psh.data_saida)
FROM __SCHEMA__.patient_status_history psh
UNION ALL
SELECT 'notif:' || nl.id::text, '__TENANT_ID__'::uuid, nl.owner_id, 'notification', nl.patient_id::text, nl.status,
((('Notificação ' || nl.channel) || ' ') || nl.status) || COALESCE(' para ' || nl.recipient_address, ''),
nl.created_at, 'notification_logs',
jsonb_build_object('channel', nl.channel, 'template_key', nl.template_key, 'status', nl.status,
'provider', nl.provider, 'failure_reason', nl.failure_reason)
FROM __SCHEMA__.notification_logs nl
UNION ALL
SELECT 'addon:' || at.id::text, at.tenant_id, at.admin_user_id, 'addon_transaction', at.id::text, at.type,
CASE at.type
WHEN 'purchase' THEN (('Compra de ' || at.amount) || ' créditos de ') || at.addon_type
WHEN 'consumption' THEN (('Consumo de ' || abs(at.amount)) || ' crédito(s) ') || at.addon_type
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
WHEN 'refund' THEN (('Reembolso de ' || abs(at.amount)) || ' créditos ') || at.addon_type
ELSE (at.type || ' ') || at.addon_type
END,
at.created_at, 'addon_transactions',
jsonb_build_object('addon_type', at.addon_type, 'amount', at.amount, 'balance_after', at.balance_after,
'price_cents', at.price_cents, 'payment_reference', at.payment_reference)
FROM public.addon_transactions at
WHERE at.tenant_id = '__TENANT_ID__'::uuid
$vw$),
('v_cashflow_projection', 3, $vw$
CREATE VIEW __SCHEMA__.v_cashflow_projection WITH (security_invoker = true) AS
SELECT gs.mes,
to_char(gs.mes, 'YYYY-MM') AS mes_label,
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS receitas_projetadas,
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS despesas_projetadas,
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'pending'), 0) AS receitas_pendentes,
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'overdue'), 0) AS receitas_vencidas,
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'pending'), 0) AS despesas_pendentes,
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'overdue'), 0) AS despesas_vencidas,
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0)
- COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS saldo_projetado,
count(fr.id) FILTER (WHERE fr.status = ANY (ARRAY['pending', 'overdue'])) AS count_registros
FROM generate_series(date_trunc('month', CURRENT_DATE::timestamp with time zone)::date::timestamp with time zone,
(date_trunc('month', CURRENT_DATE::timestamp with time zone) + '5 mons'::interval)::date::timestamp with time zone,
'1 mon'::interval) gs(mes)
LEFT JOIN __SCHEMA__.financial_records fr
ON fr.deleted_at IS NULL
AND (fr.status = ANY (ARRAY['pending', 'overdue']))
AND date_trunc('month', fr.due_date::timestamp with time zone)::date = gs.mes
GROUP BY gs.mes
ORDER BY gs.mes
$vw$),
('v_commitment_totals', 4, $vw$
CREATE VIEW __SCHEMA__.v_commitment_totals WITH (security_invoker = true) AS
SELECT '__TENANT_ID__'::uuid AS tenant_id,
c.id AS commitment_id,
COALESCE(sum(l.minutes), 0)::integer AS total_minutes
FROM __SCHEMA__.determined_commitments c
LEFT JOIN __SCHEMA__.commitment_time_logs l ON l.commitment_id = c.id
GROUP BY c.id
$vw$),
('v_patient_groups_with_counts', 5, $vw$
CREATE VIEW __SCHEMA__.v_patient_groups_with_counts WITH (security_invoker = true) AS
SELECT pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at,
COALESCE(count(pgp.patient_id), 0)::integer AS patients_count
FROM __SCHEMA__.patient_groups pg
LEFT JOIN __SCHEMA__.patient_group_patient pgp ON pgp.patient_group_id = pg.id
GROUP BY pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at
$vw$),
('v_tag_patient_counts', 6, $vw$
CREATE VIEW __SCHEMA__.v_tag_patient_counts WITH (security_invoker = true) AS
SELECT t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at,
COALESCE(count(ppt.patient_id), 0)::integer AS pacientes_count,
COALESCE(count(ppt.patient_id), 0)::integer AS patient_count
FROM __SCHEMA__.patient_tags t
LEFT JOIN __SCHEMA__.patient_patient_tag ppt ON ppt.tag_id = t.id AND ppt.owner_id = t.owner_id
GROUP BY t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at
$vw$);
-- Valida as views instanciando no próprio template (tenant nulo)
DO $$
DECLARE r record;
BEGIN
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
EXECUTE replace(replace(r.definition, '__SCHEMA__', '_tenant_template'),
'__TENANT_ID__', '00000000-0000-0000-0000-000000000000');
RAISE NOTICE 'view _tenant_template.% validada', r.view_name;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,301 @@
-- =============================================================================
-- F1.4 — Schema-per-tenant: clone/drop + registro + roteamento de canais
--
-- * public.tenant_schemas — registro dos schemas provisionados (alimenta o
-- gerador do config.toml na F5)
-- * public.channel_routing — índice global de roteamento: webhooks inbound
-- (Twilio/Evolution) precisam descobrir o tenant do canal ANTES de saber o
-- schema (decisão Q3: notification_channels mora no schema do tenant).
-- Mantido por trigger em cada tenant_<slug>.notification_channels.
-- * clone_tenant_template(tenant_id) — instancia tenant_<slug> a partir do
-- _tenant_template: tabelas + sequences locais + FKs + seeds + views + RLS
-- (policies com tenant_id EMBUTIDO — modelo multi-membership) + realtime +
-- grants + trigger de roteamento.
-- * drop_tenant_schema(tenant_id) — protegido (assert tenant_%).
--
-- NOTA: clones criados na F1/F2 ainda NÃO têm triggers de negócio (F6) e não
-- estão expostos no PostgREST (F5). _meta.triggers_pending registra isso.
-- =============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- Registro de schemas provisionados
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.tenant_schemas (
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
schema_name text NOT NULL UNIQUE,
template_version int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.tenant_schemas ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_schemas_select ON public.tenant_schemas;
CREATE POLICY tenant_schemas_select ON public.tenant_schemas
FOR SELECT TO authenticated
USING (public.is_tenant_member(tenant_id) OR public.is_saas_admin());
-- ---------------------------------------------------------------------------
-- Índice global de roteamento de canais (webhook inbound -> tenant)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.channel_routing (
channel_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
channel text NOT NULL,
provider text,
sender_address text,
twilio_subaccount_sid text,
twilio_phone_number text,
metadata jsonb,
is_active boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS channel_routing_tenant_idx ON public.channel_routing (tenant_id);
CREATE INDEX IF NOT EXISTS channel_routing_sender_idx ON public.channel_routing (sender_address) WHERE sender_address IS NOT NULL;
CREATE INDEX IF NOT EXISTS channel_routing_twilio_phone_idx ON public.channel_routing (twilio_phone_number) WHERE twilio_phone_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS channel_routing_twilio_sid_idx ON public.channel_routing (twilio_subaccount_sid) WHERE twilio_subaccount_sid IS NOT NULL;
-- Tabela de infra: só service_role (edge functions) e saas admin enxergam
ALTER TABLE public.channel_routing ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS channel_routing_saas_admin ON public.channel_routing;
CREATE POLICY channel_routing_saas_admin ON public.channel_routing
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- Trigger anexado a cada tenant_<slug>.notification_channels pelo clone
CREATE OR REPLACE FUNCTION public.trg_sync_channel_routing()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_tenant_id uuid;
BEGIN
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
IF v_tenant_id IS NULL THEN
RAISE WARNING 'trg_sync_channel_routing: schema % sem tenant correspondente', TG_TABLE_SCHEMA;
RETURN COALESCE(NEW, OLD);
END IF;
IF TG_OP = 'DELETE' THEN
DELETE FROM public.channel_routing WHERE channel_id = OLD.id;
RETURN OLD;
END IF;
INSERT INTO public.channel_routing AS cr
(channel_id, tenant_id, channel, provider, sender_address,
twilio_subaccount_sid, twilio_phone_number, metadata, is_active, updated_at)
VALUES
(NEW.id, v_tenant_id, NEW.channel, NEW.provider, NEW.sender_address,
NEW.twilio_subaccount_sid, NEW.twilio_phone_number, NEW.metadata,
COALESCE(NEW.is_active, false) AND NEW.deleted_at IS NULL, now())
ON CONFLICT (channel_id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
channel = EXCLUDED.channel,
provider = EXCLUDED.provider,
sender_address = EXCLUDED.sender_address,
twilio_subaccount_sid = EXCLUDED.twilio_subaccount_sid,
twilio_phone_number = EXCLUDED.twilio_phone_number,
metadata = EXCLUDED.metadata,
is_active = EXCLUDED.is_active,
updated_at = now();
RETURN NEW;
END;
$$;
-- ---------------------------------------------------------------------------
-- clone_tenant_template
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.clone_tenant_template(p_tenant_id uuid)
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_slug text;
v_schema text;
v_version int;
t text;
r record;
v_def text;
v_seq text;
v_n int;
v_pending text[];
v_failed text[];
BEGIN
SELECT slug INTO v_slug FROM public.tenants WHERE id = p_tenant_id;
IF v_slug IS NULL THEN
RAISE EXCEPTION 'clone_tenant_template: tenant % não existe ou sem slug', p_tenant_id;
END IF;
v_schema := public.tenant_schema_name(v_slug);
IF v_schema IS NULL THEN
RAISE EXCEPTION 'clone_tenant_template: slug % inválido', v_slug;
END IF;
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
RAISE EXCEPTION 'clone_tenant_template: schema % já existe', v_schema;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '_tenant_template') THEN
RAISE EXCEPTION 'clone_tenant_template: _tenant_template não existe (rode a F1.3)';
END IF;
SELECT (value)::int INTO v_version FROM _tenant_template._meta WHERE key = 'template_version';
EXECUTE format('CREATE SCHEMA %I', v_schema);
-- nomes qualificados nas definições geradas pelo catálogo
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
-- 1. tabelas
FOR r IN
SELECT table_name AS tab FROM information_schema.tables
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
AND table_name NOT LIKE '\_%'
ORDER BY table_name
LOOP
EXECUTE format('CREATE TABLE %I.%I (LIKE _tenant_template.%I INCLUDING ALL)',
v_schema, r.tab, r.tab);
END LOOP;
-- 2. sequences locais (defaults que apontam pro template)
FOR r IN
SELECT c.relname AS tab, a.attname AS col
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 = v_schema::regnamespace
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''_tenant_template.%'
LOOP
v_seq := r.tab || '_' || r.col || '_seq';
EXECUTE format('CREATE SEQUENCE %I.%I', v_schema, v_seq);
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
v_schema, r.tab, r.col, format('%I.%I', v_schema, v_seq));
EXECUTE format('ALTER SEQUENCE %I.%I OWNED BY %I.%I.%I',
v_schema, v_seq, v_schema, r.tab, r.col);
END LOOP;
-- 3. seeds (linhas-default do sistema guardadas no template)
-- Sem session_replication_role (postgres não é superuser no Supabase):
-- ordem de FK resolvida por tentativa-e-repetição em rounds.
v_pending := ARRAY[]::text[];
FOR r IN
SELECT table_name AS tab FROM information_schema.tables
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
AND table_name NOT LIKE '\_%'
ORDER BY table_name
LOOP
EXECUTE format('SELECT count(*) FROM _tenant_template.%I', r.tab) INTO v_n;
IF v_n > 0 THEN v_pending := v_pending || r.tab; END IF;
END LOOP;
WHILE coalesce(array_length(v_pending, 1), 0) > 0 LOOP
v_failed := ARRAY[]::text[];
FOR r IN SELECT unnest(v_pending) AS tab LOOP
BEGIN
EXECUTE format('INSERT INTO %I.%I SELECT * FROM _tenant_template.%I',
v_schema, r.tab, r.tab);
EXCEPTION WHEN foreign_key_violation THEN
v_failed := v_failed || r.tab;
END;
END LOOP;
IF array_length(v_failed, 1) = array_length(v_pending, 1) THEN
RAISE EXCEPTION 'clone_tenant_template: dependência circular nos seeds: %', v_failed;
END IF;
v_pending := v_failed;
END LOOP;
-- 4. FKs (intra-schema e pra public/auth)
FOR r IN
SELECT cl.relname AS tab, con.conname, pg_get_constraintdef(con.oid) AS def
FROM pg_constraint con
JOIN pg_class cl ON cl.oid = con.conrelid
WHERE con.contype = 'f'
AND cl.relnamespace = '_tenant_template'::regnamespace
ORDER BY cl.relname, con.conname
LOOP
v_def := replace(r.def, ' REFERENCES _tenant_template.', format(' REFERENCES %I.', v_schema));
EXECUTE format('ALTER TABLE %I.%I ADD CONSTRAINT %I %s', v_schema, r.tab, r.conname, v_def);
END LOOP;
-- 5. views (placeholders __SCHEMA__ / __TENANT_ID__)
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
EXECUTE replace(replace(r.definition, '__SCHEMA__', quote_ident(v_schema)),
'__TENANT_ID__', p_tenant_id::text);
END LOOP;
-- 6. RLS: tenant_id embutido (multi-membership: o usuário só enxerga
-- schemas de tenants onde tenant_members o lista como ativo)
FOR r IN
SELECT table_name AS tab FROM information_schema.tables
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
AND table_name NOT LIKE '\_%'
LOOP
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', v_schema, r.tab);
EXECUTE format(
'CREATE POLICY tenant_member_full ON %I.%I FOR ALL TO authenticated USING (public.is_tenant_member(%L::uuid)) WITH CHECK (public.is_tenant_member(%L::uuid))',
v_schema, r.tab, p_tenant_id, p_tenant_id);
EXECUTE format(
'CREATE POLICY saas_admin_full ON %I.%I FOR ALL TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin())',
v_schema, r.tab);
END LOOP;
-- 7. trigger de roteamento de canais
EXECUTE format(
'CREATE TRIGGER trg_channel_routing AFTER INSERT OR UPDATE OR DELETE ON %I.notification_channels FOR EACH ROW EXECUTE FUNCTION public.trg_sync_channel_routing()',
v_schema);
-- 8. realtime
FOR r IN SELECT table_name FROM _tenant_template._realtime_tables LOOP
EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE %I.%I', v_schema, r.table_name);
END LOOP;
-- 9. grants (espelha o padrão do Supabase pra schemas expostos)
EXECUTE format('GRANT USAGE ON SCHEMA %I TO anon, authenticated, service_role', v_schema);
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO anon, authenticated, service_role', v_schema);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO anon, authenticated, service_role', v_schema);
INSERT INTO public.tenant_schemas (tenant_id, schema_name, template_version)
VALUES (p_tenant_id, v_schema, COALESCE(v_version, 1));
RETURN v_schema;
END;
$$;
-- ---------------------------------------------------------------------------
-- drop_tenant_schema
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.drop_tenant_schema(p_tenant_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_schema text;
BEGIN
SELECT schema_name INTO v_schema FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
IF v_schema IS NULL THEN
v_schema := public.tenant_schema_for(p_tenant_id);
END IF;
IF v_schema IS NULL OR v_schema NOT LIKE 'tenant\_%' THEN
RAISE EXCEPTION 'drop_tenant_schema: schema inválido pra tenant % (%)', p_tenant_id, v_schema;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
RAISE EXCEPTION 'drop_tenant_schema: schema % não existe', v_schema;
END IF;
DELETE FROM public.channel_routing WHERE tenant_id = p_tenant_id;
DELETE FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
EXECUTE format('DROP SCHEMA %I CASCADE', v_schema);
END;
$$;
-- Clone/drop são operações de provisionamento: só service_role (edge) e postgres
REVOKE ALL ON FUNCTION public.clone_tenant_template(uuid) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.drop_tenant_schema(uuid) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.clone_tenant_template(uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.drop_tenant_schema(uuid) TO service_role;
COMMIT;
@@ -0,0 +1,24 @@
-- =============================================================================
-- F1.5 — Correção dos seeds do _tenant_template
--
-- O PASS 5 da F1.3 semeou TODA linha com tenant_id IS NULL de public — mas
-- patient_intake_requests (2), patient_invites (1) e notifications (2) eram
-- dados operacionais órfãos, não defaults do sistema. Cada tenant novo nasceria
-- com esses registros fantasmas.
--
-- Whitelist canônica de seeds do template (lookups/templates do sistema):
-- clinical_note_templates, contact_email_types, contact_types,
-- conversation_optout_keywords, conversation_tags, document_templates,
-- notification_templates, feriados
--
-- (20260612000003 foi corrigida em retrospecto pra instalações do zero;
-- esta migration corrige bancos que já aplicaram a versão original.)
-- =============================================================================
BEGIN;
DELETE FROM _tenant_template.patient_intake_requests;
DELETE FROM _tenant_template.patient_invites;
DELETE FROM _tenant_template.notifications;
COMMIT;
@@ -0,0 +1,167 @@
-- =============================================================================
-- F2 — Schema-per-tenant: provisionamento cria o schema físico
--
-- Os 3 pontos de criação de tenant passam a chamar clone_tenant_template()
-- logo após inserir em tenants/tenant_members. Tudo na mesma transação:
-- se o clone falhar, o tenant não nasce (atomicidade).
--
-- Pontos cobertos (F0 §levantamento — não há outros INSERT INTO tenants):
-- * provision_account_tenant — wizard de cadastro (therapist/clinic_*)
-- * create_clinic_tenant — criação avulsa de clínica
-- * ensure_personal_tenant_for_user — tenant pessoal (kind='saas'),
-- chamado também pelo trigger de signup (handle_new_user_create_personal_tenant)
--
-- Decisão Q2: TODO tenant ganha schema, inclusive therapist e pessoal.
-- Clones nascem sem triggers de negócio (F6) e fora do PostgREST (F5).
-- =============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.create_clinic_tenant(p_name text)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
declare
v_uid uuid;
v_tenant uuid;
v_name text;
begin
v_uid := auth.uid();
if v_uid is null then
raise exception 'Not authenticated';
end if;
v_name := nullif(trim(coalesce(p_name, '')), '');
if v_name is null then
v_name := 'Clínica';
end if;
insert into public.tenants (name, kind, created_at)
values (v_name, 'clinic', now())
returning id into v_tenant;
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
-- F2: schema físico do tenant (rollback junto se algo falhar)
perform public.clone_tenant_template(v_tenant);
return v_tenant;
end;
$function$;
CREATE OR REPLACE FUNCTION public.ensure_personal_tenant_for_user(p_user_id uuid)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
declare
v_uid uuid;
v_existing uuid;
v_tenant uuid;
v_email text;
v_name text;
begin
v_uid := p_user_id;
if v_uid is null then
raise exception 'Missing user id';
end if;
-- só considera tenant pessoal (kind='saas')
select tm.tenant_id
into v_existing
from public.tenant_members tm
join public.tenants t on t.id = tm.tenant_id
where tm.user_id = v_uid
and tm.status = 'active'
and t.kind = 'saas'
order by tm.created_at desc
limit 1;
if v_existing is not null then
return v_existing;
end if;
select email into v_email
from auth.users
where id = v_uid;
v_name := coalesce(split_part(v_email, '@', 1), 'Conta');
insert into public.tenants (name, kind, created_at)
values (v_name || ' (Pessoal)', 'saas', now())
returning id into v_tenant;
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
-- F2: schema físico do tenant (rollback junto se algo falhar)
perform public.clone_tenant_template(v_tenant);
return v_tenant;
end;
$function$;
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 $function$
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;
PERFORM public.seed_determined_commitments(v_tenant_id);
-- F2: schema físico do tenant (rollback junto se algo falhar)
PERFORM public.clone_tenant_template(v_tenant_id);
RETURN v_tenant_id;
END;
$function$;
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,39 @@
-- Backfill: linkar document_generated.documento_id em registros antigos
-- pra suportar re-edicao in-place de documentos gerados.
--
-- O codigo novo (DocumentGenerate.service.js saveGeneratedDocument) ja
-- preenche o documento_id no INSERT pra criacoes novas. Este script eh
-- one-off pra docs gerados ANTES desse fix.
--
-- Match: dg.pdf_path = d.bucket_path + match de tenant/patient pra evitar
-- linkar a doc errado em caso colidente. Registros sem match (paths que
-- nao existem mais em documents — docs deletados/cleanup) ficam orfaos
-- com documento_id=NULL: nao quebra nada, so nao tem caminho de re-edit.
BEGIN;
UPDATE public.document_generated dg
SET documento_id = d.id
FROM public.documents d
WHERE dg.documento_id IS NULL
AND dg.pdf_path = d.bucket_path
AND dg.patient_id = d.patient_id
AND dg.tenant_id = d.tenant_id
AND d.deleted_at IS NULL;
-- Relatorio pos-backfill
DO $REPORT$
DECLARE
v_linked int;
v_orphans int;
BEGIN
SELECT count(*) FILTER (WHERE documento_id IS NOT NULL),
count(*) FILTER (WHERE documento_id IS NULL)
INTO v_linked, v_orphans
FROM public.document_generated;
RAISE NOTICE 'document_generated: % linked, % orphans (sem documents correspondente)',
v_linked, v_orphans;
END;
$REPORT$;
COMMIT;
@@ -0,0 +1,166 @@
-- Importacao da doc Assinatura eletronica de documentos (Fase 3 #7)
-- Gerado a partir de development/saas-docs/05-assinatura-eletronica-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Assinatura eletrônica de documentos',
$HTML$<h2>Assinatura eletrônica de documentos</h2>
<p>O sistema permite enviar documentos clínicos (TCLE, contratos, autorizações, laudos) pro paciente assinar <strong>sem que ele precise ter login</strong>. O fluxo registra a assinatura com hash do conteúdo, IP, user-agent e timestamp gerando um audit trail compliance LGPD/CFP.</p>
<h3>1. Visão geral do fluxo</h3>
<ol>
<li><strong>Terapeuta</strong> abre o documento no prontuário e clica em <em>Assinar</em></li>
<li>Adiciona os signatários (nome + email) e ativa <em>"Gerar link público para assinatura"</em></li>
<li>Sistema cria signature requests + um <strong>link público temporário</strong> com token</li>
<li>Terapeuta copia a URL e envia pro paciente (WhatsApp, email, SMS manual por enquanto)</li>
<li><strong>Paciente</strong> abre o link em qualquer navegador, o documento, marca o checkbox de aceite LGPD e clica <em>Assinar</em></li>
<li>Sistema computa SHA-256 do PDF baixado, registra assinatura via RPC server-side (IP/UA capturados pelo banco)</li>
<li>Terapeuta o status atualizado no documento (pendente assinado)</li>
</ol>
<h3>2. Lado terapeuta criar solicitação</h3>
<p>No preview de um documento (na aba <em>Documentos</em> do prontuário), clique no botão <strong>Assinar</strong> na sidebar de ações. O <em>DocumentSignatureDialog</em> abre com:</p>
<ul>
<li><strong>Lista de signatários:</strong> adicione um ou mais pra cada um, nome + email são obrigatórios. O paciente principal vem pré-preenchido se disponível.</li>
<li><strong>Toggle "Gerar link público para assinatura"</strong> (default ON): cria um share_link junto com as signature requests. Sem isso, fica a request registrada o paciente precisa logar no portal pra assinar.</li>
<li><strong>Select de validade do link:</strong> 24h / 3 dias / 7 dias / 30 dias. Default 7 dias (168h).</li>
<li><strong>Submit:</strong> cria as requests + link e mostra a URL pronta pra copiar.</li>
</ul>
<h3>3. Lado paciente link público (sem login)</h3>
<p>Ao abrir o link <code>/shared/document/:token</code>, o paciente :</p>
<ul>
<li><strong>Preview do PDF</strong> inline (iframe)</li>
<li><strong>Painel azul</strong> embaixo do preview com:
<ul>
<li>Aviso LGPD/CFP explicando o que vai ser registrado</li>
<li>Checkbox <em>"Li o documento e concordo com seu conteúdo"</em> (bloqueia botões até marcado)</li>
<li>Select de signatário (se houver mais de um cadastrado)</li>
<li>Botões <strong>Assinar</strong> (emerald) e <strong>Recusar</strong> (rose)</li>
</ul>
</li>
</ul>
<p>Ao clicar Assinar:</p>
<ol>
<li>Sistema baixa o PDF e computa <strong>SHA-256</strong> client-side (proof of integrity se o doc foi alterado depois, hash não bate)</li>
<li>Chama a RPC <code>sign_document_by_token</code> passando o hash</li>
<li>RPC captura <strong>IP via inet_client_addr()</strong> e <strong>user-agent via current_setting('request.headers')</strong> server-side, à prova de spoof client-side</li>
<li>Registra em <code>document_signatures</code>: timestamp, hash, IP, UA, status='assinado'</li>
<li>Mostra tela de confirmação "Documento assinado com sucesso"</li>
</ol>
<h3>4. Audit trail registrado</h3>
<p>Cada assinatura grava:</p>
<ul>
<li><strong>signatario_id</strong> (se o signatário tem cadastro como paciente vinculado)</li>
<li><strong>signatario_nome</strong> e <strong>signatario_email</strong> (do que foi cadastrado pelo terapeuta)</li>
<li><strong>assinado_em</strong> timestamp da RPC (server time, não client clock)</li>
<li><strong>assinatura_hash</strong> SHA-256 do PDF no momento da assinatura</li>
<li><strong>ip_address</strong> capturado server-side (anti-spoof)</li>
<li><strong>user_agent</strong> header HTTP via current_setting</li>
<li><strong>status</strong> pendente / enviado / assinado / recusado / expirado</li>
</ul>
<div style="background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
<strong>🛡 Por que server-side?</strong> Capturar IP/UA no banco via <code>inet_client_addr()</code> é anti-spoof: o cliente não consegue forjar valores arbitrários. Garante que o audit trail reflete a sessão HTTP real, não um POST manipulado.
</div>
<h3>5. Recusar a assinatura</h3>
<p>O paciente pode <strong>recusar</strong> em vez de assinar útil se ele não concorda com o conteúdo. Click em <strong>Recusar</strong> abre um <em>confirm</em>; ao confirmar, o sistema registra a recusa (com timestamp + IP/UA da mesma forma) e marca a request como <code>status='recusado'</code>. O terapeuta isso na lista de signature requests e pode entrar em contato pra ajustar o documento.</p>
<h3>6. Portal do paciente lista de pendências</h3>
<p>Pacientes logados no portal (<code>/portal/documentos</code>) veem uma lista de TODOS os documentos solicitados pra eles, com KPIs no topo:</p>
<ul>
<li><strong>Total</strong> · <strong>Pendentes</strong> · <strong>Assinados</strong> · <strong>Recusados</strong></li>
</ul>
<p>Filtro por status (todos / pendentes / assinados) + lista. Click em <strong>Assinar agora</strong> num item pendente leva pro <code>/shared/document/:token</code> (mesma página pública, mas com auth garantida via portal).</p>
<h3>7. Expiração e múltiplos usos</h3>
<ul>
<li><strong>Validade do link:</strong> configurada na criação (24h/3d/7d/30d). Após expirar, retorna 410 Gone. Terapeuta pode gerar novo link pelo botão <em>Compartilhar</em> no preview do doc.</li>
<li><strong>Limite de usos:</strong> calculado como <code>max(signatários × 3, 5)</code> gerar 1 signatário 5 usos disponíveis (margem de erro / reload / multi-device).</li>
<li><strong>Cada assinatura é única:</strong> mesmo signatário não consegue assinar 2x a RPC bloqueia se houver registro com status=assinado.</li>
</ul>
<h3>8. Múltiplos signatários</h3>
<p>Documentos como termo de autorização de menor podem precisar de 2+ assinaturas (responsável legal + paciente menor, ou os dois pais). O dialog aceita N signatários; cada um recebe sua própria entry em <code>document_signatures</code>. O link público é o mesmo quando o paciente abre, escolhe qual signatário ele é no select e assina apenas a sua entry.</p>
<h3>9. Validade legal</h3>
<p>A assinatura eletrônica registrada pelo sistema atende:</p>
<ul>
<li><strong>LGPD (Lei 13.709/2018):</strong> consentimento explícito registrado com timestamp, IP e UA base legal Art. 7º I</li>
<li><strong>Código de Ética CFP:</strong> documento clínico com identificação inequívoca do signatário (nome+email+IP+hash)</li>
<li><strong>MP 2200-2/2001 (ICP-Brasil):</strong> assinatura <em>simples</em> com integridade via hash não é certificado A1/A3, mas é válida pra documentos sem exigência ICP</li>
</ul>
<p> Pra documentos que exigem ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada esse fluxo não substitui.</p>
<h3> Notas pro desenvolvedor</h3>
<ul>
<li><strong>RPCs:</strong> <code>sign_document_by_signature_id</code> (paciente logado no portal), <code>sign_document_by_token</code> (link público), <code>get_signable_document_by_token</code> (resolve token doc + signature_request), <code>list_my_signatures</code> (lista do paciente, cruza por signatario_id, signatario_email e patient.user_id)</li>
<li><strong>Service:</strong> <code>DocumentSignatures.service.js</code> com wrappers <code>signByPortal</code>, <code>signByToken</code>, <code>getSignableDocumentByToken</code>, <code>listMySignatures</code>, <code>hashDocument</code>, <code>refuseSignature</code>, <code>createSignatureRequests</code>, <code>createShareLink</code>, <code>buildShareUrl</code></li>
<li><strong>Composable:</strong> <code>useDocumentSignatures</code> (Tipo A blueprint)</li>
<li><strong>UI lado terapeuta:</strong> <code>DocumentSignatureDialog.vue</code> (component)</li>
<li><strong>UI lado paciente:</strong> <code>PortalDocumentos.vue</code> (portal logado) + <code>SharedDocumentPage.vue</code> (link público)</li>
<li><strong>Notificação automática</strong> (paciente recebe email/WA quando signature criada) pendente, depende de Módulo 6 (notifications factory channel)</li>
</ul>$HTML$,
'Documentos',
true,
'usuario',
'/melissa/paciente',
5,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como peço pra um paciente assinar um documento (TCLE, contrato, autorização)?',
$FAQ$Na aba <strong>Documentos</strong> do prontuário, clique no doc no preview, clique em <strong>Assinar</strong> (sidebar de ações). O dialog abre. Adicione o paciente como signatário (nome + email), mantenha <em>"Gerar link público para assinatura"</em> marcado, escolha validade (7 dias é o default) e clique em <strong>Solicitar</strong>. O sistema cria a request e mostra uma URL pra copiar. Envie pro paciente via WhatsApp, email, SMS como preferir.$FAQ$, 0, true),
(v_doc_id, 'Como o paciente assina sem ter login no sistema?',
$FAQ$Ele abre o link público (<code>/shared/document/:token</code>) em qualquer navegador. o PDF inline, o aviso LGPD/CFP, marca o checkbox <em>"Li o documento e concordo"</em>, e clica <strong>Assinar</strong>. O sistema computa hash SHA-256 do PDF, chama a RPC server-side que captura IP/User-Agent e registra a assinatura. Nada de cadastro, nada de senha.$FAQ$, 1, true),
(v_doc_id, 'Que informação fica registrada quando ele assina?',
$FAQ$Tudo que precisa pra audit compliance: <strong>nome e email</strong> do signatário (do cadastro), <strong>timestamp server-side</strong> (não do relógio do cliente), <strong>hash SHA-256 do PDF</strong> no momento da assinatura (qualquer alteração posterior invalida a integridade), <strong>IP</strong> e <strong>User-Agent</strong> capturados pelo banco via <code>inet_client_addr()</code> e <code>current_setting('request.headers')</code> anti-spoof. Tudo fica em <code>public.document_signatures</code>.$FAQ$, 2, true),
(v_doc_id, 'O terapeuta também precisa assinar o documento?',
$FAQ$Depende do tipo. Pra atestados, laudos e declarações, geralmente sim você gera o PDF a partir do template (que contém seu nome + registro profissional + assinatura digitalizada se você incluiu no rodapé). Pra contratos e termos com paciente como contraparte, você adiciona você mesmo como segundo signatário no dialog antes de enviar. Cada um abre o link e assina sua entry separadamente.$FAQ$, 3, true),
(v_doc_id, 'O link tem validade? E se expirar?',
$FAQ$Tem. Você escolhe 24h, 3 dias, 7 dias ou 30 dias na hora de criar (default 7d). Depois disso o link retorna erro 410 Gone. Se o paciente não assinou a tempo, gere um novo link: no preview do doc, clique em <strong>Compartilhar</strong> ou abra o dialog de assinatura novamente vai criar outro token. Limite de usos do link: ~5 (margem pra reload/multi-device), depois também expira.$FAQ$, 4, true),
(v_doc_id, 'E se o paciente recusar a assinatura?',
$FAQ$Tem botão <strong>Recusar</strong> ao lado do <em>Assinar</em>. Clique pede confirmação; ao confirmar, a request fica com <code>status='recusado'</code> com timestamp e IP/UA registrados igual à assinatura. Você o status na lista de pendências do doc e na aba do prontuário. Geralmente: ajuste o conteúdo do documento e envie nova solicitação.$FAQ$, 5, true),
(v_doc_id, 'O paciente pode assinar depois pelo Portal sem precisar do link?',
$FAQ$Sim, se ele tem conta de portal. Em <strong>/portal/documentos</strong> aparece a lista de tudo que está pendente pra ele assinar, com KPIs (total, pendentes, assinados, recusados) e botão <strong>Assinar agora</strong> que leva pra mesma página de assinatura. Útil pra pacientes que perderam o link no WhatsApp eles loga e acha tudo num lugar .$FAQ$, 6, true),
(v_doc_id, 'Como compartilho o link com o paciente — tem envio automático?',
$FAQ$Hoje o envio é <strong>manual</strong>: o dialog gera a URL, você copia e cola onde quiser (WhatsApp, email, SMS, AirDrop, QR code, link em conversa direta). Envio automático (notificação por WA/email quando signature é criada) está no roadmap, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado.$FAQ$, 7, true),
(v_doc_id, 'A assinatura tem validade legal mesmo sem certificado ICP-Brasil?',
$FAQ$Pra documentos clínicos comuns (TCLE, contrato de prestação, autorizações, declarações entre terapeuta-paciente), <strong>sim</strong>. A assinatura simples com timestamp + hash + IP + UA atende LGPD (Art. 7º I consentimento explícito) e o Código de Ética do CFP. Pra documentos que exigem certificado ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada esse fluxo não substitui.$FAQ$, 8, true),
(v_doc_id, 'O paciente consegue editar o documento antes de assinar?',
$FAQ$Não. O paciente <strong> visualiza</strong> o PDF é renderizado em iframe e a integridade é garantida pelo hash SHA-256 computado no momento da assinatura. Se o conteúdo precisar mudar, é você que ajusta o documento (editar via template ou regenerar) e envia nova solicitação. A assinatura antiga (se houve) fica registrada com o hash do conteúdo antigo o doc atual tem hash diferente, mostrando que mudou.$FAQ$, 9, true),
(v_doc_id, 'Como cancelo uma solicitação de assinatura?',
$FAQ$Hoje não um botão "cancelar" direto na UI. O caminho é: ignore (deixa expirar pelo prazo do link) ou peça pro admin marcar como <code>status='expirado'</code> no banco. Em versões futuras teremos botão de cancelar na lista de pendências do doc.$FAQ$, 10, true),
(v_doc_id, 'Posso pedir mais de uma pessoa pra assinar o mesmo documento?',
$FAQ$Sim. Pra termos com múltiplos signatários (autorização de atendimento de menor com 2 pais, contrato com responsável legal + paciente), adicione cada um como signatário separado no dialog. Cada um vira uma entry em <code>document_signatures</code>. O link público é o mesmo quando o signatário abre, escolhe quem ele é no select acima dos botões e assina apenas a entry dele. Útil também pra você incluir si mesmo (terapeuta) + paciente num contrato bilateral.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
+168
View File
@@ -0,0 +1,168 @@
-- Importação da doc Fase 1 (Busca global + Recently viewed)
-- Gerado a partir de development/saas-docs/01-busca-global-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
-- 1) Cria a doc principal
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Busca global e Acessados recentemente',
$HTML$<h2>Busca global no Layout Melissa</h2>
<p>A <strong>busca global</strong> é o atalho mais rápido para encontrar pacientes, sessões, documentos e cadastros recebidos sem precisar navegar pelos menus. Você acessa pelo <em>dock central</em> do Layout Melissa ou usando o atalho de teclado <kbd>Ctrl</kbd> + <kbd>K</kbd> (em qualquer página do Melissa).</p>
<h3>1. Como abrir</h3>
<p>Localize o campo de busca no dock central do Melissa. Ele aparece como um botão com o ícone de lupa e o placeholder <em>"Buscar paciente, agenda, atalho…"</em>, com o atalho <kbd>Ctrl K</kbd> indicado no canto direito.</p>
<div style="border: 1px solid #cbd5e1; border-radius: 12px; padding: 0 14px; height: 44px; max-width: 480px; display: flex; align-items: center; gap: 10px; background: #1e2333; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
<i class="pi pi-search" style="font-size: 0.95rem;"></i>
<span style="flex: 1; font-size: 0.9rem;">Buscar paciente, agenda, atalho</span>
<span style="font-size: 0.62rem; padding: 2px 7px; border-radius: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); letter-spacing: 0.05em;">Ctrl K</span>
</div>
<p><strong>Três jeitos de abrir:</strong></p>
<ul>
<li>Clicando no campo no dock central</li>
<li>Pressionando <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd> + K</kbd> (Mac) em qualquer página</li>
<li>Pelo menu lateral, opção "Buscar" (quando disponível)</li>
</ul>
<h3>2. O Dialog Spotlight</h3>
<p>Ao abrir, o sistema mostra um <strong>diálogo centralizado</strong> com o input grande no topo e os resultados em colunas abaixo. Isso é o padrão Spotlight (igual ao usado em macOS, Linear, GitHub, Slack).</p>
<div style="background: var(--surface-card, #fff); border: 1px solid #e2e8f0; border-radius: 14px; max-width: 520px; box-shadow: 0 12px 32px rgba(0,0,0,0.15); overflow: hidden; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
<div style="padding: 14px 18px; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; gap: 12px;">
<i class="pi pi-search" style="color: #64748b;"></i>
<span style="flex: 1; color: #94a3b8; font-size: 1.05rem;">Buscar paciente, agenda, atalho</span>
<span style="font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; background: #f1f5f9; border: 1px solid #e2e8f0; color: #64748b;">Esc</span>
</div>
<div style="padding: 6px;">
<div style="text-transform: uppercase; letter-spacing: 0.18em; color: #64748b; font-size: 0.62rem; font-weight: 700; padding: 8px 10px 4px; opacity: 0.75;">Acessados recentemente</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 8px;">
<span style="width: 32px; height: 32px; display: grid; place-items: center; border-radius: 7px; background: rgba(244,114,182,0.18); color: #ec4899; font-size: 0.9rem;">
<i class="pi pi-user"></i>
</span>
<div style="flex: 1;">
<div style="font-size: 0.88rem; font-weight: 500;">André Green</div>
<div style="font-size: 0.74rem; color: #64748b;">andre@email.com</div>
</div>
<i class="pi pi-arrow-right" style="color: #94a3b8; font-size: 0.75rem;"></i>
</div>
</div>
</div>
<h3>3. Onde a busca procura</h3>
<p>Digitando <strong>pelo menos 2 caracteres</strong>, o sistema dispara uma busca completa em 5 categorias:</p>
<ul>
<li><strong style="color: #ec4899;">Pacientes</strong> por nome completo, e-mail, telefone ou CPF</li>
<li><strong style="color: #6366f1;">Sessões</strong> por título ou nome do paciente, em qualquer data</li>
<li><strong style="color: #0ea5e9;">Documentos</strong> por nome do arquivo ou descrição</li>
<li><strong style="color: #f97316;">Cadastros recebidos</strong> solicitações de novos pacientes pendentes</li>
<li><strong>Atalhos</strong> ações rápidas como "Agenda", "Financeiro", etc.</li>
</ul>
<p>Cada categoria aparece com um <strong>ícone colorido distinto</strong> para facilitar a leitura visual. Os resultados são limitados aos 6 mais relevantes por categoria.</p>
<h3>4. Como navegar nos resultados</h3>
<p>Você pode usar o mouse ou o teclado:</p>
<ul>
<li><kbd></kbd> / <kbd></kbd> navegar entre os resultados</li>
<li><kbd>Enter</kbd> abrir o item selecionado</li>
<li><kbd>Esc</kbd> fechar o diálogo</li>
<li><kbd>Clique no backdrop</kbd> fecha também</li>
</ul>
<h3>5. Acessados recentemente</h3>
<p>Quando você abre a busca <strong>sem digitar nada</strong>, a primeira seção mostra <strong>"Acessados recentemente"</strong> os últimos 5 pacientes que você visitou (em qualquer dispositivo deste navegador).</p>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 12px 0; font-size: 0.88rem; color: #475569;">
<strong>💡 Dica:</strong> Use Ctrl+K + Enter para reabrir o último paciente acessado em 2 segundos.
</div>
<p>Esses 5 pacientes ficam salvos no seu navegador (não no banco de dados), então:</p>
<ul>
<li>São <strong>privados</strong> outros usuários não veem</li>
<li>São <strong>por navegador</strong> se trocar do Chrome pro Firefox, a lista recomeça</li>
<li><strong>Persistem</strong> após fechar o navegador (localStorage)</li>
<li>Auto-rotacionam: ao acessar o 6º paciente, o mais antigo sai</li>
</ul>
<h3>6. Clique nos resultados</h3>
<p>Ao clicar:</p>
<ul>
<li><strong>Paciente</strong> abre o prontuário (<code>/melissa/paciente?id=</code>)</li>
<li><strong>Sessão</strong> abre o evento na agenda</li>
<li><strong>Documento</strong> abre o prontuário do paciente na aba Documentos</li>
<li><strong>Cadastro recebido</strong> vai pra lista de Cadastros recebidos</li>
<li><strong>Atalho</strong> navega pra seção (Agenda, Financeiro, etc.)</li>
</ul>
<h3>7. Tema claro × escuro</h3>
<p>O Dialog adapta automaticamente as cores conforme o tema escolhido em <strong>Meu Perfil Preferências</strong>. Texto, fundos e bordas seguem as configurações do sistema. Apenas os ícones por categoria (paciente rosa, sessão índigo, documento azul, cadastro laranja) mantêm a mesma cor para preservar a identificação visual rápida.</p>
<h3> Notas pro desenvolvedor</h3>
<p>Atualmente o componente <code>MelissaBusca.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
<ul>
<li><code>id="melissa-busca-trigger"</code> no botão de trigger no dock</li>
<li><code>id="melissa-busca-dialog"</code> no Dialog</li>
<li><code>id="melissa-busca-input"</code> no input dentro do Dialog</li>
<li><code>id="melissa-busca-recent"</code> no grupo de Acessados recentemente</li>
</ul>$HTML$,
'Navegação',
true,
'usuario',
'/melissa',
1,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
-- 2) Insere os 12 FAQ items vinculados
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como abrir a busca rapidamente?',
$FAQ$Use o atalho <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd> + K</kbd> (Mac) em qualquer página do Melissa. Você também pode clicar diretamente no campo de busca no dock central.$FAQ$, 0, true),
(v_doc_id, 'Posso buscar paciente por telefone ou CPF?',
$FAQ$Sim. A busca de pacientes encontra pelo <strong>nome completo, e-mail, telefone ou CPF</strong>. Digite pelo menos 2 caracteres e aguarde os resultados.$FAQ$, 1, true),
(v_doc_id, 'O que aparece em "Acessados recentemente"?',
$FAQ$Os últimos 5 pacientes que você abriu pelo prontuário, em ordem do mais recente pro mais antigo. A lista aparece quando você abre a busca <strong>sem digitar nada</strong>.$FAQ$, 2, true),
(v_doc_id, 'Outros usuários veem meus "Acessados recentemente"?',
$FAQ$Não. A lista é <strong>privada e local</strong> fica salva apenas no seu navegador atual (localStorage). Se você logar em outro navegador ou computador, a lista começa vazia naquele dispositivo.$FAQ$, 3, true),
(v_doc_id, 'Quantos caracteres preciso digitar pra começar a buscar?',
$FAQ$<strong>Pelo menos 2</strong>. Buscas de 1 caractere são muito amplas e não disparam pesquisa. A partir de 2 caracteres, o sistema aguarda 200ms (tempo de digitação) antes de consultar o banco assim você não dispara dezenas de buscas digitando rápido.$FAQ$, 4, true),
(v_doc_id, 'Por que minha busca não retorna nada?',
$FAQ$Verifique: (1) digitou pelo menos 2 caracteres; (2) o termo está sem erros graves de digitação (a busca tolera pequenas variações via similarity); (3) o paciente/sessão realmente existe no seu cadastro. Se persistir, faça uma busca mais ampla ex: apenas o primeiro nome.$FAQ$, 5, true),
(v_doc_id, 'O que cada cor de ícone significa?',
$FAQ$Cada categoria tem uma cor própria: <strong style="color: #ec4899;">Rosa</strong> = Paciente, <strong style="color: #6366f1;">Índigo</strong> = Sessão da agenda, <strong style="color: #0ea5e9;">Azul</strong> = Documento, <strong style="color: #f97316;">Laranja</strong> = Cadastro recebido pendente. Atalhos vêm em cinza neutro.$FAQ$, 6, true),
(v_doc_id, 'Como navegar pelos resultados sem usar o mouse?',
$FAQ$Use as setas do teclado <kbd></kbd> e <kbd></kbd> para navegar entre os itens e <kbd>Enter</kbd> para abrir o selecionado. Pra fechar sem selecionar, use <kbd>Esc</kbd>.$FAQ$, 7, true),
(v_doc_id, 'Posso buscar documentos pelo nome do paciente?',
$FAQ$Sim. A busca de documentos cruza pelo nome do arquivo, descrição e <strong>nome do paciente vinculado</strong>. Ao clicar num resultado de documento, você é levado direto pra aba Documentos do prontuário daquele paciente.$FAQ$, 8, true),
(v_doc_id, 'Como limpar a lista de "Acessados recentemente"?',
$FAQ$Hoje não um botão na interface a lista é gerenciada automaticamente (limite de 5, mais antigo cai quando você acessa um novo). Pra limpar manualmente, você pode apagar os dados do site no seu navegador (Configurações Privacidade Limpar dados de navegação escopo "localStorage").$FAQ$, 9, true),
(v_doc_id, 'A busca encontra sessões antigas ou só as de hoje?',
$FAQ$Encontra sessões de <strong>qualquer data</strong> passadas e futuras. O grupo "Agenda de hoje" mostra apenas as do dia atual (preview rápido); o grupo "Sessões" inclui todas as outras encontradas no banco. Cada item mostra a data e horário da sessão.$FAQ$, 10, true),
(v_doc_id, 'Os atalhos (Agenda, Financeiro, etc.) sempre aparecem?',
$FAQ$Sim. Quando o campo está vazio, mostramos 4 atalhos padrão. Conforme você digita, os atalhos que combinam com sua busca permanecem visíveis (junto com os resultados do banco).$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
+168
View File
@@ -0,0 +1,168 @@
-- Importacao da doc do Cronometro de sessao (Melissa)
-- Gerado a partir de development/saas-docs/02-cronometro-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
-- 1) Cria a doc principal
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Cronômetro de sessão',
$HTML$<h2>Cronômetro de sessão</h2>
<p>O <strong>cronômetro de sessão</strong> acompanha o tempo decorrido durante o atendimento e é integrado com a agenda. Quando aberto a partir de uma sessão em andamento, ele vem com o paciente pré-selecionado e dispara automaticamente.</p>
<h3>1. Três jeitos de abrir</h3>
<ul>
<li>Pelo botão <strong></strong> ao lado do relógio gigante do dashboard (abre vazio, escolha o paciente ou deixe como atividade livre)</li>
<li>Pelo botão <strong></strong> que aparece sobre os cards de sessão em curso na timeline horizontal/vertical do dashboard</li>
<li>Pelo CTA <strong>"Iniciar cronômetro"</strong> no card <em>"Próximo paciente"</em> quando a sessão está em andamento</li>
</ul>
<p>Os dois últimos pré-selecionam o paciente da sessão e disparam o timer automaticamente.</p>
<h3>2. Sessão em curso na timeline</h3>
<p>Quando uma sessão entra em andamento (horário atual entre início e fim do evento), aparece um ícone <strong></strong> pulsando no canto superior direito do card do evento. O pulso é sutil, em verde pra sinalizar que pra cronometrar dali.</p>
<div style="display: flex; align-items: center; gap: 8px; background: #6366f1; color: white; padding: 4px 10px; border-radius: 4px; max-width: 320px; position: relative; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
<span style="font-size: 0.85rem; font-weight: 600;">11:00 Larissa Almeida</span>
<span style="position: absolute; top: 3px; right: 3px; width: 22px; height: 22px; display: grid; place-items: center; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.4); border-radius: 999px; color: white; box-shadow: 0 0 0 4px rgba(16,185,129,0.25);">
<i class="pi pi-stopwatch" style="font-size: 0.7rem;"></i>
</span>
</div>
<p>Clicar no <strong></strong> <strong>não abre o evento</strong> abre o cronômetro pré-configurado pra essa sessão.</p>
<h3>3. Programado vs tempo real</h3>
<p>Quando aberto via timeline ou card "Próximo paciente", o cronômetro mostra o <strong>horário programado original da sessão</strong> sob o select de paciente. Se você abriu depois do horário previsto, aparece um badge laranja <strong>"atrasada X min"</strong>.</p>
<div style="background: rgba(15,23,42,0.85); color: #cbd5e1; padding: 14px; border-radius: 10px; max-width: 360px; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
<label style="font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.5); display: block; margin-bottom: 8px;">Paciente / atividade</label>
<div style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); padding: 9px 14px; border-radius: 10px; font-size: 0.9rem;">Larissa Almeida</div>
<div style="display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 4px 0;">
<i class="pi pi-calendar" style="font-size: 0.7rem; color: rgba(255,255,255,0.55);"></i>
<span style="font-size: 0.78rem; color: rgba(255,255,255,0.7);">Programado: 11:00 11:50</span>
<span style="margin-left: 4px; padding: 1px 8px; border-radius: 999px; background: rgba(251,146,60,0.18); color: rgb(253,186,116); font-size: 0.7rem; font-weight: 500; border: 1px solid rgba(251,146,60,0.35);">atrasada 8 min</span>
</div>
</div>
<p> <strong>O cronômetro NÃO desconta o tempo de atraso automaticamente.</strong> Ele conta a duração configurada cheia (50min padrão) a partir do clique. A info "atrasada" é pra você decidir se quer encerrar antes ou estender.</p>
<h3>4. Anatomia do dialog</h3>
<ul>
<li><strong>Header:</strong> rótulo "Cronômetro" + status (<em>Pronto</em> / <em>Em andamento</em> / <em>Pausado</em>)</li>
<li><strong>Botão Minimizar:</strong> recolhe pro chip no dock</li>
<li><strong>Botão X (Encerrar sem salvar):</strong> descarta a sessão (com confirmação se houver atividade)</li>
<li><strong>Select de paciente:</strong> pode trocar manualmente; opção <em>"— Atividade livre"</em> pra usos sem paciente</li>
<li><strong>Programado + badge de atraso:</strong> aparece quando aberto via evento da agenda</li>
<li><strong>Display gigante mm:ss:</strong> vira vermelho quando passa do tempo planejado (mostra <code>-mm:ss</code>)</li>
<li><strong>±5 min:</strong> estende ou encurta o tempo configurado a qualquer momento</li>
<li><strong>Botão grande inferior:</strong> Começar / Parar</li>
</ul>
<h3>5. Minimizar e restaurar</h3>
<p>Click no <strong>botão minimizar</strong> (ou click fora do dialog) <strong>recolhe</strong> o cronômetro pra um <strong>chip flutuante no dock</strong> (canto inferior esquerdo, ao lado do ψ). O timer continua rodando em background. Click no chip restaura o dialog em tela cheia.</p>
<div style="display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px 8px 12px; background: rgba(15,23,42,0.85); border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,0.25); margin: 12px 0;">
<i class="pi pi-stopwatch" style="font-size: 0.85rem; color: #6ee7b7;"></i>
<span style="font-variant-numeric: tabular-nums; font-weight: 500; font-size: 0.85rem;">48:13</span>
<span style="font-size: 0.72rem; color: rgba(255,255,255,0.6); padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.18);">Larissa Almeida</span>
</div>
<p>Em mobile (telas &lt;768px), o chip mostra o ícone + tempo sem o nome do paciente pra caber no dock estreito. O nome continua acessível ao restaurar.</p>
<h3>6. Parar (salva) vs Fechar (descarta)</h3>
<p>Duas ações diferentes pra terminar a escolha importa:</p>
<ul>
<li><strong> Parar</strong> (botão grande inferior): encerra a contagem e <strong>SALVA o tempo decorrido no banco</strong> (evento <code>session-end</code> com elapsed em segundos). Caminho normal de fim de sessão.</li>
<li><strong>X Encerrar sem salvar</strong> (header): descarta. Pede <strong>confirmação</strong> se sessão em andamento ou tempo decorrido não fecha por acidente. Se o cronômetro está limpo (não iniciado, sem tempo), fecha direto.</li>
<li><strong>Click fora / Minimizar</strong>: NÃO encerra. Esconde o dialog e mantém o timer rodando como chip no dock.</li>
</ul>
<h3>7. Quando o tempo acaba</h3>
<p>Aos <strong>50 minutos cronometrados</strong> (ou conforme configurado), o sistema toca um <strong>som curto</strong> uma única vez. O display vira <strong>vermelho</strong> e continua contando em negativo (mostra <code>-mm:ss</code>). <strong>Não corte automático</strong> você decide quando parar.</p>
<p>O toque pode ser trocado em <strong>Configurações Cronômetro Som de término</strong>. Opções: sino, gong, soft, silêncio.</p>
<h3>8. Persistência (reload-safe)</h3>
<p>Se você fechar a aba ou recarregar o navegador com cronômetro ativo, ao voltar o sistema <strong>retoma exatamente de onde parou</strong> descontando automaticamente o tempo passado entre fechar e abrir. O snapshot fica no <code>localStorage</code> do navegador, atualizado a cada mudança de estado.</p>
<p><strong>Limite de segurança:</strong> se passar de 24h sem voltar à aba, o restore não acumula o tempo perdido (proteção contra mudanças do relógio do sistema).</p>
<h3>9. Cronômetro ativo</h3>
<p>Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong></strong> de outra sessão enquanto cronômetro rodando, o sistema mostra um toast <strong>"Cronômetro já ativo"</strong> com o nome do paciente atual <strong>e não troca</strong>. Pare o cronômetro atual antes de iniciar outro.</p>
<h3>10. Atividade livre (sem paciente)</h3>
<p>Você pode abrir o cronômetro sem paciente (botão do dashboard, sem clicar em evento específico) e selecionar <em>"— Atividade livre (sem paciente)"</em> no dropdown. Útil pra:</p>
<ul>
<li>Pausa cronometrada</li>
<li>Pomodoro pessoal</li>
<li>Atendimento informal não cadastrado</li>
</ul>
<p>Atividade livre <strong>não emite session-end</strong> ao parar (não paciente pra vincular o tempo).</p>
<h3> Notas pro desenvolvedor</h3>
<p>Atualmente o componente <code>MelissaCronometro.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
<ul>
<li><code>id="crono-trigger-hero"</code> no botão ao lado do relógio (<code>MelissaHeroClock.vue</code>)</li>
<li><code>id="crono-trigger-timeline"</code> nos botões overlay (<code>MelissaTimelineHoje.vue</code>)</li>
<li><code>id="crono-dialog"</code> no panel principal (<code>.mc-panel</code>)</li>
<li><code>id="crono-stop-btn"</code> no botão Parar (caminho do salvamento)</li>
<li><code>id="crono-close-btn"</code> no X (caminho do descarte)</li>
</ul>$HTML$,
'Sessão',
true,
'usuario',
'/melissa',
2,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
-- 2) Insere os 12 FAQ items vinculados
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como começo o cronômetro da Larissa que chegou agora pra sessão?',
$FAQ$Quando o horário programado da Larissa estiver dentro da janela do evento ( começou na agenda), aparece um botão <strong></strong> pulsando em verde no canto superior direito do card da sessão na timeline. Clique nele o cronômetro abre com a Larissa pré-selecionada e <strong> começa a contar automaticamente</strong>. Alternativa: clique no botão <em>"Iniciar cronômetro"</em> no card "Próximo paciente" do dashboard (mesmo efeito).$FAQ$, 0, true),
(v_doc_id, 'O cronômetro continua se eu fechar a aba do navegador?',
$FAQ$<strong>Sim.</strong> O estado é salvo no <code>localStorage</code> a cada mudança (paciente, play, pause, ajustes). Ao reabrir a aba (ou recarregar), o cronômetro retoma do ponto correto o tempo passado entre fechar e abrir é descontado automaticamente. Limite: se passar de 24h, o sistema não acumula esse tempo (proteção contra mudanças do relógio do sistema).$FAQ$, 1, true),
(v_doc_id, 'Cliquei no X com sessão rodando, perdi o tempo?',
$FAQ$Não, o sistema <strong>pede confirmação antes</strong>. Quando sessão em andamento ou tempo decorrido sem salvar, aparece um diálogo <em>"Encerrar sessão sem salvar?"</em>. Você precisa clicar em <strong>"Encerrar sem salvar"</strong> (botão vermelho) pra confirmar o descarte. Se o cronômetro estiver limpo (não iniciado, sem tempo), o X fecha direto não nada pra preservar.$FAQ$, 2, true),
(v_doc_id, 'Posso ter dois cronômetros rodando ao mesmo tempo?',
$FAQ$Não. Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong></strong> de outra sessão enquanto um cronômetro ativo, o sistema mostra um toast <em>"Cronômetro já ativo — sessão de X em andamento"</em> e <strong>não troca</strong>. Pare ou descarte o atual antes de iniciar outro.$FAQ$, 3, true),
(v_doc_id, 'O que significa o badge laranja "atrasada 8 min"?',
$FAQ$Significa que <strong>o cronômetro foi aberto 8 minutos depois do horário programado</strong> da sessão na agenda. Por exemplo: sessão programada pra 11:00, você inicia o cronômetro às 11:08. O badge é apenas informativo o cronômetro continua contando a duração configurada cheia (50min padrão) a partir do clique. Você decide se vai encerrar antes pra terminar no horário previsto ou deixar rodar pra dar a sessão completa.$FAQ$, 4, true),
(v_doc_id, 'O cronômetro desconta o tempo de atraso automaticamente?',
$FAQ$<strong>Não.</strong> A decisão fica com você. Cada clínica e cada terapeuta tem uma política diferente pra atraso (alguns dão sessão cheia, outros encerram no horário programado, outros estendem). O cronômetro mostra a info do atraso pra você decidir, mas conta sempre a duração configurada cheia a partir do clique.$FAQ$, 5, true),
(v_doc_id, 'Que som toca quando o tempo acaba?',
$FAQ$Por padrão, um <strong>som de sino curto</strong>, uma única vez. Você pode trocar em <strong>Configurações Cronômetro Som de término</strong>. Opções: sino, gong, soft, silêncio. O som toca <strong>exatamente na transição</strong> de tempo positivo pra zero/negativo não repete. Depois disso o display continua contando em negativo (vermelho) até você parar.$FAQ$, 6, true),
(v_doc_id, 'Como adiciono mais tempo na sessão sem reiniciar?',
$FAQ$Use os botões <strong>+5 min</strong> e <strong>-5 min</strong> ao redor do display gigante. Funcionam a qualquer momento antes, durante ou depois do tempo acabar. Cada clique soma ou desconta 5 minutos. Se o tempo está negativo (passou do limite), +5min volta a contagem pra positivo.$FAQ$, 7, true),
(v_doc_id, 'Onde fica salvo o tempo final da sessão?',
$FAQ$Quando você clica em <strong> Parar</strong>, o tempo cronometrado é gravado no banco vinculado à sessão da agenda (na tabela <code>agenda_eventos</code>, campo de duração real). Esse caminho é o oficial <strong>fechar pelo X descarta sem salvar</strong>. Sessões com menos de 5 segundos cronometrados são ignoradas (proteção contra start/stop acidentais).$FAQ$, 8, true),
(v_doc_id, 'Posso usar o cronômetro pra coisas que não são sessão de paciente?',
$FAQ$Sim. Selecione <em>"— Atividade livre (sem paciente)"</em> no dropdown de paciente. Útil pra pausa cronometrada, pomodoro pessoal, atendimento informal não cadastrado. Atividade livre <strong>não dispara session-end</strong> ao parar não paciente pra vincular o tempo no DB.$FAQ$, 9, true),
(v_doc_id, 'Como minimizo o cronômetro pra continuar trabalhando?',
$FAQ$Clique no botão <strong>minimizar</strong> no header (ícone <code></code>) ou simplesmente <strong>clique fora do dialog</strong>. O cronômetro vira um chip flutuante no dock (canto inferior esquerdo, ao lado do ψ) e continua contando em background. Pra restaurar: clique no chip. Em mobile, o chip mostra ícone + tempo (sem nome) pra caber no dock estreito.$FAQ$, 10, true),
(v_doc_id, 'Como mudo o paciente no cronômetro já aberto?',
$FAQ$Basta clicar no <strong>select de paciente</strong> e escolher outro. A troca é imediata não reinicia o tempo decorrido (a contagem continua igual). Útil quando você abriu o cronômetro no paciente errado e quer corrigir sem perder o tempo contado. Mas atenção: o <em>session-end</em> ao parar vai vincular o tempo ao paciente que estiver selecionado <em>no momento da parada</em>.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,173 @@
-- Importacao da doc da aba Documentos do paciente (Fase 2)
-- Gerado a partir de development/saas-docs/03-documentos-paciente-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Documentos do paciente',
$HTML$<h2>Documentos do paciente</h2>
<p>A aba <strong>Documentos</strong> do prontuário (em <code>/melissa/paciente?id=...&amp;tab=documentos</code>) centraliza tudo que está vinculado àquele paciente: arquivos enviados por upload, documentos gerados a partir de templates (atestados, declarações, recibos, laudos), e tudo que precisa ser compartilhado ou assinado.</p>
<h3>1. Layout 2-col</h3>
<p>A página tem 2 colunas:</p>
<ul>
<li><strong>Sidebar esquerda (~240px):</strong> lista de tipos de documento com contadores. Click num tipo filtra a lista. "Todos" mostra tudo.</li>
<li><strong>Main direita:</strong> grid de cards dos documentos do tipo selecionado, com paginação a partir de 12 itens.</li>
</ul>
<p>No <strong>mobile</strong> (&lt;1024px), a sidebar vira um drawer acessado pelo botão "Tipos" no header.</p>
<h3>2. Toolbar (header)</h3>
<p>3 botões no topo:</p>
<ul>
<li><strong> Atualizar:</strong> refetch da lista (ícone spinner quando carregando)</li>
<li><strong>📄 Gerar:</strong> abre o dialog de geração a partir de template (vide seção 5)</li>
<li><strong> Upload</strong> (botão primário): abre o dialog de envio de arquivo (vide seção 3)</li>
</ul>
<h3>3. Upload de arquivo</h3>
<p>Click no botão <strong>Upload</strong> abre um dialog que aceita:</p>
<ul>
<li><strong>Drag-and-drop</strong> ou seleção manual</li>
<li>Formatos: PDF, imagens (JPG, PNG, WebP), Word, Excel, texto</li>
<li>Metadados opcionais: <strong>tipo</strong>, <strong>categoria</strong>, <strong>descrição</strong>, <strong>tags</strong>, <strong>visibilidade</strong> (privado / compartilhado supervisor / compartilhado portal paciente)</li>
</ul>
<p>Após o upload, o arquivo aparece na lista do tipo escolhido (ou "Outro" se você não selecionou).</p>
<h3>4. Tipos de documento (sidebar)</h3>
<p>Cada documento é classificado em um tipo. Tipos disponíveis:</p>
<ul>
<li><strong>Laudo</strong> laudo psicológico, parecer</li>
<li><strong>Atestado</strong> atestado psicológico</li>
<li><strong>Declaração</strong> comparecimento, início de tratamento, encaminhamento</li>
<li><strong>Recibo</strong> recibos de pagamento gerados</li>
<li><strong>Receita</strong> receituários (uso raro em psicologia)</li>
<li><strong>Exame</strong> laudos/resultados de exames trazidos pelo paciente</li>
<li><strong>Termo assinado</strong> TCLE, autorizações</li>
<li><strong>Relatório externo</strong> relatórios de acompanhamento gerados</li>
<li><strong>Identidade</strong> RG, CPF, CNH (cópias)</li>
<li><strong>Convênio</strong> carteirinhas, autorizações de convênio</li>
<li><strong>Outro</strong> fallback pra tudo que não se encaixa nos tipos acima</li>
</ul>
<p>O contador ao lado de cada tipo mostra quantos docs daquele tipo o paciente tem. Tipos vazios ficam com opacidade reduzida.</p>
<h3>5. Gerar a partir de template</h3>
<p>Click no botão <strong>Gerar</strong> abre o <em>DocumentGenerateDialog</em> em 3 passos:</p>
<ol>
<li><strong>Selecionar template:</strong> grid com todos os templates ativos (globais + do tenant). Click num card seleciona.</li>
<li><strong>Editar variáveis:</strong> os campos do template aparecem com FloatLabel. Variáveis que vêm do sistema (nome do paciente, CRP do terapeuta, CNPJ da clínica etc) vêm preenchidas automaticamente. Banner no topo conta "X de Y preenchidos". Campos vazios mostram um hint embaixo explicando onde cadastrar o dado (ex: <em>"Perfil → Registro Profissional"</em>).</li>
<li><strong>Preview:</strong> iframe sandboxed renderizando o HTML do template com as vars substituídas. Daqui você pode voltar pra editar, baixar o PDF (sem salvar no sistema), ou salvar como documento do paciente.</li>
</ol>
<div style="background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
<strong>💡 Auto-fill cobre:</strong> dados do paciente, terapeuta (incluindo registro profissional formatado tipo "CRP 12345/SP"), clínica/tenant (incluindo CNPJ formatado), data atual em formato curto e por extenso, e se a sessão for vinculada valor da sessão em número e por extenso.
</div>
<h3>6. Editar um documento gerado (re-edição in-place)</h3>
<p>Documentos gerados a partir de template podem ser <strong>re-editados</strong> mantendo o mesmo registro (ID, audit trail e link com o paciente preservados). Click em <strong>Editar</strong> no card do doc ou na sidebar do preview:</p>
<ol>
<li>O sistema busca o template original + os valores que você usou na primeira geração</li>
<li>Abre o dialog em modo edição (header amber "Editar documento") pulando direto pro passo 2 (variáveis pré-preenchidas)</li>
<li>Você ajusta o que precisar Preview <strong>Substituir documento</strong></li>
<li>O PDF é regenerado e substitui o anterior no Storage; o doc fica com o mesmo ID, audit trail intacto</li>
</ol>
<p><strong>Documento legado</strong> (sem registro de geração ou que era um upload): o dialog mostra um toast e cai no fluxo normal de "selecione um template". Ao salvar, ele linka o doc existente ao novo template/valores.</p>
<h3>7. Preview do documento</h3>
<p>Click num card abre o <em>DocumentPreviewDialog</em>:</p>
<ul>
<li><strong>Preview inline:</strong> iframe pra PDF, imagem renderizada direto, fallback "Preview não disponível" pra outros formatos</li>
<li><strong>Sidebar de detalhes</strong> (direita): tipo, categoria, visibilidade, descrição, tags</li>
<li><strong>5 botões de ação</strong> no rodapé da sidebar:
<ul>
<li><strong>Baixar</strong> download direto do arquivo</li>
<li><strong>Editar</strong> abre o generate dialog em modo edição (seção 6)</li>
<li><strong>Compartilhar</strong> gera link compartilhável (seção 8)</li>
<li><strong>Assinar</strong> fluxo de assinatura eletrônica (seção 9)</li>
<li><strong>Excluir</strong> (vermelho) soft-delete com confirmação</li>
</ul>
</li>
</ul>
<h3>8. Compartilhar</h3>
<p>Gera um link público temporário pro paciente acessar o documento sem precisar de login. Configurável:</p>
<ul>
<li>Tempo de expiração (1h, 24h, 7 dias, custom)</li>
<li>Senha opcional</li>
<li>Permitir download ou visualização</li>
</ul>
<p>O status compartilhado fica visível na sidebar de detalhes do preview.</p>
<h3>9. Assinar</h3>
<p>Fluxo de assinatura eletrônica (modal). O documento original recebe uma <strong>página adicional de assinatura</strong> com timestamp e identificação do signatário. A assinatura é registrada em <code>document_signatures</code> com hash do conteúdo original (proof of integrity).</p>
<h3>10. Excluir e recuperar</h3>
<p>Excluir é <strong>soft-delete</strong>: o documento ganha <code>deleted_at</code> mas o arquivo permanece no Storage e o registro fica preservado por <strong>5 anos</strong> (compliance LGPD/CFP). Pra recuperar, em <strong>Configurações Lixo de documentos</strong>.</p>
<h3> Notas pro desenvolvedor</h3>
<p>O componente <code>MelissaPatientDocuments.vue</code> reusa do <code>features/documents</code>:</p>
<ul>
<li><code>useDocuments</code> composable de fetch/CRUD/URLs assinadas</li>
<li><code>DocumentCard</code>, <code>DocumentUploadDialog</code>, <code>DocumentPreviewDialog</code>, <code>DocumentGenerateDialog</code>, <code>DocumentSignatureDialog</code>, <code>DocumentShareDialog</code></li>
</ul>
<p>O linkage <code>document_generated.documento_id</code> (FK pra <code>documents</code>) é o que viabiliza a re-edição in-place. Docs gerados antes da migration de linkage precisam do backfill SQL em <code>database-novo/tmp/backfill-document-generated-link.sql</code>.</p>$HTML$,
'Documentos',
true,
'usuario',
'/melissa/paciente',
3,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como envio um documento que já existe (PDF/imagem do paciente)?',
$FAQ$Na aba <strong>Documentos</strong> do prontuário, click no botão <strong>Upload</strong> (azul, no canto superior direito). Você pode arrastar o arquivo pra área do dialog ou clicar pra selecionar. Antes de enviar, preencha o tipo, descrição e tags se quiser assim o doc vai pra categoria certa na sidebar.$FAQ$, 0, true),
(v_doc_id, 'Como gero um documento (atestado, declaração, recibo) a partir de template?',
$FAQ$Click no botão <strong>Gerar</strong> no header da aba Documentos do paciente. O dialog abre em 3 passos: (1) escolha o template, (2) confira as variáveis pré-preenchidas (e ajuste se necessário), (3) preview e <em>Salvar documento</em>. O PDF é gerado e salvo automaticamente no prontuário.$FAQ$, 1, true),
(v_doc_id, 'As variáveis (CRP, nome, CNPJ etc) preenchem sozinhas mesmo?',
$FAQ$Sim, sempre que possível. O sistema busca: dados do paciente (nome, CPF, RG, endereço, telefone, email), do terapeuta (nome, email, telefone, e o registro profissional formatado tipo <em>CRP 12345/SP</em>), da clínica (nome, endereço, telefone, CNPJ formatado), data atual em formato curto e por extenso. Se você abriu o gerador a partir de uma sessão, os dados da sessão (valor, data) também entram. Campos vazios mostram embaixo um hint dizendo onde cadastrar o dado faltante.$FAQ$, 2, true),
(v_doc_id, 'Posso editar um documento gerado sem refazer tudo do zero?',
$FAQ$Sim. Click em <strong>Editar</strong> no card do documento (ou na sidebar do preview). O dialog abre em <em>modo edição</em> com o template original selecionado e <strong>todos os valores que você usou anteriormente preenchidos</strong>. Você ajusta o que precisa, confere o preview e click em <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mas o ID e o audit trail do doc continuam os mesmos.$FAQ$, 3, true),
(v_doc_id, 'Posso editar um documento que foi feito por upload (não por template)?',
$FAQ$Sim, mas o fluxo é diferente: como não template original, o sistema mostra um aviso e abre o dialog em modo "selecione um template". Ao salvar, ele <strong>substitui o arquivo enviado por um PDF gerado</strong> e linka ao novo template. Útil pra "converter" um upload manual em algo padronizado. Se você quer trocar o arquivo, exclua o doc e faça upload do novo.$FAQ$, 4, true),
(v_doc_id, 'Como compartilho um documento com o paciente sem ele precisar logar?',
$FAQ$No preview, click em <strong>Compartilhar</strong>. Um dialog gera um link público temporário com opção de tempo de expiração (1h, 24h, 7 dias, custom) e senha opcional. O paciente acessa pelo link, sem login. O status fica visível na sidebar de detalhes do doc.$FAQ$, 5, true),
(v_doc_id, 'Como assino eletronicamente um documento?',
$FAQ$No preview, click em <strong>Assinar</strong>. O fluxo adiciona uma página de assinatura ao PDF com timestamp e identificação. A assinatura é registrada com hash do conteúdo original qualquer alteração posterior invalida a integridade. Ideal pra laudos, declarações e atestados que precisam de validade legal.$FAQ$, 6, true),
(v_doc_id, 'Excluí um documento por engano, dá pra recuperar?',
$FAQ$Sim. Exclusão é <strong>soft-delete</strong> o documento ganha um marcador <code>deleted_at</code> mas continua no banco e o arquivo permanece no Storage. Pra recuperar, em <strong>Configurações Lixo de documentos</strong>. O período de retenção é de <strong>5 anos</strong> (compliance LGPD e regulamentação CFP), depois o arquivo é purgado permanentemente.$FAQ$, 7, true),
(v_doc_id, 'Por que alguns documentos aparecem na categoria "Outro"?',
$FAQ$Documentos enviados por upload sem tipo definido caem em "Outro" automaticamente. Documentos gerados a partir de templates cujo tipo não está mapeado pras categorias padrão (declarações, atestados, laudos, etc) também exemplos: contrato de prestação de serviços, autorização para gravação, termo de consentimento. Você pode mover o doc pra outra categoria editando o tipo na hora do upload ou via menu de ações no card.$FAQ$, 8, true),
(v_doc_id, 'Quais formatos de arquivo posso fazer upload?',
$FAQ$PDF, imagens (JPG, PNG, WebP, GIF), documentos Office (DOCX, XLSX, PPTX), texto simples (TXT, CSV) e formatos compactados (ZIP). Pra qualquer formato fora dessa lista, salve como PDF antes. O preview inline funciona pra PDF e imagens outros formatos mostram a opção "Baixar arquivo" no lugar.$FAQ$, 9, true),
(v_doc_id, 'Como o sistema garante que o documento não vaza pra outros profissionais?',
$FAQ$Cada documento tem um campo de <strong>visibilidade</strong>: <em>Privado</em> ( você ), <em>Compartilhado com supervisor</em> (você + seu supervisor) ou <em>Compartilhado com portal do paciente</em> (o paciente também pelo portal). O default é Privado. RLS (Row Level Security) no banco bloqueia leitura por terceiros, independente da visibilidade. URLs do Storage são assinadas e expiram em 1h.$FAQ$, 10, true),
(v_doc_id, 'Os botões da sidebar do preview (Baixar/Editar/Compartilhar/Assinar/Excluir) não funcionavam, foi corrigido?',
$FAQ$Sim. Bug conhecido até 2026-05-22: o <code>DocumentPreviewDialog</code> emitia os 5 eventos mas o componente pai não os escutava, então nada acontecia ao clicar. Agora todos os 5 botões funcionam normalmente e o de Editar abre o dialog de geração em modo edição.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,122 @@
-- Importacao da doc da pagina de Templates de documentos (Fase 2)
-- Gerado a partir de development/saas-docs/04-documentos-templates-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Templates de documentos',
$HTML$<h2>Templates de documentos</h2>
<p>A página <strong>Templates de documentos</strong> (acessível pelo menu Prontuários Templates de documentos, ou diretamente em <code>/melissa/documentos-templates</code>) é onde você gerencia os modelos usados pra gerar atestados, declarações, recibos, laudos e outros documentos clínicos.</p>
<h3>1. Globais vs Tenant (Seus templates)</h3>
<p>A lista é dividida em 2 grupos:</p>
<ul>
<li><strong>Templates padrão (globais)</strong> vêm pré-instalados com o sistema (Declaração de Comparecimento, Atestado Psicológico, Recibo de Pagamento, Laudo Psicológico, Parecer, Encaminhamento, etc). São <strong>read-only</strong> você não pode editar nem desativar, mas pode duplicar pra personalizar.</li>
<li><strong>Seus templates (tenant)</strong> os que você criou ou duplicou. Editáveis, removíveis (desativação soft-delete).</li>
</ul>
<p>Todos os templates ativos do tenant (globais + seus) ficam disponíveis na hora de gerar um documento pro paciente.</p>
<h3>2. Lista de templates</h3>
<p>Cards em grid mostrando: nome, tipo, descrição, badge "padrão" pros globais. No card de cada template do tenant um menu de 3 pontos com: <strong>Duplicar</strong>, <strong>Editar</strong>, <strong>Desativar</strong>. Pros globais, <strong>Duplicar</strong> (e click no card abre a Preview).</p>
<h3>3. Preview de template global (read-only)</h3>
<p>Click num template padrão abre a Preview iframe sandbox renderizando o HTML completo (cabeçalho + corpo + rodapé) com estilos de A4 simulando o PDF final. Header tem botão <strong>Duplicar</strong> pra você levar pros seus templates.</p>
<h3>4. Criar novo template</h3>
<p>Botão <strong>+ Novo template</strong> abre o editor em modo "create". Campos:</p>
<ul>
<li><strong>Nome</strong> e <strong>tipo</strong> (declaração, atestado, recibo, laudo, etc) define a categoria do documento gerado</li>
<li><strong>Descrição</strong> opcional aparece na lista</li>
<li><strong>Cabeçalho</strong> (top fixo) geralmente nome da clínica, endereço, CNPJ</li>
<li><strong>Corpo</strong> (conteúdo principal) o texto do documento com variáveis interpoladas</li>
<li><strong>Rodapé</strong> (bottom fixo) assinatura, contato, observações</li>
</ul>
<h3>5. Editor rich-text + variáveis</h3>
<p>Cada bloco (cabeçalho/corpo/rodapé) tem editor WYSIWYG com formatação, listas, tabelas e inserção de imagens. Ao clicar no botão de <strong>variáveis</strong>, abre um menu com todas as variáveis disponíveis. Click numa insere <code>{{nome_da_variavel}}</code> no cursor.</p>
<div style="background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
<strong>💡 Variáveis disponíveis:</strong> <code>{{paciente_nome}}</code>, <code>{{paciente_cpf}}</code>, <code>{{paciente_rg}}</code>, <code>{{paciente_email}}</code>, <code>{{terapeuta_nome}}</code>, <code>{{terapeuta_registro}}</code> (CRP 12345/SP formatado), <code>{{terapeuta_telefone}}</code>, <code>{{clinica_nome}}</code>, <code>{{clinica_cnpj}}</code>, <code>{{data_atual}}</code>, <code>{{data_atual_extenso}}</code>, e se gerado a partir de sessão <code>{{valor}}</code>, <code>{{valor_extenso}}</code>, <code>{{data_sessao}}</code>. Lista completa no dropdown do editor.
</div>
<h3>6. Mobile (drawer pros templates)</h3>
<p>Em telas &lt;1024px a lista vira um drawer com botão "Templates" no header. Click num item fecha o drawer e mostra o preview/editor ocupando a tela toda.</p>
<h3>7. Duplicar</h3>
<p>Duplicar copia o template (incluindo cabeçalho, corpo, rodapé e variáveis) pra <em>Seus templates</em> com sufixo <em>"(cópia)"</em> no nome. Você edita à vontade depois.</p>
<h3>8. Desativar (soft-delete)</h3>
<p>Templates do tenant podem ser <strong>desativados</strong> (não excluídos). Ficam marcados com <code>ativo = false</code> e somem da lista padrão e do dropdown de geração mas o registro permanece no banco, e documentos antigos gerados a partir desse template continuam acessíveis. Pra reativar, marque "incluir desativados" no filtro (futuro atualmente via DB).</p>
<h3>9. Tipos de template</h3>
<p>Cada template tem um <strong>tipo</strong>. O tipo determina automaticamente qual categoria o documento gerado terá no prontuário do paciente:</p>
<ul>
<li><code>declaracao_comparecimento</code>, <code>declaracao_inicio_tratamento</code>, <code>encaminhamento</code> categoria <strong>Declaração</strong></li>
<li><code>atestado_psicologico</code> categoria <strong>Atestado</strong></li>
<li><code>laudo_psicologico</code>, <code>parecer_psicologico</code> categoria <strong>Laudo</strong></li>
<li><code>recibo_pagamento</code> categoria <strong>Recibo</strong></li>
<li><code>relatorio_acompanhamento</code> categoria <strong>Relatório externo</strong></li>
<li>Outros tipos (<code>termo_consentimento</code>, <code>contrato_servicos</code>, <code>autorizacao_*</code>, <code>outro</code>) categoria <strong>Outro</strong></li>
</ul>
<h3> Notas pro desenvolvedor</h3>
<p>O componente <code>MelissaDocumentosTemplates.vue</code> reusa <code>useDocumentTemplates</code> + <code>DocumentTemplateEditor</code>. A lista de tipos vem do composable (<code>TIPOS_TEMPLATE</code>). O mapeamento tipo de template tipo do documento gerado vive em <code>DocumentGenerate.service.js</code> (<code>TEMPLATE_TYPE_TO_DOC_TYPE</code>). RLS no banco: templates globais (<code>is_global = true</code>) tem leitura aberta; templates do tenant respeitam <code>tenant_id</code>.</p>$HTML$,
'Documentos',
true,
'usuario',
'/melissa/documentos-templates',
4,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Pra que serve a página de Templates?',
$FAQ$Pra você gerenciar os <strong>modelos</strong> que serão usados na hora de gerar atestados, declarações, recibos, laudos e outros documentos clínicos. Cada template tem cabeçalho, corpo e rodapé com variáveis interpoladas (nome do paciente, CRP, data, etc) quando você usa o botão <em>Gerar</em> num prontuário, é um desses templates que está sendo aplicado.$FAQ$, 0, true),
(v_doc_id, 'Por que não consigo editar os templates padrão (com badge "padrão")?',
$FAQ$Templates marcados como <strong>globais</strong> (badge azul "padrão") vêm pré-instalados com o sistema e são compartilhados entre todos os tenants. Não pra editar pra preservar a versão de referência. Pra personalizar um, click em <strong>Duplicar</strong> uma cópia vai pra <em>Seus templates</em> e ali você edita à vontade.$FAQ$, 1, true),
(v_doc_id, 'Como uso uma variável no template?',
$FAQ$No editor (cabeçalho, corpo ou rodapé), posicione o cursor onde quer a variável e clique no botão de <strong>variáveis</strong> na barra de ferramentas. Um menu lista todas as variáveis disponíveis agrupadas por categoria. Click numa variável insere <code>{{nome_da_variavel}}</code> no cursor. Na hora de gerar o documento, esse placeholder é substituído pelo valor real.$FAQ$, 2, true),
(v_doc_id, 'Quais variáveis estão disponíveis?',
$FAQ$Agrupadas por categoria <strong>Paciente:</strong> nome, CPF, RG, data nascimento, email, telefone, endereço. <strong>Terapeuta:</strong> nome, email, telefone, registro profissional (formatado tipo "CRP 12345/SP"), tipo/número/UF do registro separados. <strong>Clínica:</strong> nome, endereço, telefone, CNPJ. <strong>Sessão:</strong> data, hora, valor, valor por extenso, forma de pagamento, modalidade. <strong>Geral:</strong> data atual, data atual por extenso. Lista completa visível no menu de variáveis do editor.$FAQ$, 3, true),
(v_doc_id, 'Posso recuperar um template que eu desativei?',
$FAQ$Sim, mas hoje via banco de dados (administrador). Desativar é <strong>soft-delete</strong>: o template ganha <code>ativo = false</code> e some da lista. Documentos antigos gerados com ele continuam acessíveis. Em versões futuras teremos um filtro "mostrar desativados" pra reativar via UI.$FAQ$, 4, true),
(v_doc_id, 'Como duplico um template padrão pra personalizar?',
$FAQ$Click no card do template padrão pra abrir a <strong>Preview</strong>. No header da preview tem um botão <strong>Duplicar</strong>. Confirme a cópia aparece em <em>Seus templates</em> com sufixo "(cópia)" no nome. Em seguida click em <strong>Editar</strong> nessa cópia pra ajustar texto, variáveis, cabeçalho, rodapé.$FAQ$, 5, true),
(v_doc_id, 'Qual a diferença prática entre template Global e do Tenant?',
$FAQ$Globais são compartilhados entre todos os tenants (vêm com o sistema) e são <strong>read-only</strong>. Templates do tenant pertencem à sua clínica/conta e são editáveis. Ambos aparecem juntos na hora de gerar um documento você não precisa duplicar pra usar um global, pra personalizar. Se um global atende, use direto.$FAQ$, 6, true),
(v_doc_id, 'Posso usar imagens no template (logo da clínica, assinatura digitalizada)?',
$FAQ$Sim. O editor aceita inserção de imagens via toolbar. Recomendado: PNG ou JPG com tamanho moderado (logo até 200x80px, assinatura até 300x120px). Imagens muito grandes inflam o PDF gerado. Pra incluir o logo da clínica, prefira colocar no <strong>cabeçalho</strong> assim aparece no topo de toda página do PDF.$FAQ$, 7, true),
(v_doc_id, 'O cabeçalho e rodapé aparecem em todas as páginas do PDF?',
$FAQ$Sim. O renderizador usa CSS <code>@page</code> com cabeçalho fixo no topo e rodapé fixo no rodapé de cada página gerada. Documentos curtos (1 página) você não percebe; documentos longos (laudos extensos) repetem cabeçalho/rodapé automaticamente. Útil pra manter identificação da clínica em todas as folhas.$FAQ$, 8, true),
(v_doc_id, 'Como sei se um template tem variável obrigatória?',
$FAQ$Hoje não marcação "obrigatória" todas as variáveis declaradas no template aparecem como editáveis na hora de gerar. Se uma vier vazia (porque não cadastrou no perfil/paciente/etc), o sistema mostra um hint embaixo do campo dizendo onde cadastrar (ex: <em>"Perfil → Registro Profissional"</em>). Você pode gerar mesmo com vazias o placeholder fica como <code>{{variavel}}</code> no PDF, mas isso quase nunca é desejado.$FAQ$, 9, true),
(v_doc_id, 'Tem limite de templates por tenant?',
$FAQ$Não limite hard no banco. Em planos free pode haver limite por contrato (verifique seu plano em Configurações Plano). Recomendado manter o conjunto enxuto (10-20 templates) pra não poluir o dropdown na hora de gerar se você não usa, desative.$FAQ$, 10, true),
(v_doc_id, 'Os templates são compartilhados entre os terapeutas do mesmo tenant?',
$FAQ$Sim. Todos os templates do tenant ficam disponíveis pra todos os usuários ativos do mesmo tenant (clínica). Quem cria/edita pode ser qualquer um com permissão de edição não "templates privados por usuário" no momento. Se precisar isolar templates por terapeuta, organize por nome (ex: "Atestado · Dra. Ana").$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,143 @@
-- Importacao da doc Emissao de recibo profissional (Fase 4 #14)
-- Gerado a partir de development/saas-docs/06-recibo-profissional-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Emissão de recibo profissional',
$HTML$<h2>Emissão de recibo profissional</h2>
<p>Quando uma sessão é registrada como <strong>paga</strong>, o sistema oferece um botão <em>Emitir recibo</em> que gera um PDF profissional pré-preenchido com todos os dados do paciente, terapeuta, clínica e da sessão sem precisar passar pelo fluxo "Gerar a partir de template" manual.</p>
<h3>1. Quando o botão aparece</h3>
<p>O botão <strong>Emitir recibo</strong> (outlined, ícone PDF) aparece no <em>painel financeiro do evento</em> (<code>AgendaEventoFinanceiroPanel</code>) dentro do modal de uma sessão somente quando:</p>
<ul>
<li>A sessão tem um <strong>financial_record vinculado</strong> (foi gerada cobrança via "Receber")</li>
<li>O status do record é <strong><code>paid</code></strong> (pagamento registrado)</li>
</ul>
<p>Em sessões de pacote (status='contrato'), sem cobrança gerada, pendente, ou cancelada o botão não aparece. Use o fluxo manual de <em>Gerar</em> na aba Documentos pra emitir recibos de casos especiais.</p>
<h3>2. O que o recibo traz preenchido automaticamente</h3>
<ul>
<li><strong>Paciente:</strong> nome, CPF, RG (do cadastro do paciente)</li>
<li><strong>Sessão:</strong> data e hora, modalidade</li>
<li><strong>Valor:</strong> número (R$ 150,00) <strong>e por extenso</strong> ("cento e cinquenta reais")</li>
<li><strong>Forma de pagamento:</strong> PIX, dinheiro, cartão, maquininha, etc vindo do financial_record</li>
<li><strong>Terapeuta:</strong> nome completo + registro profissional formatado ("CRP 12345/SP")</li>
<li><strong>Clínica:</strong> nome, endereço, telefone, CNPJ formatado</li>
<li><strong>Data atual:</strong> em formato curto (22/05/2026) e por extenso ("22 de maio de 2026")</li>
</ul>
<h3>3. Registro profissional genérico</h3>
<p>O sistema suporta <strong>qualquer conselho profissional</strong>, não CRP. A formatação é automática a partir do que está cadastrado no <em>Perfil Registro Profissional</em>:</p>
<ul>
<li><strong>CRP</strong> 12345/SP (psicologia)</li>
<li><strong>CRM</strong> 67890/RJ (medicina)</li>
<li><strong>CRFa</strong> 11111/MG (fonoaudiologia)</li>
<li><strong>CREFITO</strong> 22222/SP (fisioterapia)</li>
<li><strong>CRESS</strong> 33333/RS (serviço social)</li>
<li><strong>CRN</strong> 44444/SP (nutrição)</li>
<li>Ou personalizado via tipo "Outro" + nome livre</li>
</ul>
<p>No template, a variável <code>{{terapeuta_registro}}</code> sempre traz o registro formatado, independente do conselho. Tem também variáveis individuais: <code>{{terapeuta_registro_tipo}}</code>, <code>{{terapeuta_registro_numero}}</code>, <code>{{terapeuta_registro_uf}}</code> pra uso fino.</p>
<h3>4. Valor por extenso</h3>
<p>Helper interno (<code>src/utils/valorExtenso.js</code>) converte número pra extenso em pt-BR completo até 999 milhões:</p>
<div style="background: rgba(99,102,241,0.06); border: 1px solid rgba(99,102,241,0.2); border-radius: 10px; padding: 12px 14px; margin: 12px 0; font-family: 'IBM Plex Mono', monospace; font-size: 0.82rem;">
<strong>R$ 1,00</strong> "um real"<br>
<strong>R$ 150,00</strong> "cento e cinquenta reais"<br>
<strong>R$ 1.234,56</strong> "mil duzentos e trinta e quatro reais e cinquenta e seis centavos"<br>
<strong>R$ 0,50</strong> "cinquenta centavos"<br>
<strong>R$ 1.000.000,00</strong> "um milhão de reais"
</div>
<p>Pluralização correta (real/reais, centavo/centavos), tratamento de centavos isolados ("R$ 0,X"), milhar com "mil" sem "um", milhão/milhões.</p>
<h3>5. Onde o recibo é salvo</h3>
<p>Ao clicar <strong>Emitir recibo</strong>:</p>
<ol>
<li>Sistema busca o template global <code>recibo_pagamento</code></li>
<li>Carrega todas as variáveis (auto-fill descrito acima)</li>
<li>Gera o PDF</li>
<li>Faz upload pro bucket <code>generated-docs</code></li>
<li>Insere registros em <code>documents</code> e <code>document_generated</code> (com linkage)</li>
<li>Dispara <strong>download</strong> automático no browser</li>
<li>Toast "Recibo emitido — PDF baixado e salvo nos documentos do paciente"</li>
</ol>
<p>O recibo aparece na aba <em>Documentos</em> do prontuário do paciente sob a categoria <strong>Recibo</strong>. Pode ser editado in-place, compartilhado ou assinado eletronicamente normalmente.</p>
<h3>6. Quick path vs flow manual</h3>
<p>São <strong>2 caminhos</strong> pra gerar o mesmo PDF:</p>
<ul>
<li><strong>Quick path</strong> (este): clica num botão e pronto. Recibo da sessão paga, valor exato do record, forma de pagamento idem.</li>
<li><strong>Flow manual</strong>: aba Documentos Gerar escolhe template "Recibo de Pagamento" edita valores manualmente preview salva.</li>
</ul>
<p>Use o quick path no fluxo normal. Use o manual quando precisar emitir recibo de algo que não está vinculado a sessão (consulta avulsa) ou quando precisar ajustar valores.</p>
<h3> Notas pro desenvolvedor</h3>
<ul>
<li><strong>Service:</strong> <code>emitirReciboParaSessao(eventoId, { patientId?, valor?, formaPagamento? })</code> em <code>DocumentGenerate.service.js</code>. Quick path one-call: busca template, carrega vars, gera, salva, download.</li>
<li><strong>Helper extenso:</strong> <code>src/utils/valorExtenso.js</code> pt-BR até 999 milhões. Atenção: zero retorna "zero reais", inputs inválidos retornam string vazia.</li>
<li><strong>Mapeamento:</strong> <code>TEMPLATE_TYPE_TO_DOC_TYPE['recibo_pagamento'] = 'recibo'</code> garante que o doc gerado vai pra categoria certa na sidebar.</li>
<li><strong>Template:</strong> migration <code>20260521000008_recibo_uses_terapeuta_registro.sql</code> trocou <code>"Psicólogo(a) - CRP {{terapeuta_crp}}"</code> por <code>{{terapeuta_registro}}</code> no template global. Universal pra qualquer conselho.</li>
<li><strong>Botão UI:</strong> <code>AgendaEventoFinanceiroPanel.vue</code> linha ~320, branch <code>v-else-if="record.status === 'paid'"</code>.</li>
</ul>$HTML$,
'Financeiro',
true,
'usuario',
'/melissa/agenda',
6,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como emito um recibo pra uma sessão que recebi o pagamento?',
$FAQ$Abra a sessão no calendário da agenda no painel <em>Cobrança</em> dentro do modal, com o pagamento registrado (status <strong>Pago</strong>), aparece o botão <strong>Emitir recibo</strong>. Clique uma vez. O sistema gera o PDF, salva nos documentos do paciente e dispara o download automaticamente. Toast confirma a operação.$FAQ$, 0, true),
(v_doc_id, 'Por que o botão "Emitir recibo" não aparece na minha sessão?',
$FAQ$O botão aparece quando o financial_record da sessão tem <strong>status = pago</strong>. Possíveis motivos: (1) você não gerou cobrança ainda clique em <em>Receber</em> pra registrar o pagamento primeiro; (2) cobrança está pendente registre o recebimento; (3) sessão é de pacote (status='contrato') pacotes não emitem recibo por sessão, use o fluxo manual em <em>Documentos Gerar</em>; (4) cobrança foi cancelada gere uma nova.$FAQ$, 1, true),
(v_doc_id, 'O valor por extenso vem certo ("cento e cinquenta reais")?',
$FAQ$Sim, com gramática pt-BR correta até 999 milhões. Exemplos: R$ 1,00 "um real", R$ 150,00 "cento e cinquenta reais", R$ 1.234,56 "mil duzentos e trinta e quatro reais e cinquenta e seis centavos", R$ 0,50 "cinquenta centavos". Pluralização real/reais e centavo/centavos automática.$FAQ$, 2, true),
(v_doc_id, 'O recibo funciona pra qualquer conselho profissional (CRM, CRFa…)?',
$FAQ$<strong>Sim.</strong> O template usa a variável <code>{{terapeuta_registro}}</code> que se adapta ao tipo de registro cadastrado no seu Perfil. Funciona pra CRP (psicologia), CRM (medicina), CRFa (fonoaudiologia), CREFITO (fisioterapia), CRESS (serviço social), CRN (nutrição), e qualquer outro conselho incluindo "Outro" com nome livre. A formatação genérica fica tipo "CRP 12345/SP", "CRM 67890/RJ", etc.$FAQ$, 3, true),
(v_doc_id, 'Onde o recibo fica salvo depois de emitido?',
$FAQ$Em <strong>2 lugares</strong>: (1) baixado automaticamente no seu computador via download do navegador; (2) salvo na aba <em>Documentos</em> do prontuário do paciente, na categoria <strong>Recibo</strong> da sidebar. Daí você pode reabrir, compartilhar com o paciente, enviar pra assinar, ou editar in-place se precisar ajustar.$FAQ$, 4, true),
(v_doc_id, 'Posso emitir recibo de algo que não é sessão (consulta avulsa, pacote)?',
$FAQ$Sim, mas pelo <strong>fluxo manual</strong>: na aba <em>Documentos</em> do paciente botão <strong>Gerar</strong> escolha o template <em>"Recibo de Pagamento"</em>. Você preenche os valores na mão (valor, forma de pagamento, descrição) que não vem de uma sessão específica. O resto (CRP, paciente, clínica) auto-completa igual.$FAQ$, 5, true),
(v_doc_id, 'Meu CRP/CRM aparece vazio no recibo, o que fazer?',
$FAQ$Cadastre seu registro profissional em <strong>Perfil Registro Profissional</strong>. Selecione o tipo (CRP/CRM/CRFa//Outro), número e UF. Salve. Próximos recibos gerados trazem formatado. Pra atualizar recibos antigos, abra o doc na aba Documentos do paciente e use <em>Editar</em> o sistema vai puxar o registro atualizado.$FAQ$, 6, true),
(v_doc_id, 'O CNPJ da clínica aparece formatado no recibo?',
$FAQ$Sim, automaticamente. Em <strong>Configurações Negócio (Tenant)</strong>, cadastre o CPF ou CNPJ no campo unificado. O sistema detecta pela quantidade de dígitos: 11 dígitos formata como CPF (XXX.XXX.XXX-XX), 14 como CNPJ (XX.XXX.XXX/XXXX-XX). O recibo usa a variável <code>{{clinica_cnpj}}</code> que sai formatada.$FAQ$, 7, true),
(v_doc_id, 'Errei o valor do recibo, posso corrigir sem gerar outro?',
$FAQ$Sim. na aba <em>Documentos</em> do paciente abra o recibo no preview clique em <strong>Editar</strong>. O dialog abre em modo edição com o template do recibo carregado e os valores anteriores preenchidos. Ajuste o que precisa <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mantendo o mesmo ID e audit trail.$FAQ$, 8, true),
(v_doc_id, 'Posso enviar o recibo pro paciente assinar?',
$FAQ$Sim. Recibos são documentos como qualquer outro abra na aba Documentos preview botão <strong>Assinar</strong> na sidebar. Gera link público temporário, paciente abre sem login, marca aceite LGPD, assina. Útil pra recibos de valores altos ou contratos de pacote onde você quer registro formal da concordância.$FAQ$, 9, true),
(v_doc_id, 'Recibo de uma sessão antiga vai com a data de hoje ou a data da sessão?',
$FAQ$<strong>As duas</strong>. O recibo traz a <em>data da sessão</em> ("Referente ao atendimento de 15/03/2026") e a <em>data atual de emissão</em> ("São Carlos, 22 de maio de 2026") no rodapé. Importante pra fiscal a data de emissão indica quando o documento foi formalmente criado, mesmo que a sessão tenha sido meses atrás.$FAQ$, 10, true),
(v_doc_id, 'Posso reemitir um recibo que já foi emitido pra mesma sessão?',
$FAQ$Sim, mas com cuidado. Clicar em <strong>Emitir recibo</strong> de novo gera um <strong>novo PDF</strong> e salva como novo documento na aba você fica com 2 recibos da mesma sessão. Pra apenas atualizar (sem duplicar), edite o existente em <em>Documentos preview Editar</em>. Se duplicar por engano, exclua o antigo (soft-delete preserva por 5 anos no Lixo).$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,165 @@
-- Importacao da doc Relatorios e exportacao (Fase 5 #13)
-- Gerado a partir de development/saas-docs/07-relatorios-export-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Relatórios de sessões e exportação',
$HTML$<h2>Relatórios de sessões e exportação</h2>
<p>A página <strong>Relatórios</strong> (acessível em <code>/melissa/relatorios</code> ou Prontuários Relatórios) consolida as sessões num período escolhido com KPIs, gráfico de evolução e tabela detalhada. Você pode <strong>exportar tudo pra PDF, Excel ou CSV</strong> respeitando os filtros aplicados.</p>
<h3>1. Layout 2-col</h3>
<ul>
<li><strong>Sidebar esquerda</strong> (~280px): cards de estatísticas clicáveis (atuam como filtros) + seletor de período + filtro por status</li>
<li><strong>Main direita</strong>: gráfico de evolução (Chart.js) + DataTable de sessões filtradas com paginação</li>
</ul>
<p><strong>Mobile</strong> (&lt;1024px): sidebar vira drawer acessado por botão "Filtros" no header.</p>
<h3>2. Filtros de período</h3>
<p>4 opções no seletor:</p>
<ul>
<li><strong>Esta semana</strong> domingo a sábado da semana atual</li>
<li><strong>Este mês</strong> (default) primeiro dia ao último dia do mês corrente</li>
<li><strong>Últimos 3 meses</strong> janela rolante de 3 meses até o fim do mês atual</li>
<li><strong>Últimos 6 meses</strong> idem, janela de 6 meses</li>
</ul>
<p>Ao trocar o período, dispara uma nova query no banco. Os KPIs, gráfico e tabela se atualizam.</p>
<h3>3. Estatísticas (KPIs)</h3>
<p>Sidebar mostra cards com contadores do período:</p>
<ul>
<li><strong>Total de sessões</strong> todas independente de status</li>
<li><strong>Realizadas</strong> concluídas com sucesso</li>
<li><strong>Faltas</strong> paciente faltou</li>
<li><strong>Cancelamentos</strong> sessão cancelada</li>
<li><strong>Remarcadas</strong> paciente remarcou</li>
</ul>
<p>Cada card é <strong>clicável</strong>: filtra a tabela mostrando apenas as sessões daquele status. Clique no mesmo card pra desfazer o filtro.</p>
<h3>4. Gráfico de evolução</h3>
<p>Gráfico de barras/linhas (Chart.js) mostrando a evolução de sessões no período. O agrupamento adapta automaticamente:</p>
<ul>
<li><strong>Semana / Mês</strong> agrupa por <strong>dia</strong></li>
<li><strong>3 meses / 6 meses</strong> agrupa por <strong>semana ISO</strong> ou <strong>mês ISO</strong></li>
</ul>
<p>Cores por status (verde = realizadas, vermelho = faltas, amarelo = canceladas, azul = remarcadas).</p>
<h3>5. Tabela detalhada</h3>
<p>DataTable com colunas: data/hora, paciente, modalidade, status, valor (se aplicável), forma de pagamento. Paginada (15 por página default), ordenável por qualquer coluna. Status com tag colorida.</p>
<h3>6. Exportação 3 formatos</h3>
<p>3 botões no topo da tabela (ou header da página dependendo do layout):</p>
<table>
<thead>
<tr><th>Botão</th><th>Formato</th><th>Quando usar</th></tr>
</thead>
<tbody>
<tr><td><strong>📄 PDF</strong></td><td>PDF A4</td><td>Apresentar pra contador, anexar a processo, arquivo formal com identidade visual da clínica</td></tr>
<tr><td><strong>📊 Excel</strong></td><td>XLSX</td><td>Análise no Excel/Google Sheets, fórmulas, gráficos próprios, manipulação fina</td></tr>
<tr><td><strong>📋 CSV</strong></td><td>CSV UTF-8</td><td>Importar em outro sistema, processamento via script, BI externo</td></tr>
</tbody>
</table>
<div style="background: rgba(34,197,94,0.06); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
<strong>🎯 Os filtros aplicados na tela são respeitados</strong> se você filtrou por "Realizadas" e exportou pra Excel, as realizadas vão pro arquivo. Período idem. Quer todos os status? clique no card de filtro pra desfazer antes de exportar.
</div>
<h3>7. Detalhes técnicos por formato</h3>
<h4>PDF</h4>
<ul>
<li>Renderizado client-side via HTML PDF (mesmo pipeline do gerador de documentos)</li>
<li>Cabeçalho com KPIs em destaque + tabela A4 abaixo</li>
<li>Identidade visual: nome da clínica, logo (se cadastrado), data de geração</li>
<li>Tamanho: 1 página por ~30 sessões; relatórios longos paginam automaticamente com cabeçalho/rodapé fixos</li>
</ul>
<h4>Excel (XLSX)</h4>
<ul>
<li>Gerado com <code>exceljs</code> (import dinâmico não infla o bundle inicial)</li>
<li><strong>Frozen header</strong> primeira linha fica fixa ao rolar</li>
<li><strong>Alternating rows</strong> zebrado pra leitura</li>
<li>Colunas formatadas: data como data, valor como currency BRL</li>
<li>Branded cabeçalho com cor da clínica</li>
</ul>
<h4>CSV</h4>
<ul>
<li>Vanilla JS sem dependência externa, gerado instantaneamente</li>
<li><strong>BOM UTF-8</strong> no início força Excel a abrir com acentos corretos</li>
<li><strong>Separador <code>;</code></strong> (padrão pt-BR Excel BR espera ; em vez de ,)</li>
<li>Aspas em campos com vírgula ou quebra de linha</li>
</ul>
<h3>8. Nome do arquivo gerado</h3>
<p>Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code> com timestamp da hora de geração. Garante que múltiplas exportações no mesmo dia não sobrescrevem.</p>
<h3> Notas pro desenvolvedor</h3>
<ul>
<li><strong>Service:</strong> <code>src/services/reportExport.service.js</code> com 3 funções: <code>exportSessionsToPDF</code>, <code>exportSessionsToXLSX</code>, <code>exportSessionsToCSV</code>. Todas aceitam <code>{ sessions, period, statusFilter, tenant }</code>.</li>
<li><strong>PDF:</strong> usa <code>pdf.service.htmlToPdfBlob</code> (mesmo do gerador de documentos)</li>
<li><strong>XLSX:</strong> <code>const { default: ExcelJS } = await import('exceljs')</code> code splitting</li>
<li><strong>CSV:</strong> vanilla JS com BOM + escape de campos</li>
<li><strong>Pages:</strong> <code>RelatoriosPage.vue</code> (rota classic/Rail) e <code>MelissaRelatorios.vue</code> (rota Melissa) compartilham o mesmo service</li>
<li><strong>Pendência:</strong> exportação agendada (envio automático por email no dia 1 de cada mês) depende do Módulo 6 notifications. Hoje on-demand.</li>
</ul>$HTML$,
'Relatórios',
true,
'usuario',
'/melissa/relatorios',
7,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como vejo um resumo das minhas sessões num período?',
$FAQ$Abra a página <strong>Relatórios</strong> (menu Prontuários Relatórios, ou diretamente em <code>/melissa/relatorios</code>). Você escolhe o período na sidebar esquerda (esta semana, este mês, últimos 3 ou 6 meses) e o sistema mostra KPIs em cards, gráfico de evolução e tabela detalhada de cada sessão.$FAQ$, 0, true),
(v_doc_id, 'Quais períodos posso filtrar?',
$FAQ$4 opções fixas no seletor: <strong>Esta semana</strong> (domingo a sábado da semana corrente), <strong>Este mês</strong> (default dia 1 ao último dia do mês atual), <strong>Últimos 3 meses</strong> e <strong>Últimos 6 meses</strong> (janelas rolantes terminando no fim do mês atual). Não custom date range na UI ainda pra filtrar uma data específica, exporte pra Excel ou CSV e filtre .$FAQ$, 1, true),
(v_doc_id, 'Como exporto o relatório pra PDF?',
$FAQ$No topo da página de Relatórios, clique no botão <strong>PDF</strong> (ícone vermelho de arquivo). O sistema renderiza o relatório com KPIs em destaque + tabela A4 e dispara o download. Útil pra apresentar pra contador, anexar a processos ou arquivar formalmente. O PDF traz a identidade visual da clínica (nome, logo se cadastrado, data de geração).$FAQ$, 2, true),
(v_doc_id, 'Como exporto pra Excel?',
$FAQ$Botão <strong>Excel</strong> (ícone verde) no topo da página. Gera um arquivo <code>.xlsx</code> com cabeçalho fixo (frozen header), linhas zebradas pra leitura, colunas formatadas (datas como data, valores como moeda BRL) e cabeçalho com cor da clínica. Pronto pra análise no Excel, Google Sheets ou LibreOffice.$FAQ$, 3, true),
(v_doc_id, 'Quando devo usar CSV em vez de Excel?',
$FAQ$Use <strong>CSV</strong> quando precisar importar os dados em outro sistema (ERP, BI, banco de dados), fazer processamento via script, ou compartilhar com alguém que não tenha Excel. O arquivo é mais leve e universal. Use <strong>Excel</strong> quando o destino final for análise humana formatação de moeda, gráficos próprios, fórmulas. Os 2 trazem os mesmos dados.$FAQ$, 4, true),
(v_doc_id, 'Os filtros aplicados na tela também valem pra exportação?',
$FAQ$<strong>Sim, sempre.</strong> Se você filtrou por "Realizadas" clicando no card de KPI, as sessões realizadas vão pro arquivo exportado. Período idem. Quer exportar todos os status? clique no card de filtro pra desfazer (ou clique em outro KPI e depois nele de novo) antes de exportar. Na dúvida, o título do PDF/Excel sempre traz os filtros aplicados na primeira linha.$FAQ$, 5, true),
(v_doc_id, 'O Excel exportado tem fórmulas ou só dados?',
$FAQ$Só dados. As colunas vêm formatadas (data como data, valor como moeda BRL) mas sem fórmulas pré-instaladas você adiciona o que precisar depois (somas, médias, gráficos). Decisão de design: pra evitar conflito com diferentes locales/versões do Excel, exportamos puro e você customiza.$FAQ$, 6, true),
(v_doc_id, 'Por que o gráfico às vezes mostra dias e às vezes semanas/meses?',
$FAQ$Agrupamento automático conforme o período pra evitar gráfico ilegível: <strong>Semana / Mês</strong> 7-31 colunas por dia (legível). <strong>3 meses</strong> ~13 colunas por semana ISO. <strong>6 meses</strong> ~26 colunas ou ~6 colunas por mês ISO. Se forçássemos 180 colunas em "6 meses", ficaria ilegível.$FAQ$, 7, true),
(v_doc_id, 'Posso filtrar o relatório por um paciente específico?',
$FAQ$Hoje não diretamente na página de Relatórios. Pra ver sessões de um paciente específico, no <strong>prontuário do paciente</strong> (aba Sessões) tem timeline completa com filtros próprios. Ou exporte o relatório geral pra Excel/CSV e filtre por nome do paciente no Excel.$FAQ$, 8, true),
(v_doc_id, 'Consigo ver o relatório de outro terapeuta da clínica?',
$FAQ$Depende da sua permissão no tenant. Por default, cada terapeuta as próprias sessões. Owners/admins do tenant podem ter acesso aos relatórios consolidados de todos os profissionais verifique em <strong>Configurações Equipe</strong> qual é seu papel. Pra solicitar acesso ampliado, fale com o owner do tenant.$FAQ$, 9, true),
(v_doc_id, 'Como ficam os nomes dos arquivos exportados?',
$FAQ$Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code>. Exemplo: <code>relatorio_sessoes_20260522_1430.xlsx</code>. Timestamp garante que múltiplas exportações no mesmo dia não sobrescrevem o anterior fica fácil organizar versões.$FAQ$, 10, true),
(v_doc_id, 'Posso agendar exportações automáticas (envio por email mensal)?',
$FAQ$Ainda não. Hoje a exportação é <strong>on-demand</strong> você precisa abrir a página e clicar no botão. Exportação agendada (ex: PDF mensal enviado por email no dia 1) está no roadmap pós-MVP, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado. Por enquanto, agende um lembrete pra você abrir a página todo dia 1.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
+15 -6
View File
@@ -123,13 +123,22 @@ Do `project_graphify_findings_20260504`:
- [ ] E2E Playwright crítico (#16)
- [ ] Sentry (#18)
### Fase 4 — Agenda residual (por último)
### Fase 4 — Agenda residual
- [ ] Popover snapshot stale`ev.id` + computed
- [ ] Reverse transition confirm dialogs (realizado paid, faltou multa, pacote saldo)
- [ ] Replicação Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
- [ ] C12 antecipar — iterar UX
- [ ] Doc de ajuda completa
- [x] **Popover snapshot stale** (commit `f83315b` durante C11) — watch em `MelissaLayout` cobre virtual→materializada.
- [x] **Reverse transition confirm dialogs** (commit `5684297` durante C11) — `ctx.reverseArtifacts` + dialog.
- [x] **Decomposição agenda (Fases A+B1+B2 · 2026-05-21)**`useMelissaAgenda.js` saiu de 3033L → 2042L (-991L, ~33%). 3 utils + 1 service novo (`agendaBilling.service`).
- Fase A: `features/agenda/utils/{eventoTipo,dbFields,timeHelpers,colors}.js`
- Fase B1 (commit `e7e3d1b`): service ganha `computeSeriePrice`, `generateOccurrenceDates`, `loadStatusChangeContext`, `needsStatusConfirmDialog`.
- Fase B2 (commit `049dd91`): service ganha `applyStatusDecisions`, `createPackageContract`, `materializeAndChargePerSession`.
- [x] **Replicação Rail + Clínica (Fases C+D · 2026-05-21)**
- Composable novo `useAgendaStatusChange` (Tipo A wrapper) reusável em qualquer page.
- Fase C (commit `034c2c0`): `AgendaTerapeutaPage.onUpdateSeriesEvent` refatorado + `AgendaStatusChangeConfirmDialog` plugado. Antes era `update(id, {status})` cru; agora cobre multa + pacote saldo + reverse.
- Fase D (commit `6807b44`): `AgendaClinicaPage` espelha Fase C com adaptações (`updateClinic`+`createClinic` recebem `tenantId` arg).
- [x] **C12 antecipar UX iter** (commits `9c518a2` + `b7f3c23`) — "Trocar método" pattern (UPDATE em vez de cancel cycle) + filtro cancelled no dialog Lançamentos.
- [ ] **Indicadores visuais 3 canais** (barra esquerda verde / badge $ amber / neutro) — replicar no Rail/Clínica. Custom event classNames do FullCalendar, requer `_paymentStateMap` bulk-load igual ao Melissa.
- [ ] **Popover Rail antecipar/revogar/trocar método** — Rail não tem popover separado (usa AgendaEventDialog direto), precisa refactor maior pra acomodar.
- [ ] **Doc de ajuda completa** — user enviará prompt específico.
---
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+156
View File
@@ -0,0 +1,156 @@
# F0 — Categorização para migração Schema-per-Tenant
> Gerado em 2026-06-12 a partir de varredura direta no banco local (`supabase_db_agenciapsi-primesakai`),
> grep nas edge functions (`supabase/functions/`) e no frontend (`src/`).
> Fonte do plano: `novo-rumo.txt` (blueprint do projeto irmão), adaptado às divergências deste projeto.
## Sumário executivo
| Item | Quantidade |
|---|---|
| Tabelas em `public` (BASE TABLE) | 137 |
| **Tenant-scoped** (vão pra `tenant_<x>`) — Q3 + Q5 | **78** |
| **Globais** (ficam em `public`) | **59** |
> **Atualização 2026-06-13 (Q5 — roteamento anon):** 6 tabelas anon-facing voltaram pra `public` (decisão opção C): `patient_intake_requests`, `patient_invites`, `patient_invite_attempts`, `document_share_links`, `agendador_configuracoes`, `agendador_solicitacoes`. Fluxos anônimos identificam o tenant por token/slug e não resolvem o schema físico — mantê-las em public com RLS por token evita um índice global de tokens. Removidas do `_tenant_template` na migration `20260613000001_f1b` (template v2, 78 tabelas). FK a observar na F6: `public.document_share_links.documento_id → documents` (tenant) vira coluna solta no drop.
| Funções que referenciam tabelas-tenant | **66** (não 29 — o aviso do blueprint se confirmou) |
| Views que referenciam tabelas-tenant | 6 |
| FKs global→tenant problemáticas | **1** (`whatsapp_credits_transactions.conversation_message_id`) |
| Policies de tabelas globais usando funções a refatorar | **0** (risco de policy órfã é baixo) |
| Edge functions que tocam tabelas-tenant | ~25 de 29 |
| Tenants existentes | 9 (3 clínicas + 6 terapeutas individuais) |
| Volumetria | Baixa (maior tabela tenant: `conversation_messages` 355 linhas) — migração de dados é barata |
## ⚠️ Divergências críticas vs blueprint (novo-rumo.txt)
Estas diferenças exigem adaptação do plano — o blueprint NÃO se aplica literalmente:
### D1 — Não existe `tenants.slug`
Colunas de `tenants`: `id, name, created_at, kind, papel_timbrado, cpf_cnpj`.
O blueprint assume `slug` para nomear schemas (`tenant_<slug>`).
**Opções:** (a) adicionar coluna `slug` (unique, imutável, sanitizada a partir de `name`); (b) usar `tenant_` + uuid sem hífens (feio, mas sem coluna nova).
### D2 — Membership multi-tenant via `tenant_members` (profiles.tenant_id está 100% NULL)
- `profiles.tenant_id` existe mas tem **0 linhas preenchidas**.
- Membership real: `tenant_members` (15 linhas), com **4 usuários membros de mais de um tenant**.
- Tenant ativo é resolvido no **frontend**: RPC `my_tenants()` → Pinia `tenantStore.activeTenantId` → localStorage. Sem claim no JWT. Router troca tenant por `meta.tenantScope` (clinic/personal/supervisor).
**Consequência:** o helper `current_tenant_schema()` do blueprint (baseado em `profiles.tenant_id`) **não funciona aqui**. Adaptação proposta:
- Frontend escolhe o schema diretamente: `db()` = `supabase.schema(tenantSchemaName(activeTenant))` — já sabe o tenant ativo.
- Segurança não depende da escolha do cliente: cada schema clonado ganha policies com o tenant_id **embutido**: `USING (public.is_tenant_member('<uuid-do-tenant>'))`. Usuário só lê schema de tenant onde é membro, mesmo apontando o client pra outro schema.
- RPCs que precisam de "tenant atual" passam a receber `p_tenant_id` explícito, validado com `is_tenant_member()` antes do `set_config('search_path', ...)`. Substitui `current_tenant_schema()` por `tenant_schema_checked(p_tenant_id)`.
- Edge functions: client envia o tenant ativo (header `X-Tenant-Id` ou body); a function valida membership via `tenant_members` antes de usar `.schema()`.
### D3 — 6 dos 9 tenants são terapeutas individuais (`kind='therapist'`)
Schema-per-tenant aqui significa **um schema por terapeuta** que se cadastrar. Com 9 tenants é trivial; em escala self-serve (centenas/milhares), o array `schemas` do PostgREST e o catálogo do Postgres crescem linearmente. Funciona, mas é um custo operacional permanente (config.toml + restart a cada signup, a menos que automatize).
**Recomendação:** modelo uniforme (todo tenant ganha schema, qualquer `kind`) — modelo misto (clínica com schema, terapeuta em public) dobraria a complexidade de todas as funções e do frontend.
### D4 — `tenant_id` que aponta pra `auth.users` (legado)
`email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` têm FK pra **auth.users**, não pra `tenants`. Tratar na migração de dados (mapear user→tenant via `tenant_members` ou `owner_id`).
### D5 — View `current_tenant_id` é código morto
`SELECT current_setting('request.jwt.claim.tenant_id', true)` — claim nunca populado. Remover na F6.
---
## 1. Classificação das 137 tabelas
### 1.1 TENANT-SCOPED — movem pra `tenant_<x>` (79)
**Agenda (7):** agenda_bloqueios, agenda_configuracoes, agenda_eventos, agenda_online_slots, agenda_regras_semanais, agenda_slots_bloqueados_semanais, agenda_slots_regras
**Agendador público (2):** agendador_configuracoes, agendador_solicitacoes
**Asaas — cobrança de pacientes (2):** asaas_customers, asaas_payments *(FKs → patients/financial_records confirmam: é billing da clínica→paciente, não SaaS→tenant; ver decisão Q4)*
**Billing clínico (1):** billing_contracts
**Prontuário (3):** clinical_note_templates, clinical_note_versions, clinical_notes
**Compromissos (4):** commitment_services *(sem tenant_id — join, FK confirma)*, commitment_time_logs, determined_commitment_fields, determined_commitments
**Cadastro da clínica (2):** company_profiles, medicos
**Contatos (4):** contact_email_types, contact_emails, contact_phones, contact_types
**Conversas/WhatsApp — conteúdo (13):** conversation_assignments, conversation_autoreply_log, conversation_autoreply_settings, conversation_bot_sessions, conversation_bots, conversation_messages, conversation_notes, conversation_optout_keywords, conversation_optouts, conversation_sla_breaches, conversation_sla_rules, conversation_tags, conversation_thread_tags *(dados clínicos sensíveis → LGPD favorece isolamento físico)*
**Documentos (6):** document_access_logs, document_generated, document_share_links, document_signatures, document_templates, documents *(⚠️ owned por `supabase_admin` — usar psql direto, gotcha conhecido)*
**E-mail do tenant (2):** email_layout_config, email_templates_tenant *(⚠️ D4)*
**Financeiro (4):** financial_categories *(sem tenant_id, 0 linhas, FK de financial_records — mover junto evita FK cross-schema)*, financial_exceptions, financial_records, feriados
**Convênios (2):** insurance_plan_services *(sem tenant_id — join)*, insurance_plans
**Notificações do tenant (4):** notifications *(SPLIT — ver §5)*, notification_preferences, notification_schedules, notification_templates
**Pacientes (13):** patient_contacts, patient_discounts, patient_group_patient, patient_groups, patient_intake_requests, patient_invite_attempts, patient_invites, patient_patient_tag, patient_status_history, patient_support_contacts, patient_tags, patient_timeline, patients
**Precificação/pagamento (3):** payment_settings, professional_pricing, services
**Recorrência (3):** recurrence_exceptions, recurrence_rule_services *(sem tenant_id — join)*, recurrence_rules
**Lembretes de sessão (2):** session_reminder_logs, session_reminder_settings
**Repasse (2):** therapist_payout_records *(sem tenant_id — child, FK confirma)*, therapist_payouts
> 5 tabelas tenant-scoped **não têm** coluna tenant_id (joins/children): commitment_services, insurance_plan_services, recurrence_rule_services, therapist_payout_records, financial_categories. A heurística "tem tenant_id" sozinha teria errado — confirmadas via FK.
### 1.2 Infra de mensageria (5) — ✅ DECIDIDO (Q3): TENANT-SCOPED
notification_channels, notification_queue, notification_logs, twilio_subaccount_usage, whatsapp_connection_incidents
**Decisão do Leonardo (2026-06-12):** isolamento físico máximo — as 5 movem pro schema do tenant (LGPD; conteúdo de mensagem nunca fica em public). Consequências assumidas:
- Os crons `process-notification-queue`, `process-email-queue`, `process-sms-queue`, `process-whatsapp-queue`, `whatsapp-heartbeat-check`, `conversation-sla-check` passam a varrer tenants em loop (`FOR t IN SELECT … FROM tenants` / loop no Deno com `.schema()` por tenant).
- Webhooks inbound (twilio/evolution) resolvem o tenant pelo canal ANTES de gravar → precisa de um índice global de roteamento `public.channel_routing` (channel_external_id → tenant_id) mantido por trigger no schema tenant, já que não dá pra procurar o canal em N schemas.
- `twilio_subaccount_usage.channel_id → notification_channels` fica intra-schema (OK).
- FK global→tenant continua sendo só `whatsapp_credits_transactions.conversation_message_id` (vira coluna solta).
### 1.3 GLOBAIS — ficam em `public` (53)
**SaaS core:** tenants, tenant_members, tenant_invites, tenant_features, tenant_feature_exceptions_log, tenant_modules, profiles, profile_specialties, specialties, owner_users, saas_admins, user_settings
**Planos/assinaturas:** plans, plan_features, plan_prices, plan_public, plan_public_bullets, subscriptions, subscription_events, subscription_intents_legacy, subscription_intents_personal, subscription_intents_tenant, features, modules, module_features, entitlements_invalidation
**Créditos/addons (billing SaaS→tenant):** addon_credits, addon_products, addon_transactions, whatsapp_credit_packages, whatsapp_credit_purchases, whatsapp_credits_balance, whatsapp_credits_transactions
**Plataforma:** _db_migrations, asaas_webhook_events *(staging de webhook — roteia pro tenant na F6)*, audit_logs *(auditoria cross-tenant, padrão `tenant_audit_log` do blueprint)*, email_templates_global, email_layout? não — global_notices, notice_dismissals, login_carousel_slides, math_challenges, public_submission_attempts, submission_rate_limits, support_sessions, saas_doc_votos, saas_docs, saas_faq, saas_faq_itens, saas_security_config, saas_twilio_config
**Dev/tracking (11):** dev_auditoria_items, dev_comparison_competitor_status, dev_comparison_matrix, dev_competitor_features, dev_competitors, dev_generation_log, dev_roadmap_items, dev_roadmap_phases, dev_test_items, dev_user_credentials, dev_verificacoes_items
> Tabelas com tenant_id que **ficam** em public (manter `.eq('tenant_id')` no FE): tenant_features, tenant_feature_exceptions_log, tenant_invites, tenant_members, subscriptions, subscription_intents_*, addon_credits, addon_transactions, whatsapp_credit_*, audit_logs, support_sessions, profiles (+ grupo 1.2 se ficar global).
## 2. Funções — 66 referenciam tabelas-tenant (de 445 em public)
Por categoria (lista completa no fim):
- **Triggers em tabelas tenant (18):** agendador_gerar_slug, auto_create_financial_record_from_session, fanout_inbound_message_to_notifications, fn_agenda_regras_semanais_no_overlap, fn_clinical_note_version, fn_document_signature_timeline, fn_documents_timeline_insert, fn_sla_resolve_on_outbound, fn_whatsapp_low_balance_notify, notify_on_intake, notify_on_scheduling, notify_on_session_status, sync_busy_mirror_agenda_eventos, sync_legacy_email_fields, sync_legacy_phone_fields, trg_fn_patient_risco_timeline, trg_fn_patient_status_history, trg_fn_patient_status_timeline → padrão `TG_TABLE_SCHEMA` + `tenant_id_for_schema()`
- **RPCs chamadas por usuário logado (~30):** cancel_recurrence_from, cancelar_eventos_serie, can_delete_patient, create_financial_record_for_session, create_therapist_payout, delete_commitment_full, delete_determined_commitment, export_patient_data, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, issue_patient_invite, list_financial_records, list_my_signatures, mark_as_paid, mark_payout_as_paid, rotate_patient_invite_token(+v2), safe_delete_patient, search_global, seed_default_patient_groups, seed_determined_commitments, split_recurrence_at, tenant_remove_member… → padrão `p_tenant_id` + `is_tenant_member()` + `set_config('search_path')` (adaptação D2)
- **RPCs globais/cron (~10):** cleanup_notification_queue, convert_abandoned_intake_to_lead, populate_notification_queue, sync_overdue_financial_records, unstick_notification_queue, whatsapp_heartbeat_*, sla_open_breach, sla_mark_notified, first_response_stats, _first_response_runs → padrão loop `FOR t_row IN SELECT … FROM tenants`
- **RPCs públicas/anon por token (~8):** create_patient_intake_request(+v2), get_patient_intake_invite_info, get_signable_document_by_token, sign_document_by_token, sign_document_by_signature_id, validate_share_token, match_patient_by_phone, agendador_dias_disponiveis, agendador_slots_disponiveis → o token/slug identifica o tenant → resolver schema a partir do registro
- **SQL puro (8):** _first_response_runs, can_delete_patient, get_entity_primary_phone, get_financial_report, get_financial_summary, get_patient_session_counts, list_financial_records → converter pra plpgsql (limitação 3 do blueprint; exige DROP+CREATE)
## 3. Views, FKs e policies
**6 views referenciam tabelas-tenant** → recriar dentro do schema template: audit_log_unified *(parcial — mistura audit_logs global)*, conversation_threads *(9 usos FE + 1 edge)*, v_cashflow_projection, v_commitment_totals, v_patient_groups_with_counts, v_tag_patient_counts. Demais 23 views só tocam globais — intactas.
**FK global→tenant (a única problemática):** `whatsapp_credits_transactions.conversation_message_id → conversation_messages`. Decisão: converter pra coluna solta (uuid sem constraint) — billing não pode impedir DROP de schema de tenant.
**FKs tenant→public (ok fisicamente, perdem embed PostgREST):** patients→tenant_members (responsible/therapist_member_id), *→auth.users, financial_records→tenants (coluna some), etc. → helper `attachProfiles`/fake-embed no FE (limitação 1 do blueprint).
**Policies:** nenhuma policy de tabela global usa as 66 funções → zero recriação de policy em public. Policies das tabelas movidas morrem com elas; schemas tenant ganham policies novas no clone. Helpers RLS atuais (`is_tenant_member` 65 usos, `is_saas_admin` 177, `tenant_has_feature` 56, `is_clinic_tenant` 56) continuam válidos e são reaproveitados nas policies dos schemas tenant.
## 4. Edge functions — 29 no total, ~25 tocam tabelas-tenant
Mais afetadas (refs a tabelas do grupo tenant): process-notification-queue, process-email-queue, process-sms-queue, process-whatsapp-queue, send-session-reminders(+manual,+status), conversation-sla-check, evolution-whatsapp-inbound, twilio-whatsapp-inbound, send-whatsapp-message, submit-patient-intake, save-intake-progress, get-intake-invite-info, convert-abandoned-intakes, asaas-webhook, asaas-create-payment-record, asaas-cancel-payment, asaas-sync-payment, notification-webhook, sync-email-templates.
Se grupo 1.2 ficar global (Q3), os processadores de fila mantêm a maior parte do código; o refactor concentra-se em: conversation_* (12 refs), session_reminder_logs (7), notifications (2), intake (6), asaas (4), agenda_eventos (3), patients (2).
Padrão: inbound/webhook resolve tenant pelo canal/token → `.schema(tenant_schema_for(tenant_id))`; crons varrem `tenants` em loop.
## 5. Split de `notifications` (172 linhas)
Igual ao blueprint: `tenant_<x>.notifications` (locais) + `public.notifications_sistema` (cross-tenant: avisos SaaS, suporte, system_alert). Funções notify_* ganham 2 variantes; `useNotifications.js` (18 usos FE) mescla 2 fontes com `_origem`; realtime em 2 canais (canal tenant usa `schema: tenant_<x>`). Detalhar tipos cross-tenant vs locais na F6-Lote 1 (query por `type` antes do split).
## 6. Frontend — alvos do refactor (F3)
- `src/lib/supabase/client.js` — cliente único; criar `useTenantDb.js` ao lado.
- ~100 usos `from('agenda_eventos')`, 64 `financial_records`, 45 `patients`… (tabela completa no grep de F0).
- Remover `.eq('tenant_id', …)` APENAS nas tabelas que saem; manter nas globais (tenant_members, tenant_features, subscriptions, etc.).
- `tenantStore` ganha `activeTenantSchema` (computed de slug); repositories trocam `supabase.from``db().from`.
- Realtime: canais de tabelas tenant trocam `schema: 'public'``schema: tenantSchemaName`.
- Embeds `profiles!fkey(...)` em tabelas tenant → `attachProfiles()`.
## 7. Volumetria (migração de dados barata)
Top tenant-scoped: conversation_messages 355, notifications 172, determined_commitment_fields 117, financial_records 54, determined_commitments 47, agenda_eventos 37, patients 35. Todo o resto < 40 linhas. `audit_logs` (608) fica em public. Migração completa roda em segundos; ainda assim com backup por lote (regra do blueprint).
## 8. Decisões — ✅ respondidas pelo Leonardo em 2026-06-12
| # | Decisão | Resposta |
|---|---|---|
| Q1 | Nome do schema | **Criar coluna `tenants.slug`** (unique, imutável, gerado de name) → `tenant_<slug>` |
| Q2 | Quais tenants ganham schema | **Todos** (clínicas e therapists — modelo uniforme) |
| Q3 | Infra de mensageria (5 tabelas) | **Tenant-scoped** (isolamento máximo; ver §1.2 — crons em loop + índice de roteamento de canais) |
| Q4 | asaas_customers/asaas_payments | **Tenant** (webhook roteia via staging global `asaas_webhook_events`) |
## Anexo — 66 funções (nome | kind | linguagem | é trigger)
_first_response_runs|sql · agendador_dias_disponiveis|plpgsql · agendador_gerar_slug|plpgsql|trg · agendador_slots_disponiveis|plpgsql · auto_create_financial_record_from_session|plpgsql|trg · can_delete_patient|sql · cancel_patient_pending_notifications|plpgsql · cancel_recurrence_from|plpgsql · cancelar_eventos_serie|plpgsql · cleanup_notification_queue|plpgsql · convert_abandoned_intake_to_lead|plpgsql · create_financial_record_for_session|plpgsql · create_patient_intake_request|plpgsql · create_patient_intake_request_v2|plpgsql · create_therapist_payout|plpgsql · delete_commitment_full|plpgsql · delete_determined_commitment|plpgsql · export_patient_data|plpgsql · fanout_inbound_message_to_notifications|plpgsql|trg · first_response_stats|plpgsql · fn_agenda_regras_semanais_no_overlap|plpgsql|trg · fn_clinical_note_version|plpgsql|trg · fn_document_signature_timeline|plpgsql|trg · fn_documents_timeline_insert|plpgsql|trg · fn_sla_resolve_on_outbound|plpgsql|trg · fn_whatsapp_low_balance_notify|plpgsql|trg · get_entity_primary_phone|sql · get_financial_report|sql · get_financial_summary|sql · get_patient_intake_invite_info|plpgsql · get_patient_session_counts|sql · get_signable_document_by_token|plpgsql · issue_patient_invite|plpgsql · list_financial_records|sql · list_my_signatures|plpgsql · mark_as_paid|plpgsql · mark_payout_as_paid|plpgsql · match_patient_by_phone|plpgsql · notify_on_intake|plpgsql|trg · notify_on_scheduling|plpgsql|trg · notify_on_session_status|plpgsql|trg · populate_notification_queue|plpgsql · rotate_patient_invite_token|plpgsql · rotate_patient_invite_token_v2|plpgsql · safe_delete_patient|plpgsql · search_global|plpgsql · seed_default_patient_groups|plpgsql · seed_determined_commitments|plpgsql · sign_document_by_signature_id|plpgsql · sign_document_by_token|plpgsql · sla_mark_notified|plpgsql · sla_open_breach|plpgsql · split_recurrence_at|plpgsql · sync_busy_mirror_agenda_eventos|plpgsql|trg · sync_legacy_email_fields|plpgsql|trg · sync_legacy_phone_fields|plpgsql|trg · sync_overdue_financial_records|plpgsql · tenant_remove_member|plpgsql · trg_fn_patient_risco_timeline|plpgsql|trg · trg_fn_patient_status_history|plpgsql|trg · trg_fn_patient_status_timeline|plpgsql|trg · unstick_notification_queue|plpgsql · validate_share_token|plpgsql · whatsapp_heartbeat_mark_notified|plpgsql · whatsapp_heartbeat_open_incident|plpgsql · whatsapp_heartbeat_resolve_open_incidents|plpgsql
+338
View File
@@ -0,0 +1,338 @@
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase
Contexto e objetivo
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
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.).
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui,
considerando as lições que aprendi.
Antes de começar — varredura obrigatória
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo:
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem
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.
SELECT table_name,
EXISTS(SELECT 1 FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=t.table_name
AND c.column_name='tenant_id') AS has_tenant_id
FROM information_schema.tables t
WHERE table_schema='public' AND table_type='BASE TABLE'
ORDER BY table_name;
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi
"29 funções" e eram na verdade 52. Use:
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab)
SELECT DISTINCT p.proname, p.prokind, l.lanname
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
JOIN pg_language l ON l.oid = p.prolang
CROSS JOIN tenant_tabs t
WHERE n.nspname='public'
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M')
ORDER BY 1;
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje
cuidado especial.
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>').
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas.
Plano de execução em fases
F0 — Categorização (não codar nada ainda)
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir.
F1 — Template + helpers
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints.
- Crie helpers em public:
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado.
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema.
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o
tenant_id (porque a coluna não existe mais nas tabelas tenant).
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
dele.
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo.
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE.
F2 — Provisionamento
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar
tenant novo.
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando
por profiles.tenant_id = '<id-do-tenant>').
F3 — Frontend: composable de acesso tenant
- Crie useTenantDb.js:
export function useTenantDb() {
const { perfil } = useAuth();
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
const isReady = computed(() => Boolean(schemaName.value));
function db() {
if (!schemaName.value) throw new Error('tenant não disponível');
return supabase.schema(schemaName.value);
}
return { db, schemaName, isReady };
}
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as
views/components/composables que tocam tabelas tenant.
F4 — Edge functions
Padrão pra qualquer edge function que precisa acessar tabela tenant:
const userClient = createClient(SUPABASE_URL, ANON_KEY, {
global: { headers: { Authorization: authHeader } }
});
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema');
const tenantDb = userClient.schema(tenantSchema as string);
await tenantDb.from('oficios').update(...).eq(...);
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto.
F5 — Expor schemas no PostgREST
Edite supabase/config.toml:
[api]
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...]
extra_search_path = ["public", "extensions"]
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via
migration que regenera config.toml, ou aceite gerenciamento manual.
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
Divida em lotes pequenos e teste cada um:
Lote 1 — split de notifications
Caso especial crítico. Antes do split, identifique:
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant,
system_alert global).
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos).
Decisão estrutural: notifications precisa virar duas tabelas:
- tenant_<slug>.notifications — locais do tenant.
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev).
Migration faz:
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime).
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types).
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs,
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant).
4. DROP TABLE public.notifications.
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem.
Lote 2-4 — refator das demais funções
Padrão pra TRIGGER em tabela tenant:
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE v_tenant_id uuid;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar
-- ... lógica com tabelas tenant SEM prefixo `public.` ...
END $$;
Padrão pra RPC chamada por user logado em um tenant:
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ...
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE v_schema text := public.current_tenant_schema();
BEGIN
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF;
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 { useRoute, useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
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
if (_setupClearedUid === uid && _setupClearedIsClinic === isClinic) return;
const { data } = await supabase
.from('agenda_configuracoes')
const { data } = await tenantDb().from('agenda_configuracoes')
.select('setup_concluido, setup_clinica_concluido, atendimento_mode')
.eq('owner_id', uid)
.maybeSingle();
+19 -1
View File
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAjuda } from '@/composables/useAjuda';
@@ -73,6 +73,24 @@ function fechar() {
faqAbertos.value = {};
closeDrawer();
}
// Fechar ao clicar fora
// Listener so existe enquanto o drawer esta aberto. Clique nos botoes
// que abrem/fecham o drawer (marcados com data-ajuda-toggle) sao
// ignorados senao fecha aqui e o @click reabre.
function onDocMouseDown(e) {
if (!drawerOpen.value) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('.ajuda-panel')) return; // dentro do drawer
if (t.closest('[data-ajuda-toggle]')) return; // botao trigger
closeDrawer();
}
watch(drawerOpen, (open) => {
if (open) document.addEventListener('mousedown', onDocMouseDown, true);
else document.removeEventListener('mousedown', onDocMouseDown, true);
});
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown, true));
// Highlight de elemento na página
async function handleDocClick(e) {
const anchor = e.target.closest('a[data-highlight]');
+44 -8
View File
@@ -17,31 +17,61 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const isOnline = ref(true); // começa como true; detecta em onMounted
// Estado
// Começa otimista (true) só marca offline com confirmação dupla.
const isOnline = ref(true);
const wasOffline = ref(false);
const showReconnected = ref(false);
let pollTimer = null;
let reconnectedTimer = null;
let consecutiveFailures = 0;
// Detecção real: tenta buscar um recurso minúsculo
// Em DEV, ignora completamente o polling: Vite HMR + dev server podem
// disparar falhas pontuais que geram falso positivo constante. Em DEV,
// só confia em navigator.onLine + eventos nativos (mais conservador).
const IS_DEV = import.meta.env?.DEV === true;
// Tolerância: precisa N falhas seguidas pra considerar offline. Evita
// falso positivo de slow request / HMR rebuild / network blip.
const FAILURE_THRESHOLD = 2;
const POLL_INTERVAL = IS_DEV ? 60_000 : 30_000;
const FETCH_TIMEOUT = 8_000;
// Detecção: navigator.onLine primeiro, fetch como confirmação
//
// navigator.onLine é a fonte autoritativa do browser. Se for true,
// quase certo que tem rede física. Se for false, com certeza offline.
// O fetch só serve pra detectar "rede funciona mas servidor parado".
async function checkConnectivity() {
// 1) Browser offline = confia direto, sem fetch
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
consecutiveFailures = FAILURE_THRESHOLD;
setOffline();
return;
}
// 2) Browser online confirma com HEAD no favicon (rápido, cacheável)
try {
// favicon do próprio app (cache busted) não depende de rede externa
await fetch('/favicon.ico?_t=' + Date.now(), {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000)
signal: AbortSignal.timeout(FETCH_TIMEOUT)
});
consecutiveFailures = 0;
setOnline();
} catch {
consecutiveFailures++;
// Só marca offline após N falhas consecutivas evita falso positivo
// de slow request, HMR rebuild, transient blip.
if (consecutiveFailures >= FAILURE_THRESHOLD) {
setOffline();
}
}
}
function setOnline() {
if (!isOnline.value && wasOffline.value) {
// acabou de reconectar
showReconnected.value = true;
if (reconnectedTimer) clearTimeout(reconnectedTimer);
reconnectedTimer = setTimeout(() => {
@@ -59,19 +89,25 @@ function setOffline() {
}
// Eventos nativos do browser
// navigator.onLine + offline/online events são SUPER confiáveis pra
// estado real (sem rede física, wifi caiu, etc). Outros falsos
// positivos vinham só do fetch agressivo.
function onBrowserOffline() {
consecutiveFailures = FAILURE_THRESHOLD;
setOffline();
}
function onBrowserOnline() {
consecutiveFailures = 0;
checkConnectivity();
} // confirma antes de marcar online
}
onMounted(() => {
window.addEventListener('offline', onBrowserOffline);
window.addEventListener('online', onBrowserOnline);
// Polling a cada 10 s captura quedas que não disparam evento
pollTimer = setInterval(checkConnectivity, 10_000);
// Polling defensivo captura quedas que não disparam evento
// (raras, ex: DNS travado em wifi público).
pollTimer = setInterval(checkConnectivity, POLL_INTERVAL);
// Verifica estado atual ao montar (útil se já começou offline)
checkConnectivity();
+16 -4
View File
@@ -197,8 +197,17 @@ function generateUser() {
});
}
function patientsListRoute() {
// Rota de destino do "Salvar e ver paciente". Em melissa, prefere a
// view individual do paciente recém-criado (id vem de data.id no
// emit('created')); fallback pra lista.
function patientViewRoute(patientId) {
const p = String(route.path || '');
if (p.startsWith('/melissa') && patientId) {
return { path: '/melissa/paciente', query: { id: String(patientId) } };
}
if (p.startsWith('/melissa')) {
return '/melissa/pacientes';
}
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
@@ -252,7 +261,10 @@ async function submit(mode = 'only') {
emit('created', data);
if (props.closeOnCreated) close();
if (mode === 'view') await router.push(patientsListRoute());
if (mode === 'view') {
const pid = data?.id || null;
await router.push(patientViewRoute(pid));
}
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
errorMsg.value = msg;
@@ -334,10 +346,10 @@ async function submit(mode = 'only') {
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): "Salvar" / "Salvar e fechar" -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="saving" :disabled="saving" @click="submit('only')" />
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
<Button label="Salvar e ver paciente" :loading="saving" :disabled="saving" @click="submit('view')" />
</template>
</div>
</template>
@@ -35,6 +35,8 @@ import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service';
@@ -51,6 +53,7 @@ const emit = defineEmits(['cobranca-atualizada']);
// external
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro();
// estado local
@@ -126,8 +129,7 @@ async function fetchRecord() {
// após cancelar (caso comum: cancelou sem querer ou quer recobrar).
// Sem esse filtro, o scenario ficava em 'com-cobranca' mostrando
// o cancelado, e o botão "Gerar cobrança" sumia.
const { data, error } = await supabase
.from('financial_records')
const { data, error } = await tenantDb().from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id)
.neq('status', 'cancelled')
@@ -186,6 +188,7 @@ async function confirmPayment() {
payDlgLoading.value = true;
try {
const { data, error } = await supabase.rpc('mark_as_paid', {
p_tenant_id: tenantStore.activeTenantId,
p_financial_record_id: record.value.id,
p_payment_method: payDlgMethod.value
});
@@ -213,7 +216,7 @@ function requestCancel() {
acceptSeverity: 'danger',
accept: async () => {
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;
@@ -27,6 +27,7 @@ import { gerarSlotsDoDia } from '@/utils/slotsGenerator';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
const toast = useToast();
const props = defineProps({
@@ -51,7 +52,7 @@ const regrasSemanais = ref([]); // agenda_regras_semanais
const bloqueadosByDia = ref({}); // {dia: Set('09:00'...)}
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;
regrasSemanais.value = data || [];
@@ -17,6 +17,7 @@ import { useConversationNotes } from '@/composables/useConversationNotes';
import { useConversationTags } from '@/composables/useConversationTags';
import { useConversationAssignment } from '@/composables/useConversationAssignment';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
const toast = useToast();
@@ -69,10 +70,8 @@ async function loadPatients() {
linkPatientLoading.value = true;
try {
// Carrega todos os pacientes do tenant (até 500) filter é client-side
const { data, error } = await supabase
.from('patients')
const { data, error } = await tenantDb().from('patients')
.select('id, nome_completo, telefone, email_principal, status')
.eq('tenant_id', tenantId)
.order('nome_completo', { ascending: true })
.limit(500);
if (error) throw error;
@@ -99,8 +98,7 @@ async function confirmLinkPatient() {
const tenantId = store.thread.tenant_id;
// 1) Vincula conversation_messages
const { error } = await supabase
.from('conversation_messages')
const { error } = await tenantDb().from('conversation_messages')
.update({ patient_id: patient.id })
.or(`from_number.eq.${phone},to_number.eq.${phone}`)
.is('patient_id', null);
@@ -137,8 +135,7 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) {
const phoneDigits = String(threadPhone).replace(/\D/g, '');
// Busca se já tem esse número cadastrado
const { data: existing } = await supabase
.from('contact_phones')
const { data: existing } = await tenantDb().from('contact_phones')
.select('id, contact_type_id, whatsapp_linked_at')
.eq('entity_type', 'patient')
.eq('entity_id', patientId)
@@ -149,8 +146,7 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) {
if (existing) {
// Atualiza vinculado_at se ainda não tinha
if (!existing.whatsapp_linked_at) {
await supabase
.from('contact_phones')
await tenantDb().from('contact_phones')
.update({ whatsapp_linked_at: new Date().toISOString() })
.eq('id', existing.id);
}
@@ -158,17 +154,15 @@ async function upsertWhatsappForExisting(tenantId, patientId, threadPhone) {
}
// Não tem cria novo com type='whatsapp'
const { data: types } = await supabase
.from('contact_types')
const { data: types } = await tenantDb().from('contact_types')
.select('id, slug')
.is('tenant_id', null)
.eq('is_system', true)
.eq('slug', 'whatsapp')
.maybeSingle();
const whatsappTypeId = types?.id;
if (!whatsappTypeId) return;
await supabase.from('contact_phones').insert({
tenant_id: tenantId,
await tenantDb().from('contact_phones').insert({
entity_type: 'patient',
entity_id: patientId,
contact_type_id: whatsappTypeId,
@@ -201,8 +195,7 @@ async function onPatientCreated(row) {
}
try {
// 1) Vincula TODAS as mensagens do thread (anon) a esse patient_id
const { error: msgErr } = await supabase
.from('conversation_messages')
const { error: msgErr } = await tenantDb().from('conversation_messages')
.update({ patient_id: newPatientId })
.or(`from_number.eq.${phone},to_number.eq.${phone}`)
.is('patient_id', null);
@@ -238,10 +231,9 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form
if (!tenantId || !patientId || !threadPhone) return;
try {
// Busca tipos system
const { data: types } = await supabase
.from('contact_types')
const { data: types } = await tenantDb().from('contact_types')
.select('id, slug')
.is('tenant_id', null);
.eq('is_system', true);
const celularType = types?.find((t) => t.slug === 'celular');
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)
if (celularType && formDigits && formDigits.length >= 8) {
rows.push({
tenant_id: tenantId,
entity_type: 'patient',
entity_id: patientId,
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
if (whatsappType && phoneDigits && formNoDdi !== threadNoDdi) {
rows.push({
tenant_id: tenantId,
entity_type: 'patient',
entity_id: patientId,
contact_type_id: whatsappType.id,
@@ -285,7 +275,7 @@ async function insertWhatsappContactPhone(tenantId, patientId, threadPhone, form
}
if (rows.length > 0) {
await supabase.from('contact_phones').insert(rows);
await tenantDb().from('contact_phones').insert(rows);
}
} catch (e) {
console.warn('[ConversationDrawer] insert whatsapp contact_phones:', e?.message);
@@ -13,6 +13,7 @@
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { logEvent, logError } from '@/support/supportLogger';
@@ -90,7 +91,7 @@ async function showNotif(msg) {
let name = msg.from_number || 'Desconhecido';
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;
}
@@ -142,7 +143,8 @@ function channelIcon(ch) {
function subscribe() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
const tenantSchema = tenantStore.activeTenantSchema;
if (!tenantId || !tenantSchema) {
logEvent(LOG_SRC, 'subscribe skipped — sem tenant');
return;
}
@@ -154,9 +156,8 @@ function subscribe() {
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
schema: tenantSchema,
table: 'conversation_messages'
},
(payload) => {
const m = payload.new;
@@ -16,6 +16,7 @@
-->
<script setup>
import { computed } from 'vue';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useRouter, useRoute } from 'vue-router';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
@@ -121,10 +122,9 @@ async function openConversationByThreadKey(threadKey) {
try {
const tenantId = tenantStore.activeTenantId;
const { supabase } = await import('@/lib/supabase/client');
const { data } = await supabase
.from('conversation_threads')
const { data } = await tenantDb().from('conversation_threads')
.select('*')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.maybeSingle();
if (!data) return false;
+1 -1
View File
@@ -123,7 +123,7 @@ watch(query, (v) => {
const mySeq = ++searchSeq;
debounceT = setTimeout(async () => {
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 (error) {
// eslint-disable-next-line no-console
+1 -1
View File
@@ -34,7 +34,7 @@ export const PAGES = [
{ id: 'p_t_medicos', label: 'Médicos referenciadores', icon: 'pi pi-user-edit', sublabel: 'Médicos que encaminham pacientes', path: '/therapist/patients/medicos', roles: ['therapist'], keywords: kw('medicos','encaminhadores','referenciadores','indicacao') },
{ id: 'p_t_link_externo', label: 'Link de cadastro externo', icon: 'pi pi-link', sublabel: 'Link público pra pacientes', path: '/therapist/patients/link-externo', roles: ['therapist'], keywords: kw('link','externo','publico','cadastro paciente','convite') },
{ id: 'p_t_cad_recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox', sublabel: 'Pacientes aguardando aceite', path: '/therapist/patients/cadastro/recebidos', roles: ['therapist'], keywords: kw('recebidos','pendentes','aceitar','intake','novos') },
{ id: 'p_t_doc_templates', label: 'Templates de documentos', icon: 'pi pi-file-edit', sublabel: 'Modelos reutilizáveis', path: '/therapist/documents/templates', roles: ['therapist'], keywords: kw('templates','modelos','contratos','documentos') },
{ id: 'p_cfg_doc_templates', label: 'Modelos de documentos', icon: 'pi pi-file-edit', sublabel: 'Configurações → Documentos', path: '/configuracoes/documentos/templates', roles: ['therapist','admin'], keywords: kw('templates','modelos','contratos','documentos','recibo','atestado','laudo','tcle','lgpd','consent') },
{ id: 'p_t_online_sched', label: 'Agendamento online', icon: 'pi pi-globe', sublabel: 'Página pública de agendamento', path: '/therapist/online-scheduling', roles: ['therapist'], keywords: kw('online','publico','agendar','landing','pagina','site') },
{ id: 'p_t_ag_recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-calendar-plus',sublabel: 'Solicitações da agenda pública', path: '/therapist/agendamentos-recebidos', roles: ['therapist'], keywords: kw('solicitacoes','recebidos','publico','pedidos') },
{ id: 'p_t_fin_lanc', label: 'Lançamentos financeiros', icon: 'pi pi-list', sublabel: 'Entradas e saídas', path: '/therapist/financeiro/lancamentos', roles: ['therapist'], keywords: kw('lancamentos','entradas','saidas','fluxo de caixa','receitas','despesas') },
+21 -4
View File
@@ -117,14 +117,14 @@ function buildConfig() {
];
// Toolbar completa para o corpo do e-mail
// Botões hr (linha horizontal), eraser (apagar formatação) e source (HTML)
// foram removidos não funcionavam de forma esperada.
const bodyButtons = [
'bold', 'italic', 'underline', 'strikethrough', '|',
'ul', 'ol', '|',
'font', 'fontsize', 'brush', 'paragraph', '|',
'align', '|',
'link', 'table', '|',
'hr', 'eraser', '|',
'source'
'link', 'table'
];
return {
@@ -194,7 +194,24 @@ watch(
// API exposta
defineExpose({
insertHTML: (html) => jodit?.selection.insertHTML(html)
insertHTML: (html) => jodit?.selection.insertHTML(html),
// Salva markers da seleção atual antes do foco sair do editor
// (ex: usuário abre drawer e perde o cursor). Retorna o array de
// markers que pode ser passado pra restoreSelection depois.
saveSelection: () => {
if (!jodit) return null;
try { return jodit.selection.save(); }
catch { return null; }
},
// Restaura selection a partir dos markers salvos. Re-foca o editor.
restoreSelection: (markers) => {
if (!jodit) return;
try {
jodit.focus();
if (markers) jodit.selection.restore(markers);
} catch { /* silencioso */ }
},
focus: () => jodit?.focus()
});
</script>
+18 -5
View File
@@ -65,11 +65,22 @@ const router = useRouter();
const isOnPatientsPage = computed(() => {
const p = String(route.path || '');
return p.includes('/patients') || p.includes('/pacientes');
// /melissa/paciente (singular prontuário) é página de paciente.
// /melissa/pacientes (plural lista) também.
return p.includes('/patients') || p.includes('/pacientes') || p.startsWith('/melissa/paciente');
});
function patientsListRoute() {
// Rota de destino quando o usuário pede "Salvar e ver paciente":
// no Melissa, abre o prontuário do paciente (singular, via query id)
// no Therapist/Admin, volta pra lista (não há rota dedicada de view).
function patientViewRoute(patientId) {
const p = String(route.path || '');
if (p.startsWith('/melissa') && patientId) {
return { path: '/melissa/paciente', query: { id: String(patientId) } };
}
if (p.startsWith('/melissa')) {
return '/melissa/pacientes';
}
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
@@ -82,7 +93,9 @@ async function onCreated(data) {
isOpen.value = false;
emit('created', data);
if (pendingMode.value === 'view') {
await router.push(patientsListRoute());
// data.id vem do PatientsCadastroPage (criação ou edição)
const pid = data?.id || props.patientId || null;
await router.push(patientViewRoute(pid));
}
}
</script>
@@ -197,10 +210,10 @@ async function onCreated(data) {
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): um botao -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
<Button label="Salvar e ver paciente" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
</template>
</div>
</template>
+7 -8
View File
@@ -31,6 +31,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// ─── Cache de exceções financeiras (vive enquanto o módulo estiver carregado) ─
@@ -84,10 +85,9 @@ export function useAgendaFinanceiro() {
const uid = await getUid();
const { data, error: err } = await supabase
.from('financial_exceptions')
const { data, error: err } = await tenantDb().from('financial_exceptions')
.select('*')
.eq('tenant_id', tenantId)
.eq('exception_type', exceptionType)
.or(`owner_id.eq.${uid},owner_id.is.null`)
.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') {
// Cancelar cobrança existente, se houver
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) {
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 };
@@ -202,11 +202,10 @@ export function useAgendaFinanceiro() {
if (evento.billed) {
// 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) {
await supabase
.from('financial_records')
await tenantDb().from('financial_records')
.update({
amount: chargeAmount,
final_amount: chargeAmount,
+3 -4
View File
@@ -17,6 +17,7 @@
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -102,10 +103,8 @@ export function useAuditoria() {
try {
const { from, to } = dateRange.value;
let query = supabase
.from('audit_log_unified')
.select('uid, tenant_id, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
.eq('tenant_id', tenantId)
let query = tenantDb().from('audit_log_unified')
.select('uid, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
.gte('occurred_at', from.toISOString())
.lte('occurred_at', to.toISOString())
.order('occurred_at', { ascending: false })
+5 -9
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
const DEFAULT_SETTINGS = {
@@ -37,10 +38,8 @@ export function useAutoReplySettings() {
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_autoreply_settings')
const { data, error: err } = await tenantDb().from('conversation_autoreply_settings')
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
.eq('tenant_id', tenantId)
.maybeSingle();
if (err) throw err;
if (data) {
@@ -80,9 +79,8 @@ export function useAutoReplySettings() {
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_autoreply_settings')
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
const { error: err } = await tenantDb().from('conversation_autoreply_settings')
.upsert({ ...payload }, { onConflict: 'singleton' });
if (err) throw err;
settings.value = payload;
return { ok: true };
@@ -98,10 +96,8 @@ export function useAutoReplySettings() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return [];
try {
const { data } = await supabase
.from('agenda_regras_semanais')
const { data } = await tenantDb().from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('tenant_id', tenantId)
.eq('ativo', true)
.order('dia_semana');
return (data || []).map((r) => ({
+11 -15
View File
@@ -17,6 +17,7 @@
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
function startOfMonth(d = new Date()) {
@@ -82,41 +83,36 @@ export function useClinicKPIs() {
try {
const [finRes, pendRes, patRes, eventRes, finSeriesRes] = await Promise.all([
// 1) financial_records PAGO no mês (para MRR)
supabase
.from('financial_records')
tenantDb().from('financial_records')
.select('final_amount, patient_id')
.eq('tenant_id', tenantId)
.eq('status', 'paid')
.gte('paid_at', monthStart)
.lte('paid_at', monthEnd),
// 2) financial_records pending/overdue (qualquer data)
supabase
.from('financial_records')
tenantDb().from('financial_records')
.select('status, final_amount')
.eq('tenant_id', tenantId)
.in('status', ['pending', 'overdue']),
// 3) patients por status
supabase
.from('patients')
tenantDb().from('patients')
.select('status')
.eq('tenant_id', tenantId),
,
// 4) eventos de agenda no mês (para realizado/cancelado/faltou)
supabase
.from('agenda_eventos')
tenantDb().from('agenda_eventos')
.select('status, tipo')
.eq('tenant_id', tenantId)
.gte('inicio_em', monthStart)
.lte('inicio_em', monthEnd)
.neq('tipo', 'bloqueio'),
// 5) financial_records pagos últimos 6 meses (série + top pacientes)
supabase
.from('financial_records')
tenantDb().from('financial_records')
.select('final_amount, paid_at, patient_id, patients!patient_id(nome_completo)')
.eq('tenant_id', tenantId)
.eq('status', 'paid')
.gte('paid_at', sixMonthsAgo)
.lte('paid_at', monthEnd)
+10 -15
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
@@ -37,9 +38,8 @@ export function useContactEmails() {
async function loadTypes() {
try {
const { data } = await supabase
.from('contact_email_types')
.select('id, tenant_id, name, slug, icon, is_system, position')
const { data } = await tenantDb().from('contact_email_types')
.select('id, name, slug, icon, is_system, position')
.order('position', { ascending: true })
.order('name', { ascending: true });
types.value = data || [];
@@ -56,8 +56,7 @@ export function useContactEmails() {
}
loading.value = true;
try {
const { data, error } = await supabase
.from('contact_emails')
const { data, error } = await tenantDb().from('contact_emails')
.select('id, contact_email_type_id, email, is_primary, notes, position, created_at')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
@@ -74,8 +73,7 @@ export function useContactEmails() {
}
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase
.from('contact_emails')
const q = tenantDb().from('contact_emails')
.update({ is_primary: false })
.eq('entity_type', entityType)
.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 { data, error } = await supabase
.from('contact_emails')
const { data, error } = await tenantDb().from('contact_emails')
.insert({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_email_type_id,
@@ -172,7 +168,7 @@ export function useContactEmails() {
if (sanitized.is_primary === true) {
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;
await loadEmails(entityType, entityId);
return { ok: true };
@@ -200,12 +196,12 @@ export function useContactEmails() {
saving.value = true;
try {
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 (wasPrimary) {
const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 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);
@@ -227,7 +223,6 @@ export function useContactEmails() {
saving.value = true;
try {
const rows = pendingItems.map((e) => ({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_email_type_id: e.contact_email_type_id,
@@ -236,7 +231,7 @@ export function useContactEmails() {
notes: e.notes || null,
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;
await loadEmails(entityType, entityId);
return { ok: true, count: rows.length };
+10 -17
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
function normalizeDigits(raw) {
@@ -36,9 +37,8 @@ export function useContactPhones() {
async function loadTypes() {
try {
const { data } = await supabase
.from('contact_types')
.select('id, tenant_id, name, slug, icon, is_mobile, is_system, position')
const { data } = await tenantDb().from('contact_types')
.select('id, name, slug, icon, is_mobile, is_system, position')
.order('position', { ascending: true })
.order('name', { ascending: true });
types.value = data || [];
@@ -55,8 +55,7 @@ export function useContactPhones() {
}
loading.value = true;
try {
const { data, error } = await supabase
.from('contact_phones')
const { data, error } = await tenantDb().from('contact_phones')
.select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at')
.eq('entity_type', entityType)
.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
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase
.from('contact_phones')
const q = tenantDb().from('contact_phones')
.update({ is_primary: false })
.eq('entity_type', entityType)
.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 { data, error } = await supabase
.from('contact_phones')
const { data, error } = await tenantDb().from('contact_phones')
.insert({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_type_id,
@@ -177,8 +173,7 @@ export function useContactPhones() {
await unsetOtherPrimaries(entityType, entityId, id);
}
const { error } = await supabase
.from('contact_phones')
const { error } = await tenantDb().from('contact_phones')
.update(sanitized)
.eq('id', id);
if (error) throw error;
@@ -208,15 +203,14 @@ export function useContactPhones() {
saving.value = true;
try {
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;
// Se removeu o primary, promove o próximo pra primary
if (wasPrimary) {
const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
if (remaining.length > 0) {
await supabase
.from('contact_phones')
await tenantDb().from('contact_phones')
.update({ is_primary: true })
.eq('id', remaining[0].id);
}
@@ -242,7 +236,6 @@ export function useContactPhones() {
saving.value = true;
try {
const rows = pendingItems.map((p) => ({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_type_id: p.contact_type_id,
@@ -252,7 +245,7 @@ export function useContactPhones() {
notes: p.notes || null,
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;
// Recarrega do DB pra ter IDs reais — substitui os pending_* por uuids.
await loadPhones(entityType, entityId);
+6 -9
View File
@@ -12,6 +12,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
export function useConversationAssignment() {
@@ -55,10 +56,8 @@ export function useConversationAssignment() {
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_assignments')
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.eq('tenant_id', tenantId)
const { data, error: err } = await tenantDb().from('conversation_assignments')
.select('thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.eq('thread_key', threadKey)
.maybeSingle();
if (err) throw err;
@@ -96,7 +95,6 @@ export function useConversationAssignment() {
if (!userId) return { ok: false, error: 'not_authenticated' };
const payload = {
tenant_id: tenantId,
thread_key: threadKey,
patient_id: patientId || null,
contact_number: contactNumber || null,
@@ -105,10 +103,9 @@ export function useConversationAssignment() {
assigned_at: new Date().toISOString()
};
const { data, error: err } = await supabase
.from('conversation_assignments')
.upsert(payload, { onConflict: 'tenant_id,thread_key' })
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
const { data, error: err } = await tenantDb().from('conversation_assignments')
.upsert(payload, { onConflict: 'thread_key' })
.select('thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.single();
if (err) throw err;
+5 -10
View File
@@ -12,6 +12,7 @@
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
function sanitizeBody(raw) {
@@ -42,10 +43,8 @@ export function useConversationNotes() {
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_notes')
const { data, error: err } = await tenantDb().from('conversation_notes')
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.is('deleted_at', null)
.order('created_at', { ascending: false });
@@ -82,10 +81,8 @@ export function useConversationNotes() {
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const { data, error: err } = await supabase
.from('conversation_notes')
const { data, error: err } = await tenantDb().from('conversation_notes')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
patient_id: patientId,
contact_number: contactNumber,
@@ -122,8 +119,7 @@ export function useConversationNotes() {
if (!id || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_notes')
const { error: err } = await tenantDb().from('conversation_notes')
.update({ body: clean })
.eq('id', id);
if (err) throw err;
@@ -144,8 +140,7 @@ export function useConversationNotes() {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_notes')
const { error: err } = await tenantDb().from('conversation_notes')
.update({ deleted_at: new Date().toISOString() })
.eq('id', id);
if (err) throw err;
+13 -24
View File
@@ -11,6 +11,7 @@
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
function normalizePhoneBR(raw) {
@@ -38,15 +39,11 @@ export function useConversationOptouts() {
loading.value = true;
try {
const [optsRes, kwsRes] = await Promise.all([
supabase
.from('conversation_optouts')
tenantDb().from('conversation_optouts')
.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 }),
supabase
.from('conversation_optout_keywords')
.select('id, tenant_id, keyword, enabled, is_system')
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
tenantDb().from('conversation_optout_keywords')
.select('id, keyword, enabled, is_system')
.order('is_system', { ascending: false })
.order('keyword', { ascending: true })
]);
@@ -56,7 +53,7 @@ export function useConversationOptouts() {
// Enriquece com nome do paciente
const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))];
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]));
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;
// Verifica se já existe ativo
const { data: existing } = await supabase
.from('conversation_optouts')
const { data: existing } = await tenantDb().from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', cleanPhone)
.is('opted_back_in_at', null)
.maybeSingle();
if (existing) return { ok: false, error: 'already_opted_out' };
const { data, error } = await supabase
.from('conversation_optouts')
const { data, error } = await tenantDb().from('conversation_optouts')
.insert({
tenant_id: tenantId,
phone: cleanPhone,
patient_id: patientId,
source: 'manual',
@@ -115,8 +108,7 @@ export function useConversationOptouts() {
saving.value = true;
try {
const now = new Date().toISOString();
const { error } = await supabase
.from('conversation_optouts')
const { error } = await tenantDb().from('conversation_optouts')
.update({ opted_back_in_at: now })
.eq('id', id);
if (error) throw error;
@@ -136,10 +128,9 @@ export function useConversationOptouts() {
if (!tenantId || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_optout_keywords')
.insert({ tenant_id: tenantId, keyword: clean, is_system: false, enabled: true })
.select('id, tenant_id, keyword, enabled, is_system')
const { data, error } = await tenantDb().from('conversation_optout_keywords')
.insert({ keyword: clean, is_system: false, enabled: true })
.select('id, keyword, enabled, is_system')
.single();
if (error) throw error;
keywords.value = [...keywords.value, data];
@@ -154,8 +145,7 @@ export function useConversationOptouts() {
async function toggleKeyword(id, enabled) {
saving.value = true;
try {
const { error } = await supabase
.from('conversation_optout_keywords')
const { error } = await tenantDb().from('conversation_optout_keywords')
.update({ enabled })
.eq('id', id);
if (error) throw error;
@@ -172,8 +162,7 @@ export function useConversationOptouts() {
async function deleteKeyword(id) {
saving.value = true;
try {
const { error } = await supabase
.from('conversation_optout_keywords')
const { error } = await tenantDb().from('conversation_optout_keywords')
.delete()
.eq('id', id);
if (error) throw error;
+12 -23
View File
@@ -12,6 +12,7 @@
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
function sanitizeName(raw) {
@@ -46,9 +47,8 @@ export function useConversationTags() {
loading.value = true;
try {
// RLS filtra automaticamente: system (tenant_id IS NULL) + custom do tenant ativo
const { data, error } = await supabase
.from('conversation_tags')
.select('id, tenant_id, name, slug, color, icon, position, is_system')
const { data, error } = await tenantDb().from('conversation_tags')
.select('id, name, slug, color, icon, position, is_system')
.order('position', { ascending: true })
.order('name', { ascending: true });
if (error) throw error;
@@ -67,10 +67,8 @@ export function useConversationTags() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !Array.isArray(threadKeys) || !threadKeys.length) return new Map();
try {
const { data, error } = await supabase
.from('conversation_thread_tags')
const { data, error } = await tenantDb().from('conversation_thread_tags')
.select('thread_key, tag_id')
.eq('tenant_id', tenantId)
.in('thread_key', threadKeys);
if (error) throw error;
const map = new Map();
@@ -93,10 +91,8 @@ export function useConversationTags() {
return;
}
try {
const { data, error } = await supabase
.from('conversation_thread_tags')
const { data, error } = await tenantDb().from('conversation_thread_tags')
.select('tag_id')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey);
if (error) throw error;
threadTagIds.value = new Set((data || []).map((r) => r.tag_id));
@@ -116,10 +112,8 @@ export function useConversationTags() {
try {
if (hasTag) {
const { error } = await supabase
.from('conversation_thread_tags')
const { error } = await tenantDb().from('conversation_thread_tags')
.delete()
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.eq('tag_id', tagId);
if (error) throw error;
@@ -130,10 +124,8 @@ export function useConversationTags() {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const { error } = await supabase
.from('conversation_thread_tags')
const { error } = await tenantDb().from('conversation_thread_tags')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
tag_id: tagId,
tagged_by: userId
@@ -162,17 +154,15 @@ export function useConversationTags() {
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_tags')
const { data, error } = await tenantDb().from('conversation_tags')
.insert({
tenant_id: tenantId,
name: cleanName,
slug,
color,
icon,
is_system: false
})
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.select('id, name, slug, color, icon, position, is_system')
.single();
if (error) throw error;
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;
try {
const { data, error } = await supabase
.from('conversation_tags')
const { data, error } = await tenantDb().from('conversation_tags')
.update(patch)
.eq('id', id)
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.select('id, name, slug, color, icon, position, is_system')
.single();
if (error) throw error;
allTags.value = allTags.value
@@ -224,7 +213,7 @@ export function useConversationTags() {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
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;
allTags.value = allTags.value.filter((t) => t.id !== id);
const next = new Set(threadTagIds.value);
+11 -17
View File
@@ -17,6 +17,7 @@
import { ref, computed, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// Metadata canonica das colunas do kanban — fonte unica consumida pelo
@@ -82,10 +83,8 @@ export function useConversations() {
error.value = null;
try {
const { data, error: qErr } = await supabase
.from('conversation_threads')
const { data, error: qErr } = await tenantDb().from('conversation_threads')
.select('*')
.eq('tenant_id', tenantId)
.order('last_message_at', { ascending: false })
.limit(500);
if (qErr) throw qErr;
@@ -100,7 +99,8 @@ export function useConversations() {
function subscribeRealtime() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
const tenantSchema = tenantStore.activeTenantSchema;
if (!tenantId || !tenantSchema) return;
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
@@ -110,9 +110,8 @@ export function useConversations() {
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
schema: tenantSchema,
table: 'conversation_messages'
},
(payload) => {
// refetch da lista (view agrega tudo) — debounced
@@ -129,9 +128,8 @@ export function useConversations() {
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
schema: tenantSchema,
table: 'conversation_messages'
},
(payload) => {
_scheduleLoad();
@@ -226,10 +224,8 @@ export function useConversations() {
}
threadLoading.value = true;
try {
let q = supabase
.from('conversation_messages')
let q = tenantDb().from('conversation_messages')
.select('*')
.eq('tenant_id', tenantStore.activeTenantId)
.order('created_at', { ascending: true })
.limit(500);
@@ -253,10 +249,8 @@ export function useConversations() {
// Marca unread do inbound como lido
const nowIso = new Date().toISOString();
const tenantId = tenantStore.activeTenantId;
let q = supabase
.from('conversation_messages')
let q = tenantDb().from('conversation_messages')
.update({ read_at: nowIso })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound')
.is('read_at', null);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
@@ -271,7 +265,7 @@ export function useConversations() {
const patch = { kanban_status: newStatus };
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);
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 { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { getFeriadosNacionais } from '@/utils/feriadosBR';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
@@ -59,10 +60,8 @@ export function useFeriados(opts = {}) {
}
async function _doFetch(tenantId, cacheKey) {
const { data, error } = await supabase
.from('feriados')
const { data, error } = await tenantDb().from('feriados')
.select('*')
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
.gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`)
.order('data');
@@ -98,10 +97,8 @@ export function useFeriados(opts = {}) {
// Comportamento legado (sem cache) — páginas de admin que editam.
loading.value = true;
try {
const { data, error } = await supabase
.from('feriados')
const { data, error } = await tenantDb().from('feriados')
.select('*')
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
.gte('data', `${ano.value}-01-01`)
.lte('data', `${ano.value}-12-31`)
.order('data');
@@ -114,7 +111,7 @@ export function useFeriados(opts = {}) {
// ── Criar feriado municipal ───────────────────────────────
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;
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data));
if (cache) cache.invalidate('feriados');
@@ -123,7 +120,7 @@ export function useFeriados(opts = {}) {
// ── Remover feriado municipal ─────────────────────────────
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;
municipais.value = municipais.value.filter((f) => f.id !== id);
if (cache) cache.invalidate('feriados');
+11 -9
View File
@@ -17,6 +17,7 @@
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers internos ────────────────────────────────────────────────────────
@@ -38,7 +39,7 @@ async function getUid() {
// ─── select base com joins ───────────────────────────────────────────────────
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,
status, due_date, paid_at, payment_method, payment_link,
description, notes, created_at, updated_at,
@@ -117,10 +118,8 @@ export function useFinancialRecords() {
const offset = filters.offset ?? 0;
try {
let query = supabase
.from('financial_records')
let query = tenantDb().from('financial_records')
.select(BASE_SELECT, { count: 'exact' })
.eq('tenant_id', tenantId)
.is('deleted_at', null)
.order('due_date', { ascending: false })
.range(offset, offset + limit - 1);
@@ -214,11 +213,9 @@ export function useFinancialRecords() {
const discount = payload.discount_amount ?? 0;
const amount = payload.amount ?? 0;
const { data, error: err } = await supabase
.from('financial_records')
const { data, error: err } = await tenantDb().from('financial_records')
.insert([
{
tenant_id: tenantId,
owner_id: ownerId,
patient_id: payload.patient_id ?? null,
agenda_evento_id: null,
@@ -257,14 +254,19 @@ export function useFinancialRecords() {
error.value = null;
try {
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
const { data, error: err } = await supabase.rpc('mark_as_paid', {
p_tenant_id: tenantId,
p_financial_record_id: recordId,
p_payment_method: paymentMethod
});
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);
if (idx !== -1) {
records.value[idx] = {
@@ -291,7 +293,7 @@ export function useFinancialRecords() {
error.value = null;
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;
+3 -1
View File
@@ -17,6 +17,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { downloadLgpdPDF } from '@/utils/lgpdExportFormats';
function slugify(s) {
@@ -53,7 +54,8 @@ export function useLgpdExport() {
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;
return data;
}
+8 -9
View File
@@ -17,6 +17,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
// ─── estado compartilhado ──────────────────────────────────
@@ -50,9 +51,8 @@ async function _refresh() {
// 1. Agenda hoje
{
let q = supabase.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);
else q = q.eq('owner_id', ownerId);
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('owner_id', ownerId);
const { count } = await q;
agendaHoje.value = count || 0;
}
@@ -74,10 +74,8 @@ async function _refresh() {
// 4. Conversas não lidas (mensagens inbound sem read_at)
if (tenantId) {
const { count } = await supabase
.from('conversation_messages')
const { count } = await tenantDb().from('conversation_messages')
.select('id', { count: 'exact', head: true })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound')
.is('read_at', null);
conversasUnread.value = count || 0;
@@ -92,7 +90,8 @@ function _subscribeRealtime() {
try {
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!tenantId) return;
const tenantSchema = tenantStore.activeTenantSchema;
if (!tenantId || !tenantSchema) return;
if (_realtimeChannel) {
supabase.removeChannel(_realtimeChannel);
}
@@ -100,12 +99,12 @@ function _subscribeRealtime() {
.channel(`menu_badges_conv_${tenantId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
{ event: 'INSERT', schema: tenantSchema, table: 'conversation_messages' },
() => _refresh()
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
{ event: 'UPDATE', schema: tenantSchema, table: 'conversation_messages' },
() => _refresh()
)
.subscribe();
+3 -3
View File
@@ -18,6 +18,7 @@ import { onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useNotificationStore, fireBrowserNotification } from '@/stores/notificationStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { useTenantStore } from '@/stores/tenantStore';
@@ -91,10 +92,9 @@ export function useNotifications() {
if (payload.thread_key) {
try {
const tenantId = tenantStore.activeTenantId;
const { data } = await supabase
.from('conversation_threads')
const { data } = await tenantDb().from('conversation_threads')
.select('*')
.eq('tenant_id', tenantId)
.eq('thread_key', payload.thread_key)
.maybeSingle();
if (data) {
+9 -6
View File
@@ -16,6 +16,8 @@
*/
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
export function usePatientLifecycle() {
async function canDelete(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) {
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 };
return data; // { ok, error?, message? }
}
@@ -32,8 +35,8 @@ export function usePatientLifecycle() {
async function checkActiveSchedule(patientId) {
const now = new Date().toISOString();
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),
supabase.from('recurrence_rules').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'ativo')
tenantDb().from('agenda_eventos').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'agendado').gt('inicio_em', now),
tenantDb().from('recurrence_rules').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'ativo')
]);
return {
hasFutureSessions: (evts.count ?? 0) > 0,
@@ -42,17 +45,17 @@ export function usePatientLifecycle() {
}
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 };
}
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 };
}
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 };
}
+5 -9
View File
@@ -11,6 +11,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
const DEFAULTS = {
@@ -38,15 +39,11 @@ export function useSessionReminders() {
loading.value = true;
try {
const [settingsRes, logsRes] = await Promise.all([
supabase
.from('session_reminder_settings')
tenantDb().from('session_reminder_settings')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle(),
supabase
.from('session_reminder_logs')
tenantDb().from('session_reminder_logs')
.select('id, event_id, reminder_type, sent_at, provider, skip_reason, to_phone')
.eq('tenant_id', tenantId)
.order('sent_at', { ascending: false })
.limit(30)
]);
@@ -88,9 +85,8 @@ export function useSessionReminders() {
saving.value = true;
try {
const { error } = await supabase
.from('session_reminder_settings')
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
const { error } = await tenantDb().from('session_reminder_settings')
.upsert({ ...payload }, { onConflict: 'singleton' });
if (error) throw error;
return { ok: true };
} 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 { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue';
@@ -803,8 +804,7 @@ async function openSessionRecordsDialog() {
sessionRecordsDialogOpen.value = true;
sessionRecordsLoading.value = true;
try {
const { data, error } = await supabase
.from('financial_records')
const { data, error } = await tenantDb().from('financial_records')
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
.eq('agenda_evento_id', eid)
.is('deleted_at', null)
@@ -17,6 +17,7 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useFeriados } from '@/composables/useFeriados';
import { useToast } from 'primevue/usetoast';
import DatePicker from 'primevue/datepicker';
@@ -168,7 +169,6 @@ async function confirmar() {
try {
const base = {
owner_id: props.ownerId,
tenant_id: props.tenantId,
tipo: 'bloqueio',
recorrente: false
};
@@ -204,7 +204,7 @@ async function confirmar() {
return;
}
const { error } = await supabase.from('agenda_bloqueios').insert(rows);
const { error } = await tenantDb().from('agenda_bloqueios').insert(rows);
if (error) throw error;
// Marcar sessões existentes como "remarcado"
@@ -229,7 +229,7 @@ async function marcarSessoesParaRemarcar(bloqueios) {
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcado'
for (const b of bloqueios) {
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) {
// filtra pela hora aproximada comparação UTC simplificada
@@ -250,7 +250,6 @@ async function salvarFeriadoMunicipal() {
const iso = toISO(fform.value.data);
try {
await criarFeriado({
tenant_id: props.tenantId,
owner_id: props.ownerId,
tipo: 'municipal',
nome: fform.value.nome.trim(),
@@ -20,6 +20,7 @@ import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
const props = defineProps({
modelValue: { type: Boolean, default: false },
insurancePlanId: { type: String, default: '' },
@@ -61,7 +62,7 @@ async function onSave() {
value: Number(form.value.value),
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;
toast.add({ severity: 'success', summary: 'Procedimento cadastrado', life: 2200 });
emit('created', data);
@@ -18,6 +18,7 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
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')}`;
loadingBloqueios.value = true;
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));
} catch {
/* silencioso */
@@ -152,7 +153,6 @@ async function confirmarBloqueio(feriado) {
try {
const row = {
owner_id: _ownerId.value,
tenant_id: _tenantId.value,
tipo: 'bloqueio',
recorrente: false,
titulo: `Feriado: ${feriado.nome}`,
@@ -163,11 +163,11 @@ async function confirmarBloqueio(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;
// 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]);
toast.add({
@@ -212,7 +212,6 @@ async function salvar() {
saving.value = true;
try {
await criar({
tenant_id: _tenantId.value,
owner_id: _ownerId.value,
tipo: 'municipal',
nome: form.value.nome.trim(),
@@ -11,7 +11,7 @@
| o id pra que o parent pré-selecione no select de serviços.
|
| Campos mínimos (obrigatórios no schema):
| name, price, owner_id, tenant_id
| name, price, owner_id
| Opcionais úteis:
| duration_min, description
|--------------------------------------------------------------------------
@@ -20,6 +20,7 @@
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
@@ -72,7 +73,7 @@ async function onSave() {
// Nome unico por owner (case-insensitive) espelha a validacao
// do useServices.save() pra impedir duplicata tambem quando o
// 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 (dups && dups.length > 0) {
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 = {
owner_id: ownerId,
tenant_id: tid,
name,
price: Number(form.value.price),
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
description: form.value.description?.trim().slice(0, 500) || null,
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;
toast.add({ severity: 'success', summary: 'Serviço criado', life: 2200 });
emit('created', data);
@@ -18,6 +18,7 @@
Acessível via SupportDebugBanner botão "Docs". -->
<script setup>
import { ref } from 'vue';
import { tenantDb } from '@/lib/supabase/tenantClient';
const props = defineProps({
visible: { type: Boolean, default: false }
@@ -141,7 +142,7 @@ const activeTab = ref(0);
<!-- Tab 1: Tabelas -->
<TabPanel header="Tabelas">
<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>
<table class="dd-table">
@@ -156,12 +157,12 @@ const activeTab = ref(0);
<tr>
<td><code>agenda_configuracoes</code></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>
<td><code>agenda_eventos</code></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>
<td><code>agenda_bloqueios</code></td>
@@ -217,7 +218,7 @@ const activeTab = ref(0);
<tr>
<td><code>determined_commitments</code></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>
<td><code>determined_commitment_fields</code></td>
@@ -232,7 +233,7 @@ const activeTab = ref(0);
<tr>
<td><code>services</code></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>
<td><code>professional_pricing</code></td>
@@ -636,8 +637,7 @@ async function loadEvents (ownerId, range) &#123;
logAPI('useAgendaEvents', 'loadEvents start', &#123; ownerId, range &#125;)
try &#123;
const &#123; data, error &#125; = await supabase
.from('agenda_eventos')
const &#123; data, error &#125; = await tenantDb().from('agenda_eventos')
.select('*')
.eq('owner_id', ownerId)
@@ -476,7 +476,7 @@ describe('onSendManualReminder', () => {
_functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null });
const { onSendManualReminder, toast, sendingReminder } = setup({ composer });
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(sendingReminder.value).toBe(false);
});
@@ -32,6 +32,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { buildBloqueioBackgroundEvents } from '@/features/agenda/services/agendaMappers';
export function useAgendaBloqueios() {
@@ -55,14 +56,12 @@ export function useAgendaBloqueios() {
// Query: recorrentes (qualquer data) OU não-recorrentes com
// data_inicio <= isoEnd e (data_fim ?? data_inicio) >= isoStart.
// 2 queries simples + merge pra evitar string-building frágil.
const baseNonRec = supabase
.from('agenda_bloqueios')
const baseNonRec = tenantDb().from('agenda_bloqueios')
.select('*')
.eq('recorrente', false)
.lte('data_inicio', isoEnd)
.or(`data_fim.gte.${isoStart},and(data_fim.is.null,data_inicio.gte.${isoStart})`);
const baseRec = supabase
.from('agenda_bloqueios')
const baseRec = tenantDb().from('agenda_bloqueios')
.select('*')
.eq('recorrente', true);
@@ -38,6 +38,7 @@ import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { labelStatusSessao } from './agendaEventHelpers';
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 });
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;
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
emit('updated', data);
@@ -213,8 +214,7 @@ export function useAgendaEventActions({
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString();
let q = supabase
.from('agenda_eventos')
let q = tenantDb().from('agenda_eventos')
.select('id, inicio_em, fim_em, titulo')
.eq('patient_id', pid)
.gte('inicio_em', dayStart)
@@ -41,6 +41,7 @@
import { ref, computed, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function generateRuleDates(rule) {
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {};
if (!start_date || !weekdays?.length) return [];
@@ -150,14 +151,13 @@ export function useAgendaEventLifecycle({
}
serieLoading.value = true;
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;
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 { data: realData } = await supabase
.from('agenda_eventos')
const { data: realData } = await tenantDb().from('agenda_eventos')
.select('id, inicio_em, fim_em, status, recurrence_date')
.eq('recurrence_id', rid)
.is('mirror_of_event_id', null)
@@ -236,8 +236,7 @@ export function useAgendaEventLifecycle({
// 1) Record direto (materializada que tem agenda_evento_id real)
const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::');
if (evId && !isVirtualId) {
const { data, error } = await supabase
.from('financial_records')
const { data, error } = await tenantDb().from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
@@ -255,8 +254,7 @@ export function useAgendaEventLifecycle({
// materializadas sem cobrança individual) herdam status do
// contrato pra UI mostrar "Cobrança paga" coerentemente.
if (ruleId && patientId) {
const { data: contracts } = await supabase
.from('billing_contracts')
const { data: contracts } = await tenantDb().from('billing_contracts')
.select('id, package_price, charging_style, status')
.eq('patient_id', patientId)
.eq('type', 'package')
@@ -266,8 +264,7 @@ export function useAgendaEventLifecycle({
if (upfront) {
// Confere se há record PAGO ligado a qualquer evento do
// mesmo recurrence_id (ou seja, contrato foi quitado).
const { data: siblingEvents } = await supabase
.from('agenda_eventos')
const { data: siblingEvents } = await tenantDb().from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId);
const ids = (siblingEvents || []).map((e) => e.id);
@@ -276,8 +273,7 @@ export function useAgendaEventLifecycle({
// pending OU overdue). Pacote upfront tem 1 record
// unico cobrindo toda a serie — qualquer status dele
// trava as siblings (cobranca ja emitida, imutavel).
const { data: anyRec } = await supabase
.from('financial_records')
const { data: anyRec } = await tenantDb().from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.in('agenda_evento_id', ids)
.in('status', ['paid', 'pending', 'overdue'])
@@ -315,8 +311,7 @@ export function useAgendaEventLifecycle({
const evId = props.eventRow?.id;
if (!evId) return;
try {
const { data, error } = await supabase
.from('financial_records')
const { data, error } = await tenantDb().from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
@@ -341,8 +336,7 @@ export function useAgendaEventLifecycle({
// Só faz sentido pra sessão de série
if (!patientId || !ruleId) return;
try {
const { data, error } = await supabase
.from('billing_contracts')
const { data, error } = await tenantDb().from('billing_contracts')
.select('id, type, total_sessions, sessions_used, package_price, charging_style, status, active_from')
.eq('patient_id', patientId)
.eq('type', 'package')
@@ -477,7 +471,7 @@ export function useAgendaEventLifecycle({
sendingReminder.value = true;
try {
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) {
const err = data?.error || error?.message || 'unknown_error';
@@ -522,8 +516,7 @@ export function useAgendaEventLifecycle({
if (serieValorMode) serieValorMode.value = 'multiplicar';
if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) {
supabase
.from('patients')
tenantDb().from('patients')
.select('id, nome_completo')
.eq('id', composer.form.value.paciente_id)
.maybeSingle()
@@ -602,8 +595,7 @@ export function useAgendaEventLifecycle({
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 { data } = await supabase
.from('agendador_solicitacoes')
const { data } = await supabase.from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, paciente_email')
.eq('owner_id', props.ownerId)
.eq('status', 'pendente')
@@ -625,8 +617,7 @@ export function useAgendaEventLifecycle({
const dow = new Date(dia).getDay();
loadingOnlineSlots.value = true;
try {
const { data } = await supabase
.from('agenda_online_slots')
const { data } = await tenantDb().from('agenda_online_slots')
.select('time')
.eq('owner_id', props.ownerId)
.eq('weekday', dow)
@@ -38,6 +38,7 @@
*/
import { ref, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { calcFinalPrice } from './agendaEventHelpers';
export function useAgendaEventPickerBilling({
@@ -254,13 +255,11 @@ export function useAgendaEventPickerBilling({
pacientesError.value = '';
pacientesLoading.value = true;
let q = supabase
.from('patients')
.select('id,nome_completo,email_principal,telefone,status,avatar_url,tenant_id,responsible_member_id,created_at')
let q = tenantDb().from('patients')
.select('id,nome_completo,email_principal,telefone,status,avatar_url,responsible_member_id,created_at')
.order('created_at', { ascending: false })
.limit(500);
if (props.tenantId) q = q.eq('tenant_id', props.tenantId);
if (props.restrictPatientsToOwner && props.patientScopeOwnerId) {
q = q.eq('responsible_member_id', props.patientScopeOwnerId);
}
@@ -0,0 +1,147 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaStatusChange.js
|
| Composable Tipo A que orquestra o fluxo de status change da agenda
| usando agendaBilling.service. Reusável em Melissa / Rail / Clínica.
|
| Uso:
| const { applyStatusChange, dialogOpen, dialogProps, onDialogConfirm,
| onDialogCancel } = useAgendaStatusChange({ toast });
|
| // No handler:
| await applyStatusChange({ eventoId, row, novoStatus });
|
| // No template:
| <AgendaStatusChangeConfirmDialog
| v-model="dialogOpen"
| :evento="dialogProps.evento"
| :novoStatus="dialogProps.novoStatus"
| :regraExcecao="dialogProps.regraExcecao"
| :billingContract="dialogProps.billingContract"
| :billingContractStyle="dialogProps.billingContractStyle"
| :pendingRecord="dialogProps.pendingRecord"
| :sessionPrice="dialogProps.sessionPrice"
| @confirm="onDialogConfirm"
| @update:modelValue="(v) => !v && onDialogCancel()"
| />
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import {
loadStatusChangeContext,
needsStatusConfirmDialog,
applyStatusDecisions
} from '@/features/agenda/services/agendaBilling.service';
/**
* @param {object} [opts]
* @param {object} [opts.toast] instância de useToast (PrimeVue). Opcional.
* @returns composable com state reativo + applyStatusChange
*/
export function useAgendaStatusChange({ toast = null } = {}) {
const tenantStore = useTenantStore();
// Dialog state — bindar no template
const dialogOpen = ref(false);
const dialogProps = ref({});
let _resolveDialog = null;
function _openDialog(propsObj) {
return new Promise((resolve) => {
dialogProps.value = propsObj;
dialogOpen.value = true;
_resolveDialog = resolve;
});
}
function onDialogConfirm(decision) {
if (_resolveDialog) _resolveDialog(decision);
_resolveDialog = null;
dialogOpen.value = false;
}
function onDialogCancel() {
if (_resolveDialog) _resolveDialog(null);
_resolveDialog = null;
dialogOpen.value = false;
}
/**
* Coordena: load context mostra dialog se preciso aplica decisões.
*
* @param {object} args
* @param {string} args.eventoId uuid (null pra ocorrências virtuais ainda)
* @param {object} args.row row do agenda_eventos (pode ser parcial)
* @param {string} args.novoStatus 'realizado' | 'faltou' | 'cancelado' | 'agendado'
*
* @returns {Promise<{ applied: boolean, decision: object|null, ctx: object }>}
* applied=true se passou pelo applyStatusDecisions.
* decision=null se user cancelou o dialog.
*/
async function applyStatusChange({ eventoId, row, novoStatus }) {
const ownerId = (await supabase.auth.getUser()).data?.user?.id || null;
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
// 1) Carrega contexto
const ctx = await loadStatusChangeContext({
supabase,
row,
eventoId,
status: novoStatus,
ownerId,
tenantId
});
// 2) Dialog se preciso
let decision = null;
if (needsStatusConfirmDialog(novoStatus, ctx)) {
decision = await _openDialog({
evento: row,
novoStatus,
regraExcecao: ctx.regraExcecao,
billingContract: ctx.billingContract,
billingContractStyle: ctx.billingContract?.charging_style || null,
pendingRecord: ctx.pendingRecord,
sessionPrice: row?.price ?? null
});
if (!decision) {
// user cancelou
return { applied: false, decision: null, ctx };
}
} else {
// Sem dialog — default decision vazia (só aplicar status change básico)
decision = {};
}
// 3) Aplica decisões
await applyStatusDecisions({
supabase,
toast,
eventoId,
row,
novoStatus,
ctx,
decision,
ownerId,
tenantId
});
return { applied: true, decision, ctx };
}
return {
// dialog state — pra template
dialogOpen,
dialogProps,
onDialogConfirm,
onDialogCancel,
// main action
applyStatusChange
};
}
@@ -27,6 +27,7 @@
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
// Shape interno de CommitmentItem:
// {
// service_id: uuid,
@@ -56,7 +57,7 @@ export function useCommitmentServices() {
async function loadItems(eventId) {
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;
return (data || []).map(_mapRow);
@@ -73,7 +74,7 @@ export function useCommitmentServices() {
if (!eventId) throw new Error('eventId é obrigatório para salvar commitment_services.');
// 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;
@@ -89,14 +90,14 @@ export function useCommitmentServices() {
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;
}
// 3. Marca a ocorrência como customizada (impede sobrescrita por edições do raiz)
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;
}
@@ -107,7 +108,7 @@ export function useCommitmentServices() {
async function loadRuleItems(ruleId) {
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;
return (data || []).map(_mapRow);
@@ -120,7 +121,7 @@ export function useCommitmentServices() {
async function saveRuleItems(ruleId, items) {
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;
@@ -136,7 +137,7 @@ export function useCommitmentServices() {
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;
}
@@ -171,7 +172,7 @@ export function useCommitmentServices() {
if (!ruleId) return;
// 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) {
q = q.eq('services_customized', false);
@@ -189,8 +190,7 @@ export function useCommitmentServices() {
// em batch evita N round-trips. Status considerados imutáveis: pending,
// paid, overdue. cancelled é ok propagar (record foi descartado).
const eventIds = events.map((e) => e.id);
const { data: lockedEvents, error: frErr } = await supabase
.from('financial_records')
const { data: lockedEvents, error: frErr } = await tenantDb().from('financial_records')
.select('agenda_evento_id')
.in('agenda_evento_id', eventIds)
.in('status', ['pending', 'paid', 'overdue']);
@@ -202,7 +202,7 @@ export function useCommitmentServices() {
// Para cada evento elegível: delete + insert (padrão idempotente)
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 (items?.length) {
@@ -215,7 +215,7 @@ export function useCommitmentServices() {
discount_flat: item.discount_flat ?? 0,
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;
}
}
@@ -17,6 +17,7 @@
import { computed, ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useDeterminedCommitments(tenantIdRef) {
const loading = ref(false);
const error = ref('');
@@ -39,10 +40,9 @@ export function useDeterminedCommitments(tenantIdRef) {
loading.value = true;
error.value = '';
const { data, error: err } = await supabase
.from('determined_commitments')
.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)')
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
const { data, error: err } = await tenantDb().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)')
// ✅ SOMENTE tenant corrente
.eq('active', true)
.order('is_native', { ascending: false })
.order('name', { ascending: true });
@@ -28,6 +28,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useFinancialExceptions() {
const exceptions = ref([]);
const loading = ref(false);
@@ -39,7 +40,7 @@ export function useFinancialExceptions() {
loading.value = true;
error.value = '';
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;
exceptions.value = data || [];
@@ -60,8 +61,7 @@ export function useFinancialExceptions() {
error.value = '';
try {
if (payload.id) {
const { error: err } = await supabase
.from('financial_exceptions')
const { error: err } = await tenantDb().from('financial_exceptions')
.update({
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
@@ -72,9 +72,8 @@ export function useFinancialExceptions() {
.eq('id', payload.id);
if (err) throw err;
} else {
const { error: err } = await supabase.from('financial_exceptions').insert({
const { error: err } = await tenantDb().from('financial_exceptions').insert({
owner_id: payload.owner_id,
tenant_id: payload.tenant_id ?? null,
exception_type: payload.exception_type,
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
@@ -96,7 +95,7 @@ export function useFinancialExceptions() {
async function remove(id) {
error.value = '';
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;
exceptions.value = exceptions.value.filter((e) => e.id !== id);
} catch (e) {
@@ -30,6 +30,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useInsurancePlans() {
const plans = ref([]);
const loading = ref(false);
@@ -40,8 +41,7 @@ export function useInsurancePlans() {
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('insurance_plans')
const { data, error: err } = await tenantDb().from('insurance_plans')
.select(
`
*,
@@ -66,8 +66,7 @@ export function useInsurancePlans() {
error.value = null;
try {
if (payload.id) {
const { error: err } = await supabase
.from('insurance_plans')
const { error: err } = await tenantDb().from('insurance_plans')
.update({
name: payload.name,
notes: payload.notes || null,
@@ -76,9 +75,8 @@ export function useInsurancePlans() {
.eq('id', payload.id);
if (err) throw err;
} else {
const { error: err } = await supabase.from('insurance_plans').insert({
const { error: err } = await tenantDb().from('insurance_plans').insert({
owner_id: payload.owner_id,
tenant_id: payload.tenant_id,
name: payload.name,
notes: payload.notes || null
});
@@ -93,7 +91,7 @@ export function useInsurancePlans() {
async function toggle(id, active) {
error.value = null;
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;
const plan = plans.value.find((p) => p.id === id);
if (plan) plan.active = active;
@@ -106,7 +104,7 @@ export function useInsurancePlans() {
async function remove(id) {
error.value = null;
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;
const plan = plans.value.find((p) => p.id === id);
if (plan) plan.active = false;
@@ -120,8 +118,7 @@ export function useInsurancePlans() {
error.value = null;
try {
if (payload.id) {
const { error: err } = await supabase
.from('insurance_plan_services')
const { error: err } = await tenantDb().from('insurance_plan_services')
.update({
name: payload.name,
value: payload.value
@@ -129,7 +126,7 @@ export function useInsurancePlans() {
.eq('id', payload.id);
if (err) throw err;
} 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,
name: payload.name,
value: payload.value
@@ -145,7 +142,7 @@ export function useInsurancePlans() {
async function togglePlanService(id, active) {
error.value = null;
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;
} catch (e) {
error.value = e?.message || 'Erro ao atualizar procedimento';
@@ -156,7 +153,7 @@ export function useInsurancePlans() {
async function removeDefinitivo(id) {
error.value = null;
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;
plans.value = plans.value.filter((p) => p.id !== id);
} catch (e) {
@@ -168,7 +165,7 @@ export function useInsurancePlans() {
async function removePlanService(id) {
error.value = null;
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;
} catch (e) {
error.value = e?.message || 'Erro ao remover procedimento';
@@ -29,6 +29,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function usePatientDiscounts() {
const discounts = ref([]);
const loading = ref(false);
@@ -40,7 +41,7 @@ export function usePatientDiscounts() {
loading.value = true;
error.value = '';
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;
discounts.value = data || [];
@@ -53,17 +54,19 @@ export function usePatientDiscounts() {
}
// ── 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.
async function save(payload) {
error.value = '';
try {
if (payload.id) {
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;
} 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;
}
} catch (e) {
@@ -76,7 +79,7 @@ export function usePatientDiscounts() {
async function remove(id) {
error.value = '';
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;
discounts.value = discounts.value.filter((d) => d.id !== id);
} catch (e) {
@@ -95,8 +98,7 @@ export function usePatientDiscounts() {
if (!ownerId || !patientId) return null;
try {
const now = new Date().toISOString();
const { data, error: err } = await supabase
.from('patient_discounts')
const { data, error: err } = await tenantDb().from('patient_discounts')
.select('*')
.eq('owner_id', ownerId)
.eq('patient_id', patientId)
@@ -23,6 +23,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useProfessionalPricing() {
const rows = ref([]); // professional_pricing rows
const loading = ref(false);
@@ -34,7 +35,7 @@ export function useProfessionalPricing() {
loading.value = true;
error.value = '';
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;
rows.value = data || [];
@@ -31,6 +31,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from '@/features/agenda/services/_tenantGuards';
import { logRecurrence, logError, logPerf } from '@/support/supportLogger';
@@ -326,7 +327,6 @@ function buildOccurrence(rule, date, originalIso, exception) {
owner_id: rule.owner_id,
therapist_id: rule.therapist_id,
terapeuta_id: rule.therapist_id,
tenant_id: rule.tenant_id,
// nome do paciente — injetado pelo loadAndExpand via _patient
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
// Dois selects separados evitam problemas com .or() + .is.null no Supabase JS
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 });
// Filtra por tenant quando disponível — defesa em profundidade
if (tenantId && tenantId !== 'null' && tenantId !== 'undefined') {
q = q.eq('tenant_id', tenantId);
}
return q;
return tenantDb().from('recurrence_rules').select('*').eq('owner_id', ownerId).eq('status', 'ativo').lte('start_date', endISO).order('start_date', { ascending: true });
};
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);
// 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
// (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]);
@@ -550,7 +545,7 @@ export function useRecurrence() {
// Busca nomes dos pacientes das regras carregadas
const patientIds = [...new Set(rules.value.map((r) => r.patient_id).filter(Boolean))];
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
const pMap = new Map((patients || []).map((p) => [p.id, p]));
for (const rule of rules.value) {
@@ -579,15 +574,14 @@ export function useRecurrence() {
/**
* 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
* @returns {Object} regra criada
*/
async function createRule(rule) {
const tenantId = currentTenantId();
logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type });
const safeRule = { ...rule, tenant_id: rule?.tenant_id || tenantId };
const { data, error: err } = await supabase.from('recurrence_rules').insert([safeRule]).select('*').single();
const { tenant_id: _dropTenantId, ...safeRule } = rule || {};
const { data, error: err } = await tenantDb().from('recurrence_rules').insert([safeRule]).select('*').single();
if (err) {
logError('useRecurrence', 'createRule ERRO', err);
throw err;
@@ -598,15 +592,14 @@ export function useRecurrence() {
/**
* 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) {
const tenantId = currentTenantId();
const { data, error: err } = await supabase
.from('recurrence_rules')
const { data, error: err } = await tenantDb().from('recurrence_rules')
.update({ ...patch, updated_at: new Date().toISOString() })
.eq('id', id)
.eq('tenant_id', tenantId)
.select('*')
.single();
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) {
const tenantId = currentTenantId();
const { error: err } = await supabase
.from('recurrence_rules')
const { error: err } = await tenantDb().from('recurrence_rules')
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
.eq('id', id)
.eq('tenant_id', tenantId);
;
if (err) throw err;
}
@@ -631,7 +623,9 @@ export function useRecurrence() {
* Retorna o id da nova regra criada
*/
async function splitRuleAt(id, fromDateISO) {
const tenantId = currentTenantId();
const { data, error: err } = await supabase.rpc('split_recurrence_at', {
p_tenant_id: tenantId,
p_recurrence_id: id,
p_from_date: fromDateISO
});
@@ -643,7 +637,9 @@ export function useRecurrence() {
* Cancela a série a partir de uma data
*/
async function cancelRuleFrom(id, fromDateISO) {
const tenantId = currentTenantId();
const { error: err } = await supabase.rpc('cancel_recurrence_from', {
p_tenant_id: tenantId,
p_recurrence_id: id,
p_from_date: fromDateISO
});
@@ -654,13 +650,11 @@ export function useRecurrence() {
/**
* 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) {
const tenantId = currentTenantId();
const safeEx = { ...ex, tenant_id: ex?.tenant_id || tenantId };
const { data, error: err } = await supabase
.from('recurrence_exceptions')
const { tenant_id: _dropTenantId, ...safeEx } = ex || {};
const { data, error: err } = await tenantDb().from('recurrence_exceptions')
.upsert([safeEx], { onConflict: 'recurrence_id,original_date' })
.select('*')
.single();
@@ -670,16 +664,14 @@ export function useRecurrence() {
/**
* Remove uma exceção (restaura a ocorrência ao normal).
* Filtro por tenant_id defesa em profundidade.
*/
async function deleteException(recurrenceId, originalDate) {
const tenantId = currentTenantId();
const { error: err } = await supabase
.from('recurrence_exceptions')
const { error: err } = await tenantDb().from('recurrence_exceptions')
.delete()
.eq('recurrence_id', recurrenceId)
.eq('original_date', originalDate)
.eq('tenant_id', tenantId);
;
if (err) throw err;
}
@@ -29,6 +29,7 @@
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function useServices() {
const services = ref([]);
const loading = ref(false);
@@ -39,7 +40,7 @@ export function useServices() {
loading.value = true;
error.value = '';
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;
services.value = data || [];
@@ -61,7 +62,7 @@ export function useServices() {
// Nome unico por owner (case-insensitive). No update,
// ignora o proprio id pra nao conflitar consigo mesmo
// 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);
const { data: dups, error: dupErr } = await dupQuery;
if (dupErr) throw dupErr;
@@ -71,10 +72,12 @@ export function useServices() {
if (payload.id) {
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;
} 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;
}
} catch (e) {
@@ -86,7 +89,7 @@ export function useServices() {
async function toggle(id, active) {
error.value = '';
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;
const svc = services.value.find((s) => s.id === id);
if (svc) svc.active = active;
@@ -99,7 +102,7 @@ export function useServices() {
async function remove(id) {
error.value = '';
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;
services.value = services.value.filter((s) => s.id !== id);
} catch (e) {

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