180 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

list_my_signatures (cross-tenant) -> Lote F.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:57:43 -03:00
Leonardo c3220f159c wiki: F6.0+F6.1 done, plano F6.2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:53:22 -03:00
Leonardo 003f2eb2a6 F6.0+F6.1 schema-per-tenant: clones dos 9 tenants + migracao de dados
- F6.0 (migration): clone_tenant_template pros 9 tenants existentes (schemas
  vazios; dispara trigger F5 -> expostos no PostgREST)
- F6.1 (manual supabase_admin): copia dados public -> schemas com
  session_replication_role=replica (desabilita FK check, so supabase_admin).
  Tabelas com tenant_id por filtro; 3 filhas sem tenant_id (commitment_services,
  insurance_plan_services, recurrence_rule_services) por JOIN no pai; exclui
  colunas GENERATED (net_amount, margin_brl); reset de 4 sequences; ON CONFLICT
  DO NOTHING (idempotente). Dados continuam em public (DROP so na F6.3)
- Verificado: contagens public vs schemas batem (35 patients, 37 eventos,
  355 mensagens, 54 financeiro...); seeds default replicados por schema

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:25:25 -03:00
Leonardo 07437c9ff4 wiki: F4 + F1b schema-per-tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:10:16 -03:00
Leonardo f17e9ee786 F1b: 6 tabelas anon-facing ficam em public (decisao roteamento anon)
Fluxos anon identificam tenant por token/slug e nao resolvem o schema fisico.
Decisao (opcao C): manter em public com RLS por token. Volta a global:
patient_intake_requests, patient_invites, patient_invite_attempts,
document_share_links, agendador_configuracoes, agendador_solicitacoes.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:09:46 -03:00
Leonardo 9b21642e15 F4 schema-per-tenant: edge functions roteiam pro schema do tenant
- _shared/tenant.ts: helper (adminClient, tenantDbForId, schemaForTenant,
  listTenantSchemas, resolveTenantByChannel, tenantSchemaName)
- _shared/whatsapp-hooks.ts: hooks de tabela tenant recebem tdb; RPCs de
  credito (deduct/add_whatsapp_credits) e tenant_members seguem em supa+p_tenant_id
- inbound (twilio/evolution): tenant_id da URL -> tdb pra conversation_messages
  e notification_channels
- crons de fila (process-notification/email/sms/whatsapp-queue): varrem
  listTenantSchemas e drenam a fila de cada schema (Q3: filas sao per-tenant);
  modo single-tenant se body.tenant_id vier
- crons reminders/checks (send-session-reminders, conversation-sla-check,
  whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates):
  loop por tenant
- routing por tenant_id (send-whatsapp-message, send-session-reminder-manual,
  twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId;
  channel-actions sem tenant_id varrem schemas por channel_id
- asaas-*: tenant_id do body -> tdb; asaas-webhook fica global (whatsapp_credit_purchases)
- notification-webhook (Meta): resolve tenant via channel_routing por phone_number_id,
  fan-out por message_id quando nao resolve
- caller send-session-reminder-manual passa tenant_id (evento vive no schema)

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00
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
Leonardo d7cd2541e4 wiki + padronizacao: §1.3 UX 3/4 fechado (#10/#11/#13 done · #12 bloqueado)
Atualiza PADRONIZACAO.md marcando §1.3 UX como 3 de 4 fechados.
#12 papel timbrado documentado como bloqueado em codigo externo
do UniaoApp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:22:25 -03:00
Leonardo b1e8e010c0 roadmap #13: relatorios export PDF + Excel + CSV
ROADMAP item #1.3 #13. exceljs e jspdf ja estavam no package.json
mas as paginas de relatorio so renderizavam UI — zero export.

src/services/reportExport.service.js (novo) com 3 funcoes:
- exportSessionsToPDF: layout HTML→PDF via pdf.service.js (header
  com branding tenant, KPI grid, tabela A4 com striping)
- exportSessionsToXLSX: ExcelJS workbook formatado (titulo + subtitle
  + KPIs inline + tabela com header escuro + alternating row + frozen
  header). Import dinamico — exceljs e pesado, so carrega no click.
- exportSessionsToCSV: vanilla (sem deps) com BOM UTF-8 + separador
  ';' (Excel-friendly em pt-BR)

3 botoes em ambas paginas:
- RelatoriosPage.vue (/therapist/relatorios): icones pi-file-pdf +
  pi-file-excel + pi-table no header (rounded), tooltip, disabled
  quando total=0 ou loading, toast de sucesso/erro
- MelissaRelatorios.vue (Melissa secao): mesma logica, botoes nativos
  .mr-head-btn no padrao Melissa

Filtro de status da tabela e respeitado no export (exporta o que
o usuario esta vendo). KPIs incluidos no PDF e XLSX.

§1.3 UX = 3/4 fechado: #10 (busca global) + #11 (recently viewed) +
#13 (relatorios export). #12 (papel timbrado) bloqueado em codigo
externo do UniaoApp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:36 -03:00
Leonardo 2dae4a11ae roadmap #11: recently viewed (ultimos 5 pacientes acessados)
ROADMAP item #1.3 #11. localStorage por user_id pra isolar sessoes
diferentes no mesmo browser. ROADMAP sugeria localStorage OU tabela
user_recent_access — escolhi localStorage por simplicidade (sem
migration adicional + zero round-trip por visita).

composables/useRecentPatients.js:
- useRecentPatients() — composable reativo Tipo A: items + hasItems
  + addVisit + remove + clear + refresh
- registerPatientVisit(patient) — helper stateless pra usar fora
  de setup (ex: navigation guards, action handlers)
- Sincroniza entre instancias na mesma aba via CustomEvent + 'storage'
- Max 5 items. Dedup por id, novo no topo.

Wire-up de visita (registra ao carregar prontuario):
- MelissaPaciente.vue: registerPatientVisit no loadAll apos detail.load
- PatientProntuario.vue: registerPatientVisit em loadDetail apos p resolved

Wire-up de visualizacao (mostra quando query vazia):
- GlobalSearch.vue: grupo "Acessados recentemente" antes dos Atalhos.
  goTo("recent") navega pra /therapist/patients/:id.
- MelissaBusca.vue: grupo "Acessados recentemente". emit('paciente')
  reusando a logica do MelissaLayout que ja navega pra
  /melissa/paciente?id=X.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:17:51 -03:00
Leonardo e7a9bdab5f roadmap #10: MelissaBusca usa RPC search_global (promovida de preview)
Fecha ROADMAP #1.3 #10 (busca global topbar). GlobalSearch.vue
classic+rail ja usava RPC. MelissaBusca era client-side preview com
fallback nas props (pacientes+eventos do dia) — agora consulta a
mesma RPC search_global com debounce 200ms + searchSeq pra descartar
respostas obsoletas.

3 grupos novos exibidos quando RPC retorna:
  - rpc-appointments  -> sessoes qualquer data (alem de "hoje")
  - rpc-documents     -> documentos por nome/tipo
  - rpc-intakes       -> cadastros recebidos

Pacientes mescla: RPC tem prioridade (todos os pacientes); props
mantida como fallback rapido (digitacao curta antes do debounce).
Emits estendidos: novos 'documento' + 'intake' alem dos existentes
'acao' + 'paciente' + 'evento'.

MelissaLayout atualizado:
  - @paciente agora navega pra /melissa/paciente?id=X (antes ignorava
    payload e so abria secao generica — bug existente)
  - @documento abre prontuario do paciente com tab=documentos
  - @intake abre /melissa cadastros-recebidos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:13:47 -03:00
Leonardo 36402cd0bf wiki + padronizacao: #14 recibo profissional fechado
Marca ROADMAP #1.4 #14 done em PADRONIZACAO.md (Fase 3 Gaps de MVP)
e adiciona entrada no log.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:05:52 -03:00
Leonardo 6ae651a8ae roadmap #14: recibo profissional PDF — gerador + quick path da agenda
ROADMAP item #1.4 #14. Fecha Fase 1.4 Fiscal minimo (parcial — #15
NFS-e fica pra depois).

DocumentGenerate.service estendido:
- loadTherapistData puxa registro profissional (#5 migration) e
  expoe terapeuta_registro auto-formatado ("CRP 12345/SP", "CRM
  98765/RJ"). terapeuta_crp legacy mantido por compat — preenche
  somente quando tipo=CRP.
- loadClinicData formata tenants.cpf_cnpj (11 ou 14 digitos) em
  CPF (XXX.XXX.XXX-XX) ou CNPJ (XX.XXX.XXX/XXXX-XX).
- loadAllVariables aceita {extras} (valor, formaPagamento) e
  computa valor_extenso via novo helper utils/valorExtenso.js
  (pt-BR completo ate 999 milhoes).
- saveGeneratedDocument ganha templateTipo + usa
  TEMPLATE_TYPE_TO_DOC_TYPE mapping (recibo_pagamento -> 'recibo',
  laudo -> 'laudo', atestado -> 'atestado' etc) em vez de
  hardcoded 'laudo'.
- emitirReciboParaSessao(eventoId, opts) — quick path one-call:
  busca template recibo_pagamento global, carrega variaveis,
  gera PDF blob, salva no Storage + documents + document_generated,
  dispara download.

Migration 20260521000008 substitui no template recibo_pagamento
"Psicologo(a) - CRP {{terapeuta_crp}}" por "{{terapeuta_registro}}"
e atualiza variaveis[]. Universal — funciona com qualquer conselho
(CRP/CRM/CRFa/CREFITO/CRESS/CRN).

DocumentTemplates.service.TEMPLATE_VARIABLES ganha terapeuta_
registro + _tipo + _numero + _uf (terapeuta_crp marcado legacy).

useDocumentGenerate.generateAndSave passa templateTipo no save.

AgendaEventoFinanceiroPanel ganha botao "Emitir recibo" (icon
pi-file-pdf, outlined, full width) que aparece SOMENTE quando
record.status === 'paid'. Toast de sucesso/erro. Loading state.

Fluxo end-to-end: terapeuta marca sessao como paga -> botao
"Emitir recibo" aparece -> click -> PDF baixado + aparece em
/clinic/documents/templates do paciente como tipo 'recibo'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:05:17 -03:00
Leonardo 114d755f84 wiki + padronizacao: CFP #6/#7 fechados — Compliance 1.2 done
Atualiza PADRONIZACAO.md (Fase 3 marca CFP completo, todos os 5
itens #5/#6/#7/#8/#9 done) e adiciona entrada no log.md detalhando
os 5 commits do dia + arquitetura end-to-end + proximos passos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:52:57 -03:00
Leonardo 19caa42f3b compliance CFP #7: dialog gera share_link junto com signature
DocumentSignatureDialog (terapeuta-side) ja existia com fluxo de
add signatarios. Estendido pra:
  - Checkbox "Gerar link publico para assinatura" (default ON)
  - Select de validade (24h/3d/7d/30d, default 7d)
  - Apos submit: alem de createSignatureRequests chama createShareLink
    e exibe o URL gerado num bloco emerald com botao Copy
  - Dialog fica aberto se gerou link (terapeuta copia/envia); fecha
    se nao gerou

Fluxo end-to-end agora funcional: terapeuta clica "Solicitar
assinatura" no DocumentsListPage > preenche signatarios > submit
gera signature requests + share_link > copia URL > envia via WA/
email > paciente abre /shared/document/:token > assina via fluxo
publico (RPC sign_document_by_token capturando IP/UA server-side).

Fecha ROADMAP #1.2 #6/#7 — Compliance basico BR completo (#5/#6/#7/
#8/#9 todos verdes, #6 com TCLE + Telehealth + TCLE menores + termo
sigilo + LGPD + autorizacao gravacao).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:52:01 -03:00
Leonardo 4e42881d5e compliance CFP #7: portal + fluxo de assinatura no SharedDocumentPage
ROADMAP #1.2 #7 — Assinatura eletronica no portal.

Migration 20260521000007 cria RPC list_my_signatures (SECURITY DEFINER)
que cruza auth.uid() por 3 caminhos (signatario_id, signatario_email,
patient.user_id) e devolve solicitacoes pendentes + share_token pra
link de assinatura. service.listMySignatures wrappa a RPC.

Composable useDocumentSignatures ganha loadMine().

PortalDocumentos.vue (nova) — lista signatures do paciente logado com
KPIs (total/pendentes/assinados/recusados), filtro, e botao "Assinar
agora" que navega pra /shared/document/:token. Item no portal.menu
"Documentos > Para assinar".

SharedDocumentPage.vue estendida: agora chama getSignableDocumentBy
Token primeiro (RPC nova). Quando o documento tem signatures pendentes,
mostra painel azul abaixo do preview com:
  - Aviso LGPD/CFP explicando o que sera registrado (IP/UA/timestamp/hash)
  - Checkbox aceite obrigatorio
  - Selecao de signatario quando multi-signatario
  - Botoes Assinar/Recusar com loading state
  - Computacao SHA-256 server-fetched antes do click

Fluxo: terapeuta gera doc -> cria signature + share_link -> link e
listado em /portal/documentos -> paciente clica -> /shared/document/
:token mostra doc + painel -> aceite -> assinatura registrada via RPC
sign_document_by_token (IP/UA capturados server-side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:49:27 -03:00
Leonardo 934c620295 compliance CFP #7: RPCs de assinatura + service ext + composable
Backend foundation pra assinatura eletronica (ROADMAP #1.2 #7).

Migration 20260521000006 cria 3 RPCs:
  - sign_document_by_signature_id (paciente logado, SECURITY INVOKER)
  - sign_document_by_token        (terceiro via share link, SECURITY DEFINER)
  - get_signable_document_by_token (preview pre-assinatura)

IP + user-agent capturados SERVER-SIDE via inet_client_addr() e
current_setting('request.headers'). Hash SHA-256 vem do cliente
pra integridade. Token via share link incrementa usos no UPDATE.

DocumentSignatures.service estendido com 3 wrappers RPC: signByPortal,
signByToken, getSignableDocumentByToken. useDocumentSignatures composable
novo (Tipo A blueprint) expoe state reativo + acoes: fetchForDocument,
requestSignatures, sign, refuse, signWithToken, loadByToken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:39:05 -03:00
Leonardo 8601ac0d70 compliance CFP #6: consent forms LGPD + Gravacao + tcle_online amend
ROADMAP item #1.2 #6 — biblioteca de consent forms editaveis.

Migration 20260521000005 estende CHECK constraint document_templates.tipo
com 2 valores novos:
  - termo_lgpd          — consentimento de tratamento de dados pessoais
  - autorizacao_gravacao — autorizacao gravacao sessao (audio/video)

Seed seed_060 adiciona 2 templates globais novos (is_global=true) +
faz UPDATE no template tcle_online existente acrescentando clausula
LGPD explicita (Art. 18 direitos do titular + contato exercer
direitos). Templates anteriores (TCLE base, autorizacao_menor,
termo_sigilo) ja referenciavam LGPD adequadamente.

Sobra #7 (portal de assinatura) pra fechar Fase 1.2 Compliance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:35:35 -03:00
Leonardo 3ce22dd236 wiki log: sessoes noturnas M1-M6 + Fase 2 + Asaas Fase A + CFP
Registra cronologia da leva noturna 20/05 evening -> 21/05 01:06:
Fase 0+0.5 sweep foundation, M1 Home/Components, M2 Pacientes batch,
M3+M4+M5+M6 foundation em batch, M5 quick wins, Fase 2 Graphify
hotspots, Asaas Gateway Tier 1 Fase A, Compliance CFP #5/#8/#9.
8 entradas no log.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:11 -03:00
Leonardo cd67f7e9f5 compliance CFP: #5 registro profissional + #9 especialidades
ROADMAP Fase 1.2 (Compliance basico BR). Item #5: profiles ganha
3 colunas (professional_registration_type/number/uf) com CHECK
constraint dos conselhos comuns (CRP, CRM, CRFa, CREFITO, CRESS,
CRN, RMS, outro). Item #9: catalogo public.specialties + join
M:N profile_specialties + RLS. Seed seed_050 popula 33
especialidades is_system=true (clinica, jurídica, neuropsicologia,
ABA, TCC, psicanalise etc). Service specialtiesService.js no
src/services pra consumo na UI.

Item #8 (nome social) ja estava integrado. #6 (consent forms UI)
e #7 (assinatura no portal) adiados — schemas document_templates
e document_signatures existem, falta workflow UI dedicado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:03 -03:00
Leonardo de3898878a asaas: Tier 1 Fase A foundation — migrations + service + edge function stubs
DESIGN_ASAAS_GATEWAY.md documenta arquitetura. Schema novo: 2
migrations (tables + RLS) cobrindo asaas_customers + asaas_payments
+ asaas_webhook_events. Client service asaasGatewayService.js no
features/financeiro. 3 Edge Function stubs (create-payment-record,
cancel-payment, sync-payment) — webhook financial_records eh Fase B.

Bloqueadores Fase B (implementacao real): user precisa criar conta
Asaas, gerar API keys, configurar webhook, setar ENV vars no
Supabase. Decisao modelo de negocio (A/B/C) tambem pendente.
Stops marcados claramente no DESIGN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:52 -03:00
Leonardo ee2967a075 M6: notices/conversations foundation — selects + repositories
Modulo 6 da Fase 1. noticesSelects.js extrai os 2 selects do
noticeService (GLOBAL_NOTICE_SELECT, NOTICE_DISMISSAL_SELECT) +
noticeService passa a usa-los (zero select inline). Conversations
ganha foundation: 3 services (_tenantGuards, conversationsSelects,
conversationsRepository). Channel factory (WhatsApp/SMS/Email) e
composables ficam pra sessao dedicada — escopo M6 era so destravar
o supabase.from() inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:42 -03:00
Leonardo 0956e4facc M5: tenantship + admin members + accept_invite RPC
Modulo 5 da Fase 1 + quick wins fechados. features/tenantship/ com
2 services + 2 composables (members + invites). MembersPage.vue
nova em views/pages/admin/ + rota /admin/members em routes.clinic.
Migration 20260520000005 cria RPC accept_tenant_invite (SECURITY
DEFINER + lock FOR UPDATE) — tenantInvitesRepository.acceptInvite
agora chama RPC real (nao mais stub). SaasTenantFeaturesPage
refatorada pra usar novo tenantFeatureAdminService. SetupWizardPage
2648 linhas deferido pra sessao dedicada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:33 -03:00
Leonardo fbfb95648e M4: financeiro foundation — services + composables paralelo
Modulo 4 da Fase 1. 9 arquivos novos em features/financeiro/:
4 services (_tenantGuards, financialSelects, financialRecords
Repository, financialExceptionsRepository, billingContractsRepository)
+ 4 composables (useFinancialRecords, useFinancialExceptions,
useBillingContracts, useBillingOrchestrator). Old composables ainda
em paralelo — Fase C (cutover) bloqueada pelas decisoes #2/#3/#6
de billing (memoria agenda_billing_decisoes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:23 -03:00
Leonardo 388e9a4186 M3: prontuario foundation — repositories + composables clinical_notes
Modulo 3 da Fase 1. 6 arquivos novos em features/patients/prontuario/:
services (_tenantGuards, clinicalNotesSelects, clinicalNotesRepository,
clinicalNoteTemplatesRepository) + composables (useClinicalNotes,
useClinicalNoteTemplates). Ativa quando migrations 0.5.B (clinical_notes
tables/rls/versioning + documents link) forem aplicadas no banco.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:15 -03:00
Leonardo 1c2a2b6e19 M2: patients — selects + repository + 8 composables refatorados
Modulo 2 da Fase 1 de padronizacao em batch unico. patientsSelects.js
nova com 11 constantes de select. patientsRepository.js estendido com
~15 funcoes novas (markIntakeConverted, list/get/update por
contexto, etc). 8 composables refatorados em paralelo (usePatients,
useDetail, Financial, Sessions, Messages, Documents, Recurrences,
SupportContacts) — zero supabase.from() em qualquer composable de
patients. _lastPatientId movido pra DENTRO das functions nos 3
composables que tinham. CadastrosRecebidosPage + MelissaCadastros
Recebidos pegam carona dos selects. Aguarda teste batch consolidado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:08 -03:00
Leonardo 27467bbb68 M1: features/medicos + features/insurance + ComponentCadastroRapido refactor
Modulo 1 da Fase 1 de padronizacao. Novos features/medicos (services
+ composable useMedicos) e features/insurance (idem). 3 cadastros
rapidos (medicos, convenios, ComponentCadastroRapido + Insurance
PlanQuickCreateDialog) migrados pra usar os composables novos —
zero supabase.from() em UI components. TEST_ACCOUNTS extraido pra
src/config/devTestAccounts.js. Topbar ganhou switcher de layout
+ atalhos M1 via novo useTopbarDevMenuExtras. M1.6 MelissaLayout
90 imports deferida pra sessao dedicada (memoria padronizacao_sweep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:19:57 -03:00
Leonardo f94a4ae97f padronizacao: foundation Fase 0+0.5 — blueprints + auditoria + clinical_notes
Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create
overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia
PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical
notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent
Dialog.vue.bak deletado (lixo de refator anterior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:19:45 -03:00
Leonardo 5b345c5598 relatorios: analise senior do modulo agenda pos C1-C13
Relatorio standalone HTML (com print CSS otimizado pra PDF export):
- 10 paginas estruturadas
- Sumario executivo + metricas + pontos fortes
- 10 codes smells / dividas tecnicas detalhadas
- 8 issues de UX
- 7 riscos arquiteturais
- 15 recomendacoes priorizadas (P0-P3) com esforco e impacto
- Roadmap proposto em 3 horizontes
- Apendices: 14 bugs do dia, pendencias, commits, status dos cenarios

Visao senior eng: arquitetura solida em conceito, divida tecnica
em execucao. Top 5 achados:
1. 3 hotspots >2.8k LOC cada (AgendaEventDialog 6k, MelissaLayout 4.3k)
2. Logica de status change triplicada (Melissa/Rail/Clinica)
3. billing_contracts.updated_at gotcha
4. Snapshot stale popover (mitigado mas estrutural)
5. Audit trail acumulando ruido

Recomendacao chave: extrair status change orchestrator pra composable
shared ANTES da replicacao Rail/Clinica. Senao replica os mesmos
14 bugs vezes 2.

Para PDF: abrir relatorios/RELATORIO-AGENDA-2026-05-20.html no
browser e Ctrl+P -> Salvar como PDF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:22:25 -03:00
Leonardo 4da0bc2e11 HANDOFF + log: C12 deferred (UX iterar) · testando C13
C12 fluxo critico OK no DB (antecipar/revogar/re-antecipar/realizada
detecta paid). 5 bugs corrigidos no caminho: re-antecipar nao reusa
cancelled, popover watch sync com lookup virtual->materializada,
normalizeForMelissa expoe owner_id, etc.

User adiou C12 pra iterar UX depois (pos-Rail/Clinica). Salvo em
memoria project_c12_antecipar_iterar.

C13 prep: lock "edit cobrada" ja implementado na Fase 6 (commit
1feb711). User vai validar visualmente com Joao Almeida ou Andre.

14 commits no dia. Pendencias documentadas. Working tree limpo
exceto HANDOFF/log (este commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:34:58 -03:00
Leonardo f83315baba agenda: popover watch acompanha transicao virtual->materializada
Bug do "Usar sumiu apos revogar antecipacao": o watch sincronizava
eventoSelecionado por id, mas quando virtual era materializada
(antecipar/Usar/Realizada flow) o id mudava de rec::rule::date
pra uuid real. Watch nao achava match -> popover ficava preso na
versao virtual stale -> botoes refletiam estado antigo.

Fix: lookup em 2 etapas:
1) match por id (caso comum)
2) match por recurrence_id+recurrence_date quando nao acha (caso
   virtual->materializada). Pega a versao real correspondente
   aquela data.

Estado final do teste C12 do user: status=realizado, saldo 3/4,
1 pending + 5 cancelled (audit trail de varios ciclos antecipar/
revogar). Funcionalmente OK; com o fix, retestes ficam mais limpos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:23:18 -03:00
Leonardo 7d2a405d05 agenda: normalizeForMelissa expoe owner_id/tenant_id/contract_id
Bug introduzido pelo watch sync do popover (commit b5e00a7).
Apos o sync com eventos computed, eventoSelecionado.value ficava
com apenas os campos do normalizeForMelissa return. owner_id,
tenant_id, terapeuta_id, billing_contract_id NAO estavam expostos
no normalize -> sumiam apos refresh. onAnteciparPagamento entao
mandava owner_id=null pro RPC create_financial_record_for_session
-> "null value in column owner_id violates not-null constraint".

Fix:
- normalizeForMelissa agora expoe owner_id, tenant_id,
  terapeuta_id, billing_contract_id explicitos no return
- onAnteciparPagamento ganhou fallback robusto: ev.owner_id ||
  ev._raw?.owner_id || M.ownerId.value, com throw explicito se
  nada disponivel (em vez de mandar null pro RPC)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:10:51 -03:00
Leonardo b5e00a7022 agenda: popover sincroniza com eventos + antecipar nao reusa cancelled
Dois bugs descobertos no ciclo C12 antecipar -> revogar -> reantecipar:

1) confirmAnteciparPagamento reusava record cancelled:
   Quando user revogava (vira cancelled) e antecipava de novo,
   o existRec query pegava o cancelled e UPDATE-ava pra paid no
   MESMO record id. Resultado: notes mantinham historico
   "Cancelada via reversao" + "Antecipacao revogada" + record
   reativo como paid, confuso pra audit trail. Fix: filtrar
   .neq('status', 'cancelled') na busca de existRec — agora a
   re-antecipacao via RPC cria record fresh.

2) Popover snapshot stale (pendencia documentada em
   project_melissa_popover_snapshot, antecipada pra agora):
   eventoSelecionado.value era snapshot do clique e nao acompanhava
   updates do _paymentStateMap pos M.refetch. User antecipava, o
   record paid era criado, mas o popover continuava com paymentState
   antigo -> botao continuava "Antecipar pagamento" em vez de
   alternar pra "Revogar pagamento". Fix: watch em M.eventos sincroniza
   eventoSelecionado com a versao fresh quando id bate. flush:'post'
   pra rodar apos o computed reagir.

Como o popover agora atualiza in-place, removido fecharEvento() de
confirmAnteciparPagamento e onRevogarAntecipacao — o user pode ver
o botao alternar live em vez de precisar reabrir o popover.

Cleanup do estado do Andre: deletado record orfa 3a4c79e0 pra reset
do teste C12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:07:11 -03:00
Leonardo 272c804335 agenda: revogar antecipacao de pagamento
UX gap descoberto durante teste C12: apos antecipar pagamento,
nao havia caminho via popover pra desfazer caso user tenha
errado (paciente nao pagou, errou o valor, etc).

Implementacao:
- Botao "Antecipar pagamento" agora alterna pra "Revogar
  pagamento" (vermelho, --danger) quando isAntecipacaoAtiva
  (status=agendado + paymentState=paid)
- Handler onRevogarAntecipacao em MelissaLayout: ConfirmDialog
  vermelho + cancela record paid + nota de auditoria em notes
  ("[YYYY-MM-DD] Antecipacao revogada em ...") + refetch
- Apos revogar, botao volta pra "Antecipar pagamento" — user
  pode antecipar de novo com valor/metodo corretos

Limites: so disponivel em status='agendado'. Apos Realizada o
paid representa pagamento real da sessao realizada, nao
antecipacao — estorno deve ir pelo /financeiro.

Sobre "Usar" desaparecer apos antecipar (questao do user): comportamento
correto. "Usar" cria record+consome saldo — duplicaria com paid
existente. Apos antecipar, fluxo correto e clicar Realizada (que
detecta paid pre-existente via fix anterior 00c4168).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:28:04 -03:00
Leonardo 00c4168393 agenda: C12 prep — detectar paid pre-existente em pacote saldo realizada
Preparacao pra teste C12 (antecipar pagamento). Fluxo:
1. User clica "Antecipar pagamento" em virtual futura -> cria
   record paid R$ X sem consumir saldo
2. Depois marca a sessao como Realizada -> dialog deve detectar
   o paid + so consumir saldo (NAO criar record novo, evitar
   duplicidade)

Sem esse fix, marcar Realizada apos antecipar abriria o dialog
"Gerar cobranca?" com default true, gerando record novo duplicado.

Implementacao:
- _loadStatusChangeContext: carrega ctx.existingPaidRecord (qualquer
  paid linkado ao evento, n=1)
- Dialog: nova prop existingPaidRecord + computed showAlreadyPaid
  (substitui showCobrancaPacote quando paid existe)
- Template: bloco "Sessao ja paga via antecipacao" com info do
  pagamento + preview do consumo de saldo
- _applyStatusDecisions: novo branch 4-pre roda ANTES do generatePackageCharge:
  se realizado+pacote saldo+paid existe, roda tasks pendentes (1b
  amarra) + incrementa saldo sem criar record. Return cedo.

Backfill: Andre 10/06 voltou pra agendado + saldo 2/4 (estado limpo
pra testar C12 com a sessao 10/06 antecipando).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:28:19 -03:00
Leonardo 9ead3fdc42 HANDOFF + log: C11 fechado · 4/4 sub-testes OK · proximo C12
Cenario 11 completo. 5 bugs descobertos+corrigidos durante
a bateria (UI confusa, gotcha billing_contracts.updated_at,
reverse transitions, lock sessao encerrada, label/badge
pacote-aware). Reverse trava antecipada de pos-C13 pra ja
(user hit pra valer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:23:51 -03:00
Leonardo 5965b05378 agenda: link universal pacote + refresh saldo no reverse
Dois bugs descobertos durante C11/C+D:

1) Faltou+multa SEM consumeSaldo nao amarrava billing_contract_id
no agenda_evento (so amarrava se consumeSaldo=true). Resultado:
sessao 27/05 do Andre faltou+multa-sem-consume ficou sem rastro
do contrato no DB. Reverse posterior nao detectaria saldoConsumed.
Fix: bloco 1b) universal — sempre amarra quando forward (realizado/
faltou/cancelado) + tem contract + eventoId. Cobre todos os
combos (multa-sem-consume, multa-com-consume, generatePackageCharge,
consumeSaldo solo).

2) Reverse decrementar saldo as vezes nao persistia. Suspeita: race
com ctx.billingContract.sessions_used stale do _loadStatusChangeContext
quando flows rapidos sequenciais (Realizada+gerar -> Agendada
imediato). Fix: refetch FRESH do billing_contracts.sessions_used
direto do DB ANTES de calcular newUsed. Mais robusto contra qualquer
race condition. Adicionado console.log pra futura debug.

Removida duplicidade do amarra-billing_contract_id no bloco
consumeSaldo (universal cobre).

Backfill Andre Green: 27/05 amarrado, saldo voltou pra 2/4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:21:04 -03:00
Leonardo 45984e885b agenda: label + badge cientes de pacote em sessoes state=none
Bug descoberto durante teste C11: sessao 27/05 do Andre Green
(materializada via Falta+reverse, pertence ao pacote saldo)
mostrava "A cobrar R$ 40,00" no popover mesmo sem fatura ativa.
Implicava que dava pra gerar cobranca avulsa solta — conflito
com o flow do pacote (Usar consume saldo).

Fix em paymentLabel: quando state='none' e ev.contract existe,
label muda conforme estilo:
- saldo: "Aguardando uso do pacote"
- upfront: "Coberta pelo pacote (upfront)"
Avulsa sem pacote continua mostrando "A cobrar R$ X".

Simetria em MelissaAgenda.vue badge gate: nao mostra badge $ amber
em sessao state=none com pacote amarrado (hasPacoteTied). Sem
isso, sessao agendada de pacote saldo no calendar ficava com
badge "cobranca pendente" enganoso.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:09:45 -03:00
Leonardo 3f3f2acc70 agenda: consumeSaldo agora amarra billing_contract_id no evento
Bug em cascata descoberto durante C11/B com Andre Green:
- User clicou Falta + Descontar (consumeSaldo) -> sessions_used: 1->2
- billing_contract_id do agenda_evento ficou NULL (omissao no flow)
- User clicou Agendada (reverse) -> detector saldoConsumed em
  _loadStatusChangeContext checa evRow.billing_contract_id, que esta
  NULL -> saldoConsumed=false -> bloco "Devolver saldo" NAO aparece
  no dialog -> saldo NAO devolvido
- Next Falta mostra "Descontar 2 para 3" em vez de "1 para 2"

Fix: bloco consumeSaldo agora tambem amarra billing_contract_id no
agenda_eventos. Replica o padrao que ja existe no generatePackageCharge
e no onUsarSessao. Sem isso, qualquer reverse pos-consumeSaldo nao
detecta o saldo consumido.

Backfill manual do Andre: sessions_used voltou pra 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:50:26 -03:00
Leonardo 5684297243 agenda: reverse transition trava (Agendada apos artefatos)
User hit pra valer a pendencia documentada (reverter
realizado/faltou/cancelado pra agendado deixa records/saldo
orfaos). Decidido implementar trava AGORA em vez de pos-C13.

Quando user clica "Agendada" no popover/dialog em sessao que
tem artefatos pendentes (cobranca pending, multa, saldo consumido
em pacote saldo), abre o AgendaStatusChangeConfirmDialog com nova
variante "reverse":

1. Lista records pending vinculados (descricao + valor) com radio
   [Cancelar (recomendado) | Manter ativa]
2. Warning textual pra records PAID (estorno e manual pelo
   Financeiro — sem radio, so info)
3. Saldo consumido (pacote saldo): radio [Devolver 1 sessao | Manter]

No confirm:
- Cancela records pending escolhidos (status='cancelled' + notes
  de auditoria)
- Decrementa sessions_used + reativa contract se estava completed
- Desamarra billing_contract_id do evento se devolveu saldo
- Status muda pra agendado (ja foi aplicado pelo _applyStatusUpdateOnly)

Se nao tem artefato algum (sessao agendado -> agendado, ou
realizado sem records): aplica direto sem dialog (existing
behavior via _needsConfirmDialog).

_loadStatusChangeContext agora carrega reverseArtifacts (status
anterior, records ativos, saldoConsumed) quando novoStatus=agendado.

Memoria project_agenda_reverse_transitions atualizada — pendencia
fechada antes da hora.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:40:19 -03:00
Leonardo 16dfa02bd1 agenda pacote saldo: fix root cause + sequential awaits
ROOT CAUSE DESCOBERTO durante C11/A com Andre Green:
billing_contracts NAO tem coluna updated_at. UPDATEs em
_applyStatusDecisions passavam updated_at -> Postgres retornava
"column updated_at does not exist" -> Promise.allSettled engolia
como {status: 'rejected'} silencioso -> toast warn generico que
user nao percebia. Resultado: sessions_used nunca incrementava.

Bug existia em DOIS lugares:
1. consumeSaldo block (faltou/cancelado pacote saldo) - afetaria
   C11/B, C11/C, C11/D
2. generatePackageCharge block (realizado pacote saldo) - afetou
   C11/A

Em ambos: removido updated_at do patch (.update({...})).

ADICIONAL: generatePackageCharge refatorado pra usar AWAITS
SEQUENCIAIS (igual onUsarSessao do MelissaLayout que sempre
funcionou):
- 4a) UPDATE agenda_eventos.billing_contract_id (faltava!)
- 4b) RPC create_financial_record_for_session
- 4c) UPDATE billing_contracts.sessions_used + status=completed

Cada step com try/catch + console.error + toast distinto. Sem mais
falhas escondidas em Promise.allSettled paralelo.

Backfill manual do estado do Andre Green: evento 6e70476f agora
amarrado ao contract 691118da com sessions_used=1.

Memoria nova: project_billing_contracts_no_updated_at.md pra evitar
o gotcha no futuro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:25:04 -03:00
Leonardo 079509e001 agenda: dialog pacote saldo realizada — 2 sub-questions claras
Antes (UX confusa): bloco "Gerar cobranca no pacote?" tinha so um
Select "Como cobrar?" com options mixadas:
- "Enviar link de pagamento (Asaas)"
- "Ja recebi - PIX"
- "Ja recebi - Dinheiro"
- etc

User selecionou "Ja recebi - PIX" pensando que era "cobrar via PIX"
durante teste C11/A com Andre Green. Resultado: fatura virou paid
sem o user ter recebido de verdade. Ambiguidade entre "como cobrar"
(header) e "ja recebi" (options).

Refactor: espelhar o padrao da avulsa (showRegistrarPagto):
1. Sub-question "A sessao ja foi paga?" radio Sim/Nao (default Nao)
2. Se Nao -> Select "Como vai cobrar?" [Apenas registrar pendente |
   Enviar link de pagamento (Asaas)]
3. Se Sim -> Select "Como recebeu?" [PIX | Dinheiro | Deposito |
   Maquininha] (sem prefixo "Ja recebi" — header ja deixa claro)

Defaults safer: markPaid=false em ambos contextos (avulsa e pacote)
pra evitar marcar paid sem querer. paymentMethod='pending' inicial.

Handler em useMelissaAgenda._applyStatusDecisions: pos-processamento
agora usa decision.markPaid explicito no caso pacote saldo:
- markPaid=true -> record vira paid + payment_method=X
- markPaid=false + paymentMethod='link' -> pending + payment_method='asaas'
- markPaid=false + paymentMethod='pending' -> pending sem metodo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:11:28 -03:00
Leonardo 7dc7dcede0 wiki: log session C10 fechado completo
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:36:02 -03:00
Leonardo 1e74a115de HANDOFF: C10 fechado · 5/5 sub-testes OK · proximo C11
Cenario 10 (status change avulsa) completo:
- A: Realizada sem markPaid (record pending preservado)
- A2: Realizada + markPaid maquininha (paid + paid_at + payment_method)
- B: Faltou + multa fixed R$ 30 (original cancelled + nova multa)
- C: Cancelado >2h (original cancelled, sem multa)
- C2: Cancelado tardio <2h, full charge (original cancelled + nova taxa)

Bugs descobertos + corrigidos durante a bateria: cobranca dupla na
multa (cancela original agora), _reloadRange not defined no escopo
de _buildHandlers, badge $ amber em sessao encerrada, paymentLabel
usando ev.price em vez de paymentAmount pra pending, popover
permitindo emissao de fatura em sessao cancelada.

3 pendencias pos-C13 mapeadas em memoria + addendum HTML do doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:35:28 -03:00
Leonardo 753182cfad agenda: C10 pos-test fixes + lock sessao encerrada + addendum doc
Bugs descobertos durante testes C10/A2/B/C com user:

1) _reloadRange not defined: _buildHandlers nao destruturava
   _reloadRange do deps (passava mas nao desempacotava). Toast
   ReferenceError ao tentar reload pos-status change. Fix em
   useMelissaAgenda.js:_buildHandlers.

2) Badge $ amber em sessao cancelada: MelissaAgenda.vue badge gate
   ignorava status. Cancelado+state=none (records cancelled
   filtrados) ainda recebia badge "cobranca pendente". Fix: gate
   sessaoEncerrada (cancelado/faltou) -> sem badge nunca.

3) Botao "Gerar cobranca" em sessao encerrada: AgendaEventoFinanceiro
   Panel mostrava botao mesmo em cancelado/faltou -> user podia
   emitir fatura nova em sessao que nao aconteceu. Fix: v-if
   !isSessaoEncerrada + label muda pra "Sessao cancelada · sem
   cobranca ativa".

4) paymentLabel usava ev.price em vez de paymentAmount pra state
   'pending': caso multa R$ 30 mostrava R$ 150 (ev.price original).
   Fix: usar paymentAmount tambem em pending.

5) Lock total em sessao encerrada (cancelado/faltou):
   - "Editar sessao" SOME do popover
   - Realizada/Falta/Reagendar/Cancelar disabled com tooltip
   - Apenas "Agendada" continua funcional (caminho explicito de
     recuperacao). Single path de saida do estado encerrado.

Adicoes UX em AgendaStatusChangeConfirmDialog:
- Hint contextual sobre min_hours_notice explicando POR QUE multa
  veio (des)marcada por padrao: "Cancelou 18.5h antes da sessao.
  Regra: multa apenas quando cancelamento <2h -> sem multa por
  padrao." Terapeuta ve a razao e pode inverter conscientemente.

Adicoes UX em MelissaEventoPanel:
- Botao "Agendada" (variante --info azul cyan) no grupo status
  pra reset/recuperacao. CSS .evento-act--info hover + is-current.

Doc:
- Addendum C10 no topo de src/docs/agenda-compromisso-financeiro
  -cenarios.html capturando todas as divergencias/melhorias vs
  mockup original + 3 pendencias pos-C13 (reverse transitions,
  popover snapshot, A2 markPaid stale).

Pendencias salvas em memoria pra puxar pos-C13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:59:05 -03:00
Leonardo 3caf5792f8 agenda popover: botao Agendada + fixes pos-C10/B
Adicoes (durante teste C10/A2):
- Botao "Agendada" no popover (pi-calendar, variante --info azul
  cyan) pra permitir reset de status realizado/faltou/cancelado
  voltando pra agendado sem precisar abrir o AgendaEventDialog.
  Wire-up: emit 'agendar' -> onAgendar -> updateEventoStatus.
- CSS .evento-act--info: hover + is-current com tom cyan
  (#38bdf8 do domainColors da agenda). Highlight generico
  rgba(255,255,255,0.12) era invisivel em light mode.

Bug fixes durante teste C10/B com Otto Rank:
- MelissaEventoPanel paymentLabel: usar paymentAmount tambem pra
  state='pending' (antes so 'paid' usava; pending caia em ev.price
  e mostrava R$ 150 original quando o pendente real era R$ 30 da
  multa).
- useMelissaAgenda onUpdateSeriesEvent: chamar _reloadRange() apos
  _applyStatusDecisions. Sem isso o paymentStateMap+amountMap nao
  re-populavam apos status change com multa -> FullCalendar e
  popover ficavam stale ate F5/troca de view.

Pendencia salva em memoria: travas em reverse transitions
(faltou->agendado deixa multa orfa). User hit pra valer com Otto
durante teste, R$ 30 limpo manualmente no DB. Implementar pos-C13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:16:15 -03:00
Leonardo d6423da9b4 agenda: pre-C10 fix _applyStatusDecisions cancela pendingRecord
Bug: ao mudar status pra faltou/cancelado com multa configurada
em financial_exceptions, _applyStatusDecisions INSERIA o novo
record da multa MAS deixava o pendingRecord original em pending.
Resultado: cobranca dupla (R$ 200 original + R$ 30 multa = R$ 230).

Fix em useMelissaAgenda.js:1450-1505:
- applyFine agora carrega data da sessao na description ("Multa
  por falta - sessao dd/mm/aa") pro paciente identificar na fatura.
- Novo bloco 2b: cancela ctx.pendingRecord quando faltou/cancelado,
  com nota de auditoria appendada em notes ("[YYYY-MM-DD] Cancelada
  - substituida por multa de no-show" ou similar). Vale tanto pra
  caso com multa quanto sem (status mudado sem aplicar multa).

Fix dormente em useAgendaFinanceiro.js:59 ('fixed' -> 'fixed_fee')
- charge_mode no schema eh 'fixed_fee' mas calcChargeAmount usava
  'fixed' silenciosamente caia no fallback. Path nao exercitado na
  Melissa (usa _applyStatusDecisions, nao handleStatusChange), mas
  iria quebrar se algum dia fosse.

Pre-teste C10: financial_exceptions seedadas no DB para tenant
Bruno Terapeuta / owner Leonardo:
- patient_no_show: fixed_fee R$ 30
- patient_cancellation: full, min_hours_notice=2, default_consume_on_miss=true

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:27:16 -03:00
Leonardo ec0a24f5c8 agenda: C9 OK + rowGroup por paciente em /financeiro + bubble cobranca-atualizada
Cenário 9 (Per-session — Michael Balint 12 × R$ 150)
- Testado e passou. 1 rule + 12 agenda_eventos materializadas + 12
  financial_records pending. Sem billing_contract. Badge $ em todas as
  12 sessões. Conforme esperado.

/melissa/financeiro-lancamentos: agrupado por paciente
- DataTable com rowGroupMode='subheader' + groupRowsBy='patient_id'
- Header de grupo com avatar + nome + badge "N lançamento(s)"
- expandableRowGroups + v-model:expandedRowGroups; watcher popula
  todos os grupos da página atual como expandidos (sempre que
  recordsGrouped muda — refletindo paginação/filtros)
- Sort outer por nome do paciente, preserva inner order
  (pai → filhos de multas/taxas via mesmo agenda_evento_id)

Bubble-up @cobranca-atualizada → M.refetch
- Antes: ao marcar como pago no dialog, o card no FC ficava stale
  até trocar de view. AgendaEventoFinanceiroPanel emitia
  cobranca-atualizada mas só o loadOccFinancialRecord do dialog
  escutava; o _paymentStateMap da agenda nao re-rodava.
- Fix: AgendaEventDialog ganhou _onCobrancaAtualizada que dispara
  loadOccFinancialRecord() E emit('cobranca-atualizada') pra cima.
  MelissaLayout escuta nos 2 dialogs e chama M.refetch() +
  refetchEventosHoje(). Card passa pra borda verde na hora.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:55:06 -03:00
Leonardo fad1f4ebd4 agenda: C8 OK + Usar/Revogar pacote saldo + UI de contract + ajustes UX
Cenário 8 (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
- Testado e passou. DB: 1 rule, 0 events, 1 contract (saldo), 0 records.
  Visual: 12 virtuais limpas no calendário.

UI de pacote (saldo + upfront)
- _ruleContractMap em useMelissaAgenda: bulk-load popula contract info
  (id, style, totalSessions, sessionsUsed, packagePrice) por
  recurrence_id. Query recurrence_rules.patient_id como fonte
  autoritativa — cobre saldo sem materializadas (sem isso, ruleToPatient
  via records vinha vazio pra saldo)
- normalize injeta `contract` no evento via ruleContractMap
- MelissaEventoPanel: nova linha colorida (violeta saldo, verde upfront)
  com "Pacote saldo · N/M usadas" ou "Pacote · N/M realizadas"
- AgendaEventDialog: info card mt-4 com header+body+hint explicando
  modelo, gateado por occFinancialLoading (spinner durante carga
  pra evitar piscar entre Usar/Revogar)

Handlers Usar/Revogar atômicos
- onUsarSessao em MelissaLayout: materializa virtual (preserva
  determined_commitment_id da regra) → status=realizado +
  billing_contract_id → create_financial_record_for_session →
  sessions_used++ → (se atingiu total) contract.status=completed
- onRevogarSessao: cancela record + sessions_used-- + reativa contract
  se estava completed + status=agendado. Bloqueia se record paid
  (precisa estorno formal pelo Financeiro)
- Ambos aceitam payload {eventRow, contract} do dialog OU fallback
  pra eventoSelecionado do popover
- Botão "Usar" verde no popover (paymentState=none) substituído por
  "Revogar" vermelho (paymentState=pending). Equivalente "Usar agora"/
  "Revogar uso" no info card do dialog

Fix enum status_evento_agenda
- 'realizada' não existe no enum — DB exige 'realizado' (masculino).
  Corrigido em todas as ocorrências do handler

Fix campo "Título" indevido em sessão
- Sessão sem determined_commitment_id → selectedCommitment=null →
  isSessionEvent=false → mostra campo Título (que é só pra não-sessão)
- Fix: materialize do Usar inclui determined_commitment_id (insert
  path); update path backfilla via query da rule se NULL; Revogar
  também backfilla pra consistência

Fix "Gerar fatura" não cabe em saldo
- Botão "Gerar fatura" do popover hide quando há contractInfo. Em
  saldo, gerar fatura solta criaria cobrança duplicada sem incrementar
  sessions_used. Fluxo correto: "Usar"

Recorrências Aplicadas — UI
- Header stats coloridos: total **azul**, realizadas **verde**,
  faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
- Pills com badge sólido por status (emerald-600 realizado, amber-600
  faltou, stone-500 cancelado, violet-600 remarcado)

Race condition no dialog
- AgendaEventDialog mostrava botões Usar/Revogar baseado em
  occFinancialRecord async; durante ~500ms de load, botão errado
  podia piscar. Fix: spinner "Verificando estado…" enquanto
  occFinancialLoading=true; botões só renderizam após
- Popover não fixado (race window pequena, fechar/reabrir resolve)

3 decisões UX confirmadas antes de codar
- Editar serviço pago → NÃO (cobrança fiscal imutável)
- Alternar Particular/Convênio/Gratuito em série cobrada → NÃO
- Gerar fatura individual em pacote upfront → NÃO (duplicação)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:27:20 -03:00
Leonardo 1feb7112ff agenda: C7 OK + Fase 6 lock-edit ativada em Melissa + cross-week payment propagation
Cenário 7 (Pacote UPFRONT — Ana Souza Ferreira 4×R$ 200 = R$ 800)
- Testado e passou. User criou Ana, pagou os R$ 800 em dinheiro pelo
  Financeiro. Borda verde + popover "Pago R$ 800" funcionando.

Fase 6 (lock-edit cobrada) ativada em Melissa
- Removido guard `if (!props.occurrenceMode) return;` em
  loadOccFinancialRecord (useAgendaEventLifecycle.js:217+). Agora ele
  carrega em ambos modos (Rail/Clínica E Melissa)
- loadOccFinancialRecord SINTETIZA record paid/pending pra siblings de
  contrato upfront ativo — assim TODAS as ocorrências da série mostram
  "Cobrança paga R$ 800 do pacote" no AgendaEventDialog
- AgendaEventDialog card Sessão/Honorários (flow Melissa) ganhou lock
  template: Tag em vez de Select billingType quando occFinancialRecord
  existe; Message com cadeado "Cobrança de R$ X já emitida"
- AgendaEventoFinanceiroPanel só renderiza dentro do lock quando record
  é REAL (não sintetizado) — evita "Gerar cobrança" indevido em sibling
- paymentSummary do Resumo lateral unificado pra usar occFinancialRecord
  (em vez do sessionPaymentRecord paralelo de antes)

Cross-week propagation de pacote upfront
- BUG: ao navegar pra semana só com virtuais (sem reais), bulk-load
  caía no else `_rulePaymentMap.value = {}` — virtuais perdiam estado
  paid herdado
- FIX em useMelissaAgenda._reloadRange:
  * Maps (payment/amount/rule) inicializados SEMPRE no início
  * Propagação roda independente de realIds.length (depende só de
    ruleIdsInView.size>0, considera reais E virtuais com recurrence_id)
  * Query cross-week: pra cada rule em view, busca QUALQUER evento
    sibling em qualquer semana + seus records pra determinar estado do
    contrato. Encontra o record do pacote mesmo em outra semana
- Saldo NÃO propaga (filter: charging_style='upfront' || NULL); cada
  sessão de saldo gera cobrança individual ao realizar
- Memória durável: memory/project_cross_week_propagation.md

Visualização de virtuais cobertas
- MelissaEventoPanel.showPaymentRow: virtuais só escondem quando state
  ='none'. Com paid/pending herdado, exibem linha colorida
- MelissaAgenda fcEvents: isPaidSession e badge $ pendente removeram
  exigência de !is_occurrence. Virtuais herdadas via propagação mostram
  borda verde / badge amber

Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" quando paymentVariant
  ='none' && !is_occurrence. Click → gerarCobrancaManual direto, fecha
  popover pra impedir double-click. Tooltip: "Gerar fatura agora"
- Wire em MelissaLayout via novo emit gerar-cobranca + handler
  onGerarCobrancaQuick

Info de pacote no popover
- Header agora mostra "Sessão · Pacote · N sessões" (computed
  seriesLabel lê de _raw do rule)

Botão "Excluir série inteira"
- Novo emit delete-series em MelissaEventoPanel + botão ao lado de
  "Excluir sessão" quando evento tem recurrence_id
- Handler onDeleteSeries em MelissaLayout: hard delete em 3 etapas
  (financial_records pendentes → agenda_eventos materializados →
  recurrence_rules CASCADE leva exceptions). Bloqueia se algum record
  paid (estorno via Financeiro primeiro)

cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception.type=
  'cancel_session' (era visível com status cancelado; doc dizia que
  some). patient_missed/therapist_canceled/holiday_block permanecem
  como histórico

recurrence_exceptions cancel idempotente
- MelissaLayout onDeleteEvento usa upsert com onConflict pra exception
  cancel — não quebra mais com unique violation em re-cancel

billing_contract_id na 1ª materializada
- _createPackageContract agora .select() o contrato após insert e seta
  billing_contract_id no insert da 1ª agenda_eventos materializada

onVerLancamentos cobre virtual de upfront
- Antes virtual sempre toast "Sem lançamentos". Agora busca records via
  siblings da série pra encontrar o do pacote. Saldo/sem pacote continua
  com toast

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:54:23 -03:00
Leonardo c23d0a574f agenda: C5+C6 testes OK + atalho Gerar fatura + RPC idempotência fix
DB
- migration 20260519000001: create_financial_record_for_session passa a
  ignorar status='cancelled' na idempotência (era bug — cancelar e tentar
  regerar travava silencioso retornando o cancelado)

Cenário 5 (convênio) — fixes pra save + visualização
- Convênio: amount lia 'price' (null) → agora detecta via insurance_plan_id
  e usa insurance_value. payment_method forçado 'convenio' (era 'asaas')
- Popover: ev.price era null em convênio → normalize expõe insurance_value
  e paymentLabel faz fallback. Linha mostra "A receber R$ X" corretamente
- /financeiro: branch novo pra payment_method='convenio' → pill violeta
  com pi-id-card (antes ficava sem indicador, igual particular)

Cenário 6 (recorrente sem pacote, Maria Magali) — materialização
- chargeMode='none' não materializava a 1ª (todas viravam virtuais, sem
  badge $). Agora materializa a 1ª no fluxo de criação recorrente
- Bug intermediário: usei 'paciente_id' (Portuguese) mas agendaRepository
  dropa esse campo. Corrigido pra 'patient_id' (English DB column)

Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (paymentVariant
  ='none' + sessão materializada)
- Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick
  (chama gerarCobrancaManual, fecha popover pra impedir double-click)
- Bulk-load do useMelissaAgenda e fetchRecord do AgendaEventoFinanceiroPanel
  agora filtram status='cancelled' (resolve badge $ residual + botão sumido)

Header do popover: info de pacote/série
- "Sessão · Pacote · N sessões" ou "Sessão X de Y" abaixo do tipo
  (computed seriesLabel lê do _raw da rule)

Título do dialog "Sessão do Pacote · Sessão"
- Quando commitment name é "Sessão" (default), drop pra evitar duplicação
- Outros nomes (Avaliação, etc) permanecem com forma completa

Excluir série inteira (popover)
- Novo botão "Excluir série" no popover quando evento pertence a recorrência
- Hard delete: financial_records pendentes → agenda_eventos materializados
  → recurrence_rules (CASCADE leva exceptions + rule_services)
- Bloqueia se algum record tem status='paid' (estornar primeiro)

cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception type=
  'cancel_session' (era visível com status cancelado; doc dizia "some
  da agenda" mas código mantinha. Honra a promessa do diálogo)
- patient_missed / therapist_canceled / holiday_block permanecem visíveis
  como histórico

UX outros
- "+ Novo convênio" toolbar em ConfiguracoesConveniosPage (botão faltava
  — empty state mandava clicar em botão inexistente)
- InsurancePlanServiceQuickCreateDialog: cadastrar procedimento POR CIMA
  do AgendaEventDialog sem sair da agenda. Auto-seleciona quando nada
  estava selecionado antes
- Hint contextual abaixo do card Sessão/Honorários: convênio = "Nº guia
  opcional"; gratuito = "sem cobrança". Particular sem hint
- recurrence_exceptions cancel agora usa upsert com onConflict
  (idempotente, não quebra com unique violation em re-cancel)
- goToConveniosConfig removida (dead code após quick-create inline)

CSS
- .aed-row-50 perdeu margin-bottom (user request)
- .field-card.mb-4 ganhou margin-top: 1rem (scoped a composer wrappers)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:42 -03:00
Leonardo e95ed9b585 agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio
DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
  baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)

Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
  Handler aplica payment_method sempre; status='paid'+paid_at apenas
  quando markPaidNow=true && method != 'link'. Asaas (link) sempre
  liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
  e (opcional) status='paid' quando user marca "ja recebi".

Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
  pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
  Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
  ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
  financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
  pi-map-marker via novo sessionPaymentRecord (sem guard de
  occurrenceMode, contrario ao occFinancialRecord que continua so pra
  Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
  sem cobranca c/ valor, sem cobranca s/ valor.

UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
  POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
  novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
  selecionado, com copy variavel (0 procedimentos: chamada urgente;
  1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
  estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
  guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
  Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.

Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
  — sessoes avulsas eram salvas como presencial independente da
  escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
  configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
  filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
  _buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
  escopo de _buildHandlers).

Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
  status pra realizada/faltou/cancelado, com opcoes de markPaid ou
  gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
  cinza (background events) do MelissaAgenda.

Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
  de teste manual. C1-C4 ja validados. Cada teste validado vira parte
  da doc final pra area de ajuda (pos-Fase 9).

Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
  arquiteturais sobre billing).
- HANDOFF.md atualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:18 -03:00
Leonardo 41c44272a3 agenda: dialog UX (bloqueio paciente arquivado/inativo + resumo sticky + card extras + observacao via commitment)
BLOQUEIO REAL Inativo + AVISO Arquivado futura:
- useAgendaEventComposer.canSave agora bloqueia edicao de sessao futura
  quando !perms.canReschedule. Antes canReschedule era dead code
  (definido em getPatientAgendaPermissions mas nunca consumido). Pra
  Inativo: canReschedule=false => Save desabilitado de verdade (antes
  o aviso "Remarcacao bloqueada" mentia, save acontecia mesmo).
- Aviso novo (severity=info) em AgendaEventDialog + V2 pra Arquivado +
  futura + edit: "Sessao futura editavel; novos agendamentos e
  recorrencias bloqueados". Cobria um gap onde nao havia aviso nenhum
  pra esse cenario.

RESUMO FLUTUANTE acompanha o Dialog:
- ResizeObserver no .p-dialog.agenda-event-composer mede top + altura
  e sincroniza com :style do aside via ref resumoStyle. Antes o aside
  tinha top:5vh fixo — dialog baixo (Bloqueio/Atividade) centrava
  vertical e o resumo ficava preso la em cima desalinhado.

CARD "Campos Extras (compromisso)":
- Bloco de selectedCommitmentFields extraido da fields-grid pra um
  .field-card proprio com header pi-list + titulo + .aed-extras-body
  (padding 0.85rem). Hierarquia visual clara: campos do compromisso
  ficam isolados dos campos do form principal.
- Bind especial pra f.key==='notes': v-model="form.observacoes" em vez
  de form.extra_fields.notes. Mantem compat com a coluna nativa
  agenda_eventos.observacoes (consumida por relatorios/prontuario).
- Textareas hardcoded de Observacao removidas do form (fields-grid +
  side-card direito) — agora vem como campo extra do commitment Sessao,
  via migration 20260511000001 (commit anterior).

OUTROS:
- Card "Pagamento" renomeado pra "Sessao / Honorarios" (cobre os 3
  tipos: gratuito, particular, convenio — terminologia mais alinhada
  ao vocabulario clinico).
- composer-grid e composer-right ganharam gap:0 — os cards filhos
  ja tem mb-4 proprio (Tailwind ~1rem), gap do flex duplicava.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:57:16 -03:00
Leonardo dba595fd2d db: migration session_default_notes_field + schema regenerado
Migration 20260511000001 adiciona campo 'notes' (Observacao, textarea,
sort_order=30) como campo extra default no commitment determinado 'Sessao'.
Antes Sessao era a unica excecao entre os nativos — Leitura/Supervisao/
Aula/Analise ja tinham. Padroniza pra que a Observacao da sessao siga o
mesmo mecanismo de extra_fields dos outros, e o frontend remova a textarea
hardcoded do AgendaEventDialog (proximo commit).

Backfill: insere 'notes' em TODOS os commitments Sessao ja existentes
(idempotente). Forward-fix: substitui a funcao seed_determined_commitments
incluindo o bloco de Sessao + 'notes' pra novos tenants.

Schema regenerado via db.cjs schema-export pra refletir o estado pos-
migration. agenciapsi-db-dashboard.html regenerado pelo
generate-dashboard.cjs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:56:59 -03:00
Leonardo af8aee9188 wiki: documenta recorrencia da agenda + log da sessao 2026-05-11
Nova pagina [[recorrencia-agenda]] cobrindo: modelo "1 real + N-1 virtual"
via useRecurrence, quem expande virtuais (composables corrigidos em 39cf017),
pattern de materializacao ao mudar status (4 caminhos), view listAll de
2 anos no MelissaAgenda, visual de evento inativo, e query SQL pra detectar
rows orfas. index.md ganhou link sob Concepts.

Log entry da sessao 2026-05-11 10:50 cobrindo os 6 commits previos
(8b0e633..39cf017): time picker, services nome unico, paciente arquivado/
inativo, AgendaEventDialog overhaul, view lista Melissa, expansao+
materializacao de recorrencia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:56:49 -03:00
Leonardo 39cf0178e6 agenda: expandir e materializar ocorrencias de recorrencia (cross-layout)
PROBLEMA 1 — recorrencias virtuais nao apareciam em listas de sessao
============================================================
Sistema cria 1 row real em agenda_eventos por recorrencia (a primeira
ocorrencia) + 1 regra em recurrence_rules. As N-1 sessoes seguintes sao
geradas em runtime via useRecurrence.loadAndExpand. AgendaTerapeutaPage e
AgendaClinicaPage ja usavam loadAndExpand, mas composables compartilhados
("Hoje", widget, prontuario, ver todas) so liam agenda_eventos direto —
serie semanal de 4 sessoes aparecia como 1.

Fix em 3 composables cross-layout:
- usePatientSessions.load — range padrao -6mo a +12mo, filtra virtuais
  por patient_id apos loadAndExpand. Cobre MelissaPaciente Tab Agenda +
  PatientProntuario legacy.
- useMelissaEventos._fetchRange — merge real + virtual no range visivel.
  Cobre widget "Hoje" (MelissaLayout), mini-cal, fallback standalone do
  MelissaAgenda. Falha do expand cai silencioso pra so-reais.
- useMelissaTodasSessoesPaciente.fetch — mesma logica do paciente, range
  -6mo a +12mo. Cobre "Ver todas as sessoes" do MelissaAgenda.

normalizeEvent agora aceita shape de virtual (paciente_nome/patient_name)
alem de joined query (patients.nome_completo). Expoe is_occurrence +
recurrence_id pra consumidores diferenciarem.

PROBLEMA 2 — UPDATE em id virtual quebra com "invalid input syntax for type uuid"
============================================================
Apos #1, ocorrencias virtuais aparecem na UI. Quando o user mudava status
(via botoes do MelissaEventoPanel, watcher do form.status no
AgendaEventDialog, ou botoes diretos no MelissaPaciente Tab Agenda), o
UPDATE caia direto no PostgreSQL com id "rec::ruleId::date" — sintaxe
invalida pra coluna UUID.

Materializacao em 4 caminhos:
- usePatientSessions.updateStatus(sessionOrId, status) — aceita row inteira
  agora. Se virtual, busca row real por recurrence_id+date, ou cria nova
  copiando campos da virtual (com status aplicado).
- useAgendaEventActions watcher do form.status — emit('updateSeriesEvent',
  { ..., row: form }) em vez de UPDATE direto. Parent materializa.
- MelissaLayout.updateEventoStatus — detecta virtual, delega pro
  M.onUpdateSeriesEvent passando row: ev (sem isso, dialogEventRow ficaria
  vazio porque user nao abriu o dialog antes — criava row orfa sem
  patient_id).
- MelissaPaciente — @updateSeriesEvent do dialog local aponta pro
  onSessaoDialogUpdateSeries (wrapper que delega pro composable que sabe
  materializar). Antes apontava pro save normal.

useMelissaAgenda.onUpdateSeriesEvent atualizado:
- aceita row opcional do chamador (prioridade > dialogEventRow > vazio).
- guard: aborta com toast se rid (recurrence_id) for null, em vez de
  criar row orfa.
- error check no .maybeSingle (antes ignorado — query falhando seguia pro
  insert e duplicava sessoes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:46:58 -03:00
Leonardo 279b4f78e8 melissa/agenda: view lista 2 anos + selector SelectButton + sticky header + visual inativo
View lista: 'listWeek' -> custom 'listAll' (duration { years: 2 },
centrada em hoje via gotoDate(hoje - 1 ano) no setView). Antes a lista
mostrava so 7 dias e ocultava 3 das 4 ocorrencias semanais — agora cobre
passado + presente + futuro numa varredura. Cap MAX_RANGE_DAYS=730 do
loadAndExpand bate exato com 2 anos.

Banner showRecurrenceHint: aparece quando ha virtuais visiveis em
day/week/month (nao mostra em listAll). Texto curto + botao "Ver na
lista" que chama setView('lista').

Sticky day header (.fc-list-day): adicionado position:relative + z-index 3
+ bg opaco. Sem isso, .fc-event passava POR CIMA do header conforme
scroll (stacking context da row de evento ganhava do cushion sem z-index).

View selector: botoes manuais (.ma-cal__view-btn) -> PrimeVue SelectButton.
Visual herdado do tema, menos CSS custom.

Visual evento inativo: classNames=['ma-evt--inactive-patient'] em fcEvents
quando paciente_status === 'Arquivado'|'Inativo'. CSS aplica borda
tracejada + opacidade 0.58 (italico em list view). Mantem a cor do
commitment pra preservar contexto.

FC touch defaults: adiciona spread de FC_TOUCH_DEFAULTS (utility commitada
antes) — paridade touch <-> mouse, tap dispara select na hora em vez de
exigir long-press de 1000ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:46:31 -03:00
Leonardo 988a4e5892 agenda: AgendaEventDialog overhaul — picker DataTable + time picker + cadastro inline
Picker de paciente: button list -> DataTable (.aed-patient-dt) com
rowClass condicional pra blocked + Tags Arquivado/Inativo + ordenacao
Ativo > Inativo > Arquivado. Pareia com selectPaciente do composable
(commit anterior).

Time picker: header com header-dot colorido + titulo dinamico
"Nova {commitment.name}" + subtitulo "Inicio da sessao e duracao"
(espelha o header do dialog principal). Inicio e Termino lado a lado
(Termino readonly, derivado de fimDateTime). Cards "Horarios disponiveis"
(.aed-card) + chips de duracao rapida (.aed-pill, 30/50/60/90m) + Select
"Outra" pareando com AgendaEventDialogV2. Card de Termino destacado
embaixo da Duracao removido (info ja vai no input ao lado do Inicio).
Mini calendar (.mc-mini) estilo MelissaAgenda mini-cal — grid 6x7, sem
dots/feriados, sync com form.dia ao abrir.

Cadastro completo inline: importa PatientCadastroDialog dentro do dialog
em vez de redirecionar pra rota nova-aba (vazaria do layout Melissa).
Botao pi-id-card no patient-hero abre. Usa prop hideViewListButton
adicionada antes pra esconder "Salvar e ver pacientes".

Popovers de ajuda nos InputGroups do card Pagamento (servico/convenio
help refs separados pra nao colidir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:46:15 -03:00
Leonardo 8f4e6679eb agenda: pacientes arquivados/inativos visiveis e bloqueados no picker
AgendaEventDialogV2.filteredPatients agora mostra TODOS os pacientes
(antes filtrava status='Ativo' silenciosamente), ordenados Ativo > Inativo
> Arquivado. Items nao-Ativo vem com Tag colorida + disabled + tooltip
explicativo — UX clara: o paciente aparece (user nao "perde" no search)
mas nao da pra agendar.

selectPaciente bloqueia non-Ativo (defesa em camadas: template ja marca
disabled, mas se alguem chamar a funcao programaticamente por cache stale
etc, a regra continua valendo). Copia status pro form pra canSave aplicar
getPatientAgendaPermissions corretamente.

3 specs novas em useAgendaEventPickerBilling.spec cobrem o bloqueio +
copia do status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:45:57 -03:00
Leonardo 8e3c09d1b1 agenda: services nome unico por owner + cadastro in-flow
services: useServices.save e ServiceQuickCreateDialog agora validam nome
unico por owner (ilike, case-insensitive; ignora self no update). Antes
era possivel criar dois servicos com nome igual via paths diferentes.

cadastro in-flow: ComponentCadastroRapido e PatientCadastroDialog ganham
prop hideViewListButton. Quando true (uso dentro de outro fluxo, ex:
cadastrar paciente direto no AgendaEventDialog), esconde "Salvar e ver
pacientes" — navegar pra lista abandonaria o evento em edicao.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:44:27 -03:00
Leonardo 8b0e633aac agenda: centralize FullCalendar touch defaults
Sem long-press delays customizados, tap em slot vazio precisa de 1000ms
antes de disparar select — diverge totalmente do mouse (clique abre na
hora). Mesmo problema em eventDrop. Move pra utils/fcDefaults.js e
aplica nos 4 calendars (AgendaCalendar, AgendaClinicMosaic,
AgendaTerapeutaPage, MelissaAgenda no proximo commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:44:16 -03:00
Leonardo 646cec5833 HANDOFF: estado completo MelissaPaciente Fases 1-8 + iteracao pos-Fase 8
Reescreve HANDOFF.md com:
- Status final: 24 commits no branch, working tree limpa
- Historico completo dos commits (mais recente -> mais antigo)
- Lista de arquivos novos/modificados (composables, utils, paginas)
- Pendentes pra proxima sessao
- 5 decisoes arquiteturais documentadas
- Hotspots de drift no AgendaEventDialog
- Comandos uteis pra retomar

Adiciona entry no log.md descrevendo a iteracao pos-Fase 8 (16 commits
de UX/funcionalidades novas + debugging do AgendaEventDialog reuse).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:52:00 -03:00
Leonardo 6ad91e7853 MelissaPaciente: passa preset-commitment-id pro AgendaEventDialog (fix botao Salvar sumido)
User: "Botao pra salvar nao esta aparecendo".

CAUSA: o footer com botao Salvar tem v-if="step === 2". O lifecycle
do composer (linha 359 do useAgendaEventLifecycle) decide step inicial
assim:

  if (composer.isEdit.value) step.value = 2;
  else if (props.presetCommitmentId) {
    composer.form.value.commitment_id = preset;
    composer.step.value = 2;
  } else step.value = 1;

Eu setava determined_commitment_id no eventRow (que populava
form.commitment_id via resetForm), mas NAO passava props.presetCommitmentId.
Resultado: lifecycle ia pra step=1 (escolha de tipo). E como lockType=true
escondia o conteudo do step 1 com v-if, o dialog ficava com Body vazio
+ footer step=2 nao renderizando.

FIX: passar :preset-commitment-id="sessaoDialogEventRow?.determined_commitment_id".
Como ja resolvo o id do commitment "Sessão" no goAgendar, reuso aqui
direto sem ter que duplicar o lookup.

Resultado: dialog abre direto em step=2, footer aparece, botao Salvar
visivel (com :disabled="!canSave" — ainda exige paciente_id +
items/billing valido, comportamento normal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:43:10 -03:00
Leonardo cf1cd67314 MelissaPaciente: pre-popula eventRow com commitment_id + paciente nome/avatar/status
User detectou bug: jornada/freq/billing continuavam ocultos mesmo apos
o fix do commit anterior, e o resumo lateral nao mostrava o nome do
paciente apesar de aparecer no subtitle do header. Diagnostico correto:
form.paciente_nome estava vazio.

CAUSA: meu watch lockType (commit 73788c7) chamava selectCommitment
APOS o lifecycle watcher do composer rodar resetForm(). Mas resetForm
le do props.eventRow — e eu so passava paciente_id + tipo. Sem
paciente_nome/avatar/status no eventRow, o form ficava com paciente_id
solto e nome vazio. E sem determined_commitment_id, o lifecycle setava
step=1 antes do meu watch tentar consertar via selectCommitment, gerando
race condition (lifecycle await nextTick + resetForm DESFAZIA o trabalho
do watch sync).

FIX em goAgendar() do MelissaPaciente:
1. Acha o commitment "Sessão" (native_key='session') em
   melissaAgenda.commitmentOptions e pre-popula determined_commitment_id
   no eventRow. resetForm le isso e ja deixa form.commitment_id setado
   na inicializacao — isSessionEvent fica true imediatamente, sem
   precisar do watch lockType.
2. Pre-popula paciente_nome/avatar/status no eventRow direto dos
   computeds (nomeCompleto, avatarUrl, statusPaciente) que ja existem
   no MelissaPaciente desde a Fase 3. Composer s o faz fetch async de
   nome quando isEdit=true — pra criacao precisa vir no eventRow.

Resultado: dialog abre ja com:
- paciente_id + nome + avatar + status preenchidos no resumo lateral
- commitment_id setado, isSessionEvent=true
- Jornada de trabalho aparece
- Billing radio (particular/convenio/gratuito) aparece
- Frequencia aparece

O watch lockType continua valido como redundancia (caso commitmentOptions
chegue async), mas agora nao e mais o caminho principal.

301 specs passando. ESLint 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:38:41 -03:00
Leonardo 73788c7031 AgendaEventDialog: lockType auto-seleciona commitment "Sessão" (fix jornada/billing/freq sumidos)
User apontou que jornada de trabalho, frequencia e billing (particular/
convenio/gratuito) sumiam quando o dialog abria do prontuario. Causa:
meu watch original do commit 30d09eb so forcava step.value=2 sem
inicializar form.commitment_id. Sem commitment, o computed
isSessionEvent virava false e esses 3 blocos do template (que dependem
de isSessionEvent) ficavam ocultos:

- jornadaDialog: <Message v-if="jornadaDialog && isSessionEvent">
- frequencia: bloco v-if="!hasSerie" tem gates internos de billing/
  patient que dependem de isSessionEvent
- billing radio (particular/convenio/gratuito): isSessionEvent

FIX: watch agora chama selectCommitment(sessao) — exatamente o que o
user faria clicando no card "Sessão" no step 1. Isso seta:
- form.commitment_id pro id do native_key='session'
- form.extra_fields = {} populado pelos fields do commitment
- step.value = 2

Adicionei props.commitmentOptions ao watch dep — necessario pq quando
o dialog abre antes do tenant load terminar, commitmentOptions chega
vazio inicialmente. Watch re-roda quando popula.

Idempotente: so chama selectCommitment se ainda nao tem commitment_id
ou se id atual nao bate com sessao.id (re-open com mesmo lockType
nao reinicializa redundantemente).

301 specs do agenda continuam passando. ESLint: 31 errors pre-existentes
(mesmos do commit anterior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:33:08 -03:00
Leonardo 30d09eb2ac AgendaEventDialog: props lockType + lockPatient + slot #headerLeft (additivos)
User escolheu caminho A: modificar AgendaEventDialog em vez de copiar.
Mudancas SAO ADITIVAS — comportamento atual dos 5 callsites legacy
(TherapistDashboard, PatientsListPage, MelissaAgenda,
MelissaAgendamentosRecebidos, MelissaLayout) preservado.

VALIDACAO: rodei os 7 spec files do agenda — 301 testes passaram.
Zero regressao.

ADICIONADO em src/features/agenda/components/AgendaEventDialog.vue
- Prop lockType (Boolean, default false): pula step 1 (escolha de tipo)
  e vai direto pro form. Watch immediate em [lockType, modelValue]
  forca step.value=2 quando lockType=true e dialog abre.
- Prop lockPatient (Boolean, default false): esconde botoes "trocar"/
  "limpar" do paciente. Mostra icon de lock com tooltip "Paciente do
  prontuario". Cobre o cenario "criar sessao pra paciente fixo" sem
  precisar do isEdit que o patientLocked computed exige.
- Slot #headerLeft: substitui o conteudo esquerdo do header (default
  era header-dot + headerTitle + previewRange). Permite callsites
  customizar com icon+title+subtitle proprios sem mexer no resto do
  header (X / actions).
- v-if no Step 1: "step === 1 && !lockType"
- v-if nos buttons trocar/limpar: "!patientLocked && !lockPatient"
- Lock icon: "patientLocked || lockPatient" + tooltip dinamico

MELISSAPACIENTE.VUE
- Reverte o inject-only do commit 88dff50.
- Mantem o inject(MELISSA_AGENDA_KEY) APENAS pra LER dados pesados
  (commitmentOptions, workRules, allEvents, agendaSettings, feriados,
  ownerId, tenantId) — evita re-fetch.
- State LOCAL pro dialog: sessaoDialogOpen, sessaoDialogEventRow,
  sessaoDialogStartISO, sessaoDialogEndISO. Nao colide com o dialog
  global do MelissaLayout que continua na Agenda.
- goAgendar(): inicializa eventRow com paciente_id fixo + tipo='sessao'
  + defaults razoaveis (proximo slot 15min + duracao da agenda),
  abre o dialog local.
- Handlers onSessaoDialogSave / onSessaoDialogDelete delegam pros
  handlers globais (M.onDialogSave/Delete) e ao final refetcham
  sessions+recorrencias do paciente in-place.
- Render <AgendaEventDialog> com lock-type=true + lock-patient=true
  + slot #headerLeft custom (icon pi-calendar-plus em quadrado
  primary 40x40 + "Nova sessão" + nome do paciente como subtitulo).

Resultado: prontuario tem o MESMO componente da Agenda (form completo
de sessao, frequencia com preview de ocorrencias + conflitos,
vinculacao de servicos/billing, edicao de serie, etc) mas pre-fixado
no contexto do paciente, com header proprio e single source of truth.

ESLint: 31 errors pre-existentes em ambos arquivos (variaveis declaradas
nao usadas — confirmado via git stash baseline). 0 errors da minha
mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:27:32 -03:00
Leonardo 88dff50223 MelissaPaciente: usa AgendaEventDialog GLOBAL via inject (em vez de dialog local)
User pediu pra trazer o AgendaEventDialog completo da Agenda pra dentro
do prontuario. Estrategia: NAO duplicar o dialog (que ja vive no
MelissaLayout). Em vez disso, reusar via provide/inject — pattern que
ja existe (MELISSA_AGENDA_KEY).

NOVO em src/layout/melissa/composables/useMelissaAgenda.js
- onCreateEventoForPatient(patientId) — espelha onCreateEvento (defaults
  hoje proximo slot 15min, duracao default), mas injeta paciente_id no
  dialogEventRow. Adicionada ao return do composable.

MELISSAPACIENTE.VUE
- inject(MELISSA_AGENDA_KEY) pra acessar a instancia do useMelissaAgenda
  do MelissaLayout.
- goAgendar(): chama melissaAgenda.onCreateEventoForPatient(patientId)
  (defensive: warn toast se nao tem inject ou funcao).
- Watch em melissaAgenda.dialogOpen pra refetchar sessions+recorrencias
  quando o dialog fecha (true -> false), independente se foi save ou
  cancel.

REMOVIDO (sem mais necessario — AgendaEventDialog faz tudo)
- Refs novaSessaoOpen, novaSessaoForm
- Catalogos FREQ_OPCOES, DIAS_SEMANA_OPCOES, QTD_SESSOES_OPCOES,
  SESSAO_TIPOS, SESSAO_DURACOES, SESSAO_MODALIDADES
- Helpers toggleDiaSelecionado, qtdSessoesEfetiva, novaSessaoCtaLabel
- Function salvarSessao (~110L de logica avulsa+recorrencia)
- Import supabase (nao usado direto mais)
- Import useRecurrence (era pro createRule no salvarSessao)
- Import WEEKDAY_LABEL_BLOCK (era pro preview de freq)
- Template <Dialog> Nova Sessao com header custom + form + freq chips +
  qtd sessoes + footer (~180L)

Resultado: MelissaPaciente fica mais enxuto e usa exatamente o mesmo
dialog completo que MelissaAgenda — todos os recursos do AgendaEventDialog
(tipos de evento, paciente picker, comprometimento de servicos/billing,
freq com preview de ocorrencias + conflitos, validacao por work rules,
edicao de serie etc) ficam disponiveis no prontuario sem duplicacao.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:05:25 -03:00
Leonardo b040e15c9b MelissaPaciente: header custom do dialog Nova Sessao (icone + titulo + nome)
Antes: header simples "Nova sessão".
Agora: layout 3-col com:
- Icon pi-calendar-plus em quadrado primary 40x40
- Title "Nova sessão" (1rem font-weight 700)
- Subtitle: nome completo do paciente (truncate com ellipsis)

CSS .mpa-dlg-head + variants. Reusavel se outros dialogs precisarem
do mesmo padrao (Lancamento poderia usar tambem futuramente).

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:56:30 -03:00
Leonardo 42a39ed3ea MelissaPaciente: dialog Nova Sessao usa "Frequencia" estilo AgendaEventDialog
User pediu pra trocar o checkbox "Repetir semanalmente" + radios pelo
mesmo widget de Frequencia que existe no AgendaEventDialog. Replicado
1:1 o pattern (chips + qtd sessoes).

REMOVIDO
- Checkbox "Repetir semanalmente"
- 3 radios de fim_tipo (open/count/data)
- Inputs inline associados (fim_count, fim_data)

ADICIONADO no form
- novaSessaoForm.freq: 'avulsa' (default) | 'semanal' | 'quinzenal' |
  'diasEspecificos'
- novaSessaoForm.diasSelecionados: array<int> (so usado em
  diasEspecificos)
- novaSessaoForm.qtdMode: '4' | '8' | '12' | 'personalizar'
- novaSessaoForm.qtdCustom: number (so usado em personalizar)

ADICIONADO catalogos (FREQ_OPCOES, DIAS_SEMANA_OPCOES, QTD_SESSOES_OPCOES)
e helpers (toggleDiaSelecionado, qtdSessoesEfetiva computed,
novaSessaoCtaLabel computed).

ADICIONADO no template:
- Chips horizontais "Avulsa / Semanal / Quinzenal / Dias específicos"
  (estilo .mpa-freq-chip — pill arredondado, primary quando active)
- Preview com icon refresh: "Toda quarta, às 14:00" / "A cada 2 semanas,
  toda quarta..."
- Grid de dias da semana (Seg Ter Qua Qui Sex Sab Dom) so quando
  diasEspecificos
- Quantidade de sessoes: chips "4 sessoes / 8 / 12 / Personalizar"
  + InputNumber show-buttons quando personalizar
- Label dinamica do CTA: "Agendar sessão" (avulsa) / "Criar recorrência"

LOGICA salvarSessao mapeia freq -> recurrence_rules:
- avulsa: caminho original (createSession + INSERT agenda_eventos)
- semanal: type='weekly', interval=1, weekdays=[dow]
- quinzenal: type='biweekly', interval=2, weekdays=[dow]
- diasEspecificos: type='custom_weekdays', interval=1, weekdays=[selecionados]
Sempre com max_occurrences (qtd efetiva) — sem mais open-ended por
default. Toast detalha "{N} sessoes previstas".

Validacoes:
- diasEspecificos exige >=1 dia selecionado (toast warn)
- qtd efetiva >= 1 (cobrindo personalizar invalido)

CSS: ~120L (substitui o bloco .mpa-recur antigo). Usa accent var
--p-primary-color pra match do app theme. .mpa-freq-chip / .mpa-dia-chip
hover/active states. .mpa-freq-preview com bg color-mix do primary.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:51:43 -03:00
Leonardo 9e76e4e6ea MelissaPaciente: bloco "Recorrencias do paciente" na Tab Agenda
User aprovou a ideia. Adiciona contexto "este paciente tem sessao toda
segunda 14h" direto no prontuario, evitando duplicacao de regras e
deixando claro o estado da serie.

NOVO src/features/patients/composables/usePatientRecurrences.js (~110L)
- load(patientId): SELECT recurrence_rules WHERE patient_id (DESC start_date)
- cancel(ruleId) / reactivate(ruleId): UPDATE status + auto-reload
- Computeds derivados: ativas, canceladas, totalAtivas, totalCanceladas
- busy flag pra disable de buttons

EXTENSAO src/features/patients/utils/patientFormatters.js
- WEEKDAY_LABEL + WEEKDAY_LABEL_SHORT (arrays 0=Domingo..6=Sabado)
- fmtRecurrenceLabel(rule): "Toda segunda às 14:00", "Quinzenal · Terça
  às 09:00", "Qua, Sex às 16:00" (custom_weekdays), "Mensal às 14:00",
  "Anual" — cobre todos os types do useRecurrence.
- fmtRecurrenceFim(rule): "Sem data de fim" / "Até DD/MM/YYYY" /
  "N sessões no total"

MELISSAPACIENTE.VUE
- Composable + handlers (onCancelRecurrence, onReactivateRecurrence) com
  toast feedback.
- recorrenciasShowCanc ref + recorrenciasVisiveis computed (toggle "ver
  canceladas").
- loadAll inclui recorrenciasHook.load.
- salvarSessao no caminho recorrente recarrega sessions+recorrencias em
  Promise.all (regra recem-criada aparece na lista imediatamente).
- 5o KPI na Tab Agenda: "Recorrencias" com count ativas + cap dinamica
  (cor #a855f7 quando > 0, cinza quando 0).
- Bloco <section class="mpa-panel"> entre KPIs e filter chips listando
  rules ativas (default) ou todas (toggle "Ver canceladas" no header,
  so aparece quando ha canceladas):
  - Icon roxo .mpa-recur-item__icon
  - Top: label + Tag status (verde Ativa / amarelo Cancelada)
  - Meta: duracao + modalidade + fim + "desde DATE"
  - Obs (quando preenchido): block textual
  - Actions: pi-ban (cancelar) ou pi-undo (reativar) com tooltip
- border-left adaptativa (#a855f7 ativo / cinza cancelado) + opacity 0.7
  pros cancelados.
- Mobile: stack icon+main em 2-col 2-row; actions full-width abaixo.

CSS: ~120L novos. Padrao Melissa: status pills, icon roxo distintivo
(diferente das sessoes que usam cinza), border-left por status.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:37:45 -03:00
Leonardo f1d6fbad73 MelissaPaciente: dialog nova sessao integra useRecurrence (recorrencia semanal)
User apontou que ja existe sistema de recorrencia pronto (useRecurrence.js
+ tabela recurrence_rules + MelissaRecorrencias). Integrei no dialog de
nova sessao.

NOVO no dialog:
- Checkbox "Repetir semanalmente" + texto explicativo (cria serie no
  mesmo dia da semana e horario)
- Quando ativado, mostra 3 opcoes radio:
  - "Sem data de fim" (open-ended — continua ate cancelar)
  - "Apos N sessoes" (max_occurrences)
  - "Ate <data>" (end_date)
- Cada opcao com input inline disabled quando nao selecionada
- Label do botao salvar muda dinamicamente: "Agendar sessao" -> "Criar
  recorrencia"

LOGICA salvarSessao() ramificada:
- Se repetir = false: caminho original (createSession + INSERT em
  agenda_eventos)
- Se repetir = true: caminho NOVO via useRecurrence.createRule:
  - type: 'weekly', interval: 1
  - weekdays: [inicio.getDay()] (calculado do dia da semana selecionado)
  - start_date: f.data
  - end_date / max_occurrences conforme fim_tipo
  - start_time: f.hora
  - duration_min, modalidade, titulo_custom, observacoes, status: 'ativo'
  - Insere row em recurrence_rules; ocorrencias sao geradas dinamicamente
    pelo expandRules() do composable. Sessoes confirmadas/realizadas
    viram rows reais sob demanda.

Validacoes adicionais:
- fim_tipo='data' exige fim_data preenchido (toast warn)
- fim_tipo='count' exige fim_count >= 1 (toast warn)

Reload das sessoes ao final pra refletir caso start_date seja hoje
(occurrence ja entra na timeline).

Toast de sucesso aponta pra "Recorrencias" como destino pra gerenciar
a serie.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:32:36 -03:00
Leonardo a8ab13b201 MelissaPaciente: dialog inline nova sessao + createSession mutation
Espelha o padrao do "Lancamento" mas pra agenda — botao "Agendar" agora
navega pra aba Agenda e abre dialog de nova sessao.

NOVO em src/features/patients/composables/usePatientSessions.js
- createSession(patientId, payload) — INSERT agenda_eventos com
  status='agendado', resolve owner_id (auth.getUser) e tenant_id (lazy
  import tenantStore). Auto-reload via _lastPatientId.
  Validacao: inicio_em + fim_em obrigatorios.
  Retorna {ok, data?, error?}.

NOVO em MelissaPaciente.vue
- Refs novaSessaoOpen + novaSessaoForm (tipo/data/hora/duracao_min/
  modalidade/titulo_custom/observacoes)
- 3 catalogos:
  - SESSAO_TIPOS: Sessao/Primeira/Retorno/Avaliacao/Devolutiva
  - SESSAO_DURACOES: 30/40/45/50/55/60/90/120 min
  - SESSAO_MODALIDADES: Presencial/Online
- goAgendar() agora alem de navegar pra aba Agenda, tambem inicializa
  o form (default amanha 09:00, sessao 50min presencial) e abre o dialog.
- salvarSessao() handler com validacao (data + hora) e construcao de
  inicio_em/fim_em a partir de data + hora + duracao_min. Local time
  -> ISO via Date constructor.
- <Dialog> 460px com form: Tipo + grid 2-col (data + hora) + grid 2-col
  (duracao + modalidade) + titulo opcional + observacoes Textarea.
- CSS .mpa-novo-lanc__opt pra "(opcional)" em cinza.

Validacoes:
- Data e hora obrigatorios (warn toast)
- Date constructor invalido -> warn toast

Pra criar sessoes mais complexas (recorrencia, multi-paciente, conflitos
de agenda), o user vai pra MelissaAgenda direto que tem o
AgendaEventDialog completo. Aqui no prontuario eh o caminho rapido.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:00 -03:00
Leonardo 21c71f75d6 MelissaPaciente: addFinancial navega pra Financeiro + novo botao Agendar
- addFinancial(): antes so abria o dialog inline. Agora primeiro navega
  pra activeTab='financ' (da contexto visual), fecha drawer mobile e
  entao abre o dialog. User ve a aba Financeiro atualizar imediatamente
  apos salvar.
- goAgendar() novo: navega pra activeTab='agenda', fecha drawer mobile.
  Sem dialog — a aba Agenda ja tem KPIs + lista por mes + acoes inline
  (realizada/falta/cancelar). Pra criar nova sessao o user usa
  MelissaAgenda direto (fora do prontuario).
- Botao "Agendar" novo na sidebar Acoes Rapidas, abaixo de "Lancamento",
  com icon pi-calendar-plus verde #10b981.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:16:22 -03:00
Leonardo 64005a5b07 MelissaPaciente: fix openWhatsapp + dialog inline novo lancamento financeiro
DOIS BUGS DE COMPORTAMENTO:

1. openWhatsapp nao abria o drawer
   conversationDrawerStore.openForPatient(patientId) espera STRING id,
   nao objeto. Eu passava { id, name, phone, avatar_url } — store
   ignorava e drawer nunca abria.
   FIX: passar String(props.patientId) (mesmo pattern que MelissaPacientes).
   BONUS: a store seta this.error sem dar throw quando paciente nao tem
   telefone cadastrado. Detectamos com `if (err && !isOpen)` e mostramos
   toast warn com a mensagem da store ("Paciente sem telefone cadastrado").
   Funcao virou async pra aguardar o openForPatient.

2. addFinancial era placeholder "Em breve"
   User correto: o sistema ja tem suporte (composables/useFinancialRecords
   tem createManualRecord). Implementado dialog inline simples no
   prontuario.

NOVO em src/features/patients/composables/usePatientFinancial.js
- createRecord(patientId, payload) — INSERT financial_records com
  type='receita', resolve owner_id (auth.getUser) e tenant_id (lazy
  import tenantStore pra evitar circular). Auto-reload via _lastPatientId.
  Retorna {ok, data?, error?}.

NOVO em MelissaPaciente.vue
- Refs novoLancOpen + novoLancForm (description/amount/due_date/payment_method)
- PAYMENT_METHODS array (Pix/Cartao/Dinheiro/Transferencia/Boleto/Convenio)
- addFinancial() agora abre o dialog (era toast "em breve")
- salvarLancamento() handler com validacao (valor > 0, due_date obrigatorio)
- <Dialog> v-model:visible 420px com form: descricao + grid 2-col
  (valor InputNumber BRL + vencimento date input) + select forma
- CSS .mpa-novo-lanc + responsive (1-col em <540px)

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:12:22 -03:00
Leonardo 301a7124a7 MelissaPaciente: editPatient abre PatientCadastroDialog INLINE (sem sair)
Bug reportado: ao clicar "Editar dados" no prontuario, o user era
redirecionado pra /melissa/pacientes?edit=X (que entao abria o cadastro
em MelissaPacientes). Isso saia da tela do prontuario — comportamento
incorreto.

FIX: importar PatientCadastroDialog no MelissaPaciente e abrir por cima
da pagina (z-index PrimeVue ~1100 > .mpa-page z-index 40). Ao salvar,
recarrega os dados do paciente in-place via detail.load().

ADICIONADO
- Import PatientCadastroDialog
- Refs locais cadastroOpen + cadastroPatientId
- editPatient() agora seta refs e abre dialog (era router.push)
- onPatientSaved() handler que fecha o dialog e refetcha o detail
- <PatientCadastroDialog v-model="cadastroOpen" ...> renderizado depois
  da .mpa-page no template

O watch route.query.edit em MelissaPacientes (Fase 8) continua valido
pra deep-links externos, mas o fluxo MelissaPaciente -> editar nao usa
mais essa rota.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:06:49 -03:00
Leonardo 5d2c389486 MelissaPaciente: fix sidebar cards encolhendo + gap das abas main
DOIS BUGS DE LAYOUT corrigidos via CSS (post-Fase 8 polish):

1. CARDS DA SIDEBAR sendo encolhidos
   .mpa-side__scroll eh display:flex flex-direction:column. Os cards
   .mpa-w filhos NAO tinham flex-shrink:0, entao quando havia muitos
   cards stacked (Acoes + Nav 7 tabs + Sub-nav Perfil 6 + Vinculos),
   o flex shrink default (1) reduzia cada card proporcionalmente.
   Combinado com .mpa-w { overflow:hidden } (necessario pro radius),
   itens internos das listas eram cortados/escondidos.
   FIX: .mpa-side__scroll > .mpa-w { flex-shrink: 0; height: auto; }
   Agora cada card cresce ate o tamanho real do conteudo, e o scroll
   vertical do .mpa-side__scroll lida com overflow.

2. ABAS DO MAIN sem gap entre elementos
   <div class="mpa-tab"> nao tinha CSS definido. Os filhos (KPIs grid,
   panels, cards) ficavam colados. .mpa-main eh flex-col com gap, mas
   como cada aba envolve seus elementos num <div .mpa-tab>, esse div
   precisa replicar o spacing.
   FIX: .mpa-tab { display: flex; flex-direction: column; gap: 12px; }

Visivel em todas as 7 abas. Fase 1 ja deveria ter incluido — passou
despercebido.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:55:38 -03:00
Leonardo 159b80db6c MelissaPaciente: full-width + sidebar "Voltar pra Pacientes" no lugar de Configuracoes
Feedback do user pos-Fase 8:
1. Janela full-width (prontuario tem KPIs + tabelas + timeline — precisa
   de espaco). Removido o `right: max(6px, min(50%, calc(100% - 1006px)))`
   da .mpa-page no @media >=1024px. Mantém apenas inset 6px nos 4 lados.
2. Botao "Configuracoes" da sidebar removido (prontuario pertence a
   Pacientes, nao a Configuracoes — nao faz sentido o atalho global de
   cfg-* aqui). No mesmo lugar visual entra o botao "Voltar para Pacientes"
   com mesma classe .mpa-cfg-btn (reaproveita estilo) + modifier
   .mpa-cfg-btn--back pra hover sutilmente diferente.

REMOVIDO
- Import MelissaConfigList (nao usado mais)
- Refs cfgOpen + funcoes toggleCfg/fecharCfg
- Template do dual-mode (cfgOpen ? MelissaConfigList : cards)
- CSS .mpa-cfg-btn.is-open + .mpa-cfg-btn__chev + .mpa-side__scroll--cfg

ADICIONADO
- close() agora faz history.back se houver historia, fallback pra
  /melissa/pacientes (cobre deep-link direto). Antes ia sempre pra
  /melissa/pacientes — agora respeita de onde o user veio (Agenda OU
  Pacientes).
- goToPacientes() handler novo pro botao "Voltar pra Pacientes".
- .mpa-cfg-btn--back hover style.

Tooltip do X mudou de "Voltar (Esc)" pra "Fechar (Esc)" — semantica mais
clara (o X fecha; o botao da sidebar voltar EXPLICITO).

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:52:42 -03:00
Leonardo 71ee51d38f MelissaPaciente Fase 8: wire-up final (Dialog -> route /melissa/paciente?id=X)
PLANO DE 8 FASES COMPLETO. Os 2 callsites Melissa do PatientProntuario.vue
legacy (3593L Dialog) trocam por navegacao pra MelissaPaciente nativo via
router.push. PatientProntuario continua intocado pros 2 callsites legacy
fora do Melissa (TherapistDashboard, PatientsListPage).

MELISSAPACIENTE.VUE — wire-up interno
- Imports: useRouter + useConversationDrawerStore
- close(): emit + router.push('/melissa/pacientes')
- editPatient(): emit + router.push('/melissa/pacientes', query: {edit: id})
  pra MelissaPacientes auto-abrir o cadastroFullDialog
- openWhatsapp(): emit + conversationDrawerStore.openForPatient({id, name,
  phone, avatar_url}) — drawer global desce sobre Melissa
- addFinancial(): emit + toast "Em breve" (Fase 9 — dialog inline)

MELISSAPACIENTES.VUE
- Removeu import PatientProntuario + refs prontuarioOpen/prontuarioPatient
- Removeu <PatientProntuario> template (substituido por comentario)
- abrirProntuario(p): router.push('/melissa/paciente', query: {id})
- onMounted detecta route.query.edit -> abre cadastroFullDialog +
  router.replace pra limpar query (handshake com MelissaPaciente)
- Comentario header atualizado

MELISSAAGENDA.VUE
- Removeu import PatientProntuario + refs prontuarioOpen/prontuarioPatient
- Removeu <PatientProntuario> template
- abrirProntuarioPorId(id): router.push pra rota Melissa nativa
- abrirProntuarioPaciente / openProntuario / kebab "Prontuario" delegam
  pra abrirProntuarioPorId

MELISSALAYOUT.VUE
- Render <MelissaPaciente> simplificado: so @close="fecharSecao".
  Acoes edit/open-whatsapp/add-financial ficam internas.

ESLint: 0 errors da minha mudanca (9 pre-existentes nos arquivos tocados
sao baseline; confirmados via git stash — mesmos errors em ambos lados).

PLANO COMPLETO. Total de 8 commits no branch (Fases 1-8). MelissaPaciente.vue
~2400L + 5 composables (~407L) + utils ~280L. PatientProntuario.vue
intocado pra fallback legacy (TherapistDashboard, PatientsListPage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:21:35 -03:00
Leonardo 167e864b8a MelissaPaciente Fase 7: Tabs Documentos + Conversas (KPIs + embed componentes existentes)
Duas tabs entregues numa sessao — sao mais leves porque reusam
DocumentsListPage e PatientConversationsTab existentes (testados em
producao no PatientProntuario legacy) com KPIs Melissa por cima.

EXTENSAO src/features/patients/utils/patientFormatters.js
- fmtSize(bytes): legivel B/KB/MB/GB
- DOC_TYPE_LABEL map: atestado/receita/laudo/encaminhamento/termo/etc
- chConvLabel(c): whatsapp -> WhatsApp / sms -> SMS / email -> E-mail

EXTENSAO src/features/patients/composables/usePatientDocuments.js
- topType computed: { tipo, count, label } do tipo mais comum
- pendentes computed: count status_revisao === 'pendente'
- sizeTotalFormatted computed: fmtSize(totalBytes)

EXTENSAO src/features/patients/composables/usePatientMessages.js
- primeiraMensagem computed (mais antiga)
- canais computed: Set de m.channel unicos

MELISSAPACIENTE.VUE — Tab Documentos
- 4 KPIs adaptativos (so renderizam com dados):
  Total + sizeTotalFormatted / Mais comum / Ultimo / Revisao pendente
- DocumentsListPage embedded no card Melissa (mpa-embed wrapper).
  Reusa upload/preview/listagem testados.

MELISSAPACIENTE.VUE — Tab Conversas
- 4 KPIs: Mensagens com canais / Recebidas % / Enviadas % / Ultima
- CTA "Abrir conversa no drawer" estilo WhatsApp pill verde #25d366
  que emite open-whatsapp pro parent (Fase 8 integra com
  conversationDrawerStore.openForPatient)
- PatientConversationsTab embedded — thread completa com filter/media

CSS: ~50L novos (mpa-conv-cta + mpa-embed wrapper).

Removido kpiDocumentos nao usado (substituido por documentsHook.total
direto).

ESLint: 0 errors da minha mudanca.

PROXIMA: Fase 8 wire-up final (Dialog -> router.push em MelissaPacientes/
MelissaAgenda; decisao sobre TherapistDashboard + PatientsListPage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:06:29 -03:00
Leonardo e7c0f6c4f5 MelissaPaciente Fase 6: Tab Financeiro completa + mark paid (mutation que legacy nao tem)
EXTENSAO src/features/patients/utils/patientFormatters.js
- recordStatus(r): pago / vencido (paid_at NULL && due_date < hoje) / pendente
- RECORD_STATUS_LABEL map
- fmtPaymentMethod(v): PIX/Cartao/Dinheiro/Boleto/Transferencia/Convenio
  cobrindo variantes pt-br + camelCase

EXTENSAO src/features/patients/composables/usePatientFinancial.js
- ref `busy` + `_lastPatientId` interno
- recordsOrdenados computed: DESC por due_date com fallback created_at
- markPaid(recordId): UPDATE financial_records SET paid_at=NOW() +
  auto-reload via _lastPatientId. Retorna {ok, error?}
- markUnpaid(recordId): reverte (paid_at=NULL) + auto-reload

MELISSAPACIENTE.VUE — script
- Imports: recordStatus, RECORD_STATUS_LABEL, fmtPaymentMethod
- markRecordPaid(r): chama financialHook.markPaid + toast success/error
- revertRecordPaid(r): chama markUnpaid + toast

MELISSAPACIENTE.VUE — Tab Financeiro reescrita (substitui placeholder Fase 1)
- Loading state
- Empty state com CTA "Novo lancamento" (mpa-quick-btn--cta)
- 3 KPIs: Pago / Pendente com proxVenc / Em atraso (cor adaptativa
  vermelho quando > 0, cinza quando 0)
- Header "Lancamentos" com badge count + botao "+ Novo" no canto
- Tabela 6-col responsiva:
  - Vencimento (date mono + relative)
  - Descricao
  - Forma (PIX/Cartao/etc)
  - Valor (mono right-aligned)
  - Status pill colorida (verde pago / vermelho vencido / azul pendente)
  - Action button (pi-check verde marca pago / pi-undo amarelo reverte)
- border-left adaptativa por status
- Mobile: tabela colapsa em cards 2-col 4-row

DIFERENCA DO LEGACY: o PatientProntuario.vue exibe a tabela mas NAO
permite marcar pago/reverter direto dela. MelissaPaciente adiciona essa
acao inline (mutation auto-reload).

CSS: ~190L novos. Padrao Melissa: status pills com color-mix, JetBrains
Mono pra valores, header cell uppercase letter-spacing.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:57:42 -03:00
Leonardo 8a8d2e05bd MelissaPaciente Fase 5: Tab Agenda completa (KPIs + filtros + grupos por mes + acoes)
EXTENSAO src/features/patients/utils/patientFormatters.js: +2 helpers
- fmtHourShort (HH:MM 24h pt-br) — usado na coluna data dos cards
- fmtDayShort (DOW abreviado pt-br sem ponto) — usado na coluna data

EXTENSAO src/features/patients/composables/usePatientSessions.js
- Novo ref `busy` pra disable de buttons durante mutation
- _lastPatientId guardado internamente pra auto-reload
- Nova funcao `updateStatus(sessionId, novoStatus)` que faz
  supabase.from('agenda_eventos').update({status}) + auto-reload da
  lista de sessoes. Retorna {ok, error?}.

MELISSAPACIENTE.VUE — script
- agendaFilter ref ('all' default) + AGENDA_FILTERS array com 6 opcoes
  (Todas, Proximas, Passadas, Realizadas, Faltas, Canceladas)
- agendaSessoesFiltradas computed: filtra por future/past/status (regex)
- agendaAgrupadas computed: agrupa por "Mes de YYYY" DESC
- updateSessionStatus(ev, status, msg): chama sessionsHook.updateStatus +
  toast de sucesso/erro
- Removido `void toast` (toast usado de verdade agora)

MELISSAPACIENTE.VUE — Tab Agenda reescrita (substitui placeholder Fase 1)
- 4 KPI cards no padrao Visao Geral (numerados 01-04):
  Total / Realizadas (% do total) / Faltas (cor adaptativa) / Proxima
- 6 filter chips redondas (cor primary quando active)
- Empty state contextual (sem sessoes vs filtro vazio)
- Grupos por mes com header (label + badge count)
- Cards 3-col: data column (DOW + dia + hora) | main (status tag + chips
  modalidade/duracao + relative + titulo + note 2-line clamp) | actions
  (3 buttons: ok/warn/danger com tooltip + cor adaptativa no hover)
- Mobile: stack date+main em 2 cols; actions full-width abaixo

CSS: ~150L novos. Padrao visual Melissa: data column estilo calendario,
actions hover muda cor por intent (verde realiz / amarelo falta / vermelho
cancel), border-left por status.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:53:59 -03:00
Leonardo 1278e93b01 MelissaPaciente Fase 4: Tab Prontuario MVP (evolucao via session.observacoes)
O legacy PatientProntuario.vue tem a aba Prontuario como PLACEHOLDER
("Em breve" rich empty state). O MVP entregue aqui SUPERA o legacy: usa
agenda_eventos.observacoes como nota evolutiva — funcional ja hoje sem
precisar de schema novo.

ESTADO + COMPUTEDS adicionados ao MelissaPaciente.vue:
- pronFilter ref ('com-evolucao' default) + PRON_FILTERS com 5 opcoes
  (Com evolucao / Todas / Realizadas / Faltas / Cancelamentos)
- pronSessions computed: filtra sessoes por status/presenca de observacoes
- sessoesComEvolucao computed: count de sessoes com observacoes nao-vazia

TEMPLATE Tab Prontuario (substitui placeholder Fase 1):
- Hint banner explicativo no topo (icon info + "Prontuario em construcao")
- 4 mini-stats em grid: com evolucao / realizadas / faltas / total
- 5 filter chips redondas — selecao default 'com-evolucao' filtra so
  sessoes que tem nota
- Empty states contextuais (sem sessoes / sem evolucao / filtro vazio)
- Lista de sessoes:
  - border-left colorida por status (verde/vermelho/amarelo/cinza)
  - head com data + relative + chips status/modalidade/duracao
  - block "Evolucao" destacado quando tem observacoes (bg medium + border
    primary + label uppercase + texto pre-wrap)
  - "Sem evolucao registrada" italico cinza quando nao tem
- Roadmap card (border dashed) listando 4 features futuras: anamnese
  estruturada / plano terapeutico / evolucao por temas / assinatura
  digital + LGPD Art. 18.

CSS: ~200L novos. Padrao Melissa (chips estilo MelissaTags, border-left
adaptativa, label uppercase nos blocks de evolucao).

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:46:58 -03:00
Leonardo 4fc0e3a02b MelissaPaciente Fase 3: Tab Perfil completa (6 sections stacked + anchors)
EXTENSAO: src/features/patients/utils/patientFormatters.js
- +5 formatters: pickField (compartilhado), onlyDigits, fmtCPF (000.000.000-00),
  fmtRG (passthrough), fmtPhoneMobile ((XX) 9XXXX-XXXX), fmtGender
  (Masculino/Feminino/Nao-binario/Outro), fmtMarital (Solteiro/Casado/
  Divorciado/Viuvo/Uniao estavel).

MELISSAPACIENTE.VUE — script
- 30+ field computeds usando pickField (cobre snake_case + camelCase):
  birthValue, telefone/Alternativo, email/Alternativo, genero, estadoCivil,
  naturalidade, ondeNosConheceu, encaminhadoPor, observacoes, notasInternas
  + 8 campos de endereco + 5 dados adicionais + 4 responsavel.
- groupNames/groupLabel/groupCountLabel pra bloco Origem.
- scrollToProfileSection(key): liga sidebar sub-nav -> scrollIntoView do
  anchor #mpa-perfil-XXX. Em mobile fecha o drawer.

MELISSAPACIENTE.VUE — Tab Perfil reescrita
Diferente do PatientProntuario legacy que usa PrimeVue Accordion (1 painel
aberto por vez), o Melissa nativo mostra os 6 cards stacked com scroll
suave do sidebar sub-nav. Mais legivel em desktop, mais rapido de escanear.

- 1. Informacoes Pessoais: 2-col com Dados de cadastro (nome/data nasc
  com idade inline/genero/estado civil/CPF/RG/naturalidade) + Contato +
  Origem (grupos/tags chips/onde nos conheceu/encaminhado por). tel: e
  mailto: links onde ha valor. Observacoes full-width quando preenchido.
- 2. Endereco: grid 2-col com 8 fields.
- 3. Dados Adicionais: grid 2-col com escolaridade/profissao/parente/grau/
  tel parente.
- 4. Responsavel: 1-col com nome/CPF/tel + observacao block textual.
- 5. Anotacoes Internas: card com hint lock + textblock min-height.
- 6. Sessoes: lista compacta scrollable (max-height 360px) com titulo/
  data/duracao/modalidade chips + tag status.

CSS: ~250L novos pros componentes (mpa-fields/field-row/field-grid-2/
field-block/sess/sess-list). Pattern visual Melissa: cards com label
uppercase, separadores horizontais sutis, links primary, monospace pra
CPF/RG/CEP.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:43:03 -03:00
Leonardo ab7526b8d7 MelissaPaciente Fase 2: Tab Visao Geral completa (4 KPIs + timeline + msgs + notas)
Reescreveu o placeholder da aba Visao Geral por uma versao 1:1 do
PatientProntuario.vue legado, com estilo Melissa nativo e dados
alimentados pelos composables criados na Fase 1.

NOVO: src/features/patients/utils/patientFormatters.js (~165L)
- Helpers compartilhaveis extraidos do PatientProntuario:
  parseDateLoose, fmtDateBR, fmtDateTimeBR, fmtCurrency, fmtRelative
  (pt-br: "agora"/"ha 5 min"/"em 2 dias"/"ha 3 sem"), sessionDuration,
  calcAge.
- STATUS_LABEL e STATUS_SEVERITY pra mapear status de sessao (cobre
  variantes: realizado/realizada, falta/faltou, cancelado/cancelada).
- tagStyle com contraste auto (luminance WCAG-ish: bg colorido +
  texto preto/branco baseado em luminance < 0.45).
- Sera reutilizado pelas Fases 3-7 e na Fase 8 substitui as funcoes
  duplicadas do PatientProntuario.

EXTENSAO de composables (Fase 1):
- usePatientSessions: novo computed `ultimasAtendidas` (top 6 sessoes
  com status realiz/falt/cancel/remarc pra Timeline). totalRealizadas/
  Faltas/Canceladas refinados pra usar regex (cobre variantes pt-br).
- usePatientFinancial: novo computed `statusFinanceiro` que retorna
  { emDia: bool, proxVenc: record, totalPendente, totalPago, vencidos }
  pra alimentar KPI 02 com info detalhada de status financeiro.

MELISSAPACIENTE.VUE — Visao Geral reescrita:
- 4 KPI cards ricos (substituem os simples da Fase 1):
  - 01 Sessoes: realizadas / total + faltas + canceladas
  - 02 Pagamento: status (Em dia/atraso) + prox venc + cor adaptativa
    (vermelho atrasado / primary ok)
  - 03 Proxima sessao: relative + datetime + modalidade
  - 04 Mensagens: ultima relative + direction + count
- Grid 2-col abaixo (1.4fr / 1fr em >=900px):
  - Timeline coluna esquerda: dots coloridos por status, tags severity,
    chips modalidade + duracao, nota observacoes inline.
  - Coluna direita: Mensagens recentes (4) com border-left in/out +
    meta direction/relative + body 3-line clamp; Notas e observacoes
    em card papel com label uppercase e icone lock.
- Removeu kpiEmAberto/Atrasado nao usados (statusFinanceiro encapsula).

CSS: ~280L novos pros componentes (KPIs ricos, panel base, empty rich,
timeline, mensagens, notas). Mantem o pattern visual Melissa.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:31:36 -03:00
Leonardo df61cc4d99 MelissaPaciente Fase 1: foundation (5 composables + skeleton 7 tabs + slug paciente)
Inicio do port do PatientProntuario.vue (3593L Dialog) pra Melissa nativo.
Plano em 8 fases — esta entrega cobre apenas a Fase 1 (foundation).
PatientProntuario continua intocado nos 4 callsites (TherapistDashboard,
MelissaAgenda, MelissaPacientes, PatientsListPage); migration acontece
nas fases 2-8.

5 COMPOSABLES NOVOS em src/features/patients/composables/
- usePatientDetail.js (108L): patients + groups + tags
- usePatientSessions.js (83L): agenda_eventos + computeds proxima/ultima/totais
- usePatientFinancial.js (82L): financial_records + computeds totalRecebido/Aberto/Atrasado
- usePatientMessages.js (64L): conversation_messages + computeds recentes/totalIn/Out
- usePatientDocuments.js (70L): documents + computeds total/Bytes/tiposCount

Cada composable encapsula a query original do PatientProntuario.vue +
adiciona computeds derivados. Reutilizaveis em outros lugares no futuro
(dashboards, relatorios, etc).

MELISSAPACIENTE.VUE NOVO (1190L) em src/layout/melissa/
- Prefixo CSS .mpa-*. Chrome glass + drawer mobile + right: max(...) >=1024px
  (mesmo padrao MelissaAgendador/Negocio).
- Header: avatar + nome + ageLabel + pronomes + Tag status/convenio +
  risco-elevado pill + actions (Conversar / Editar / Close).
- Subheader condicional: banner risco elevado.
- Body 2-col: sidebar 320px (esquerda, drawer no mobile) + main flex 1.
- Sidebar com 4 cards: Acoes Rapidas / Navegacao 7 tabs / Sub-nav Perfil /
  Vinculos (chips grupos+tags).
- Main: 7 tabs (Visao Geral / Perfil / Prontuario / Agenda / Financeiro /
  Documentos / Conversas). Visao Geral ja mostra 4 KPIs reais via composables.
  Outras 6 abas com placeholders "Em desenvolvimento — Fase X".

MELISSALAYOUT.VUE
- Import MelissaPaciente.
- SECOES.paciente entry novo.
- 'paciente' adicionado ao MELISSA_NON_CONFIG_SLUGS.
- Render condicional com :patient-id="String(route.query.id || '')"
  — navegacao via /melissa/paciente?id=xxx.

ESLint: 0 errors da mudanca. 2 errors pre-existentes em MelissaLayout
(duplicate key 'financeiro' L242, empty block L1130) — nao toquei essas
linhas. PatientProntuario tem outros pre-existentes nao tocados.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:23:48 -03:00
Leonardo f3f0d831d2 Melissa: preview teleport 3-way no Agendador/LinkExterno + chrome 6 paginas
PADRAO PREVIEW 3-WAY (mobile/sidebar/floating)
- Replica o pattern do MelissaNegocio em MelissaAgendador e MelissaLinkExterno.
- Mobile: preview teleporta pro topo do main, acima de tudo (diferente do
  Negocio que vai pro drawer).
- Mid-desktop (1024-1339): teleporta pro fim da sidebar inline.
- Wide-desktop (>=1340): painel flutuante glass fora do fake dialog,
  ancorado a +14px do right edge da .X-page com width 320px.

MELISSAAGENDADOR (.mag-page)
- Importa AgendadorPreview (componente legacy do ConfiguracoesAgendadorPage).
- isWideDesktop ref + matchMedia('(min-width: 1340px)') + previewTarget computed.
- 3 placeholders + Teleport com card mag-w--side mag-w--preview.
- Adiciona right: max(6px, min(50%, calc(100% - 1006px))) em .mag-page no
  @media >=1024px (necessario pra abrir espaco pro floating).

MELISSALINKEXTERNO (.ml-page)
- Restruturacao: sidebar (Como funciona / Boas praticas) movida da DIREITA
  pra ESQUERDA + mobile drawer pattern (botao Menu, Teleport, transitions,
  backdrop) espelhando MelissaAgendador.
- 3-way teleport do preview com placeholders nos 3 alvos.
- ml-side ganha width 320px + scroll proprio.
- Right rule + floating preview CSS.

COMPONENTE NOVO: src/components/cadastro/CadastroExternoPreview.vue (~350L)
- Phone-frame 260px estilo AgendadorPreview replicando o CadastroPacienteExterno
  publico: nav (logo Psi + chip verificado), hero (avatar 38px + nome split
  firstName/lastName em accent + work_description label + clinic name),
  stepper 4 dots (1 active), card etapa 1 (numero decorativo + tag "Etapa
  1 de 4" + title "Sobre voce" + 3 input bars + CTA "Continuar"), powered by.
- Recebe :token e busca info via mesma edge function que o publico
  (get-intake-invite-info), watch refetcha quando token rotaciona.
- Sem token ou sem dados, fallback gracioso pra placeholders ("Profissional"
  + iniciais).

CHROME EM 6 PAGINAS TABULARES (sem preview)
- Apenas o right: max(6px, min(50%, calc(100% - 1006px))) no @media >=1024px,
  fazendo a janela ficar do mesmo tamanho do MelissaAgendador.
- MelissaCadastrosRecebidos (.mcr), MelissaRecorrencias (.mr), MelissaGrupos
  (.mg), MelissaTags (.mt), MelissaCompromissos (.mc), MelissaMedicos (.mm).
- +9 a 12 linhas por arquivo. Cada um nao tinha @media >=1024px ainda.

ESLint: 0 errors da minha mudanca. 2 errors pre-existentes em
MelissaRecorrencias.vue (totalDone unused L235, v-for/v-bind:key L584) -
nao toquei aquelas linhas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:08:56 -03:00
397 changed files with 52062 additions and 8863 deletions
+391 -172
View File
@@ -1,229 +1,448 @@
# HANDOFF — 2026-05-06 (Melissa Pages aplicando blueprint + ConversationDrawer WhatsApp redesign + commits)
# HANDOFF — 2026-05-20 (C10 ✅ + C11 ✅ + C12 ⏳ deferido · testando C13)
Documento de continuidade. **Quando voltar, comece lendo esta página.**
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
> **🟢 ENTREGUE HOJE** — Blueprint tabular aplicado nas **6 Melissa Pages restantes**
> (Compromissos, Grupos, Tags, Médicos, Conversas, Recorrências) + dialogs
> harmonizados com `FloatLabel + IconField + section dividers` + dialogs
> "Pacientes do grupo/tag/médico" com cor primary nos avatares + redesign
> completo do `ConversationDrawer` pra estilo WhatsApp (avatar circular, bg
> "papel de parede", bolhas com tail simulada, time/status overlay no canto,
> compose pill + send circular verde) + fix de cor de tags/grupos no
> MelissaPacientes (`g.cor → g.color` em 20 lugares).
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** C10 e C11 fechados.
> **C12 fluxo crítico OK no DB mas UX confusa** — adiado pra iterar
> pós-Rail/Clínica (memória project_c12_antecipar_iterar). Agora
> **testando C13** (edit cobrada — invariante imutabilidade SimplePractice).
> Implementação JÁ existe (Fase 6 do commit 1feb711 — Message com cadeado +
> AgendaEventoFinanceiroPanel embedded). Só validação visual + persistência.
> **🟢 COMMITADO** — Working tree estava com 4 sprints acumulados (~50 arquivos).
> Foram criados **5 commits** lógicos antes do push, do mais antigo pro mais
> recente. Ver seção "Histórico de commits" abaixo.
> **🟢 14 COMMITS NO DIA**. C10 (5/5), C11 (4/4), C12 deferred (DB OK),
> reverse transition trava implementada, popover watch sync implementado.
> Pós-C13: replicar Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
> + iterar C12 UX + doc de ajuda (pendência separada).
> **🟡 AINDA PENDENTE** — Sub-sessão 2 do A66 (V2 dialog): user não gostou
> do design do esqueleto entregue em 2026-05-05. Aguarda feedback específico
> antes de iterar. Detalhes na seção "Sessões dedicadas pendentes".
### C13 — passos de teste (próximo)
Paciente: **João Almeida Martins** (sessão 20/05 9:00 realizada + paid R$ 40 maquininha) ou **André Green 20/05** (paid PIX).
Esperado ao abrir o AgendaEventDialog:
- Message azul com cadeado: "Cobrança de R$ X já emitida..."
- AgendaEventoFinanceiroPanel renderiza embaixo do Message
- Card "Aplicar alterações em" oculto (v-if="!occFinancialRecord")
- Só horário/observações editáveis; valor/serviços/tipo travados
### C11 sub-test results
| # | Teste | DB validado |
|---|---|---|
| 11A | Realizada + markPaid PIX | sessions_used 0→1, record paid R$ 40 PIX |
| 11B | Falta + Descontar saldo | sessions_used 1→2, sem multa |
| 11C | Falta + Multa SEM consumir | sessions_used stays 2, multa pending R$ 30 |
| 11D | Cancelado + default_consume_on_miss=true | sessions_used 2→3, sem multa (>2h) |
### Bugs descobertos + corrigidos durante C11
- UI "Como cobrar?" com options "Já recebi" misturadas → refatorado pra "Já recebi?" radio Sim/Não + select condicional
- `billing_contracts` sem coluna `updated_at` → UPDATE falhava silently em Promise.allSettled (root cause do saldo não incrementar). Trocado pra awaits sequenciais com error handling explícito
- Reverse transitions deixavam multa órfã → dialog reverse implementado com radio "cancelar pending" + "devolver saldo" + warning pra paid
- Botão "Gerar cobrança" em sessão encerrada → bloqueado
- Lock total em cancelado/faltou: Editar sessão some, status mudanças disabled exceto Agendada (recovery)
- Label "A cobrar R$ X" em pacote saldo state=none → "Aguardando uso do pacote"
- Badge $ amber em pacote saldo state=none → suprimido
- billing_contract_id não amarrado em alguns flows → link universal antes dos blocos forward
- Reverse saldo decrementar: refresh sessions_used FRESH do DB antes do UPDATE (anti-race)
### Pendências mapeadas pós-C13
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot. Fix: guardar ev.id, derivar via computed
- ~~Reverse transitions~~ ✓ implementado ahead of schedule
- **Cleanup teste**: Otto sessão 5364f631 leftover (não-critical)
### C10 sub-test results
| # | Teste | DB validado | Notas |
|---|---|---|---|
| A | Realizada sem markPaid | ✅ status=realizado, record=pending | Bubble do C9 funcionou |
| A2 | Realizada + markPaid maquininha | ✅ status=realizado, record=paid, payment_method=cartao_maquininha, paid_at set | João Almeida |
| B | Faltou + multa R$ 30 (fixed_fee) | ✅ original cancelled + nova multa "Multa por falta · sessão dd/mm/aa" | Otto Rank |
| C | Cancelado >2h antecedência | ✅ original cancelled, sem multa | Otto / Karen |
| C2 | Cancelado tardio (<2h) full charge | ✅ original cancelled + nova "Taxa de cancelamento tardio · sessão dd/mm/aa" | Karen Horney |
### Pendências mapeadas durante C10 — pós-C13
- **Reverse transitions**: faltou/cancelado → agendado deixa multa órfã. Implementar confirm dialog oferecendo auto-cancelar multa.
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot, não acompanha _paymentStateMap. Fix: guardar ev.id, derivar via computed.
- **Cleanup teste**: Otto sessão 5364f631 às 19:30 UTC tem record pending R$ 40 leftover do teste A original. Apagar quando convenient.
Memórias relevantes:
- `project_agenda_reverse_transitions.md`
- `project_melissa_popover_snapshot.md`
### Code-fix aplicado em 20/05 (pré-C10)
- **`useMelissaAgenda.js:1450-1505`** — `_applyStatusDecisions` agora cancela
o `ctx.pendingRecord` quando faltou/cancelado (com ou sem multa). Antes
inseria a multa mas DEIXAVA o original pending → cobrança dupla
(R$ 200 + R$ 30 = R$ 230). Audit trail vai em `notes` do record
cancelado, descrição da multa nova carrega data: "Multa por falta · sessão 20/05/26".
- **`useAgendaFinanceiro.js:59`** — fix dormente `'fixed'``'fixed_fee'`
(off-by-key contra schema; path nunca exercitado na Melissa, mas iria
quebrar se algum dia fosse).
### Financial exceptions seedadas (tenant Bruno Terapeuta / owner Leonardo)
- `patient_no_show``fixed_fee R$ 30`
- `patient_cancellation``full`, `min_hours_notice=2`, `default_consume_on_miss=true`
---
## 🚦 STATUS — Working tree LIMPA
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 10 (Status change AVULSA)
```
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
```
Doc HTML diz: testar status change numa sessão avulsa com cobrança pendente,
mudando entre realizado / faltou / cancelado. As consequências financeiras
seguem `financial_exceptions` (regras configuradas pelo terapeuta sobre o
que acontece com a cobrança nesses casos).
(após `git push`. Antes do push: 5 commits ahead.)
Possíveis pacientes pra teste: usar Joyce, Sándor ou outro com cobrança
avulsa pendente já criada.
**Esperado** (depende das `financial_exceptions` configuradas no tenant):
- Realizada: status muda; cobrança permanece (caminho default)
- Faltou: pode ter regra → cobrança 100% (paciente paga falta) ou cancela
- Cancelado: pode ter regra → cancelar cobrança ou cobrar parcial
Conferir:
- `STATUS_TO_EXCEPTION` mapping em `useAgendaFinanceiro.js`
- `getFinancialExceptionRule(tenantId, exceptionType)` retorna a regra
- `handleStatusChange` orquestra: agenda update + financial adjust
Após C10: C11 (status change pacote saldo — usar a infra do Usar/Revogar)
→ C12 (antecipar pagamento) → C13 (edit cobrada).
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e
**Clínica** (`AgendaClinicaPage.vue`).
---
## 📦 Histórico de commits criados hoje
## 📦 O que foi feito em 20/05 madrugada (C9 + rowGroup financeiro + bubble cobranca-atualizada)
Em ordem cronológica de criação (mais antigo → mais novo):
### Cenário 9 ✅ (Per-session — Michael Balint 12 × R$ 150)
Testado e passou. Criou-se 1 rule + 12 agenda_eventos materializadas + 12 financial_records pending. Sem billing_contract. Cada sessão com badge $ amber individual. **Sem nenhuma `linha de pacote`** no popover (não tem contract → não aparece). Conforme esperado.
1. **`957e912`** — `Melissa polish + Prontuario Visao Geral + agenda historico`
- Sprints B (05-03) + C (05-04) acumulados:
- NotificationDrawer/Item redesign
- Dock pins compose (`useMelissaDockPins`) + cache store global (`melissaCacheStore`)
- MelissaAgenda timeline FullCalendar parity + cards resumo + histórico card
- `useFeriados` cache opt-in
- PatientProntuario aba Visão Geral nova
- DB migration `20260504000001_fix_cancel_notifications_excluido.sql`
- 19 files, +5203 285
### `/melissa/financeiro-lancamentos` agrupado por paciente
- DataTable com `rowGroupMode='subheader'` + `groupRowsBy='patient_id'`
- Default: todos os grupos da página expandidos (watcher popula `expandedGroups` com unique patient_ids quando `recordsGrouped` muda)
- Header de grupo: avatar pequeno + nome + badge "N lançamento(s)"
- Click no chevron contrai/expande (auto via PrimeVue `expandableRowGroups`)
- Sort estável: ordena outer por nome do paciente, preserva inner order (pai → filhos de multas/taxas)
2. **`6d9b36d`** — `A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto`
- 5 composables (1986L total): `agendaEventHelpers`, `useAgendaEventComposer`, `useAgendaEventActions`, `useAgendaEventPickerBilling`, `useAgendaEventLifecycle`
- 5 specs em `__tests__/` (75+76+28+43+43 = **265 testes**, 495/495 passando)
- AgendaEventDialog 3522 → 2632 linhas (-25%)
- `AgendaEventDialogV2.vue` esqueleto (~1100L, 3 zonas) + preview em `/preview/agenda-dialog-v2`
- Backup byte-idêntico em `AgendaEventDialog.vue.bak`
- Dialogs auxiliares: `InsurancePlanQuickCreateDialog`, `ServiceQuickCreateDialog`
- 17 files, +10966 1298
### Bubble-up `@cobranca-atualizada`
Antes: `AgendaEventoFinanceiroPanel.@cobranca-atualizada` disparava só `loadOccFinancialRecord` (interno do dialog). O `_paymentStateMap` da agenda ficava stale → card no FC só atualizava ao trocar de view.
3. **`269b531`** — `Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore`
- Sprint E (05-05): Blueprint canônico em `blueprints/melissa-table-page-blueprint.md` (~530L, 18 seções)
- MelissaCadastrosRecebidos refator pro blueprint
- **MelissaAgendamentosRecebidos** novo (substitui o embed)
- MelissaPacientes refator parcial (subheader, sombras, status pills coloridas, email/phone colunas próprias, mobile pencil+popover, fix scroll com `min-height: 0`, restore de arquivados)
- `restorePatient` no `patientsRepository`
- 10 files, +4824 301
Agora: `AgendaEventDialog._onCobrancaAtualizada` faz duas coisas:
1. `loadOccFinancialRecord()` — refresca estado interno do dialog
2. `emit('cobranca-atualizada')` — bubble pra MelissaLayout
4. **`98f7252`** — `Melissa: 6 Pages aplicando blueprint + dialogs unificados + Conversa estilo WhatsApp`
- Sprint F (05-06, esta sessão):
- **MelissaCompromissos**: blueprint mantendo row design original (color stripe + name + badges + descrição + meta inline)
- **MelissaGrupos** + **MelissaTags**: blueprint completo + dialog "Pacientes do grupo/tag" com lista vinculada via `patient_group_patient` / `patient_patient_tag`
- **MelissaMedicos**: blueprint + dialog "Pacientes encaminhados" usando cor primary; dialog editar com 4 seções (Identificação/Contato/Localização/Obs) espelhando PatientsCadastroPage
- **MelissaConversas**: subheader, sidebar reestruturada, alerta unlinked no topo, kanban mobile com `min-height` nas colunas, fix bug `filters` é `ref({})` então no script precisa `.value`
- **MelissaRecorrencias**: button list de status, busca por nome do paciente, footer Limpar filtros
- **ConversationDrawer**: redesign WhatsApp (avatar primary, bg "papel de parede", bolhas com tail, time/status overlay, compose pill + send circular #00a884)
- 7 files, +7879 1467
5. **`15103ed`** — `Cleanup: backups antigos removidos + dashboard config + HANDOFF/log`
- Backups `database-novo/backups/2026-03-27` e `2026-03-29` removidos
- `db.config.json` + `generate-dashboard.cjs` + `dashboard.html` atualizados
- HANDOFF.md (estado 05-05) + log.md
- 11 files, +435 87172
MelissaLayout escuta nos 2 dialogs (principal + occurrenceMode) e chama `onCobrancaAtualizada` que dispara `M.refetch() + refetchEventosHoje()`. Resultado: card na agenda passa pra borda verde imediatamente após marcar pago.
---
## 📋 RESUMO da sessão 2026-05-06
## 📦 O que foi feito em 19/05 madrugada (C8 + Usar/Revogar saldo + UI de pacote)
### Padrões consolidados nas 6 páginas Melissa restantes
### Cenário 8 ✅ (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
Testado e validado. Contract criado com `charging_style='saldo'`, 0 events materializadas, 0 records. Modelo Cliniko: sessões materializam on-demand via Usar.
Cada página agora segue o blueprint:
### UI do pacote (saldo + upfront)
- **`_ruleContractMap`** em useMelissaAgenda: bulk-load agora popula contract info (id, style, totalSessions, sessionsUsed, packagePrice) por `recurrence_id`. Query usa `recurrence_rules.patient_id` como fonte autoritativa (cobre saldo sem materializadas).
- **Normalize** injeta `contract` no evento → popover acessa via `ev.contract`.
- **Popover** (`MelissaEventoPanel`): nova linha colorida abaixo do payment:
- Saldo: violeta `"Pacote saldo · N/M usadas"` + botão verde **"Usar"** (paymentState=none) OU vermelho **"Revogar"** (paymentState=pending)
- Upfront: verde `"Pacote · N/M realizadas"` (sem botão; cobrança já tratada)
- **AgendaEventDialog**: info card mt-4 (saldo violeta / upfront emerald) com header (pacote+contador), body (total/per-session/restam), botão "Usar agora" ou "Revogar uso", hint explicando o modelo. Gateado por `occFinancialLoading` (spinner durante carga) pra evitar piscar entre estados.
- **Subheader explicativo** logo abaixo do header (1-2 frases descrevendo a página + ações principais com `<strong>`)
- **Sidebar reestruturada** em 2 zonas:
- `.xx-side` com `bg: var(--m-bg-soft)` + `border-right` (visual de coluna lateral)
- `.xx-side__scroll` (flex 1, overflow auto) com cards `xx-w--side` (margin lateral 12px + sombra)
- `.xx-side__footer` (flex-shrink 0, padding 12px, bg-soft, border-top) com botão **"Limpar filtros"** global
- **Xs inline** ao lado do título de cada filter card (vermelho 18×18, aparece só quando filtro ativo)
- **Transition `xx-clear`** no footer (fade + collapse 240ms)
- **Body sem padding/gap** (sidebar fica colada à esquerda; main column tem padding interno próprio)
- **Mobile drawer** com sidebar teleportada perde bg/border-right (drawer já tem chrome) + footer vira `position: sticky; bottom: 0` com bg blur
### Handlers Usar/Revogar atômicos
**`onUsarSessao`** em MelissaLayout (aceita payload do popover OU do dialog):
1. Materializa virtual se necessário (preserva `determined_commitment_id` da regra)
2. Status='realizado' + link `billing_contract_id`
3. `create_financial_record_for_session` RPC com per-session amount
4. Incrementa `billing_contracts.sessions_used`
5. Se atingiu total → contract `status='completed'`
6. Toast verde + fecha popover/dialog
### Dialogs harmonizados (Tags / Grupos / Médicos)
**`onRevogarSessao`** desfaz tudo:
1. Cancela financial_record (status='cancelled')
2. Decrementa sessions_used (não fica negativo)
3. Reativa contract se estava completed
4. Status volta pra 'agendado'
5. Bloqueia se record já está `paid` (precisa estorno formal pelo Financeiro)
6. **Backfill** de `determined_commitment_id` se NULL (fix de legado)
Espelhando o pattern do **PatientsCadastroPage > Identidade**:
### Fix: enum status_evento_agenda
Era `'realizada'` no insert/update, DB exige `'realizado'` (masculino). Corrigido em todas as ocorrências.
- **Section dividers**: `<span class="text-[0.7rem] font-bold uppercase tracking-widest text-[var(--p-primary-color)]">` + linha `h-px` primary-tinted
- **Cada campo**: `FloatLabel variant="on"` + `IconField` + `InputIcon` + InputText/Select com `variant="filled"`
- **Grid**: `grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7`
- **Erro inline**: `<small class="text-red-500">` + `pi-exclamation-circle`
- **Footer**: Button PrimeVue padrão (Cancelar secondary text + Salvar com `pi-check`)
- **Bordas dos inputs**: padrão do PrimeVue (sem CSS scoped sobreescrevendo)
### Fix: campo "Título" indevido no dialog
Sessão sem `determined_commitment_id` `selectedCommitment=null` `isSessionEvent=false` → mostra campo Título (que é só pra não-sessão). Fix:
- Materialize do Usar inclui `determined_commitment_id` da regra
- Update path do Usar (sessão real após revogar) backfilla via query da rule
- Revogar também backfilla — garante consistência mesmo sem novo Usar
- SQL massivo de backfill disponível no HANDOFF pra limpar rows legadas
### Dialog "Pacientes vinculados" (Tags / Grupos / Médicos)
### Fix: "Gerar fatura" não cabe em sessão de saldo
Hide do botão "Gerar fatura" no popover quando há `contractInfo`. Geraria cobrança solta sem incrementar saldo → duplicação. Fluxo correto: usar "Usar".
Pattern unificado:
### Recorrências Aplicadas: cores + badges
- Header stats: total **azul**, realizadas **verde**, faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
- Pills: badge sólido por status (Realizado=emerald-600, Faltou=amber-600, Cancelado=stone-500, Remarcado=violet-600)
- **Borda 2px na cor da entidade** (cor da tag/grupo via `:pt root style`); médicos usam `var(--p-primary-color)` (sem cor própria)
- **Header**: avatar quadrado/circular colorido + título com cor da entidade + sub com count
- **Toolbar**: search + count pill colorido
- **Estados**: loading (cor da entidade), erro (vermelho), empty (icon tinted), sem-resultado-de-busca
- **DataTable interna**: Paciente (avatar com iniciais primary-tinted + nome + email) / Telefone / Botão "Abrir" outlined
- **Click "Abrir"** → reusa `PatientCadastroDialog` com `:patient-id`
- **Sem footer "Fechar"** — o X do header é o único botão de fechar
- **X do header** estilizado como `.xx-close` (32×32, bg --m-bg-soft, border, hover bg-soft-hover) via `:pt="{ pcCloseButton: { root: { class: 'xx-pdlg-close-btn' } } }"` + CSS `:global()` (Dialog é teleportado pra body)
### ConversationDrawer redesign (estilo WhatsApp)
- **Header**: avatar circular 40×40 com iniciais + nome em destaque + sub (canal icon + número formatado mono)
- **Container de mensagens**: bg "papel de parede" (`color-mix` bege esverdeado WA + radial-gradient pattern de pontos)
- **Bolhas**:
- Inbound light `#ffffff` / dark `#202c33` — top-left zerado simulando tail
- Outbound light `#d9fdd3` / dark `#005c4b` — top-right zerado simulando tail
- Padding `6/10/18/10` (extra bottom pra meta)
- Border-radius 8px + sombra `0 1px 0.5px rgba(0,0,0,0.13)`
- Detecção dark via `:global(.p-dark) / html.dark / [data-theme="dark"]`
- **Meta** (HH:MM + status checks): `position: absolute` no canto inferior direito DENTRO do balão
- ✓ enviada / ✓✓ entregue / ✓✓ azul `#53bdeb` lida / ✗ vermelho falhou
- **Compose**:
- Botões emoji + templates à esquerda do input
- Textarea com `border-radius: 22px` (pill)
- Botão **Send circular 40×40** verde `#00a884` (cor send WA), translate-up no hover
### Bug fix: cores de tags/grupos no MelissaPacientes
`patientsRepository.listGroups()` e `listTags()` mapeiam `cor → color` (camelCase frontend-friendly). O template do MelissaPacientes lia `g.cor` / `t.cor` (PT-BR) em **20 lugares** — sempre `undefined` → fallback caía no cinza/hex hardcoded. Trocado pra `g.color` / `t.color` via `replace_all`. Outros consumers (PatientsCadastroPage) já usavam `.color` correto, não foram afetados.
### Race condition no dialog
- AgendaEventDialog mostrava botões "Usar"/"Revogar" baseado em `occFinancialRecord` que carrega async
- Durante load (~500ms), botão errado podia aparecer → snap pro correto depois
- Fix: spinner "Verificando estado…" enquanto `occFinancialLoading=true`; botões só renderizam após
- Popover decidiu manter como está (race window pequena, fechar/reabrir resolve)
---
## 🛠️ Sessões dedicadas pendentes
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
### A66 — Refactor `AgendaEventDialog` V2 (3 sub-sessões)
### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira)
Testado e validado. Usuária criou Ana, R$ 200/sessão × 4 = R$ 800, marcou como pago em dinheiro pelo Financeiro. Visualização correta em mês AND em semana navegando pelas 4 semanas.
**Estado**:
- ✅ Sub-sessão 1 (composables) — 5 composables + 265 testes, 495/495 suite passando, AgendaEventDialog 3522→2632 linhas (-25%)
- 🟡 Sub-sessão 2 (template V2) — esqueleto entregue 2026-05-05, **user não gostou do design**, aguarda feedback específico
- ⏳ Sub-sessão 3 (migração nos 9 consumers) — depende do V2 estabilizar
### Fase 6 (lock-edit cobrada) ativada em Melissa
Antes: `loadOccFinancialRecord` tinha guard `if (!props.occurrenceMode) return;` — só carregava em Rail/Clínica (edição de ocorrência). Em Melissa, `sessionPaymentRecord` paralelo alimentava só o Resumo lateral, sem trigger de lock.
**Próxima ação**: user dá feedback design → eu itero V2.
Agora unificado: `occFinancialRecord` carrega em ambos modos:
- Card Sessão / Honorários ganha **Tag** (em vez de Select billingType) quando há cobrança
- Body do card mostra **Message "Cobrança de R$ X já emitida"** + cadeado
- Tipo de cobrança (Particular/Convênio/Gratuito) bloqueado
- Edição de serviços/preço bloqueada
Perguntas em aberto:
- Estrutura: 3 zonas (PACIENTE/QUANDO/O QUÊ) tá errado? Prefere 2 zonas? 1 coluna scroll? Tabs?
- Hierarquia: hero PACIENTE muito grande/pequeno?
- Densidade: airy demais ou apertado demais?
- Chips de duração/scope/status: muito visuais?
- Mobile: já testou viewport pequeno?
- Referência visual: Win11? Cleaner? Mais como V1? Algum app?
### Propagação cross-week de pacote upfront pago/pendente
**Bug descoberto durante C7:** ao navegar pra semanas futuras (onde só virtual da Ana 2/3/4 aparecia, sem real event paid na view), o `_rulePaymentMap` era zerado pelo else branch do bulk-load → virtuais perdiam estado paid.
Fix em `useMelissaAgenda.js _reloadRange`:
- Maps (paymentStateMap, amountMap, rulePaymentMap) inicializados SEMPRE no início
- Propagação agora roda **independente de realIds.length** (ie, mesmo em semanas só-com-virtuais)
- Coleta `ruleIdsInView` de TODOS eventos da view (reais + virtuais com recurrence_id)
- Cross-week query: pra cada rule em view, busca QUALQUER evento sibling (inclusive em outras semanas) + seus records paid/pending → determina estado do contrato
- Propaga estado pra eventos reais (via map) + virtuais (via rulePaymentMap acessado pelo normalize)
### Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (`paymentVariant === 'none' && !is_occurrence`)
- Click → `gerarCobrancaManual` direto, fecha popover pra impedir double-click
- Tooltip: "Gerar fatura agora"
### Info de pacote no popover
- Header agora mostra `Sessão · Pacote · N sessões` (computed `seriesLabel` lê de `_raw` do rule)
### Botão "Excluir série inteira"
- Novo emit `delete-series` em `MelissaEventoPanel` + botão ao lado de "Excluir sessão" quando evento tem `recurrence_id`
- Handler `onDeleteSeries` em MelissaLayout faz hard delete: `financial_records` pendentes → `agenda_eventos` materializados → `recurrence_rules` (CASCADE leva exceptions + rule_services)
- Bloqueia se algum record tem `status='paid'` (estornar primeiro)
### RPC `create_financial_record_for_session` ignora cancelled
**Migration 20260519000001:** idempotência da RPC passou a filtrar `AND status != 'cancelled'` além de `deleted_at IS NULL`. Antes: cancelar cobrança sem querer → todo "Gerar fatura" subsequente retornava o cancelado em vez de criar nova. Toast verde mentindo.
Memória durável em `memory/project_rpc_idempotency_cancelled.md`.
### `cancel_session` exception some da agenda
- `useRecurrence.expandRules` agora pula ocorrência com `exception.type === 'cancel_session'` (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha)
- `patient_missed` / `therapist_canceled` / `holiday_block` permanecem visíveis como histórico
### `recurrence_exceptions` cancel idempotente
- Cancel de ocorrência (virtual e materializada) usa `upsert` com `onConflict: 'recurrence_id,original_date'` — não quebra mais com unique violation quando há exception zumbi de tentativa anterior.
### Visualização paid/pending de upfront em virtuais
- `MelissaEventoPanel.showPaymentRow` antes excluía virtuais incondicionalmente. Agora só esconde quando `paymentState === 'none'` (saldo/sem pacote continua limpo; upfront propagado mostra).
- `MelissaAgenda.fcEvents`: removida exigência de `!is_occurrence` no `isPaidSession` e no badge $ pendente. Virtuais herdadas via propagação mostram borda verde/badge amber.
### `onVerLancamentos` cobre virtual de upfront
- Antes: virtual sempre toast "Sem lançamentos". Agora: busca records via siblings da série pra encontrar o do pacote. Saldo/sem pacote continua com toast.
### Confirmação 3 decisões UX (não codar)
Antes de C7, user perguntou e concordou:
1. Editar serviço já lançado e pago → **NÃO** (cobrança fiscal imutável)
2. Alternar Particular/Convênio/Gratuito em série com cobrança ativa → **NÃO** (mesma razão)
3. "Gerar fatura" extra em sessão coberta por contrato upfront → **NÃO** (duplicaria cobrança)
Tudo isso o lock-edit (Fase 6 ativada acima) cobre.
---
## ⏭️ PRÓXIMOS PASSOS (sugestão)
## 📦 O que foi feito em 18/05
### 1. Restore arquivados na `PatientsListPage.vue` (layout Rail)
### Cenário 4 (Joyce · "Já recebi") ✅
- Testado e passou: toast "Cobrança paga R$ 180,00 recebido via PIX", record nasceu `paid + payment_method=pix + paid_at=now()`.
A `PatientsListPage.vue` tem KPI "Arquivados" mas SEM botão Restaurar. Replicar o pattern da MelissaPacientes:
- Helper `isArquivado(p)` (case-insensitive)
- Botão condicional ↶ "Restaurar" baseado em `p.status === 'Arquivado'`
- Click → confirm → `restorePatient(id, { tenantId })` do mesmo repository → toast + refetch
- Toggle visual: ↶ undo primary quando arquivado / 🗑 trash vermelho quando ativo
### Novo indicador: barra esquerda verde para sessão paga
- Brainstorm de 6 opções; user escolheu #6 (3 canais visuais distintos por estado).
- `MelissaAgenda.vue:395-419` — computa `isPaidSession` (sessão+paciente+não-virtual+`paymentState==='paid'`) e adiciona classe `ma-evt--paid` ao FC event (combina com `ma-evt--inactive-patient` se ambos).
- `MelissaAgenda.vue:2325-2335` — CSS força `border-left-color: #10b981 !important` (emerald-500, 4px). `!important` necessário porque FC seta `borderColor` inline. Trata também list view (`.fc-list-event-dot`).
- Doc HTML atualizado: legenda "Indicadores visuais" agora descreve **3 estados** (pendente / pago / neutro) com 3 mocks empilhados; estado-alvo do C4 reescrito mencionando a barra verde.
- Decisão salva em `memory/project_agenda_payment_indicators.md`.
### 2. Decidir A66 V2 design
### Linha "Cobrança" no popover + Resumo do dialog
- **Popover `MelissaEventoPanel`** — antes só mostrava amber "A receber R$ X" pra pendente. Agora cobre os 3 estados, com cor + ícone por variante:
- `paid``pi-check-circle` verde, label **"Pago · R$ X,XX"**
- `pending``pi-dollar` amber, label **"A receber R$ X (cobrança pendente)"** (mantido)
- `none``pi-dollar` amber, label **"A cobrar R$ X"** ou **"Cobrança ainda não gerada"** (mantido)
- CSS reescrito em 3 modificadores `.evento-row--pay-{paid|pending|none}` (com dark mode).
- **Resumo lateral do `AgendaEventDialog`** — nova linha entre `pi-clock` e `pi-map-marker` em ambas as cópias (mobile inline + desktop floating).
- Novo ref `sessionPaymentRecord` em `useAgendaEventLifecycle.js:104+` (sem guard de `occurrenceMode`, contrário ao `occFinancialRecord` que continua só pra Rail/Clínica). Loader `loadSessionPaymentRecord` chamado no mesmo lifecycle.
- Computed `paymentSummary` em `AgendaEventDialog.vue:951+` retorna `{icon, cls, label}` pra 5 casos: paid (verde + paid_at), overdue (vermelho + due_date), pending (amber + due_date), sem cobrança c/ valor (neutro), sem cobrança s/ valor (neutro).
- `@cobranca-atualizada` do `AgendaEventoFinanceiroPanel` agora também dispara `loadSessionPaymentRecord` pra a linha refrescar.
- **Importante:** `occFinancialRecord` (que aciona lock-edit) NÃO foi tocado de propósito — esse é território da Fase 6/C13 (Edit cobrada). Manter dois refs separados evita ativar lock prematuro em Melissa.
Aguarda feedback. Sem feedback, posso:
- Tentar uma direção alternativa (ex: 1 coluna scroll mais minimalista)
- Comparar com referências externas (Outlook, Cal.com, Linear)
- Voltar pro V1 polido em vez de redesenhar
### Preparação do C5 (Sándor + Unimed Nacional) — UX de convênio refinado (3 issues)
### 3. Outras Melissa Pages?
User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
Todas as 9 páginas tabulares Melissa já estão alinhadas ao blueprint:
- ✅ Cadastros Recebidos, Agendamentos Recebidos, Pacientes (Sprint E)
- ✅ Compromissos, Grupos, Tags, Médicos, Conversas, Recorrências (Sprint F)
1. **Botão "Cadastrar" do procedimento navegava pra `/pages/notfound`**
- Root cause: `goToConveniosConfig` em `AgendaEventDialog.vue` prefixava com `/therapist` ou `/admin`, mas `/configuracoes/*` é rota **raiz** sob `AppLayout` (sibling, não filho). Em Melissa, convênios mora dentro do próprio layout via `secao: 'cfg-convenios'` (sem URL própria).
- Fix descartado: o user não queria sair da agenda. Em vez disso, criamos um quick-create inline (ver #2). `goToConveniosConfig` foi removida (dead code virou armadilha).
Não há mais páginas pendentes do plano original.
2. **Quick-create de procedimento inline (sem sair da agenda)**
- Novo componente `InsurancePlanServiceQuickCreateDialog.vue` (modelo do `InsurancePlanQuickCreateDialog`). 2 campos: nome do procedimento + valor que o convênio paga. Insere em `insurance_plan_services` pro `insurance_plan_id` ativo.
- Wiring em `useAgendaEventLifecycle.js`: novo `planServiceQuickDlgOpen` + `openPlanServiceQuickCreate()` + `onPlanServiceCreated(service)`. Após criar, recarrega `loadInsurancePlans` e **auto-seleciona** o novo procedimento **só quando nada estava selecionado antes** (preserva escolha quando user já tinha selecionado X e está só cadastrando Y pra próxima).
- UI refatorada (`AgendaEventDialog.vue:3110+`): a caixa cinza com botão "Cadastrar" agora aparece **sempre** que um convênio está selecionado. Quando 0 procedimentos: **"Este convênio ainda não tem procedimentos cadastrados."** Quando 1+: **"Se quiser adicionar mais procedimentos a este convênio:"**.
- `planServiceQuickDlgOpen` adicionado ao `anyChildDialogOpen` pra esconder o Resumo flutuante enquanto o quick-create está aberto.
3. **Botão "+ Novo convênio" faltando em `/melissa/cfg-convenios` (e na rota canônica também)**
- Root cause: `ConfiguracoesConveniosPage.vue` tinha o form de "Novo convênio" condicionado a `addingNew === true`, mas **nenhum botão setava esse flag**. Empty state mandava "Clique em 'Novo convênio'" sem botão pra clicar.
- Fix: toolbar simples no topo do template `<template v-else>` com `<Button label="Novo convênio" icon="pi pi-plus" @click="addingNew = true">`. Empty state corrigida pra apontar pro botão certo.
### Hint contextual abaixo do card Sessão / Honorários
- User pediu mensagem clarificando que "Nº da guia" é opcional em convênio.
- **Tentativa 1 (errou o lugar):** coloquei o hint em `AgendaEventDialog.vue:1826` dentro do bloco `v-if="occurrenceMode"` (só edita ocorrência em Rail/Clínica). User não viu.
- **Tentativa 2 (correta):** adicionado em `AgendaEventDialog.vue:2305+` (fluxo principal Melissa, fora do occurrenceMode). Mantive a tentativa 1 também — não atrapalha, só ativa em outro contexto.
- Texto: convênio = **"Nº da guia é opcional — você pode salvar a sessão e preencher depois, quando o convênio responder."** Gratuito = **"Sessão gratuita — nenhum lançamento será gerado no Financeiro."** Particular = sem hint (não há ambiguidade).
- Condição: `isSessionEvent && !occFinancialRecord && billingType === 'convenio'|'gratuito'`. Esconde quando há cobrança paga/pendente (lock-edit) — Message do panel já cobre.
- CSS: `.aed-billing-hint` em `AgendaEventDialog.vue:3558+` — barra esquerda primary, fundo neutro leve, fonte 0.78rem.
- Label do "Nº da Guia" no service-picker dialog também ganhou **(opcional)**.
---
## 📚 Tracking persistente
## 📦 O que foi feito antes (16/05 noite/madrugada)
- **A66** — sub-sessão 2 (V2 design) aguardando feedback do user
- **Blueprint tabular Melissa** — referência canônica: `MelissaCadastrosRecebidos.vue`. Todas as 9 páginas alinhadas.
- **Restore pacientes** — implementado no Melissa; replicar no Rail (`PatientsListPage.vue`)
- **Migration aplicada local**: `20260504000001_fix_cancel_notifications_excluido.sql`. Já aplicada no DB local.
### Cenário 1 (Bloqueio) ✅
1. **Fix `bloqueioCobrindo is not defined`**função estava no escopo de `useMelissaAgenda` mas `onSelectTime` mora no `_buildHandlers` (outro escopo). Passada via `deps`. Mesmo padrão que `_openStatusDialog`.
2. **Soft warn dentro do dialog** em vez de toast atrás do overlay — novo ref `dialogBlockOverlap` no composable + nova prop `blockOverlapWarning` no `AgendaEventDialog` + Message warn no topo do step 1. Reset nos outros openers (`onCreateEvento`, `onCreateEventoForPatient`, `onEditEvento`).
3. **Doc HTML Cenário 1 expandido** em 1a (criar bloqueio) + 1b (agendar sobre bloqueio), com mock visual da Message + comparação com agendador público (que veta).
### Cenário 2 (Avulsa sem cobrança) ✅
4. **Fonte da hint chargeMode** subiu de `0.72rem``0.8125rem` (acima de `text-xs`).
5. **Card Frequência avulsa** refeito — antes era empty state convidando configurar; agora renderiza com `.aed-pay-summary` (mesma estrutura do estado configurado: "Tipo: Avulsa · Sessão única, sem repetição" + botão Editar).
6. Doc HTML Cenário 2 atualizado.
### Cenário 3 (Avulsa cobrar ao salvar) ✅
7. **Refactor payment: `paymentSettlement` → `paymentMethod` + `markPaidNow`**
- UI antiga misturava método e status num único Select ("Já recebi — PIX").
- Agora 2 controles: Select forma (Enviar link / PIX / Dinheiro / Depósito / Cartão maquininha — SEM prefixo "Já recebi —") + SelectButton status (Cobrança pendente / Já recebi (dar baixa)).
- SelectButton só aparece quando método ≠ link (Asaas só liquida via webhook).
- Watcher força `markPaidNow=false` se voltar pra 'link'.
- Wire: AgendaEventDialog → useAgendaEventActions → useMelissaAgenda (handler avulsa + `_createPackageContract`).
8. **Indicadores visuais de pagamento** (novidade da sessão):
- Bulk-load de `financial_records` em `_reloadRange` etapa 4 (1 query única, mapa eventId → 'paid' | 'pending' | 'none').
- `normalizeForMelissa` agora injeta `paymentState` + `price` no evento.
- **Badge $ no canto** dos eventos da agenda — círculo amber 16px no canto superior direito. Só pra sessão + paciente + não-virtual + paymentState !== 'paid'.
- **Linha "A receber"** no popover (`MelissaEventoPanel`) — texto adaptativo: "A receber R$ X (cobrança pendente)" se pending, "A cobrar R$ X" se none, "Cobrança ainda não gerada" se sem valor.
9. **🐛 Bug fix `pickDbFields` faltando `modalidade`** — sessões avulsas eram salvas sem modalidade, DB caía no default 'presencial' independente da escolha. Adicionado ao whitelist em `useMelissaAgenda.js:74`. **TODAS as sessões avulsas criadas no Melissa antes desse fix estão como 'presencial' no DB** — pode precisar rodar UPDATE manual no banco se quiser corrigir histórico. Gotcha salvo em `memory/project_pickdbfields_whitelist.md`.
10. **Doc HTML atualizada amplamente**:
- Nova seção topo `★ Indicadores visuais de pagamento` com mocks (badge $ + linha popover) e link em violeta no TOC.
- Caixa violeta "Indicadores visuais" em cada cenário relevante (C2-C9).
- C4 ganhou caixa verde "estado-alvo" (sem badge, sem linha — pago).
- Receita do C3 e C4 atualizadas com os 3 controles (Cobrança ao salvar / Forma de pagamento / Status do pagamento) e opções limpas (sem prefixo "Já recebi —").
---
## 📦 Setup pra retomar
## 🧭 Onde estamos no plano de 9 fases
```powershell
# Limpa cache do Vite (recomendado depois de muita mudança em styles)
Remove-Item -Recurse -Force node_modules\.vite -ErrorAction SilentlyContinue
# Sobe dev
npm run dev
# Build sanity check (opcional, mas roda em ~25s)
npm run build
```
**Suite de testes** (495 testes incluindo o A66):
```powershell
npm run test
```
**Login**: user com `layout_variant=melissa` no profile pra testar
direto em `/melissa/...`. Pra testar Rail (regressão), troca em
`/account/profile` → terceiro card "Layout".
| Fase | Status |
|---|---|
| **1** Compromisso SEM paciente | ✅ |
| **2** Compromisso COM paciente | ✅ testado (C1-C3 done) |
| **3** Recorrência + replicar occurrenceMode Rail/Clínica | ⏳ |
| **4** Modo disparo cobrança híbrido | ⚠️ parcial |
| **5** Status change → confirm dialog | 🔄 Melissa codado + indicadores visuais done; falta testar (C10-C12) + replicar Rail/Clínica |
| **6** Edit cobrada | ✅ |
| **7** Pagamento separado | ⏳ |
| **8** Refund/credit note | ⏳ |
| **9** Plano Inicial | 📋 |
---
**Estado limpo, push pendente. Quando voltar, próximo passo natural é o feedback do A66 V2 ou o restore na PatientsListPage. Sua escolha.**
## 📋 Roteiro de testes restantes (`src/docs/agenda-compromisso-financeiro-cenarios.html`)
| # | Cenário | Status |
|---|---|---|
| 1 | Bloqueio (criar + agendar sobre) | ✅ |
| 2 | Avulsa sem cobrança | ✅ |
| 3 | Avulsa cobrar ao salvar | ✅ |
| 4 | Avulsa "já recebi" no salvar | ✅ |
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
| 8 | Pacote saldo (Otávio 12 × R$ 50) | ✅ |
| 9 | 1 por sessão (Michael Balint 12 × R$ 150) | ✅ |
| **10** | **Status change avulsa (realizado/faltou/cancelado)** | 🔴 **PRÓXIMO** |
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
| 11 | Status change pacote saldo | ⏳ |
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
| 13 | Edit cobrada | ⏳ (parcialmente — lock ativo em Melissa pós-19/05 noite) |
---
## 📋 Como retomar amanhã (cego)
1. `git status` — confirmar working tree intacto
2. **Ler HANDOFF até o fim**
3. Abrir `src/docs/agenda-compromisso-financeiro-cenarios.html` no browser pra ver o estado atual do doc viva
4. **Começar pelo Cenário 4** (Joyce, "Já recebi (dar baixa)")
5. Cada cenário que passar:
- Atualizar status pra ✅ aqui no HANDOFF
- Se descobrir bug ou texto divergente, corrigir código + doc na hora
6. Quando todos os 13 passarem: replicar em **Rail** e **Clínica**
7. Adicionar `professional_cancellation` no `STATUS_TO_EXCEPTION`
8. Marcar Fase 5 como ✅
9. Decidir Fase 4 (modo disparo cobrança híbrido) OU Fase 3 (replicar occurrenceMode)
---
## 🚨 Pendência IMPORTANTE — não esquecer
**Pós-Fase 9** (quando concluirmos TODAS as fases 1-9):
- User vai passar prompt específico pra criar **documentação completa da parte de ajuda** do sistema
- Está em `memory/project_pendencia_doc_ajuda.md`
- O doc `agenda-compromisso-financeiro-cenarios.html` já está sendo escrito de forma que vira a doc final pra usuário (cada teste validado vira parte da doc)
**Histórico modalidade='presencial' no DB:**
- Bug do `pickDbFields` afetou TODAS as sessões avulsas criadas no Melissa até 16/05/2026
- Se quiser corrigir histórico, rodar UPDATE manual identificando sessões cuja modalidade visual era online (não há como saber retroativamente — perdido)
- Going forward o fix já cobre
---
## ⚠️ Gotchas duráveis (atualizados)
- **`MelissaBloqueios.vue` admin ≠ `BloqueioDialog` (4 modos)** — casos distintos
- **`agenda_excecoes` foi dropada** em 13/05
- **`financial_records.type` undefined sem `type` no BASE_SELECT** — fix 14/05 cedo
- **`financial_records.description` undefined sem `description` no BASE_SELECT** — fix 14/05 noite
- **`handleStatusChange` em `useAgendaFinanceiro.js` está ÓRFÃO** — não reativar
- **`_openStatusDialog` + `bloqueioCobrindo` + `dialogBlockOverlap`** declarados no `useMelissaAgenda` mas usados em `_buildHandlers` — passados via `deps`. **NÃO ESQUECER ao replicar em Rail/Clínica**
- **`billing_contracts.charging_style`** distingue upfront/saldo/per_session
- **Ocorrência virtual tem `id="rec::<rule>::<date>"`** — detectar via `typeof === 'string' && startsWith('rec::')` antes de query Supabase
- **`chargeMode` default dinâmico:** `'session'` em avulsa, `'none'` em recorrente
- **Toast atrás do overlay do dialog** — usar Message no topo do dialog em vez de toast quando contexto for dentro de dialog modal
- **Cuidado com `pickDbFields` whitelist** — `useMelissaAgenda.js:74` descarta campos não listados silenciosamente. Sintoma: campo escolhido na UI mas DB tem valor default. Memória: `memory/project_pickdbfields_whitelist.md`
- **`paymentSettlement` foi renomeado** em 16/05 — agora `paymentMethod` (string) + `markPaidNow` (bool). Handler aplica `payment_method` sempre, `status='paid'` só quando markPaidNow=true && method!='link'
- **Bulk-load de paymentState em `_reloadRange` etapa 4** — 1 query única em `financial_records` mapeada por `agenda_evento_id`. Anota `paymentState` no normalize. Badge na agenda + linha popover lêem daqui
---
## 🧠 Decisões persistidas (memory/)
**Indicadores visuais (16/05):**
- Badge $ no canto: só sessão + paciente + não-virtual + !paid
- Linha popover: 3 textos (a receber pending / a cobrar none / cobrança não gerada)
- Bulk-load 1x por _reloadRange, não query por evento
- Ocorrências virtuais sempre paymentState='none' (cobertas por contrato)
**Payment refactor (16/05):**
- Separar método (forma) de status (já pago?) — controles independentes na UI
- Método 'link' (Asaas) força markPaidNow=false (gateway externo)
- Wire format: `arg.paymentMethod` + `arg.markPaidNow` (no lugar de `arg.paymentSettlement`)
**Bugs evitar repetir:**
- Sempre adicionar campo novo ao `pickDbFields.allowed` quando adicionar coluna em agenda_eventos
- Sempre adicionar campo novo ao `BASE_SELECT` quando query custom
- Detectar `is_occurrence` ou `rec::` antes de query por UUID
- Refs/funções do composable principal NÃO ficam acessíveis em `_buildHandlers` — passar via `deps`
- Toast dentro de dialog modal fica atrás do overlay — usar Message
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
# Freemium / PLG
Épico iniciado em 2026-06-13, branch `feat/freemium-plg` (sobre [[Migracao Schema-per-Tenant]]). Objetivo: qualquer visitante cria conta gratuita sozinho, confirma e-mail, e o ambiente do tenant é provisionado automaticamente. Plano gratuito limitado + botão "Upgrade PRO". Blueprint-diretor: `novo-rumo.txt` (raiz), vindo do sistema-irmão (sindicato) e adaptado a clínica.
## Descoberta (Fase 0) — o que já existia
O sistema já estava ~70-85% pronto:
- **Planos free existem**: `clinic_free`, `therapist_free` (+ supervisor/patient) com `plan_features.limits` semeado (`clinic_free``clinic_calendar {max_patients:30, max_therapists:5}`, `online_scheduling {sessions_per_month:40}`, `reminders {reminders_per_month:50}`, `documents.upload {max_storage_mb:500}`; 14 features premium OFF).
- **Feature gating completo**: `entitlementsStore.js` (views `v_tenant_entitlements`/`v_user_entitlements`), `FeatureGate.vue`, guard `meta.feature``/upgrade` (`guards.js:814`), badge PRO no menu.
- **Provisionamento schema-per-tenant**: `ensure_personal_tenant`/`provision_account_tenant``clone_tenant_template`. Setup Wizard.
- **Signup self-service**: `/lp` (pricing dinâmico de `v_public_pricing`) → `/auth/signup` (`Signup.vue:219` `signUp` inline, cria intent só no pago).
- RPCs `activate_subscription_from_intent`, `change_subscription_plan`. `tenants.slug` 100% populado.
**Gap confirmado:** limites semeados mas **ninguém lê/enforça**. Sem confirmação de e-mail (`enable_confirmations=false`), sem /onboarding, signup só coleta email+senha, sem welcome email, sem os extras.
## Decisões (Fase 0.5)
1. **Modelo do blueprint** — confirmação de e-mail ON; signup grava escolha em `raw_user_meta_data` + signOut-local + tela "confirme e-mail"; provisionamento+intent viram RPCs idempotentes no 1º login (`auto_provision_free_tenant(p_slug_override)`, `processar_pos_signup`); guard manda logado-sem-tenant → `/onboarding`. Reescreve o signup inline.
2. **Pacientes** = recurso limitado. Trigger BEFORE INSERT em `patients` lê limits em runtime, resolve tenant por `TG_TABLE_SCHEMA`, conta linhas vivas, `RAISE 'PLAN_LIMIT_REACHED|patients|<n>'`. clinic_free=30, therapist_free=20. No template + backfill 9 schemas.
3. **Slug escolhido** no signup (sugestão sanitizada + `slug_disponivel(p_slug)→{ok,motivo}`), imutável, trava 3 camadas.
4. **Todos os 4 extras**: /saas/usuarios + `notify_all_devs`; esqueci-email (magic link por slug, dica mascarada); blacklist (email|slug); root_redirect.
## Pegadinhas (do blueprint, ⚠️ caras no irmão)
- **#1** Signup sem sessão (confirmação ON) → tudo com `auth.uid()` quebra em silêncio. Gravar escolha em metadata, processar pós-confirmação.
- **#2** signOut `scope:'local'` se não veio sessão — senão vaza sessão anterior e joga no painel errado.
- **#3** Logado-sem-tenant nunca cai em painel quebrado → `/onboarding` resolve estados (provisionando, slug-colidiu, pago-aguardando, sem-acesso, erro).
- **#4** Sino de notificação singleton precisa re-buscar ao trocar de user (logout+login).
## Divergência de infra
Blueprint pede welcome email via **Resend**; aqui é **SMTP/Mailpit** (`process-email-queue`). Reusar o pipeline SMTP existente (best-effort), não Resend.
## Fases
- **F1** ✅ DONE (2026-06-13) — therapist_free ganhou max_patients=20; trigger `enforce_patient_plan_limit` em patients (lê `plan_features.limits` em runtime, resolve plano via `tenant_active_plan_id`, conta vivos, RAISE `PLAN_LIMIT_REACHED|patients|n`); helpers globais + wiring + backfill 9 schemas. Front: `utils/planLimit.js` (toast com CTA via grupo system-alerts) nos 3 pontos de criação de paciente + botão **Upgrade PRO** no AppTopbar quando plano é free. Migrations: `20260613000005_*` + `manual/freemium_f1_plan_limits.supabase_admin.sql`. Testado em ROLLBACK (clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado).
- **F2** 🟡 NÚCLEO DONE (2026-06-13) — `enable_confirmations=true` (config.toml, gitignored, ativa no restart do stack); RPCs `slug_disponivel`/`auto_provision_free_tenant`/`processar_pos_signup` (manual/freemium_f2_provisioning.supabase_admin.sql, testados em ROLLBACK clínica+terapeuta); **fix de regressão** `log_audit_change` (migration 20260613000006) que quebrava INSERT em tenant_members; Signup.vue reescrito (kind+nome+slug ao vivo+metadata, signOut-local + tela confirme-email); OnboardingPage.vue (provision+estados slug-colidiu/erro); guard → /onboarding; rota registrada. Build OK. **Restam (polish):** welcome email best-effort (infra SMTP schema-per-tenant) + apresentação do free na vitrine (public_name/preço "Grátis"/bullets — os planos já são is_visible=true mas sem nome/preço).
- **F3** — Extras (4).
- **F4** — Deploy (hosted, dirigido pelo Leonardo).
Método: commits por assunto; cada migration testada em transação com ROLLBACK antes de aplicar; build a cada bloco front.
@@ -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]]
@@ -0,0 +1,228 @@
---
title: Pesquisa de mercado — fluxo de compromisso e cobrança
date: 2026-05-13
status: levantamento
players: Cliniko, SimplePractice, TherapyNotes
---
## Contexto do produto
SaaS BR pra clínicas de psicologia, multi-tenant. Agenda + paciente + recorrência já funcionando. Invariante "cobrança emitida é imutável pelo dialog da agenda" já implementada (padrão SimplePractice). Auditando fase-a-fase o fluxo antes de fechar gaps. Restrições fiscais BR: PIX, NFS-e, LGPD.
Cross-links: [[recorrencia-agenda]], [[index]]
---
## 1. Criação de compromisso SEM paciente
### Cliniko
- **Default:** existe entidade dedicada chamada **Unavailable block**. Não é appointment — não interfere em relatórios clínicos. Funciona como bloqueio puro de calendário (almoço, reunião, férias, manutenção).
- **Admin pode:** criar **Unavailable block types** customizados (nome, duração default, cor). Aceita arquivamento individual ("Archive" remove o bloco).
- **Fonte:** [Scheduling time off](https://help.cliniko.com/en/articles/1023892-scheduling-time-off), [Changing Your Calendar to Time Blocks](https://help.cliniko.com/en/articles/1024048-changing-your-calendar-to-time-blocks).
### SimplePractice
- **Default:** duas entidades distintas — **Calendar event** (cinza escuro, para reunião, supervisão, tempo pessoal) e **Out of office (OOO) block** (cinza claro, para indisponibilidade que deve bloquear request de agendamento). Calendar events também podem ser recorrentes.
- **Admin pode:** marcar evento como recorrente; OOO bloqueia automaticamente o widget de pedidos de horário online.
- **Fonte:** [Creating a calendar event](https://support.simplepractice.com/hc/en-us/articles/41930878513933-Creating-a-calendar-event), [Managing out of office blocks](https://support.simplepractice.com/hc/en-us/articles/41931023345165-Managing-out-of-office-blocks).
### TherapyNotes
- **Default:** dois tipos — **Scheduled Event** (atividade não-clínica: reunião, supervisão, treinamento; aparece no calendário do clínico) e **Unavailable** (vetar agendamento de pacientes em horários específicos: férias, almoço, compromisso pessoal). Ambos suportam descrição, duração e recorrência sem vincular paciente.
- **Admin pode:** decidir clínico-alvo, frequência (one-time ou recurring), texto livre.
- **Fonte:** [Schedule Non-Clinical Events](https://support.therapynotes.com/hc/en-us/articles/30661451456667-Schedule-Non-Clinical-Events), [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
**Convergência:** os 3 têm entidade não-clínica separada de "appointment" — nunca usam appointment-sem-paciente como hack.
---
## 2. Criação de compromisso COM paciente
### Cliniko
- **Default:** appointment exige paciente + appointment type + data/hora + practitioner. Paciente pode ser criado on-the-fly direto do dialog do appointment com apenas nome (descrição/categoria são opcionais).
- **Admin pode:** definir custom patient fields opcionais; appointment type carrega billable items default associados.
- **Fonte:** [Booking an appointment](https://help.cliniko.com/en/articles/1024061-booking-an-appointment), [Set up appointment types](https://help.cliniko.com/en/articles/1023911-set-up-appointment-types).
### SimplePractice
- **Default:** appointment exige cliente. Existe entidade intermediária chamada **Prospective client / Inquiry** — perfil parcial usado pra leads vindos de contact form ou pedido online. Pode-se enviar intake antes mesmo de aceitar o appointment (perfil definitivo só nasce ao aceitar).
- **Admin pode:** mandar link de agendamento; criar task de follow-up; enviar intake; rodar prescreener; converter inquiry em client.
- **Fonte:** [Managing prospective clients on the Inquiries page](https://support.simplepractice.com/hc/en-us/articles/33726366744589-Managing-prospective-clients-on-the-Inquiries-page), [Adding a new client](https://support.simplepractice.com/hc/en-us/articles/12416306860429-Adding-a-new-client-and-navigating-your-Clients-and-contacts-list).
### TherapyNotes
- **Default:** appointment clínico exige client + clinician + appointment type + date. Cliente novo precisa pelo menos de **last name**; demais campos (DOB, endereço, e-mail, sexo administrativo, HIPAA acknowledgment) só viram obrigatórios quando se vai submeter claim de plano ou ativar portal.
- **Admin pode:** liberar last-name-only para um "stub client" que recebe billable items mas não é submetível a plano até completar cadastro.
- **Fonte:** [Add a New Client](https://support.therapynotes.com/hc/en-us/articles/30661347776539-Add-a-New-Client), [Schedule a Clinical Appointment](https://support.therapynotes.com/hc/en-us/articles/30661407698203-Schedule-a-Clinical-Appointment).
**Convergência:** todos aceitam appointment com cadastro de paciente mínimo. SimplePractice é o único com camada formal de "lead" pré-prontuário.
---
## 3. Cobrança / fatura — quando é gerada?
### Cliniko
- **Default:** invoice é **explicitamente criada** pelo usuário a partir do appointment (botão "Create invoice" no card do compromisso). Não há geração automática no agendamento.
- **Admin pode:** vincular billable items / produtos a um appointment type, então o "Create invoice" já vem populado. Em fluxo de pagamento online, a invoice é gerada e marcada como paga automaticamente no momento do pagamento confirmando o appointment.
- **Fonte:** [Create an invoice](https://help.cliniko.com/en/articles/1023907-create-an-invoice), [Relate billable items and products to an appointment type](https://help.cliniko.com/en/articles/1023847-relate-billable-items-and-products-to-an-appointment-type).
### SimplePractice
- **Default:** geração **automática**, configurável globalmente entre Daily (overnight, à meia-noite do timezone da prática), Monthly ou Manual. Status do appointment determina se vira invoice: apenas appointments com status **Show**, **Late canceled** ou **No show** geram invoice automaticamente.
- **Admin pode:** escolher daily/monthly/manual em Settings → Client billing → Client billing documents. Recomendação oficial: Daily quando cobra na hora da sessão; Monthly quando fecha o mês.
- **Fonte:** [Setting up your billing and automations](https://support.simplepractice.com/hc/en-us/articles/207925643-Setting-up-your-billing-and-automations), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing), [Best practices for time-of-session billing](https://support.simplepractice.com/hc/en-us/articles/115000837406-Best-practices-for-time-of-session-billing).
### TherapyNotes
- **Default:** billing line item é gerado **quando a nota da sessão é completada e assinada** pelo clínico. Cada appointment tem aba Billing acessível direto do dialog, mas o disparo de claim/invoice depende de note signed.
- **Admin pode:** configurar default billing method por payer; o To-Do list cria o lembrete pra submeter claim ou gerar CMS-1500 assim que a nota é assinada.
- **Fonte:** [Billing Overview](https://support.therapynotes.com/hc/en-us/articles/30661437130139-Billing-Overview), [Submit Electronic Claims](https://support.therapynotes.com/hc/en-us/articles/30661415430811-Submit-Electronic-Claims), [Quick Start: Billing](https://support.therapynotes.com/hc/en-us/articles/30661397280155-Quick-Start-Billing).
**Convergência:** ninguém cobra no momento de criar o appointment (futuro). Cliniko = manual sob demanda. SimplePractice = automático pós-sessão (status driven). TherapyNotes = automático pós-assinatura de nota (clinical-doc driven).
---
## 4. Recorrência (séries) — billing
### Cliniko
- **Default:** repeating appointment (daily/weekly/fortnightly/monthly). Cada ocorrência é **appointment independente**; invoice continua sendo manual por ocorrência. Pra pacotes, recomenda usar **patient cases + account credit**: cobra o pacote inteiro upfront, o crédito fica no perfil do paciente e é consumido por cada invoice subsequente.
- **Admin pode:** decidir entre invoice-por-sessão (manual ou via pagamento online) ou pacote upfront via account credit.
- **Fonte:** [Book repeating appointments](https://help.cliniko.com/en/articles/1777286-book-repeating-appointments), [Tracking packages with patient cases and account credit](https://help.cliniko.com/en/articles/6477363-tracking-packages-with-patient-cases-and-account-credit).
### SimplePractice
- **Default:** série de até 100 ocorrências, recorrência semanal/mensal/anual. Cada ocorrência é independente para billing — invoice é criada na ocorrência conforme regra global daily/monthly. Editar uma ocorrência pergunta "just this one" ou "all in series". Ao deletar série inteira incluindo passado, **passa por cima** de ocorrências sem nota ou invoice anexada; ocorrências com invoice/nota são preservadas.
- **Admin pode:** ajustar fee de ocorrência já faturada via **fee adjustment invoice** (novo doc que ajusta o saldo, não toca a invoice original — esse é exatamente o padrão "cobrança emitida imutável" já adotado no projeto).
- **Fonte:** [Managing recurring appointments](https://support.simplepractice.com/hc/en-us/articles/41930568779021-Managing-recurring-appointments), [Creating invoices](https://support.simplepractice.com/hc/en-us/articles/207925663-Creating-invoices).
### TherapyNotes
- **Default:** recurring appointments indefinidos ou com data-fim. Cada ocorrência tem nota e billing independentes — billing line item nasce com a assinatura de cada nota individualmente.
- **Admin pode:** cancelar "só esta" ou "todas futuras" da série; alertas podem ser anexados à série inteira.
- **Fonte:** [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
**Convergência:** os 3 tratam ocorrência como unidade de billing. Pacote upfront é exceção (Cliniko via account credit). Nenhum gera "fatura única da série".
---
## 5. No-show / cancelamento tardio
### Cliniko
- **Default:** plataforma não impõe fee; fornece ferramenta — terms of use no online booking + janela mínima de cancelamento (lock). Se paciente pagou full upfront online, ele **não consegue** cancelar pelo link; deposit parcial libera cancelamento.
- **Admin pode:** configurar minimum notice (várias opções entre "sem restrição" e "vários dias"); redigir política nos terms of use; aplicar fee manualmente via invoice.
- **Fonte:** [Restrict when a patient can cancel an appointment](https://help.cliniko.com/en/articles/1150562-restrict-when-a-patient-can-cancel-an-appointment), [Let patients cancel their appointments](https://help.cliniko.com/en/articles/1023945-let-patients-cancel-their-appointments).
### SimplePractice
- **Default:** statuses formais — **No show** e **Late canceled** (ambos billable, ambos geram invoice como qualquer Show quando auto-billing está ativo). Cancelamento dentro da janela permitida vira status não-billable.
- **Admin pode:** definir janela (24h ou 48h são presets) em Settings; statuses vão pra Client billing summary; appointments late-canceled aparecem em vermelho no calendário.
- **Fonte:** [Setting up your practice's cancellation policy](https://support.simplepractice.com/hc/en-us/articles/360046771271-Setting-up-your-practice-s-cancellation-policy), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing).
### TherapyNotes
- **Default:** **Missed Appointment Note** dedicada — registra ausência e tem checkbox que automaticamente cria billing line item para fee de cancelamento. TherapyPortal mostra warning ao paciente quando ele tenta cancelar fora da janela.
- **Admin pode:** habilitar/desabilitar criação automática de fee; configurar valor; texto da política aparece no portal.
- **Fonte:** [Complete a Missed Appointment Note](https://support.therapynotes.com/hc/en-us/articles/30661183276315-Complete-a-Missed-Appointment-Note), [TherapyNotes 4.15 release notes](https://blog.therapynotes.com/version-4-15).
**Convergência:** todos têm conceito de "cobrar pelo no-show". SimplePractice é o mais automatizado (status billable triggera invoice junto com os outros). TherapyNotes é o mais explícito (note dedicada + checkbox). Cliniko é o mais manual.
---
## 6. Reembolso / cancelamento de cobrança emitida
### Cliniko
- **Default:** invoice criada por engano pode ser **arquivada** (Archive button). **Número fiscal não retorna** — invoice 000001 arquivada não pode ser reemitida com o mesmo número. Reembolso real usa botão **Reverse** que cria credit note com itens negativos; usuário escolhe **Create credit & refund** (devolve dinheiro) ou **Create credit** (vira account credit). Para desfazer um refund, arquiva-se a credit note.
- **Fonte:** [Archive an invoice](https://help.cliniko.com/en/articles/1359931-archive-an-invoice), [Recording refunds: an overview](https://help.cliniko.com/en/articles/4372587-recording-refunds-an-overview), [Undo a refund](https://help.cliniko.com/en/articles/4521200-undo-a-refund).
### SimplePractice
- **Default:** invoice paga **não deve ser deletada** (deletar quebra alocação de pagamento). Refund full ou parcial é fluxo separado. Pagamentos cash/check/external podem ser deletados se foram erro; pagamento online com cartão não pode ser deletado, só refunded. Para mudar fee de invoice já emitida, usa **fee adjustment invoice** (novo doc com diff).
- **Fonte:** [Navigating client payments](https://support.simplepractice.com/hc/en-us/articles/8497757602957-Navigating-client-payments), [Managing unallocated client payments](https://support.simplepractice.com/hc/en-us/articles/42078634883469-Managing-unallocated-client-payments).
### TherapyNotes
- **Default:** **deletar pagamento ≠ refund** — deletar só remove o registro, não devolve dinheiro. Refund usa botão **Enter Refund** no Patient Accounting do tab Billing. Refund de payer (plano) tem opção dedicada que marca valor negativo automaticamente.
- **Fonte:** [Edit, Delete and Refund Client Payments](https://support.therapynotes.com/hc/en-us/articles/30661497068443-Edit-Delete-and-Refund-Client-Payments).
**Convergência:** os 3 distinguem "anular registro" de "estornar dinheiro". Os 3 preservam histórico fiscal (Cliniko via número não-reaproveitável + credit note; SimplePractice via fee adjustment; TherapyNotes via refund line item). Padrão "cobrança imutável" do projeto está alinhado com o estado da arte.
---
## Tabela comparativa 3 × 6
| Etapa | Cliniko | SimplePractice | TherapyNotes |
|---|---|---|---|
| 1. Compromisso sem paciente | Unavailable block (tipos customizáveis) | Calendar event + OOO block (2 entidades) | Scheduled Event + Unavailable (2 tipos) |
| 2. Compromisso com paciente | Quick-create paciente (nome basta) | Lead (Inquiry) → cliente formal | Last name basta; demais campos só pra claim |
| 3. Quando gera cobrança | Manual via botão no appointment | Automático overnight (Daily/Monthly/Manual) condicionado a status billable | Quando nota da sessão é assinada |
| 4. Recorrência billing | Ocorrência individual ou pacote upfront (account credit) | Série até 100; ocorrência individual; fee adjustment para edit pós-fatura | Ocorrência individual; billing nasce na assinatura de cada nota |
| 5. No-show / late cancel | Política em terms of use; lock manual | Statuses billable (No show / Late canceled); janela 24h/48h | Missed Appointment Note com checkbox auto-fee |
| 6. Refund / cancel cobrança | Archive + Reverse → credit note | Não deletar invoice paga; fee adjustment + refund | Enter Refund (delete ≠ refund) |
---
## Consenso de mercado
1. **Bloqueio de tempo é entidade própria**, separada de appointment. Nunca um appointment "sem paciente".
2. **Cadastro mínimo de paciente** (1 campo) é aceito; campos pesados só ficam obrigatórios na hora de cobrar plano ou ativar portal.
3. **Recorrência cria ocorrências independentes** para billing; nenhum gera "fatura única da série".
4. **Edit de uma ocorrência pergunta "esta / todas / futuras"** — padrão consagrado.
5. **Cobrança nunca é gerada na criação do appointment futuro** — sempre depois (sessão, status, nota, ou trigger manual).
6. **Cobrança emitida é imutável**; ajustes vêm via documento novo (credit note, fee adjustment invoice, refund line item). Validação direta do invariante do projeto.
7. **Deletar pagamento ≠ reembolsar dinheiro** — distinção explícita nos 3.
8. **Janela de cancelamento configurável + política em texto livre** é o mínimo.
## Divergência
- **Quem aciona a cobrança:** Cliniko = humano clica. SimplePractice = job overnight via status. TherapyNotes = assinatura de nota clínica. Três paradigmas distintos.
- **Lead / prospect:** SimplePractice tem entidade formal (Inquiry). Cliniko e TherapyNotes esperam o paciente já ter perfil mínimo.
- **No-show fee:** SimplePractice = mais automatizado (status billable). TherapyNotes = mais auditável (note dedicada). Cliniko = mais manual.
- **Pacote upfront:** Cliniko documenta explicitamente via account credit. SimplePractice/TherapyNotes não têm pacote nativo — cobram ocorrência a ocorrência.
- **Reaproveitamento de número de invoice arquivada:** Cliniko proíbe (alinhado com fiscal BR via NFS-e). Outros não documentam regra equivalente.
---
## Perguntas-chave pro produto decidir
1. **O que dispara a cobrança no fluxo padrão?**
a) Manual (humano clica) — máxima auditabilidade, exige disciplina (Cliniko).
b) Job automático com base em status do appointment (SimplePractice) — pouco atrito, dependente de status estar correto.
c) Assinatura de nota da sessão (TherapyNotes) — vincula clínica e financeira, atrasa cobrança se nota demora.
**Trade-off:** quanto mais automático, menos atrito mas mais risco de cobrança errada; quanto mais manual, mais fricção mas auditoria perfeita.
2. **Devemos ter conceito formal de "lead/contato" antes de prontuário?**
a) Sim — entidade Inquiry separada com pipeline (modelo SimplePractice).
b) Não — paciente nasce na quick-create do agendamento com nome só (modelo Cliniko/TherapyNotes).
**Trade-off:** Inquiry casa com funil comercial mas duplica entidade; quick-create é simples mas dificulta funil de pré-vendas.
3. **Recorrência cobra cada ocorrência ou suporta pacote upfront?**
a) Só ocorrência individual (SimplePractice/TherapyNotes).
b) Suporta também pacote upfront com saldo (Cliniko via patient case + account credit).
**Trade-off:** pacote upfront atende prática que vende "10 sessões antecipado"; ocorrência-a-ocorrência casa direto com NFS-e brasileira (1 nota por serviço).
4. **No-show vira invoice automática ou exige ação manual?**
a) Automático — status "No show" / "Late canceled" entram no auto-billing como Show (SimplePractice).
b) Semi — note dedicada com checkbox que controla geração (TherapyNotes).
c) Manual — admin cria invoice de no-show à mão (Cliniko).
**Trade-off:** automático reduz perda mas pode constranger paciente sem revisão; manual exige rotina disciplinada.
5. **Edição de uma ocorrência de série recorrente: o que faz com cobrança já emitida?**
a) Bloqueia edição (invariante atual — alinhado com SimplePractice "fee adjustment invoice" preservando original).
b) Permite edição com nova cobrança suplementar (delta).
c) Permite edição e refaz a cobrança (cancela + recria).
**Trade-off:** opção a é a mais defensável fiscalmente (NFS-e já transmitida não pode ser silenciosamente mutada); b atende UX; c é perigoso mas familiar.
6. **Janela de cancelamento: presets ou livre?**
a) Presets (24h / 48h) com texto da política livre (SimplePractice).
b) Configuração granular por appointment type (Cliniko).
c) Cliente final só vê warning, sem lock (TherapyNotes).
**Trade-off:** presets cobrem 90% dos casos; granular casa com clínica que tem terapia de grupo + casal + individual com janelas diferentes.
7. **Reembolso preserva o documento fiscal original?**
a) Sim, sempre — credit note nova, número fiscal original nunca volta (Cliniko + alinhado com NFS-e brasileira: cancelamento ≠ deletar).
b) Sim, mas via fee adjustment que não toca a invoice (SimplePractice).
c) Sim, refund é line item separado (TherapyNotes).
**Trade-off:** modelo brasileiro de NFS-e exige (a) ou (c); SimplePractice (b) só funciona em mercados sem NF transmitida por API.
8. **Pagamento via PIX (e cartão online) confirma e marca invoice paga automaticamente?**
a) Sim — pagamento confirmado dispara appointment confirmado + invoice paga (Cliniko online payment).
b) Pagamento é entidade separada que pode ser alocada/desalocada (SimplePractice).
**Trade-off:** auto-confirm é UX premium mas exige tolerância a falhas de webhook do PSP; pagamento desalocado é seguro mas exige conciliação.
---
## Implicações imediatas pro projeto
- O invariante "cobrança emitida é imutável" já implementado é consenso de mercado — manter.
- "Compromisso sem paciente" precisa virar entidade própria (block/event), não um appointment com paciente null. Ver [[recorrencia-agenda]] para integração com expansão de série.
- Recorrência por ocorrência individual é o caminho seguro (cabe em NFS-e). Pacote upfront fica para fase 2.
- Disparo de cobrança: avaliar híbrido SimplePractice (status-driven) + TherapyNotes (note-signed), com fallback manual estilo Cliniko.
- Perguntas 1, 4, 5, 7, 8 são pré-requisito pra fechar o gap atual de billing antes de F1 de fiscal.
@@ -0,0 +1,216 @@
---
title: Plano de auditoria fase-a-fase — fluxo de compromisso da agenda
date: 2026-05-13
status: em-andamento
related: [[agenda-billing-pesquisa-mercado]], [[recorrencia-agenda]]
---
## Contexto
Auditoria do ciclo completo de compromisso da agenda, fase-a-fase, validando cada etapa contra a [[agenda-billing-pesquisa-mercado|pesquisa de mercado]] (Cliniko / SimplePractice / TherapyNotes). Cada fase tem 3 entregas: **auditar o que existe**, **decidir o gap**, **codar**.
## Decisões já tomadas (5 das 8 perguntas)
| # | Decisão |
|---|---|
| 1 | Disparo de cobrança: **híbrido configurável** (manual / status-driven / note-signed) |
| 4 | No-show: **semi-automático via dialog de confirmação** ao mudar status |
| 5 | Edit de cobrada: **bloqueia** (já implementado) |
| 7 | Refund: **credit note nova** (alinhado NFS-e) |
| 8 | Pagamento: **entidade separada** de financial_records |
Pendentes: #2 (lead/Inquiry), #3 (pacote upfront), #6 (janela de cancelamento — provavelmente já resolvido por `min_hours_notice` em `financial_exceptions`).
---
## Plano de 8 fases
Ordem por dependência ("o que destrava o quê") e por estado atual.
### ✅ Fase 1 — Compromisso SEM paciente (bloqueio/feriado/exceção) — **CONCLUÍDA 2026-05-13**
**Auditoria fez:**
-`agenda_excecoes` é tabela órfã (0 referências em src/) — apesar de schema, policies, trigger e enums existentes
-`agenda_bloqueios` é a entidade canônica usada pelos 3 layouts
-`BloqueioDialog` (4 modos: horário/período/dia/feriados) é compartilhado por Melissa Agenda (via `MelissaLayout.vue:2186`), Rail e Clínica
-`MelissaBloqueios.vue` tem form inline próprio pra **admin/edit** (caso de uso legítimo distinto do dialog de 4 modos)
- ✅ Bloqueios não eram renderizados no FullCalendar — apenas impediam criação. UX inconsistente vs pausas/feriados que aparecem como background events
- ⚠️ Tipos customizáveis de bloqueio: descartado no MVP (sem cliente real)
- ⚠️ Robustez de `marcarSessoesParaRemarcar`: adiado pra Fase 5 (status change)
**Aplicado:**
1. Migration `20260513000001_drop_agenda_excecoes.sql` — dropa tabela + 2 enums + trigger; policies caem com CASCADE
2. `agendaMappers.js`: nova função `buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd)` — renderiza bloqueios como background events cinza (`#6b728033`), suporta dia-inteiro, com hora, e recorrente semanal
3. Novo composable `useAgendaBloqueios.js` — load por owner único OU array (multi-owner pra Clínica), `buildEventsForRange` reutilizável
4. Wire em `useMelissaAgenda` + `MelissaAgenda.vue` — bloqueios concatenados ao `fcEvents`
5. Wire em `AgendaTerapeutaPage` — bloqueios concatenados ao `calendarEvents`
6. Wire em `AgendaClinicaPage` — bloqueios consolidados de todos os ownerIds
7. Refs stale removidas de `database-novo/docs/schema_map.md` e `database-novo/db.config.json`
**Verificação:**
- ESLint nos arquivos modificados: 0 errors novos (11 pré-existentes em código não-tocado)
- Vitest `agendaMappers.spec.js`: 40/40 tests passed
- ⚠️ **Falta rodar a migration no banco local** (pendente de execução manual; arquivo SQL pronto)
- ⚠️ **Falta validar visualmente** nos 3 layouts (Melissa/Rail/Clínica) — verificar que bloqueios aparecem em cinza após criar pelo BloqueioDialog
---
### 🟢 Fase 2 — Compromisso COM paciente
**Estado:** dialog refatorado em 11/05 (cards 40px, picker DataTable, 50/50 layout, 3 estados Sessão/Honorários, conceito Pacote, resumo flutuante). Working tree.
**Auditar:**
- Fluxo de cadastro mínimo de paciente in-line (já existe via `PatientCadastroDialog` quick mode?)
- Decidir #2 (Inquiry/lead separado ou só quick-create)
- Modalidade presencial/online consistente
**Gap potencial:**
- Quick-create exige só nome ou mais campos? (Cliniko: só nome; TherapyNotes: só last name)
- Decisão #2 (Inquiry/lead) — adiar pra v2 provável
**Codar:** ajustes pequenos, principalmente UX. Provavelmente quase nada novo.
---
### 🟢 Fase 3 — Recorrência
**Estado:** modelo "1 real + N-1 virtual" + `occurrenceMode` no 2º dialog estabilizado em 12/05. Ver [[recorrencia-agenda]].
**Auditar:**
- `occurrenceMode` já replicado em Melissa; falta Rail (`AgendaTerapeutaPage` L1630 + L3080) e Clínica (`AgendaClinicaPage` L1119 + L2398)
- Decisão #3 (pacote upfront via account credit) — adiar provável
**Codar:** replicar `occurrenceMode` em Rail/Clínica. Talvez add de pacote upfront (Cliniko model) numa fase futura.
---
### 🟠 Fase 4 — Cobrança: modo de disparo configurável (DECISÃO #1)
**Estado:** Fase 1 atual ("Gerar cobrança ao salvar") existe como checkbox em criação avulsa+particular. Não tem setting de modo.
**Auditar:**
- Onde vive a config? Card novo em `/configuracoes/excecoes-financeiras` ou página irmã `/configuracoes/cobranca-defaults`?
- Granularidade: por tenant (clínica), por owner (terapeuta), ou ambos com herança?
**Gap:**
- Tabela/coluna nova pra `charge_trigger_mode` enum (`manual` / `status_driven` / `note_signed`)
- UI de config
- Job overnight pra modo `status_driven` (Supabase edge function + cron)
- Trigger no signature de nota pra `note_signed` (depende de modulo de notas; nao temos)
- Checkbox atual da agenda passa a fazer sentido **só em modo manual** (ou vira override universal?)
**Codar:**
1. Migration: setting de modo (tenant_billing_settings ou colunas em agenda_configuracoes)
2. UI de config
3. Job pra modo status_driven (avaliar se entra na v1 ou v2)
4. Refator do checkbox atual pra respeitar o modo
---
### 🟠 Fase 5 — Status change → cobrança com confirm dialog (DECISÃO #4)
**Estado:** lógica automática roda em `useAgendaFinanceiro.handleStatusChange`. Consulta regra em `financial_exceptions`, cria/ajusta/cancela `financial_record` SEM perguntar.
**Auditar:**
- Quais status disparam: hoje só `faltou` e `cancelado` (mapping `STATUS_TO_EXCEPTION`)
- `professional_cancellation` na tabela mas não no mapping
- Onde `handleStatusChange` é chamado (quais entradas de status change disparam)
**Gap:**
- Confirm dialog ao mudar status pra `faltou` / `cancelado`: *"Aplicar cobrança de R$X conforme regra? [Sim / Não / Editar valor]"*
- Adicionar `professional_cancellation` ao mapping (status atual da agenda inclui? checar)
- Decidir: dialog aparece **sempre** ou só quando `charge_mode !== 'none'`
**Codar:**
1. Dialog componente novo (`AgendaStatusChargeConfirmDialog.vue`)
2. Interceptar `handleStatusChange` antes da aplicação automática
3. Adicionar `professional_cancellation` no mapping
4. Toast diferenciado pra "aplicado/recusado/editado"
---
### 🟢 Fase 6 — Edit de cobrada (DECISÃO #5 — JÁ IMPLEMENTADO)
**Estado:** `propagateToSerie` filtra por `financial_records` em status imutável. UI lock em `AgendaEventDialog` via `occFinancialRecord`. Working tree.
**Auditar:** validar contra cenários reais (testar série com 4 sessões, 2 cobradas, 2 abertas; editar template; verificar que cobranças não mudam).
**Codar:** zero (talvez add de aviso UX se faltar clareza).
---
### 🔴 Fase 7 — Pagamento como entidade separada (DECISÃO #8)
**Estado:** hoje `financial_records.paid_at` marca pagamento (acoplado). Não tem entidade `payments` independente.
**Auditar:**
- Como financial_records.paid_at é usado hoje (queries de receita, dashboards, conciliação)
- Webhook PSP existente? (provável que PIX e cartão sejam manuais hoje)
**Gap:**
- Migration: tabela `payments` (id, amount, method, paid_at, source, allocated_to_record_id NULL-able)
- Alocação manual de pagamento "solto" a um financial_record
- Pagamento parcial (1 payment cobre N records ou 1 record recebe N payments?)
- Repo + composable + UI
**Codar:** fase pesada — provavelmente sub-dividir.
---
### 🔴 Fase 8 — Reembolso / credit note (DECISÃO #7)
**Estado:** hoje só tem `financial_records.status='cancelled'`. Não preserva original como doc fiscal.
**Auditar:** processo fiscal atual (já emite NFS-e? quando? como cancela?)
**Gap:**
- Migration: tabela `credit_notes` (id, original_record_id, amount, reason, issued_at)
- Constraint: credit note tem valor ≤ |original|
- UI no Financeiro pra "Reembolsar"
- Integração com NFS-e (pode ser separada)
**Codar:** fase pesada — provavelmente sub-dividir.
---
### 🟣 Fase 9 — Plano Inicial (entrevista + N sessões regulares)
**Estado:** apenas conceito; nada codado.
**Pedido do user (2026-05-14):** clínica cobra **1 entrevista inicial** (valor X) + **4 sessões regulares** (valor Y cada). É o "plano de entrada" pra novos pacientes. User faz isso manualmente hoje na clínica dele.
**Conceito:**
- Config nas settings da agenda do tenant:
- Toggle "Habilitar plano inicial"
- Valor entrevista (R$)
- Qtd de sessões regulares (default 4)
- Valor por sessão regular (R$)
- (Opcional) Texto/descrição que aparece no fluxo
- Quando user cria 1ª sessão de **paciente novo** (sem histórico):
- Sistema oferece: "Aplicar plano inicial? Entrevista R$ X + 4× R$ Y = total R$ Z"
- Ao aceitar, materializa 5 sessões com `price` diferenciado: 1ª = X, demais = Y
- Pode ser tratado como 1 série recorrente "especial" com 1ª ocorrência destacada
- OU como 2 entidades distintas (1 avulsa entrevista + 1 série de 4)
**Decisões pendentes:**
- Estrutura: série única com 1ª diferenciada OU avulsa + série separada?
- Onde fica a config: `agenda_configuracoes` (jsonb adicional?) ou tabela nova `intake_plans`?
- "Paciente novo" = sem sessões anteriores? Ou marcador manual no cadastro?
- Plano único do tenant ou múltiplos planos (avaliação clínica, avaliação neuropsi, etc)?
**Cabe na Fase 4 (cobrança)?** Não — Fase 4 é só modo de disparo; aqui é estrutura de pacote pré-configurado. Fica como Fase 9 separada.
---
## Ordem sugerida de execução
| Ordem | Fase | Razão |
|---|---|---|
| 1ª | **Fase 1** | Curta, validação, define se tem cleanup de tabelas necessário |
| 2ª | **Fase 5** | Destrava UX urgente (confirm dialog evita cobrar errado) |
| 3ª | **Fase 4** | Híbrido configurável — destrava racional do checkbox atual |
| 4ª | **Fase 2** | Quase 100% pronta, validar e finalizar |
| 5ª | **Fase 3** | Replicar `occurrenceMode` em Rail/Clínica |
| 6ª | **Fase 6** | Já feito; só testar |
| 7ª | **Fase 7** | Refator estrutural pesado — entra depois das fases UX |
| 8ª | **Fase 8** | Depende fiscal NFS-e — pode ir pra v2 |
| 9ª | **Fase 9** | Plano Inicial (entrevista + 4 sessões) — pedido do user, conceito pronto, codar pós-7 |
## Como cada fase termina
1. Página da fase na wiki é atualizada com o resultado
2. Commit dedicado com prefixo `agenda(fase-N): ...`
3. Update no [[index]] da wiki
4. Entrada no `log.md`
+7
View File
@@ -14,6 +14,8 @@ _(people, places, organizations, products — pages that describe a thing)_
_(ideas, frameworks, patterns, principles — pages that describe a concept)_
- [[recorrencia-agenda]] — modelo "1 real + N-1 virtual", materialização ao mudar status, view `listAll`, visual de paciente inativo
## Sources
_(summaries of specific sources you've ingested)_
@@ -22,6 +24,11 @@ _(summaries of specific sources you've ingested)_
_(synthesized answers to questions you've asked, filed back as pages)_
- [[agenda-billing-pesquisa-mercado]] — comparativo Cliniko / SimplePractice / TherapyNotes do ciclo compromisso→cobrança (6 etapas), consenso/divergência e 8 perguntas-chave pro produto
- [[agenda-compromisso-fluxo]] — plano de auditoria fase-a-fase (8 fases) do ciclo de compromisso da agenda; ordem de execução + decisões já tomadas
---
*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)
- [[Freemium PLG]] — signup self-service + Upgrade PRO; plano gratuito limitado (pacientes); confirmação de e-mail + onboarding; branch feat/freemium-plg
+146
View File
@@ -0,0 +1,146 @@
# Recorrência na Agenda
Como o sistema modela e exibe sessões recorrentes. Decisões arquiteturais importantes que não são óbvias só lendo o código.
## Modelo de dados — "1 real + N-1 virtual"
Quando o user cria "4 sessões semanais", o sistema escreve **só 2 rows** no banco:
1. **1 row em `agenda_eventos`** — a primeira ocorrência, materializada. Tem `recurrence_id` apontando pra regra abaixo.
2. **1 row em `recurrence_rules`** — a "semente": `start_date`, `type='weekly'`, `interval=1`, `max_occurrences=4` (ou `open_ended=true`).
As **sessões 2, 3, 4 NÃO existem no banco**. São geradas em runtime por `useRecurrence.loadAndExpand` — chamado pelas páginas de agenda quando precisam exibir um range. ID virtual: `rec::ruleId::originalDateISO`.
Trade-off da escolha:
- ✅ Cria recorrência infinita (open-ended) sem inflar o DB
- ✅ Mudança na regra (preço, modalidade, etc) reflete em todas as ocorrências automaticamente
- ❌ Toda exibição precisa chamar `loadAndExpand` no range visível
- ❌ Edição de uma ocorrência específica exige **materializar** primeiro (criar a row real com `recurrence_id` + `recurrence_date`)
## Quem expande virtuais (e quem não)
**Expande:**
- `AgendaTerapeutaPage` (Rail) — `loadAndExpand` no range mensal + na busca
- `AgendaClinicaPage` (Clínica) — mesma coisa, com tenant
- `useMelissaAgenda._reloadRange` (Melissa FullCalendar) — expande no range visível
- `usePatientSessions.load` — range -6mo a +12mo, filtra por `patient_id`
- `useMelissaEventos._fetchRange` — expande no range pedido. Cobre widget "Hoje", mini-cal, fallback
- `useMelissaTodasSessoesPaciente.fetch` — range -6mo a +12mo, filtra por `patient_id`
**Antes de 2026-05-11** os 3 últimos NÃO expandiam — uma série semanal de 4 aparecia como 1. Comentário no código admitia "adicionar quando promover Melissa pra produção". Bug resolvido nesta data.
## Cap do range — `MAX_RANGE_DAYS = 730`
`useRecurrence.expandRules` loga warning quando o range visível ultrapassa 730 dias (2 anos). É só warning, não bloqueia. A `listAll` view custom do MelissaAgenda usa exatamente `duration: { years: 2 }` pra bater no cap.
## Materialização — "ao mudar status numa virtual"
UPDATE direto numa row com `id = "rec::..."` quebra com `invalid input syntax for type uuid`. Pra mudar status (cancelar, marcar realizada, etc) numa ocorrência virtual, é preciso:
1. Buscar em `agenda_eventos` se já existe row materializada (`recurrence_id` + `recurrence_date`).
2. Se sim, UPDATE status nela.
3. Se não, **INSERT nova row** copiando campos da virtual + status novo.
Pattern central: **`useMelissaAgenda.onUpdateSeriesEvent(...)`** (e gêmeas em `AgendaTerapeutaPage` / `AgendaClinicaPage`). Aceita opcional `row` do chamador — quando o user clica direto no evento sem abrir o dialog antes, `dialogEventRow` está vazio e a função precisaria buscar a regra de outro lugar.
### Caminhos que mudam status (e como chegam à materialização)
| Onde | Composable/Handler | Comportamento virtual |
|---|---|---|
| `MelissaEventoPanel` (painel lateral do calendário) | `MelissaLayout.updateEventoStatus` | Detecta `is_occurrence` → delega `M.onUpdateSeriesEvent({ row: ev, ... })` |
| `AgendaEventDialog` SelectButton form.status (Cancelado/Remarcar) | `useAgendaEventActions` watcher | `emit('updateSeriesEvent', { row, ... })` em vez de UPDATE direto |
| `AgendaEventDialog` pills da série | `useAgendaEventLifecycle.onPillStatusChange` | Já emitia desde sempre |
| `MelissaPaciente` Tab Agenda botões diretos | `usePatientSessions.updateStatus(ev, status)` | Aceita row inteira; se virtual, materializa internamente |
**Guard importante** em `onUpdateSeriesEvent`: se `recurrence_id` resolver pra `null` (callerRow + dialogEventRow ambos sem ele), aborta com toast. Antes criava row órfã com `patient_id: null` aparecendo "Faltou sem nome" no calendário.
## View `listAll` no MelissaAgenda
View custom (não built-in do FC) com `duration: { years: 2 }`. `setView('lista')` faz `gotoDate(hoje - 1 ano)` pra centrar passado + presente + futuro. Substituiu `listWeek` que mostrava só 7 dias.
Banner `showRecurrenceHint` aparece nas outras views (dia/semana/mês) quando há virtuais visíveis — botão "Ver na lista" troca pra `listAll`. Sem o banner, user não percebe que tem ocorrências fora do range.
## Visual de evento inativo
`normalizeEvent` (`useMelissaEventos.js` + `useMelissaAgenda.normalizeForMelissa`) carrega `paciente_status` do JOIN. MelissaAgenda aplica `classNames: ['ma-evt--inactive-patient']` quando `'Arquivado'|'Inativo'` — CSS dá borda tracejada + opacidade 0.58 + itálico em list view. Mantém a cor do commitment pra não perder contexto.
Picker do AgendaEventDialog / V2: mostra TODOS os pacientes (Ativo > Inativo > Arquivado), nao-Ativos com Tag + disabled + tooltip. `selectPaciente` bloqueia non-Ativo como defesa em camadas.
## Quando algo der errado
Se aparecer "sessão fantasma sem nome" no calendário, provavelmente é row órfã criada por materialização sem `patient_id`/`recurrence_id`. Query pra detectar:
```sql
SELECT id, inicio_em, status, patient_id, recurrence_id
FROM agenda_eventos
WHERE patient_id IS NULL
AND recurrence_id IS NULL
AND tipo = 'sessao'
AND created_at > NOW() - INTERVAL '1 day';
```
Causa raiz já corrigida em 2026-05-11 (guard contra `rid` null em `onUpdateSeriesEvent`), mas o pattern de query é útil pra catch futuros.
## Invariante de cobrança em séries — "cobrança emitida é imutável"
**Padrão adotado (SimplePractice / TherapyNotes / Cliniko):** `financial_records` em status `pending`/`paid`/`overdue` são **imutáveis pelo dialog da agenda**. Ajustes só via fluxo do Financeiro (cancelar + refaturar). Garante:
- Trilha fiscal estável.
- Paciente não vê valor "mágico" mudando.
- Dashboards de MRR e projeção consistentes.
### Como o sistema honra a invariante
**1. Lock no `occurrenceMode`** (`AgendaEventDialog.vue`):
- Card "Sessão / Honorários" detecta `occFinancialRecord` via query `financial_records` filtrada por `agenda_evento_id` + status `in ('pending','paid','overdue')`.
- Se record existe → renderiza apenas `AgendaEventoFinanceiroPanel` + mensagem de lock + Tag de status. Select de billingType e botão "Editar itens" desaparecem.
- Se record não existe (virtual ou materializado sem cobrança) → edição livre, marca `services_customized=true` ao salvar.
- Card "Aplicar alterações em" também é ocultado quando há cobrança (mudanças estruturais não se aplicam — usuário só pode mexer em status/horário).
**2. Filtro em `propagateToSerie`** (`useCommitmentServices.js`):
- Após filtrar eventos elegíveis (recurrence_id + opcionalmente fromDate + opcionalmente services_customized=false), faz 1 query batch em `financial_records` pra coletar `agenda_evento_id` lockados.
- Remove esses IDs da lista de elegíveis antes de fazer `delete + insert` de `commitment_services`.
- Resultado: editar template da regra **nunca toca** ocorrências cobradas, mesmo em escopo `todos`.
**3. Aviso fixo no dialog pai** (em `isEdit && hasSerie`):
- Mensagem inline abaixo do `AgendaEventoFinanceiroPanel`: "Alterações de tipo ou serviços afetam apenas sessões futuras ainda não cobradas. Cobranças já emitidas permanecem inalteradas — para ajustá-las, acesse o Financeiro."
### Opção `todos_sem_excecao` removida da UI
- O nome confundia (sugeria "ignorar cobranças") quando na verdade era "ignorar customização operacional" (`services_customized=true`).
- Backend mantém o caso pra compat, mas `editScopeOptions` agora só retorna 3 valores: `somente_este`, `este_e_seguintes`, `todos`.
- Mercado consolidado (SimplePractice etc) não expõe override de customizações — admin que precisa reseta sessão-a-sessão.
### Onde está cada peça
- `src/features/agenda/composables/useAgendaEventLifecycle.js``loadOccFinancialRecord` + `occFinancialRecord` ref
- `src/features/agenda/components/AgendaEventDialog.vue` — card lock/unlock + aviso pai
- `src/features/agenda/composables/useCommitmentServices.js:162``propagateToSerie` com filtro financial_records
- `src/features/agenda/composables/useAgendaEventComposer.js:91``editScopeOptions` com 3 valores
- `src/components/agenda/AgendaEventoFinanceiroPanel.vue` — UI do fluxo Financeiro embarcado
## 2º dialog empilhado — edição de ocorrência (occurrenceMode)
Quando o user clica "Editar" em uma pill da lista "Recorrências Aplicadas", abre um **segundo `AgendaEventDialog` empilhado** por cima do principal. Ele compartilha o mesmo componente, mas com a prop `occurrenceMode=true` que muda comportamento:
- **Título:** `Pacote · X de Y Sessões` (computa `occurrenceIndex` via `currentRecurrenceDate` + `serieEvents`) em vez do padrão `Sessão do Pacote · {nome}`.
- **Layout enxuto:** renderiza apenas 4 cards na ordem: (1) Dados da Recorrência (read-only summary), (2) Status, (3) Horário, (4) Aplicar alterações em. Tudo o resto (paciente-hero, fields-grid, serie-panel, sessão/honorários, frequência, extras, resumo mobile) fica oculto via `v-if="!occurrenceMode"`.
- **Escopo `Aplicar alterações em`:** migrou do `composer-right` do dialog pai pra dentro do dialog de ocorrência. O pai não mostra mais esse card — pra mudar escopo, o user obrigatoriamente vai pela pill.
- **Horário editável:** botão "Ajustar horário" não fica `:disabled="isEdit"` no occurrenceMode (no pai sim — data/horário do pacote inteiro é imutável após criação).
Stack relevante:
- `MelissaLayout.vue:2160` monta o 2º dialog passando `:occurrenceMode="true"` + `eventRow={ ...row, recurrence_date, _is_virtual }` via refs `agendaOccDialog*` (destructurados de `useMelissaAgenda` no setup — refs aninhados não auto-unwrap no template).
- `useMelissaAgenda.onEditSeriesOccurrence` popula `occDialogEventRow` + abre `occDialogOpen=true`. Substituiu o pattern antigo de mutar `dialogEventRow` in-place (que trocava silenciosamente os dados do dialog atual).
- `useAgendaEventLifecycle.onPillEditClick` emite `editSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual })`.
**Pendente replicar:** Rail (`AgendaTerapeutaPage`) e Clínica (`AgendaClinicaPage`) ainda têm só o dialog principal — o 2º só existe no Melissa por enquanto.
## Referências de código
- `src/features/agenda/composables/useRecurrence.js``loadAndExpand`, `expandRules`, `mergeWithStoredSessions`, `buildOccurrence`
- `src/layout/melissa/composables/useMelissaAgenda.js:817``onEditSeriesOccurrence`
- `src/layout/melissa/composables/useMelissaAgenda.js:837``onUpdateSeriesEvent`
- `src/features/agenda/composables/useAgendaEventActions.js:65` — watcher do form.status
- `src/features/patients/composables/usePatientSessions.js:189``updateStatus` com materialização
- `src/features/agenda/components/AgendaEventDialog.vue` — props `occurrenceMode`, computeds `occurrenceIndex` / `occurrenceTotalSessions` / `headerMainLabel`
- `src/layout/melissa/MelissaLayout.vue:655``updateEventoStatus` do `MelissaEventoPanel`
- `src/layout/melissa/MelissaLayout.vue:2160` — 2º AgendaEventDialog empilhado
- `src/layout/melissa/MelissaAgenda.vue:244``VIEW_MAP.lista = 'listAll'`
+514
View File
@@ -0,0 +1,514 @@
# Composable Blueprint
> **Stack:** Vue 3 Composition API + Pinia (para state global) + Supabase via repository
> **Canônicos:** `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
> **Aplicável:** todo composable que orquestra estado reativo sobre uma repository
---
## 1. Princípio
Composable é **wrapper fino** sobre a repository. Responsabilidade:
- Manter **estado reativo** (data + loading + error)
- Chamar a repository (delegação 1:1)
- (Opcional) Cachear com stale-while-revalidate
- (Opcional) Compor outros composables
**Não faz:**
- Lógica de banco direta (vai no repository)
- Lógica de UI (vai no componente)
- Manipulação de DOM
- I/O direto fora do repository
> Regra de ouro: **se o composable tem `from('...')` do Supabase, ele virou repository disfarçado — refatorar.**
---
## 2. Estrutura de arquivos
```
src/features/<modulo>/composables/
├── use<Entity>.js # CRUD básico (thin wrapper)
├── use<Entity>Clinic.js # variant clinic-scoped (se aplicável)
├── use<Entity>Settings.js # config/preferences (com cache opt-in)
├── use<Entity>Lifecycle.js # orquestrador de estados (se domain complexo)
└── <entity>Helpers.js # funções puras auxiliares (não-composable)
```
**Convenção de nome:** sempre `use<Entity>...`. Funções helpers de domínio NÃO usam prefixo `use` — não são composables.
---
## 3. State shape canônico
Todo composable expõe **no mínimo** este shape:
```js
const rows = ref([]); // ou single ref dependendo do domínio
const loading = ref(false); // boolean
const error = ref(''); // string vazia, não null — facilita v-if
```
**Decisões importantes:**
| Refs | Tipo | Inicial | Por quê |
|---|---|---|---|
| `loading` | `boolean` | `false` | Padrão V3 — UI binda `:disabled="loading"` direto |
| `error` | `string` | `''` (vazio) | `v-if="error"` é falsy-friendly; sem null check |
| `rows`/data | `Array` ou objeto | `[]` ou `null` | Reset pra `[]` em erro de load — UI fica previsível |
**Anti-pattern:** misturar `error = ref(null)` num composable e `error = ref('')` em outro. Canonize `''` no projeto inteiro.
---
## 4. Tipos de composable (3 patterns)
### Tipo A — Thin wrapper (default) · referência: `useAgendaClinicEvents.js`
CRUD direto, sem cache, com loading/error em TODA operação:
```js
import { ref } from 'vue';
import { listX, createX, updateX, deleteX } from '@/features/<modulo>/services/<feature>Repository';
export function useX() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadRange({ startISO, endISO, ...scope } = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listX({ startISO, endISO, ...scope });
} catch (e) {
error.value = e?.message || 'Falha ao carregar.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function create(payload, opts = {}) {
loading.value = true;
error.value = '';
try {
return await createX(payload, opts);
} catch (e) {
error.value = e?.message || 'Falha ao criar.';
throw e; // ← re-throw: composable repassa o erro pro componente decidir
} finally {
loading.value = false;
}
}
async function update(id, patch, opts = {}) { /* idem */ }
async function remove(id, opts = {}) { /* idem */ }
return { rows, loading, error, loadRange, create, update, remove };
}
```
**Por que re-throw nas mutações?** Componente precisa saber se o `await` falhou pra:
- Mostrar toast
- Não fechar modal
- Não navegar
- Manter form com dados
`error.value` é só pra estado reativo persistente. Mutação síncrona precisa de throw também.
### Tipo B — Thin wrapper "extra-leve" · referência: `useAgendaEvents.js`
Variant aceitável quando mutações **não precisam de loading**:
```js
async function create(payload) {
return createX(payload); // ← repassa erro nativamente; componente try/catch
}
```
**Quando usar:** UIs onde criar/editar tem feedback próprio (skeleton no item criado, optimistic UI, etc.). Default é o Tipo A.
### Tipo C — Cache com stale-while-revalidate · referência: `useAgendaSettings.js`
Para dados raros/pesados (settings, preferences, listas estáveis):
```js
import { ref } from 'vue';
import { getX } from '../services/<feature>Repository';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
export function useX(opts = {}) {
const useCache = !!opts.cache;
const cache = useCache ? useMelissaCacheStore() : null;
const data = ref(null);
const loading = ref(false);
const error = ref('');
async function _doFetch() {
const result = await getX();
data.value = result;
if (cache) {
const key = result?.owner_id || 'anon';
cache.set('xKey', result, key);
}
return result;
}
async function load() {
if (cache) {
const cached = cache.get('xKey', undefined, MELISSA_CACHE_TTL.xKey);
if (cached) {
data.value = cached;
_doFetch().catch((e) => console.warn('[useX] revalidate', e));
return;
}
}
loading.value = true;
error.value = '';
try {
await _doFetch();
} catch (e) {
error.value = e?.message || 'Falha ao carregar.';
data.value = null;
} finally {
loading.value = false;
}
}
return { data, loading, error, load };
}
```
**Decisões do Tipo C:**
- **`opts.cache` default `false`** — páginas de configuração que editam settings esperam mudança imediata após salvar, então cache opt-in.
- **Cache key inclui scope** (`owner_id`/`tenant_id`) — invalida automaticamente em troca de usuário/tenant.
- **TTL constants no store** — `MELISSA_CACHE_TTL.<feature>` (não hardcoded no composable).
- **Stale-while-revalidate:** retorna cached SE existe + dispara fetch em background (sem await).
- **Revalidate fail é warn**, não error — UI já tem dados válidos do cache.
---
## 5. Convenções de nomenclatura
### Funções
| Operação | Nome canônico | Variantes aceitas |
|---|---|---|
| Listar com filtro | `loadRange` / `loadMy<X>` | `load<Scope><Range>` |
| Criar | `create` | `create<Scope>` (se houver ambiguidade) |
| Atualizar | `update` | `update<Scope>` |
| Remover | `remove` | `remove<Scope>` (nunca `delete` — palavra reservada) |
| Recarregar | `refresh` | `reload` |
| Limpar estado | `reset` / `clear` | — |
**Scope sufixo** quando o composable serve múltiplos contextos: `loadMyRange` (terapeuta) vs `loadClinicRange` (admin).
### State refs
- `rows` — coleção principal (array)
- `record` — single (quando faz sentido)
- `data` — genérico (settings, config)
- `loading` — boolean único; se há múltiplos `loading` (load vs save), nomear: `loadingList`, `saving`
- `error` — string única; mesmo princípio: `loadError`, `saveError` se precisar
---
## 6. Anatomia padrão de uma operação `load*`
```js
async function loadXxx(args) {
// 1. Validação leve (early return, não throw)
if (!args?.required) return;
// 2. State flag
loading.value = true;
error.value = '';
try {
// 3. Delegate pra repository (UMA chamada — se múltiplas, Promise.all)
const result = await listX(args);
// 4. Mutate state
rows.value = result;
} catch (e) {
// 5. Erro humano + reset de data (UI fica previsível)
error.value = e?.message || 'Mensagem PT-BR genérica.';
rows.value = [];
} finally {
// 6. Sempre limpar loading
loading.value = false;
}
}
```
**Por que early-return em vez de throw na validação?** Composable é wrapper — chamadas inválidas (ex: `ownerId` ainda não chegou no mount) não devem quebrar UI. Throw fica pra repository.
---
## 7. Múltiplos fetches paralelos
Quando uma operação precisa de N queries:
```js
async function _doFetch() {
const [cfg, rules, profile] = await Promise.all([
getMyAgendaSettings(),
getMyWorkSchedule(),
getMyProfile()
]);
settings.value = cfg;
workRules.value = rules;
profile.value = profile;
}
```
**Regras:**
- `Promise.all` (não `Promise.allSettled`) — falha de qualquer query falha a operação inteira
- Exception: quando uma query é opcional/best-effort → `Promise.allSettled` + processa por result
- **Nunca** sequenciar fetches independentes (await + await + await)
---
## 8. Composição de composables
Composable pode usar outros composables, mas:
```js
// ✅ certo — composição estrutural
export function useAgendaEventLifecycle() {
const events = useAgendaEvents();
const billing = useAgendaFinanceiro();
const settings = useAgendaSettings({ cache: true });
async function realizar(eventId) {
// orquestra os 3
}
return { ...events, realizar, ... };
}
// ❌ errado — não compor pra economizar 1 linha
export function useOnlyToWrapList() {
const { rows, loadMyRange } = useAgendaEvents();
return { rows, loadMyRange }; // ← isso é um re-export inútil
}
```
**Regra:** compõe quando há **orquestração**. Se é só forward, importa direto.
---
## 9. Anti-patterns (NÃO fazer)
### ❌ Composable que tem `supabase.from('...')` direto
```js
// ❌ — violação de camadas
export function useFoo() {
async function load() {
const { data } = await supabase.from('foo').select('*');
}
}
```
✅ Move pra repository, composable só delega.
### ❌ `error` ora `null`, ora `''`, ora `Error`
Canonize `string` (default `''`). Errors do JS dão `e?.message || 'fallback PT-BR'`.
### ❌ Não resetar `rows` em erro de load
```js
// ❌
async function loadRange() {
try { rows.value = await listX(); } catch (e) { error.value = e.message; }
// rows.value mantém dados antigos = UI mostra coisa stale + alerta de erro
}
```
✅ Reset `rows.value = []` no catch — UI fica determinística.
### ❌ Não re-throw mutações
```js
// ❌
async function create(payload) {
try { return await createX(payload); }
catch (e) { error.value = e.message; }
// componente faz `await create()` e nunca sabe que falhou
}
```
✅ Re-throw após setar `error.value`.
### ❌ `Promise.all` quando uma falha é aceitável
Quando uma das queries pode falhar sem invalidar as outras, usar `Promise.allSettled`. Comum em listings que enriquece com lookups opcionais.
### ❌ State global em variável módulo
```js
// ❌ — vaza entre componentes que compartilham o composable
const rows = ref([]);
export function useFoo() {
return { rows };
}
```
✅ State sempre DENTRO da `function useFoo()`. Se precisar global, use Pinia store.
### ❌ Composable que faz `watch` no próprio state pra "side effect"
```js
// ❌
const rows = ref([]);
watch(rows, () => { /* save algo */ });
```
✅ Mover `watch` pro componente — composable não decide quando salvar.
**Exceção:** watch pra sincronizar com prop externa do composable (`watchEffect(() => loadRange(props.range))`) é OK.
### ❌ Composable retornando objeto enorme
Se o `return` tem 20+ chaves, o composable está fazendo coisa demais. Quebrar em N composables menores ou extrair Pinia store.
---
## 10. Cache store (Tipo C complementar)
Quando criar um composable Tipo C, garantir que existe entry em:
- `src/stores/melissaCacheStore.js``MELISSA_CACHE_TTL.<feature>` constante (TTL em ms)
- `.get(key, scope, ttl)` retorna valor ou null
- `.set(key, value, scope)` salva com timestamp
- Invalidação manual: `.invalidate('<feature>')`
**TTL guidelines:**
| Tipo de dado | TTL sugerido |
|---|---|
| Settings/preferences | 5 min |
| Listas estáveis (specialties, plans) | 30 min |
| Catálogo (services, pricing) | 10 min |
| Multi-tenant lookups | 5 min |
| Anything user-edited | NÃO cachear (Tipo A) |
---
## 11. Checklist de auditoria por módulo
Quando rodar `/audit-module <nome>`, validar cada composable:
- [ ] Não tem `supabase.from(...)` direto — só importa da repository
- [ ] State shape: `rows`/`data`, `loading: boolean`, `error: string`
- [ ] `error` é string, default `''`
- [ ] Reset de data em erro de load (`rows.value = []`)
- [ ] Mutações re-throw após setar error.value
- [ ] Nomenclatura: `loadRange`/`load<Scope>`, `create`, `update`, `remove`
- [ ] `remove` não `delete` (palavra reservada)
- [ ] Validação leve usa early-return (não throw)
- [ ] Múltiplos fetches em `Promise.all` (não sequencial)
- [ ] State DENTRO da `function use*()` (não em variável de módulo)
- [ ] Sem `watch` em própria state pra side effect (mover pro componente)
- [ ] Helpers de domínio em arquivo separado sem prefixo `use`
- [ ] Se cacheia (Tipo C): `opts.cache` opt-in, default `false`; TTL em `MELISSA_CACHE_TTL`; cache key inclui scope
- [ ] Return statement com chaves explícitas (não `return { ...state, ...actions }` opaco)
- [ ] Return ≤ 15 chaves (>15 = composable fazendo coisa demais)
Divergências viram items em `dev_auditoria_items` com:
- `categoria`: `padronizacao`
- `tag`: `padronizacao:<modulo>`
- `severidade`: alta se camada quebrada (composable com `from()`); média se viola convenção (error null vs ''); baixa se cosmético (nome de função)
---
## 12. Exemplo completo (template)
```js
/*
| Arquivo: src/features/patients/composables/usePatients.js
*/
import { ref } from 'vue';
import {
listPatients,
createPatient,
updatePatient,
deletePatient
} from '@/features/patients/services/patientsRepository';
export function usePatients() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadRange({ search, status, tenantId } = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listPatients({ search, status, tenantId });
} catch (e) {
error.value = e?.message || 'Falha ao carregar pacientes.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
return await createPatient(payload);
} catch (e) {
error.value = e?.message || 'Falha ao criar paciente.';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch) {
loading.value = true;
error.value = '';
try {
return await updatePatient(id, patch);
} catch (e) {
error.value = e?.message || 'Falha ao atualizar paciente.';
throw e;
} finally {
loading.value = false;
}
}
async function remove(id) {
loading.value = true;
error.value = '';
try {
await deletePatient(id);
} catch (e) {
error.value = e?.message || 'Falha ao remover paciente.';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadRange, create, update, remove };
}
```
---
## 13. Referências
- Canônicos: `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
- Repository pareado: `blueprints/repository-blueprint.md`
- Cache store: `src/stores/melissaCacheStore.js`
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
@@ -0,0 +1,431 @@
# Quick-Create Overlay Blueprint
> **Status:** Pattern **universal**. Promovido de agenda-only em 2026-05-20 após audit baseline (`development/02-auditoria/AUDIT_BASELINE.md`) identificar 3 candidates já em produção fora da agenda.
> **Stack:** Vue 3 + PrimeVue Dialog
> **Canônicos:**
> - `src/features/agenda/components/ServiceQuickCreateDialog.vue` (referência completa)
> - `src/features/agenda/components/InsurancePlanQuickCreateDialog.vue`
> - `src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue`
> **Legacy a refatorar (supabase direto, sem repository):**
> - `src/components/CadastroRapidoMedico.vue` → migrar pra `features/medicos/components/` (módulo 1 da Fase 1)
> - `src/components/CadastroRapidoConvenio.vue` → migrar pra `features/insurance/components/`
> - `src/components/ComponentCadastroRapido.vue` → migrar pra path apropriado conforme dono da entidade
---
## 1. Princípio
**Problema:** usuário está num fluxo (ex: agendar uma sessão) e precisa de uma entidade dependente que ainda não existe (serviço, convênio, plano). Navegar pra outra página significa **perder o contexto** do form em progresso.
**Solução:** mini-dialog **por cima** do dialog/fluxo atual, com **campos mínimos** pra criar a entidade, e ao salvar **pré-seleciona** ela no select que disparou o quick-create.
**Regra absoluta:** criar dependência faltante em **qualquer fluxo** deve **abrir overlay POR CIMA, nunca navegar pra fora**. Aplicável em todo o sistema desde a promoção do blueprint (2026-05-20). Origem do pattern: agenda (memória `feedback_agenda_inline_quick_create`, agora generalizada).
---
## 2. Quando aplicar (vs alternativas)
| Situação | Solução |
|---|---|
| Fluxo crítico travado por dependência faltante (form em progresso) | **Quick-create overlay** ✅ |
| Cadastro completo, com todos os campos | Página dedicada `/entity/new` ou Dialog full |
| Apenas selecionar item existente | Select com busca; sem botão "+" |
| Onboarding ou setup wizard | Não — fluxo é a página inteira, não um overlay |
**Anti-uso:** quick-create NÃO é "shortcut pra criar do menu lateral". É **fallback contextual** quando o form atual depende de algo que falta. O parent **precisa estar pronto pra receber o evento `created`** e usar o ID.
---
## 3. Estrutura do componente `<Entity>QuickCreateDialog.vue`
```vue
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
// CANÔNICO: importar da repository do feature dono da entidade.
// LEGACY: 3 componentes em src/components/ usam supabase direto — refatorar quando módulo dono for tocado na Fase 1.
import { createX } from '@/features/<feature>/services/<feature>Repository';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' } // pré-preenche do search atual do select
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({ /* só campos MÍNIMOS obrigatórios + 1-2 opcionais úteis */ });
const saving = ref(false);
// Resetar form toda vez que abre
watch(() => props.modelValue, (v) => {
if (v) form.value = { /* defaults + initialName */ };
});
const canSave = () => /* validação leve */;
async function onSave() {
if (!canSave()) return;
saving.value = true;
try {
// Sanitize (trim + maxlength slice + nullif vazio) ANTES de chamar repository
const payload = {
name: form.value.name.trim().slice(0, 120),
// ...resto sanitizado
};
// Repository injeta owner_id (uid logado) + tenant_id (store) + faz uniqueness check
// e throw em erro. Quick-create só decide o que mostrar ao usuário.
const data = await createX(payload);
toast.add({ severity: 'success', summary: '<Entity> criado', life: 2200 });
emit('created', data); // ← parent usa data.id pra pré-selecionar
visible.value = false;
} catch (e) {
// Repository pode throw com message conhecido (ex: "Nome em uso") — mostra como warn ou error
const isDup = /em uso|já existe|duplicate/i.test(e?.message || '');
toast.add({
severity: isDup ? 'warn' : 'error',
summary: isDup ? 'Nome em uso' : 'Falha ao criar',
detail: e?.message || 'Erro inesperado',
life: 4000
});
} finally {
saving.value = false;
}
}
</script>
<!--
LEGACY-NOTE (2026-05-20): os 3 quick-creates em src/components/ (CadastroRapidoMedico,
CadastroRapidoConvenio, ComponentCadastroRapido) ainda usam supabase direto. Padrão acima
é o CANÔNICO pós-promoção. Refator vai acontecer no módulo correspondente da Fase 1.
-->
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
header="Novo <entity>"
class="w-[94vw] max-w-md"
>
<!-- Campos mínimos: 3-5 inputs, nada mais -->
<div class="flex flex-col gap-3 pt-1"> ... </div>
<template #footer>
<Button label="Cancelar" text :disabled="saving" @click="visible = false" />
<Button label="Salvar" :loading="saving" :disabled="!canSave()" @click="onSave" />
</template>
</Dialog>
</template>
```
---
## 4. Contrato canônico de props/emits
### Props (sempre)
| Prop | Tipo | Default | Função |
|---|---|---|---|
| `modelValue` | `Boolean` | `false` | Visibilidade do dialog. Two-way via `v-model`. |
| `ownerId` | `String` | `''` | Owner_id (terapeuta). Default: usuário logado. |
| `initialName` | `String` | `''` | Pré-preenche o campo nome com o search atual do select (UX win). |
### Props (opcionais por entidade)
- `parentId` (`String`) — quando a entidade tem hierarquia (ex: `plan_id` em `plan_service`)
- `defaultDurationMin` (`Number`) — quando faz sentido herdar valor do contexto
- Outras herdadas do contexto, **nunca** mais que 3 props extras (senão vira form pesado, não quick-create)
### Emits
| Evento | Payload | Quando |
|---|---|---|
| `update:modelValue` | `Boolean` | `v-model` two-way |
| `created` | `Object` (row inserida completa) | Após insert bem-sucedido |
**Nunca emitir** `cancelled`, `closed`, `error` — parent não precisa saber dessas distinções; `update:modelValue=false` cobre.
---
## 5. Integração no parent
### Slot do botão `+` ao lado do select
```vue
<div class="flex gap-2 items-center">
<Select v-model="selectedServiceId" :options="services" optionLabel="name" optionValue="id" class="flex-1" />
<Button
icon="pi pi-plus"
v-tooltip.top="'Cadastrar novo serviço'"
severity="secondary"
size="small"
@click="openServiceQuickCreate"
/>
</div>
```
### Lock do dialog parent
Parent **precisa** travar seu próprio `dismissableMask` e `closeOnEscape` enquanto qualquer quick-create child está aberto, senão clicar fora fecha tudo:
```vue
<Dialog
v-model:visible="parentVisible"
:dismissableMask="!anyChildDialogOpen"
:closeOnEscape="!anyChildDialogOpen"
...
>
```
```js
const serviceQuickCreateOpen = ref(false);
const insuranceQuickCreateOpen = ref(false);
const anyChildDialogOpen = computed(() =>
serviceQuickCreateOpen.value || insuranceQuickCreateOpen.value
);
```
### Renderização dos quick-creates DENTRO do parent
```vue
<!-- DENTRO do template do parent dialog, antes do </Dialog> -->
<ServiceQuickCreateDialog
v-model="serviceQuickCreateOpen"
:owner-id="ownerId"
:initial-name="serviceSearchText"
@created="onServiceCreated"
/>
```
### Handler `on<Entity>Created`
```js
function onServiceCreated(row) {
// 1. Inserir na lista local (sem re-fetch)
services.value = [row, ...services.value];
// 2. Pré-selecionar no select
selectedServiceId.value = row.id;
// 3. (Opcional) Focar o próximo campo
nextTick(() => priceInputRef.value?.focus());
}
```
### Handler `openXQuickCreate`
```js
function openServiceQuickCreate() {
serviceSearchText.value = currentSearchInSelect.value; // capture pra initialName
serviceQuickCreateOpen.value = true;
}
```
---
## 6. Convenções de UX
### Campos mínimos absolutos
Quick-create **não é cadastro completo**. Inclui só:
- 1 campo obrigatório principal (nome)
- 1-2 campos obrigatórios secundários (preço, duração)
- 1 campo opcional (descrição)
Resto (categorias, tags, configurações avançadas) edita depois em `/entity/:id`.
### Maxlength visível
```vue
<InputText v-model="form.name" maxlength="120" />
```
Slice no save: `.trim().slice(0, 120)` — defesa em profundidade.
### Botão "+" sempre `size="small"` `severity="secondary"`
Discrição visual — não compete com CTA do dialog parent.
### Toast em vez de inline error
Mini-dialog não tem espaço pra banner de erro. Toast no canto superior direito (padrão PrimeVue) basta.
### `autofocus` no primeiro input
```vue
<InputText autofocus v-model="form.name" />
```
Usuário já está em modo "digitar" — pular o clique no input.
### `:loading="saving"` no botão Salvar
Spinner + disabled simultâneo. PrimeVue já dá o efeito visual.
---
## 7. Anti-patterns (NÃO fazer)
### ❌ Navegar pra rota nova no botão "+"
```js
// ❌ — destrói o form em progresso
function openServiceQuickCreate() {
router.push('/saas/services/new');
}
```
✅ Abre o overlay.
### ❌ Quick-create que pede 10 campos
Se a entidade exige cadastro complexo (campos condicionais, validações cruzadas, upload de arquivo), **não cabe num quick-create**. Use página dedicada e aceite que o usuário perde contexto. Ou crie um wizard.
### ❌ Sem `dups check` antes do insert
```js
// ❌ — usuário clica 2x, cria duplicata silenciosa
await supabase.from('services').insert(payload).select().single();
```
`ilike` por `name` antes; aborta com warn toast.
### ❌ Não emitir o objeto completo no `created`
```js
// ❌
emit('created', { id: data.id }); // parent precisa de mais que id
// ❌ pior ainda
emit('created'); // parent não sabe o que foi criado
```
`emit('created', data)` — row completa do banco.
### ❌ Não capturar `initialName` do search atual
Quando usuário digita "Sessão 50min" no select e clica "+", o `initialName=` deve já vir preenchido. Senão usuário re-digita.
### ❌ Parent sem `anyChildDialogOpen` no lock
Sem o lock, clicar fora do quick-create child fecha o parent inteiro. Bug clássico.
### ❌ Re-fetch da lista após `created`
```js
// ❌ — round-trip desnecessário; o evento já trouxe o row
async function onServiceCreated() {
await loadServices();
}
```
✅ Inserir o `row` recebido direto na lista local; só re-fetch se houver lógica de ordenação complexa.
### ❌ Múltiplos quick-creates abertos ao mesmo tempo
Permitir abrir um quick-create de plano de saúde enquanto outro de serviço está aberto = stack visual confuso. Force fechar o atual antes de abrir o próximo, OU mantenha o lock no `anyChildDialogOpen` que cobre.
---
## 8. Sanitização (memória `feedback_sanitizacao`)
Toda entrada de quick-create:
```js
const name = form.value.name?.trim().slice(0, 120) || null;
const description = form.value.description?.trim().slice(0, 500) || null;
const price = form.value.price != null ? Number(form.value.price) : null;
```
Padrão: `trim()``slice(maxlength)``nullif vazio` → cast tipo.
Pro upload (não comum em quick-create, mas se houver): mime allowlist + size check antes de submitter.
---
## 9. Promotion History & Path Convention
### Histórico
- **2026-05-04** — Pattern nasceu em `features/agenda/` com 3 quick-creates (Service, InsurancePlan, InsurancePlanService). Documentado como **agenda-only** com promotion criteria explícito.
- **2026-05-20** — Audit baseline identificou 3 candidates já em produção fora da agenda: `CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue` (todos `supabase` direto, em `src/components/`). Promotion criteria atingida 3×. **Blueprint promovido pra universal.**
### Path convention pós-promoção
| Caso | Path | Exemplo |
|---|---|---|
| Entidade pertence a 1 feature claro | `src/features/<feature>/components/<Entity>QuickCreateDialog.vue` | `features/medicos/components/MedicoQuickCreateDialog.vue` |
| Entidade é cross-feature (raro) | `src/components/quick-create/<Entity>QuickCreateDialog.vue` | (nenhum hoje) |
**Anti-pattern:** quick-create morando em `src/components/` raiz sem subpasta — perde discoverability e mistura com componentes utilitários.
### Plano de migração dos 3 legacy
Cada refator acontece **quando o módulo dono for tocado na Fase 1**:
| Componente atual | Path destino | Quando | Fix obrigatório |
|---|---|---|---|
| `src/components/CadastroRapidoMedico.vue` | `src/features/medicos/components/MedicoQuickCreateDialog.vue` | Módulo 1 (Home/Components) — pode criar `features/medicos/` se ainda não existe | Migrar pra repository; usar `_tenantGuards` |
| `src/components/CadastroRapidoConvenio.vue` | `src/features/insurance/components/InsurancePlanQuickCreateDialog.vue` (consolidar com o existente na agenda?) | Módulo 1 | Idem; **verificar se duplica `features/agenda/components/InsurancePlanQuickCreateDialog.vue`** |
| `src/components/ComponentCadastroRapido.vue` | depende do que cria | Módulo 1 | Idem |
### Boilerplate DRY (futuro, não-prioritário)
Quando houver 5+ quick-creates seguindo o pattern, considerar:
- `useQuickCreateLock()` composable que encapsula `anyChildDialogOpen` (DRY entre parent dialogs com 2+ children)
- `<BaseQuickCreateDialog>` wrapper component com slots `#fields`, `#footer-extra` e props padrão
**Não fazer agora** — 6 instâncias ainda é pouco pra inflar abstração. Pattern atual (cada quick-create standalone) é fácil de entender e copiar.
---
## 10. Checklist de auditoria
Aplica-se a **todo quick-create do sistema** pós-promoção (2026-05-20):
- [ ] Path correto (feature folder se entidade pertence a 1 feature; `src/components/quick-create/` se cross-feature)
- [ ] Nome do arquivo: `<Entity>QuickCreateDialog.vue` (PascalCase)
- [ ] Props canônicas: `modelValue`, `ownerId`, `initialName`
- [ ] Emits canônicos: `update:modelValue`, `created`
- [ ] `Dialog` com `modal`, `:draggable="false"`, `:closable="!saving"`
- [ ] Form reset quando abre (`watch modelValue`)
- [ ] Sanitização: `trim() + slice(maxlength) + nullif` ANTES de chamar repository
- [ ] **Insert via repository** (não supabase direto) — repository injeta `owner_id`+`tenant_id` e faz uniqueness check
- [ ] Toast feedback em success/warn/error (warn quando erro for "nome em uso", error caso contrário)
- [ ] Emit `created` com row completo (não só id)
- [ ] Parent: `anyChildDialogOpen` computed lock
- [ ] Parent: `dismissableMask` e `closeOnEscape` bindados ao lock
- [ ] Parent: handler `on<Entity>Created` insere row na lista local e pré-seleciona
- [ ] Parent: `initialName` capturado do search atual do select
- [ ] Botão "+": `size="small"` `severity="secondary"` `v-tooltip`
- [ ] `autofocus` no primeiro input
- [ ] `:loading="saving"` + `:disabled="!canSave()"` no Salvar
- [ ] Máximo 3-5 inputs no form (senão não é quick-create — vira página dedicada)
Divergências viram items em `dev_auditoria_items` com:
- `categoria`: `padronizacao`
- `tag`: `padronizacao:<modulo>` (módulo dono da entidade)
- `severidade`: **alta** se usa supabase direto em vez de repository, ou viola lock (vaza dismiss); **média** se viola contrato (emits/props); **baixa** se cosmético
---
## 11. Referências
- Canônicos: `src/features/agenda/components/ServiceQuickCreateDialog.vue`, `InsurancePlanQuickCreateDialog.vue`, `InsurancePlanServiceQuickCreateDialog.vue`
- Parent integrador: `src/features/agenda/components/AgendaEventDialog.vue` (linhas ~3081-3107, ~3170, ~3274, ~3307)
- Legacy a refatorar: `src/components/CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue`
- Dialog base: `blueprints/dialog-blueprint.md`
- Repository pareado: `blueprints/repository-blueprint.md`
- Audit baseline: `development/02-auditoria/AUDIT_BASELINE.md` (3 candidates descobertos em 2026-05-20)
- Memória: `feedback_agenda_inline_quick_create.md` (superseded — pattern agora universal), `feedback_sanitizacao.md`
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
+379
View File
@@ -0,0 +1,379 @@
# Repository Blueprint
> **Stack:** Supabase JS client + Vue 3 (Pinia stores)
> **Canônico:** `src/features/agenda/services/` (validado em C1-C13 + análise sênior 2026-05-20)
> **Aplicável:** todo módulo com acesso a tabela `*` com `tenant_id`
---
## 1. Princípio
Camada **thin** entre Supabase e composables. **Funções puras** + **tenant guards** + **SELECT canônico**. Sem classes, sem state, sem singletons. Idempotente, testável, descartável.
Composable orquestra estado e cache. **Repository só fala com o banco.**
---
## 2. Estrutura de arquivos
```
src/features/<modulo>/services/
├── _tenantGuards.js # SHARED entre repositories do feature
├── <feature>Selects.js # SELECT canônico + helpers de flatten
├── <feature>Repository.js # CRUD escopo terapeuta (owner_id = uid)
└── <feature>ClinicRepository.js # CRUD escopo clínica (se aplicável)
```
**Regra do `_tenantGuards.js`:** se o feature tem 2+ repositories (terapeuta + clínica), os guards saem pra arquivo compartilhado. Se só tem 1, pode ficar no topo do próprio repo.
---
## 3. Tenant guards canônicos
Copiar **literal** de `src/features/agenda/services/_tenantGuards.js`:
```js
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
export function assertIsoRange(startISO, endISO) {
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
}
export function sanitizeOwnerIds(ownerIds) {
return (ownerIds || []).filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
}
```
**Por quê string `'null'`/`'undefined'`?** Vindo de URL params/localStorage stringificado, esses casos aparecem como string literal. Defesa em profundidade.
---
## 4. SELECT canônico
**Extrair pra constante exportada.** Inline SELECT em 3 lugares = divergência sutil (FKs explícitas em uns, não em outros) = bug.
```js
/**
* Select canônico de <tabela> com joins.
*
* FKs explícitas (obrigatórias quando há múltiplas colunas apontando pra mesma tabela):
* - <tabela>_<col>_fkey
*/
export const <FEATURE>_SELECT = `
id, owner_id, tenant_id, ...,
patients!<tabela>_<col>_fkey (
id, nome_completo, avatar_url, status
)
`.trim();
```
E o **flatten helper** ao lado:
```js
/**
* Achata o aninhamento de patients dentro da row.
* Mantém ambas formas (flat + nested) pra compat com call sites variados.
*/
export function flatten<Feature>Row(r) {
if (!r) return r;
const patient = r.patients || null;
return {
...r,
paciente_nome: patient?.nome_completo || r.paciente_nome || '',
paciente_avatar: patient?.avatar_url || r.paciente_avatar || '',
paciente_status: patient?.status || r.paciente_status || ''
};
}
```
---
## 5. Convenções de assinatura
### Funções puras exportadas
```js
// ✅ certo
export async function listMyEvents({ startISO, endISO, ownerId, tenantId } = {}) { ... }
// ❌ errado — classe com state
class AgendaRepository {
async list() { ... }
}
```
### Args nomeados (destructure)
Posicionais quebram com refator. Default `= {}` evita TypeError se chamarem sem args.
### `tenantId` opcional → resolve via store
Helper local no repository:
```js
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from './_tenantGuards';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertTenantId(tenantId);
return tenantId;
}
```
Por que opcional? Composable pode passar `tenantId` explícito (testes, multi-tenant ops). Default chega via store.
### Errors throw, nunca silent
```js
const { data, error } = await supabase.from('...').select(...);
if (error) throw error; // ✅
// ❌ if (error) return null;
// ❌ if (error) console.error(error);
```
Composable decide se faz `try/catch` + toast.
### Ranges half-open
```js
// ✅ certo — half-open
.gte('inicio_em', startISO).lt('inicio_em', endISO)
// ❌ errado — fechado, gera off-by-one no último ms
.gte('inicio_em', startISO).lte('inicio_em', endISO)
```
### Strip campos legados antes de insert/update
```js
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...safePayload } = payload;
```
Quando há migração de coluna em andamento ou campo virtual no UI.
---
## 6. Operações CRUD — pattern
### Create (owner-scoped)
```js
export async function create<Feature>(payload) {
if (!payload) throw new Error('Payload vazio.');
const uid = await getUid();
const tid = resolveTenantId();
const { paciente_id: _dropped, ...rest } = payload;
const insertPayload = { ...rest, tenant_id: tid, owner_id: uid };
const { data, error } = await supabase
.from('<tabela>')
.insert([insertPayload])
.select(<FEATURE>_SELECT)
.single();
if (error) throw error;
return flatten<Feature>Row(data);
}
```
**Sempre:**
- `tenant_id` injetado do store (não aceita do payload)
- `owner_id` injetado do uid logado (ignora do payload — clinic-scoped variant pode aceitar explícito)
- `.select(...)` + `.single()` retorna o registro completo
### Update
```js
export async function update<Feature>(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const { paciente_id: _dropped, ...safePatch } = patch;
const { data, error } = await supabase
.from('<tabela>')
.update(safePatch)
.eq('id', id)
.eq('tenant_id', tid) // ← defesa em profundidade — RLS reforça no banco
.select(<FEATURE>_SELECT)
.single();
if (error) throw error;
return flatten<Feature>Row(data);
}
```
### Delete
```js
export async function delete<Feature>(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase
.from('<tabela>')
.delete()
.eq('id', id)
.eq('tenant_id', tid);
if (error) throw error;
return true;
}
```
### List (range query)
```js
export async function list<Feature>({ startISO, endISO, ownerId, tenantId } = {}) {
assertIsoRange(startISO, endISO);
const uid = ownerId || (await getUid());
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('<tabela>')
.select(<FEATURE>_SELECT)
.eq('tenant_id', tid)
.eq('owner_id', uid)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true });
if (error) throw error;
return (data || []).map(flatten<Feature>Row);
}
```
### Clinic-scoped variant (admin/secretaria)
Diferenças em relação ao owner-scoped:
- `tenantId` **obrigatório explícito** (sem default via store — admin pode operar em qualquer tenant onde tem permissão)
- `ownerIds` é array (multi-terapeuta no mosaico) → `sanitizeOwnerIds` antes do `.in(...)`
- Permite definir `owner_id` no create (admin cria pra qualquer terapeuta do tenant)
- Sem `excludeMirror` automático — depende do uso
Referência: `src/features/agenda/services/agendaClinicRepository.js`
---
## 7. Anti-patterns (NÃO fazer)
### ❌ Inline SELECT espalhado
```js
// ❌ em useFoo.js
const { data } = await supabase.from('events').select('id, owner_id, patient_id, ...');
// ❌ em fooRepository.js
const { data } = await supabase.from('events').select('id, owner_id, ...'); // ← divergente
```
✅ Extrair pra `<feature>Selects.js`.
### ❌ `useTenantStore()` em vários arquivos
```js
// ❌ em 5 arquivos diferentes
const tenantStore = useTenantStore();
const tid = tenantStore.activeTenantId;
if (!tid) throw new Error('...');
```
`resolveTenantId(tenantIdArg)` no topo do repo.
### ❌ Aceitar `owner_id` do payload em create owner-scoped
```js
// ❌ permite usuário criar evento "de outro terapeuta"
await supabase.from('events').insert({ ...payload, tenant_id: tid });
```
✅ Sempre injetar `owner_id` do uid logado (sobrescreve qualquer valor do payload).
### ❌ `delete()` sem `.eq('tenant_id', tid)`
```js
// ❌ RLS deveria pegar, mas defesa em profundidade
await supabase.from('events').delete().eq('id', id);
```
✅ Sempre filtra `.eq('tenant_id', tid)` mesmo com RLS ativo.
### ❌ Return null em erro
```js
// ❌
if (error) {
console.error(error);
return null;
}
```
`throw error`. Composable decide o que fazer.
### ❌ Range fechado
```js
// ❌ — `2026-05-20` no `endISO` faz aparecer o dia inteiro do 20
.gte('inicio_em', startISO).lte('inicio_em', endISO)
```
✅ Half-open: `.gte(...).lt(...)`. Caller passa `endISO` como o início do próximo bucket.
### ❌ `paciente_id` (ou outro campo legado) chegando ao banco
A migração já dropou colunas legadas. Strip no `safePayload` evita 400 silencioso.
---
## 8. Checklist de auditoria por módulo
Quando rodar `/audit-module <nome>`, validar:
- [ ] `services/_tenantGuards.js` existe (ou inline se 1 repo só)
- [ ] `services/<feature>Selects.js` existe e exporta `<FEATURE>_SELECT`
- [ ] `services/<feature>Repository.js` é pure functions (sem classe/state)
- [ ] `resolveTenantId(tenantIdArg)` local — não `useTenantStore()` espalhado
- [ ] Toda operação injeta `tenant_id` no insert/update
- [ ] Create owner-scoped injeta `owner_id` do uid logado (ignora do payload)
- [ ] Update/delete filtram `.eq('id').eq('tenant_id', tid)` — defesa em profundidade
- [ ] FKs explícitas nos joins (`<tabela>!<fk_name>`)
- [ ] Errors `throw`, nunca silent
- [ ] Ranges half-open (`gte + lt`)
- [ ] Strip de campos legados em insert/update
- [ ] Clinic-scoped variant (se existe) sem default via store, tenantId obrigatório
- [ ] `flatten<Feature>Row` definido se há joins aninhados
Divergências viram items em `dev_auditoria_items` com:
- `categoria`: `padronizacao`
- `tag`: `padronizacao:<modulo>`
- `severidade`: alta se viola segurança (tenant leak), média se viola convenção, baixa se cosmético
- `arquivo`: path do arquivo
- `solucao`: referência ao item do checklist
---
## 9. Referências
- Canônico: `src/features/agenda/services/`
- Variant clinic: `src/features/agenda/services/agendaClinicRepository.js`
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
- Decisões macro: `development/02-auditoria/PADRONIZACAO.md`
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgenciaPsi DB · 2026-05-04</title>
<title>AgenciaPsi DB · 2026-05-11</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#6366f1;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6;--ok:#34d399;--warn:#fbbf24;--pend:#f87171;--leg:#94a3b8}
@@ -101,7 +101,7 @@
<body>
<div class="topbar">
<div class="brand">Agência<span>Psi</span> DB</div>
<span class="gen">2026-05-04 · 04/05/2026, 14:09:20</span>
<span class="gen">2026-05-11 · 11/05/2026, 10:51:25</span>
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
<div class="pills">
<div class="pill"><strong>138</strong> tabelas</div>
+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',
+5 -2
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"
@@ -106,7 +109,7 @@
"contact_email_types", "contact_emails"
],
"Agenda / Agendamento": [
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes",
"agenda_online_slots", "agenda_regras_semanais",
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
"agendador_configuracoes", "agendador_solicitacoes"
+1 -2
View File
@@ -43,13 +43,12 @@ Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-0
| `module_features` | Features por módulo |
| `tenant_modules` | Módulos ativos por tenant |
### Agenda (11 tabelas)
### Agenda (10 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `agenda_bloqueios` | Bloqueios de horário |
| `agenda_configuracoes` | Configurações da agenda por tenant_member |
| `agenda_eventos` | Eventos da agenda (sessões, bloqueios) |
| `agenda_excecoes` | Exceções na agenda (horários extras, bloqueios pontuais) |
| `agenda_online_slots` | Slots de agendamento online |
| `agenda_regras_semanais` | Regras semanais de disponibilidade |
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
@@ -0,0 +1,57 @@
-- =============================================================================
-- F5 (parte supabase_admin) — refresh dinâmico dos schemas expostos no PostgREST
--
-- ⚠️ APLICAR COMO supabase_admin (postgres NÃO é superuser neste stack e não
-- consegue ALTER ROLE authenticator). Mesmo padrão do gotcha de `documents`.
--
-- docker exec -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres \
-- -f /dev/stdin < database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql
--
-- A config in-database do PostgREST (db-config, ligada por padrão) lê
-- pgrst.db_schemas da role `authenticator`. Setar essa GUC + NOTIFY reload
-- expõe/retira schemas tenant SEM restart do container. A GUC persiste em
-- pg_db_role_setting (sobrevive a supabase stop/start).
--
-- A lista é derivada SEMPRE de public.tenant_schemas (fonte da verdade dos
-- schemas provisionados). Disparada pelo trigger em tenant_schemas (migration
-- 20260613000002) a cada clone/drop de tenant.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.refresh_pgrst_schemas()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER -- roda como o OWNER (supabase_admin/superuser)
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_list text;
BEGIN
SELECT string_agg(s, ', ' ORDER BY ord, s)
INTO v_list
FROM (
SELECT 'public'::text AS s, 0 AS ord
UNION ALL SELECT 'graphql_public', 1
UNION ALL SELECT schema_name, 2 FROM public.tenant_schemas
) x;
-- baseline defensivo se a tabela ainda não existir / vazia
IF v_list IS NULL OR v_list = '' THEN
v_list := 'public, graphql_public';
END IF;
EXECUTE format('ALTER ROLE authenticator SET pgrst.db_schemas = %L', v_list);
NOTIFY pgrst, 'reload config'; -- re-lê db_schemas
NOTIFY pgrst, 'reload schema'; -- reconstrói o cache de schema
RETURN v_list;
END;
$$;
-- Garante owner superuser (caso a função já existisse owned por postgres)
ALTER FUNCTION public.refresh_pgrst_schemas() OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.refresh_pgrst_schemas() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.refresh_pgrst_schemas() TO postgres, service_role;
-- Seta o baseline imediatamente
SELECT public.refresh_pgrst_schemas();
@@ -0,0 +1,140 @@
-- =============================================================================
-- F6.1 — Migração de DADOS public -> schemas tenant (cutover)
--
-- ⚠️ APLICAR COMO supabase_admin (precisa SET session_replication_role=replica
-- pra desabilitar checagem de FK durante o bulk insert — postgres não pode).
--
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_1_migrate_data.supabase_admin.sql
--
-- COPIA (não move) os dados de cada tenant pras suas tabelas no schema. Os
-- dados continuam em public até o DROP da F6.3. Idempotente via ON CONFLICT
-- DO NOTHING (rodar de novo não duplica).
--
-- * tabelas com tenant_id: INSERT ... SELECT WHERE tenant_id = <id>, sem a
-- coluna tenant_id (não existe no schema)
-- * 3 filhas sem tenant_id (commitment_services, insurance_plan_services,
-- recurrence_rule_services): particionadas via JOIN no pai
-- * financial_categories / therapist_payout_records: 0 linhas, ignoradas
-- * as 6 tabelas anon-facing (F1b) NÃO existem no schema → naturalmente fora
-- * reset de sequences (4 tabelas bigserial) ao final
-- =============================================================================
SET session_replication_role = replica;
DO $$
DECLARE
t_row record;
tab record;
v_cols text;
v_n bigint;
-- filhas sem tenant_id: tabela -> (pai, fk_local, pk_pai)
child_joins jsonb := jsonb_build_object(
'commitment_services', jsonb_build_object('parent','agenda_eventos','fk','commitment_id'),
'insurance_plan_services', jsonb_build_object('parent','insurance_plans','fk','insurance_plan_id'),
'recurrence_rule_services', jsonb_build_object('parent','recurrence_rules','fk','rule_id')
);
cj jsonb;
BEGIN
FOR t_row IN
SELECT t.id AS tenant_id, ts.schema_name
FROM public.tenants t
JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
ORDER BY t.created_at, t.id
LOOP
FOR tab IN
SELECT c.relname AS table_name
FROM pg_class c
WHERE c.relnamespace = t_row.schema_name::regnamespace
AND c.relkind = 'r'
AND c.relname NOT LIKE '\_%'
ORDER BY c.relname
LOOP
-- pula se a tabela não existe em public (defensivo)
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema='public' AND table_name=tab.table_name) THEN
CONTINUE;
END IF;
-- colunas presentes em AMBOS (schema e public): exclui tenant_id
-- (some no schema), singleton (só no schema, fica no default) e
-- colunas GENERATED (net_amount, margin_brl — não aceitam INSERT)
SELECT string_agg(quote_ident(sc.column_name), ', ' ORDER BY sc.ordinal_position)
INTO v_cols
FROM information_schema.columns sc
WHERE sc.table_schema = t_row.schema_name AND sc.table_name = tab.table_name
AND sc.is_generated = 'NEVER'
AND EXISTS (SELECT 1 FROM information_schema.columns pc
WHERE pc.table_schema='public' AND pc.table_name=tab.table_name
AND pc.column_name = sc.column_name
AND pc.is_generated = 'NEVER');
IF v_cols IS NULL THEN CONTINUE; END IF;
cj := child_joins -> tab.table_name;
IF cj IS NOT NULL THEN
-- filha sem tenant_id: particiona via JOIN no pai
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ch '
|| 'JOIN public.%I p ON p.id = ch.%I WHERE p.tenant_id = %L '
|| 'ON CONFLICT DO NOTHING',
t_row.schema_name, tab.table_name, v_cols,
(SELECT string_agg('ch.'||quote_ident(x), ', ' ORDER BY ord)
FROM (SELECT trim(both ' ' from unnest(string_to_array(v_cols, ','))) AS x,
generate_subscripts(string_to_array(v_cols, ','),1) AS ord) y),
tab.table_name,
(cj->>'parent'), (cj->>'fk'),
t_row.tenant_id
);
ELSIF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name=tab.table_name AND column_name='tenant_id') THEN
-- tabela com tenant_id: filtro direto
EXECUTE format(
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
t_row.schema_name, tab.table_name, v_cols, v_cols, tab.table_name, t_row.tenant_id
);
ELSE
-- sem tenant_id e não é filha mapeada (financial_categories etc.):
-- só migra se tiver 0 dependência de tenant — pula (vazias hoje)
CONTINUE;
END IF;
GET DIAGNOSTICS v_n = ROW_COUNT;
IF v_n > 0 THEN
RAISE NOTICE 'F6.1 %.%: % linhas', t_row.schema_name, tab.table_name, v_n;
END IF;
END LOOP;
END LOOP;
END $$;
-- ---------------------------------------------------------------------------
-- Reset de sequences (tabelas bigserial) em cada schema
-- ---------------------------------------------------------------------------
DO $$
DECLARE
t_row record;
r record;
v_seq text;
BEGIN
FOR t_row IN SELECT schema_name FROM public.tenant_schemas LOOP
FOR r IN
SELECT c.relname AS table_name, a.attname AS column_name
FROM pg_attrdef d
JOIN pg_class c ON c.oid = d.adrelid
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
WHERE c.relnamespace = t_row.schema_name::regnamespace
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'
LOOP
v_seq := pg_get_serial_sequence(format('%I.%I', t_row.schema_name, r.table_name), r.column_name);
IF v_seq IS NOT NULL THEN
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0) + 1, false)',
v_seq, r.column_name, t_row.schema_name, r.table_name);
RAISE NOTICE 'F6.1 seq %.% -> %', t_row.schema_name, r.table_name, v_seq;
END IF;
END LOOP;
END LOOP;
END $$;
SET session_replication_role = origin;
@@ -0,0 +1,434 @@
-- =============================================================================
-- F6.2 Lote B — triggers schema-aware
-- ⚠️ APLICAR COMO supabase_admin (trigger functions sao owned por supabase_admin):
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \n-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \n-- < database-novo/manual/f6_2b_schema_aware_triggers.supabase_admin.sql
--
-- Estratégia hybrid: as funções são reescritas IN PLACE pra operar no schema do
-- TG_TABLE_SCHEMA (search_path dinâmico + tenant_id_for_schema). Como ficariam
-- erradas nas tabelas de public (TG_TABLE_SCHEMA='public'), DESANEXAMOS dos
-- tenant-tables de public e ANEXAMOS só nos schemas. Writes de public via RPCs
-- ainda-não-migrados (Lote D) perdem esses side-effects no curto hybrid —
-- aceitável (public vai ser dropado na F6.3 e o app lê dos schemas).
--
-- Exclui os que escrevem em notifications (Lote C, com o split):
-- notify_on_session_status, fanout_inbound_message_to_notifications,
-- cancel_notifications_on_opt_out/on_session_cancel, fn_notify_agenda_status_change
-- =============================================================================
BEGIN;
-- ───────────────────────────────────────────────────────────────────────────
-- 1) Rewrites — tabelas tenant via search_path (unqualified); globais com public.
-- ───────────────────────────────────────────────────────────────────────────
-- audit_logs é GLOBAL → tenant_id vem do schema
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
BEGIN
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
IF TG_OP = 'DELETE' THEN
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
ELSE
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
IF v_changed IS NULL THEN RETURN NEW; END IF;
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
END IF;
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
RETURN COALESCE(NEW, OLD);
END $$;
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO patient_status_history (patient_id, status_anterior, status_novo, motivo, encaminhado_para, data_saida, alterado_por, alterado_em)
VALUES (NEW.id, CASE WHEN TG_OP='INSERT' THEN NULL ELSE OLD.status END, NEW.status, NEW.motivo_saida, NEW.encaminhado_para, NEW.data_saida, auth.uid(), now());
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
VALUES (NEW.id, 'status_alterado', 'Status alterado para ' || NEW.status,
CASE WHEN TG_OP='INSERT' THEN 'Paciente cadastrado'
ELSE 'De ' || OLD.status || '' || NEW.status || CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END END,
CASE NEW.status WHEN 'Ativo' THEN 'green' WHEN 'Alta' THEN 'blue' WHEN 'Inativo' THEN 'gray' WHEN 'Encaminhado' THEN 'amber' WHEN 'Arquivado' THEN 'gray' ELSE 'gray' END,
auth.uid(), now());
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
VALUES (NEW.id, CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
NEW.risco_nota, CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END, auth.uid(), now());
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.auto_create_financial_record_from_session()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_price numeric(10,2); v_services_total numeric(10,2); v_already_billed boolean;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF NEW.status::text <> 'realizado' THEN RETURN NEW; END IF;
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN RETURN NEW; END IF;
IF NEW.tipo::text <> 'sessao' THEN RETURN NEW; END IF;
IF NEW.patient_id IS NULL THEN RETURN NEW; END IF;
IF NEW.billing_contract_id IS NOT NULL THEN RETURN NEW; END IF;
SELECT billed INTO v_already_billed FROM agenda_eventos WHERE id = NEW.id;
IF v_already_billed = TRUE THEN
IF EXISTS (SELECT 1 FROM financial_records WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL) THEN
RETURN NEW;
END IF;
END IF;
v_price := NULL;
IF NEW.recurrence_id IS NOT NULL THEN
SELECT COALESCE(SUM(rrs.final_price), 0) INTO v_services_total
FROM recurrence_rule_services rrs WHERE rrs.rule_id = NEW.recurrence_id;
IF v_services_total > 0 THEN v_price := v_services_total; END IF;
IF v_price IS NULL OR v_price = 0 THEN
SELECT price INTO v_price FROM recurrence_rules WHERE id = NEW.recurrence_id;
END IF;
END IF;
IF v_price IS NULL OR v_price = 0 THEN v_price := NEW.price; END IF;
IF v_price IS NULL OR v_price <= 0 THEN RETURN NEW; END IF;
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, type, amount, discount_amount, final_amount, clinic_fee_pct, clinic_fee_amount, status, due_date)
VALUES (NEW.owner_id, NEW.patient_id, NEW.id, 'receita', v_price, 0, v_price, 0, 0, 'pending', (NEW.inicio_em::date + 7));
UPDATE agenda_eventos SET billed = TRUE WHERE id = NEW.id;
RETURN NEW;
EXCEPTION WHEN OTHERS THEN
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%', NEW.id, SQLERRM;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_sla_resolve_on_outbound()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_thread_key text;
BEGIN
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
v_thread_key := COALESCE(NEW.patient_id::text, 'anon:' || COALESCE(NEW.to_number, 'unknown'));
UPDATE conversation_sla_breaches SET resolved_at = now(), resolved_by_message_id = NEW.id
WHERE thread_key = v_thread_key AND resolved_at IS NULL;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE next_version integer; reason text;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT COALESCE(MAX(version_number), 0) + 1 INTO next_version FROM clinical_note_versions WHERE note_id = NEW.id;
IF TG_OP = 'INSERT' THEN reason := 'criacao';
ELSIF TG_OP = 'UPDATE' THEN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN reason := 'soft_delete';
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN reason := 'restore';
ELSE reason := 'edicao'; END IF;
ELSE reason := 'desconhecido'; END IF;
INSERT INTO clinical_note_versions (note_id, version_number, title, content_text, content_structured, change_reason, created_at, created_by)
VALUES (NEW.id, next_version, NEW.title, NEW.content_text, NEW.content_structured, reason, now(), COALESCE(NEW.updated_by, NEW.created_by));
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_patient_id uuid; v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT d.patient_id, d.nome_original INTO v_patient_id, v_doc_nome FROM documents d WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
VALUES (v_patient_id, 'documento_assinado', 'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo), 'green', 'documento', NEW.documento_id, NEW.signatario_id, NEW.assinado_em);
END IF;
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
VALUES (NEW.patient_id, 'documento_adicionado', 'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'), 'blue', 'documento', NEW.id, NEW.uploaded_by, NEW.uploaded_at);
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.sync_legacy_email_fields()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
SELECT email INTO v_primary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
SELECT email INTO v_secondary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE patients SET email_principal = v_primary, email_alternativo = v_secondary WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE medicos SET email = v_primary WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END $$;
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
SELECT number INTO v_primary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
SELECT number INTO v_secondary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE patients SET telefone = v_primary, telefone_alternativo = v_secondary WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE medicos SET telefone_profissional = v_primary, telefone_pessoal = v_secondary WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END $$;
CREATE OR REPLACE FUNCTION public.fn_agenda_regras_semanais_no_overlap()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_count int;
BEGIN
IF new.ativo IS false THEN RETURN new; END IF;
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT count(*) INTO v_count FROM agenda_regras_semanais r
WHERE r.owner_id = new.owner_id AND r.dia_semana = new.dia_semana AND r.ativo IS true
AND (TG_OP = 'INSERT' OR r.id <> new.id)
AND (new.hora_inicio < r.hora_fim AND new.hora_fim > r.hora_inicio);
IF v_count > 0 THEN RAISE EXCEPTION 'Janela sobreposta: já existe uma regra ativa nesse intervalo.'; END IF;
RETURN new;
END $$;
-- valida member consistency: tenant_id vem do schema; tenant_members é GLOBAL
CREATE OR REPLACE FUNCTION public.patients_validate_member_consistency()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_tid uuid; v_tenant_responsible uuid; v_tenant_therapist uuid;
BEGIN
v_tid := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
SELECT tenant_id INTO v_tenant_responsible FROM public.tenant_members WHERE id = NEW.responsible_member_id;
IF v_tenant_responsible IS NULL THEN RAISE EXCEPTION 'Responsible member not found'; END IF;
IF v_tid IS NULL THEN RAISE EXCEPTION 'tenant não resolvido para schema %', TG_TABLE_SCHEMA; END IF;
IF v_tenant_responsible <> v_tid THEN RAISE EXCEPTION 'Responsible member must belong to the same tenant'; END IF;
IF NEW.patient_scope = 'therapist' THEN
IF NEW.therapist_member_id IS NULL THEN RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist'; END IF;
SELECT tenant_id INTO v_tenant_therapist FROM public.tenant_members WHERE id = NEW.therapist_member_id;
IF v_tenant_therapist IS NULL THEN RAISE EXCEPTION 'Therapist member not found'; END IF;
IF v_tenant_therapist <> v_tid THEN RAISE EXCEPTION 'Therapist member must belong to the same tenant'; END IF;
END IF;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 2) sync_busy_mirror — CROSS-TENANT: evento pessoal espelha "Ocupado" nas
-- clínicas onde o owner é therapist. Escreve no schema de OUTROS tenants.
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sync_busy_mirror_agenda_eventos()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_source_tenant uuid;
is_personal boolean;
should_mirror boolean;
v_owner uuid;
v_src_id uuid;
clinic record;
v_cschema text;
BEGIN
-- anti-recursão: espelho não espelha
IF TG_OP <> 'DELETE' THEN
IF NEW.mirror_of_event_id IS NOT NULL THEN RETURN NEW; END IF;
v_owner := NEW.owner_id; v_src_id := NEW.id;
ELSE
IF OLD.mirror_of_event_id IS NOT NULL THEN RETURN OLD; END IF;
v_owner := OLD.owner_id; v_src_id := OLD.id;
END IF;
v_source_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
is_personal := (v_source_tenant = v_owner); -- convenção: tenant pessoal tem id = owner
IF TG_OP = 'DELETE' THEN
should_mirror := (OLD.visibility_scope IN ('busy_only','private'));
ELSE
should_mirror := (NEW.visibility_scope IN ('busy_only','private'));
END IF;
IF NOT is_personal THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
-- DELETE ou não-deve-espelhar: remove espelhos em todas as clínicas do owner
IF TG_OP = 'DELETE' OR NOT should_mirror THEN
FOR clinic IN
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
LOOP
v_cschema := public.tenant_schema_for(clinic.tenant_id);
IF v_cschema IS NULL THEN CONTINUE; END IF;
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
v_cschema, v_src_id, 'personal_busy_mirror');
END LOOP;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
-- INSERT/UPDATE com espelho: upsert "Ocupado" em cada clínica do owner
FOR clinic IN
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
LOOP
v_cschema := public.tenant_schema_for(clinic.tenant_id);
IF v_cschema IS NULL THEN CONTINUE; END IF;
EXECUTE format(
'INSERT INTO %I.agenda_eventos (owner_id, terapeuta_id, patient_id, tipo, status, titulo, observacoes, inicio_em, fim_em, mirror_of_event_id, mirror_source, visibility_scope, created_at, updated_at) '
|| 'VALUES ($1,$1,NULL,$2::public.tipo_evento_agenda,$3::public.status_evento_agenda,$4,NULL,$5,$6,$7,$8,$9,now(),now()) '
|| 'ON CONFLICT (mirror_of_event_id) WHERE mirror_of_event_id IS NOT NULL '
|| 'DO UPDATE SET owner_id=EXCLUDED.owner_id, terapeuta_id=EXCLUDED.terapeuta_id, tipo=EXCLUDED.tipo, status=EXCLUDED.status, titulo=EXCLUDED.titulo, observacoes=EXCLUDED.observacoes, inicio_em=EXCLUDED.inicio_em, fim_em=EXCLUDED.fim_em, updated_at=now()',
v_cschema)
USING v_owner, 'bloqueio', 'agendado', 'Ocupado', NEW.inicio_em, NEW.fim_em, v_src_id, 'personal_busy_mirror', 'public';
END LOOP;
-- remove espelhos de clínicas onde o vínculo therapist active sumiu
FOR clinic IN
SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts
WHERE NOT EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = v_owner AND tm.role='therapist' AND tm.status='active' AND tm.tenant_id = ts.tenant_id
)
LOOP
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
clinic.schema_name, v_src_id, 'personal_busy_mirror');
END LOOP;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 3) financial_records_inject_tenant — OBSOLETO no schema (sem coluna tenant_id).
-- Mantém em public (legacy) mas NÃO anexa nos schemas.
-- ───────────────────────────────────────────────────────────────────────────
-- ───────────────────────────────────────────────────────────────────────────
-- 4) Detach dos tenant-tables de public + attach nos schemas
-- ───────────────────────────────────────────────────────────────────────────
-- Detacha das tabelas tenant em public os triggers schema-aware (ficariam errados lá)
DO $$
DECLARE
aware text[] := ARRAY[
'log_audit_change','trg_fn_patient_status_history','trg_fn_patient_status_timeline',
'trg_fn_patient_risco_timeline','auto_create_financial_record_from_session',
'fn_sla_resolve_on_outbound','fn_clinical_note_version','fn_document_signature_timeline',
'fn_documents_timeline_insert','sync_legacy_email_fields','sync_legacy_phone_fields',
'fn_agenda_regras_semanais_no_overlap','patients_validate_member_consistency',
'sync_busy_mirror_agenda_eventos'
];
r record;
BEGIN
FOR r IN
SELECT c.relname AS tab, t.tgname
FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_proc p ON p.oid=t.tgfoid
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
END LOOP;
END $$;
-- Attach nos schemas. Specs derivadas dos triggerdefs REAIS de public, com
-- tenant_id removido de WHEN/UPDATE OF (não existe no schema). __T__ = schema.tabela.
CREATE OR REPLACE FUNCTION public.attach_schema_aware_triggers(p_schema text)
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
specs jsonb := jsonb_build_array(
jsonb_build_object('tab','patients','name','trg_patient_status_history','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history()'),
jsonb_build_object('tab','patients','name','trg_patient_status_timeline','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline()'),
jsonb_build_object('tab','patients','name','trg_patient_risco_timeline','spec','AFTER UPDATE OF risco_elevado ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline()'),
jsonb_build_object('tab','patients','name','trg_audit_patients','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','patients','name','trg_patients_validate_members','spec','BEFORE INSERT OR UPDATE OF responsible_member_id, patient_scope, therapist_member_id ON __T__ FOR EACH ROW EXECUTE FUNCTION public.patients_validate_member_consistency()'),
jsonb_build_object('tab','agenda_eventos','name','trg_audit_agenda_eventos','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','agenda_eventos','name','trg_auto_financial_from_session','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_ins','spec','AFTER INSERT ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND new.visibility_scope = ANY (ARRAY[''busy_only''::text, ''private''::text])) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_upd','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND (new.visibility_scope IS DISTINCT FROM old.visibility_scope OR new.inicio_em IS DISTINCT FROM old.inicio_em OR new.fim_em IS DISTINCT FROM old.fim_em OR new.owner_id IS DISTINCT FROM old.owner_id)) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_del','spec','AFTER DELETE ON __T__ FOR EACH ROW WHEN (old.mirror_of_event_id IS NULL) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
jsonb_build_object('tab','financial_records','name','trg_audit_financial_records','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','financial_records','name','trg_financial_records_auto_overdue','spec','BEFORE UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue()'),
jsonb_build_object('tab','documents','name','trg_audit_documents','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
jsonb_build_object('tab','documents','name','trg_documents_timeline_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert()'),
jsonb_build_object('tab','document_signatures','name','trg_ds_timeline','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_document_signature_timeline()'),
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_clinical_note_version()'),
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_update','spec','AFTER UPDATE OF content_text, content_structured, title, deleted_at ON __T__ FOR EACH ROW WHEN (old.content_text IS DISTINCT FROM new.content_text OR old.content_structured IS DISTINCT FROM new.content_structured OR old.title IS DISTINCT FROM new.title OR old.deleted_at IS DISTINCT FROM new.deleted_at) EXECUTE FUNCTION public.fn_clinical_note_version()'),
jsonb_build_object('tab','conversation_messages','name','trg_sla_resolve_on_outbound','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound()'),
jsonb_build_object('tab','contact_emails','name','trg_contact_emails_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields()'),
jsonb_build_object('tab','contact_phones','name','trg_contact_phones_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields()'),
jsonb_build_object('tab','agenda_regras_semanais','name','trg_agenda_regras_semanais_no_overlap','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap()'),
jsonb_build_object('tab','agenda_configuracoes','name','trg_agenda_cfg_sync','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.agenda_cfg_sync()')
);
el jsonb; v_count int := 0; v_target text;
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
v_target := format('%I.%I', p_schema, el->>'tab');
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec', '__T__', v_target);
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END $$;
DO $$
DECLARE r record; v int;
BEGIN
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
v := public.attach_schema_aware_triggers(r.schema_name);
RAISE NOTICE 'F6.2B %: % triggers schema-aware', r.schema_name, v;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,266 @@
-- =============================================================================
-- F6.2 Lote C — split de notifications (tenant-local vs SaaS cross-tenant)
--
-- ⚠️ APLICAR COMO supabase_admin (CREATE OR REPLACE de funções owned por
-- postgres E supabase_admin; superuser preserva o owner):
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_2c_notifications_split.supabase_admin.sql
--
-- Neste projeto, TODAS as notificações atuais (inbound_message, session_status,
-- system_alert, new_patient) são tenant-LOCAIS (avisos cross-tenant do SaaS
-- vivem em global_notices). Então:
-- * notifications continua tenant-local → já vive no schema do tenant (F6.1)
-- * public.notifications_sistema é criado como o canal SaaS→tenant / dev
-- cross-tenant (vazio hoje; pronto pro futuro: suporte, billing, etc.)
-- Triggers de notif reescritos schema-aware; os que disparam em tabelas PUBLIC
-- (notify_on_intake, notify_on_scheduling) roteiam pro schema via EXECUTE format.
-- =============================================================================
BEGIN;
-- ───────────────────────────────────────────────────────────────────────────
-- 1) notifications_sistema (GLOBAL, cross-tenant)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.notifications_sistema (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL, -- destinatário (user do tenant OU dev)
tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE, -- contexto (nullable: alerta global)
type text NOT NULL,
ref_id uuid,
ref_table text,
payload jsonb,
read_at timestamptz,
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS notif_sistema_owner_idx ON public.notifications_sistema (owner_id, created_at DESC) WHERE archived = false;
ALTER TABLE public.notifications_sistema ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS notif_sistema_owner ON public.notifications_sistema;
CREATE POLICY notif_sistema_owner ON public.notifications_sistema
FOR ALL TO authenticated USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());
-- realtime
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname='supabase_realtime' AND schemaname='public' AND tablename='notifications_sistema') THEN
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications_sistema;
END IF;
END $$;
-- helper pro futuro: emite notificação cross-tenant (dev/SaaS -> destinatário)
CREATE OR REPLACE FUNCTION public.notify_user_sistema(
p_owner_id uuid, p_type text, p_payload jsonb,
p_tenant_id uuid DEFAULT NULL, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_id uuid;
BEGIN
INSERT INTO public.notifications_sistema (owner_id, tenant_id, type, ref_id, ref_table, payload)
VALUES (p_owner_id, p_tenant_id, p_type, p_ref_id, p_ref_table, p_payload)
RETURNING id INTO v_id;
RETURN v_id;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 2) Rewrites dos triggers de notif (tenant-local) — schema-aware
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.notify_on_session_status()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_nome text;
BEGIN
IF NEW.status IN ('faltou','cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
SELECT nome_completo INTO v_nome FROM patients WHERE id = NEW.patient_id LIMIT 1;
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
VALUES (NEW.owner_id, 'session_status', NEW.id, 'agenda_eventos',
jsonb_build_object(
'title', CASE WHEN NEW.status='faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
'detail', COALESCE(v_nome,'Paciente') || '' || to_char(NEW.inicio_em,'DD/MM HH24:MI'),
'deeplink', '/therapist/agenda',
'avatar_initials', upper(left(COALESCE(v_nome,'?'),2))));
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_target_user uuid; v_title text; v_detail text; v_initials text; v_deeplink text;
v_patient_name text; v_payload jsonb; v_tenant uuid;
BEGIN
IF NEW.direction <> 'inbound' THEN RETURN NEW; END IF;
v_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF NEW.patient_id IS NOT NULL THEN
SELECT nome_completo INTO v_patient_name FROM patients WHERE id = NEW.patient_id;
END IF;
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
IF v_patient_name IS NOT NULL THEN
v_initials := upper(left(v_patient_name,1)) || COALESCE(upper(left(split_part(v_patient_name,' ',2),1)),'');
ELSE v_initials := '?'; END IF;
v_deeplink := '/admin/conversas';
v_payload := jsonb_build_object('title',v_title,'detail',v_detail,'avatar_initials',v_initials,
'deeplink',v_deeplink,'channel',NEW.channel,'conversation_message_id',NEW.id,
'patient_id',NEW.patient_id,'from_number',NEW.from_number);
-- destinatário: responsável do paciente (tenant_members é GLOBAL)
IF NEW.patient_id IS NOT NULL THEN
SELECT tm.user_id INTO v_target_user
FROM patients p JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
WHERE p.id = NEW.patient_id AND tm.status = 'active' LIMIT 1;
IF v_target_user IS NOT NULL THEN
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
VALUES (v_target_user, 'inbound_message', NULL, 'conversation_messages', v_payload);
RETURN NEW;
END IF;
END IF;
-- fallback: fan-out pros admins/therapists ativos do tenant (global)
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
SELECT tm.user_id, 'inbound_message', NULL, 'conversation_messages', v_payload
FROM public.tenant_members tm
WHERE tm.tenant_id = v_tenant AND tm.status = 'active'
AND tm.role IN ('clinic_admin','tenant_admin','therapist');
RETURN NEW;
END $$;
-- helper de cancelamento: notification_queue é tenant; herda search_path do trigger chamador
CREATE OR REPLACE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL, p_evento_id uuid DEFAULT NULL)
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE v_canceled integer;
BEGIN
UPDATE notification_queue SET status='cancelado', updated_at=now()
WHERE patient_id = p_patient_id AND status IN ('pendente','processando')
AND (p_channel IS NULL OR channel = p_channel)
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
GET DIAGNOSTICS v_canceled = ROW_COUNT;
RETURN v_canceled;
END $$;
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_opt_out()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'whatsapp');
END IF;
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'email');
END IF;
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'sms');
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, NULL, NEW.id);
END IF;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 3) Triggers que disparam em tabelas PUBLIC (intake/scheduling, F1b) —
-- roteiam a notificação pro schema do tenant via EXECUTE format
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.notify_on_intake()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_schema text;
BEGIN
IF NEW.status = 'new' THEN
v_schema := public.tenant_schema_for(NEW.tenant_id);
IF v_schema IS NULL THEN RETURN NEW; END IF;
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
USING NEW.owner_id, 'new_patient', NEW.id, 'patient_intake_requests',
jsonb_build_object('title','Novo cadastro externo','detail',COALESCE(NEW.nome_completo,'Paciente'),
'deeplink','/therapist/patients/cadastro/recebidos','avatar_initials',upper(left(COALESCE(NEW.nome_completo,'?'),2)));
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION public.notify_on_scheduling()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_schema text;
BEGIN
IF NEW.status = 'pendente' THEN
v_schema := public.tenant_schema_for(NEW.tenant_id);
IF v_schema IS NULL THEN RETURN NEW; END IF;
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
USING NEW.owner_id, 'new_scheduling', NEW.id, 'agendador_solicitacoes',
jsonb_build_object('title','Nova solicitação de agendamento',
'detail', COALESCE(NEW.paciente_nome,'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome,'') || '' || COALESCE(NEW.tipo,''),
'deeplink','/therapist/agendamentos-recebidos',
'avatar_initials', upper(left(COALESCE(NEW.paciente_nome,'?'),1) || left(COALESCE(NEW.paciente_sobrenome,''),1)));
END IF;
RETURN NEW;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- 4) Detach dos notif-triggers tenant de public + attach nos schemas (estende
-- attach_schema_aware_triggers com os 5 triggers de notif tenant)
-- ───────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
aware text[] := ARRAY['notify_on_session_status','fanout_inbound_message_to_notifications',
'cancel_notifications_on_opt_out','cancel_notifications_on_session_cancel'];
r record;
BEGIN
FOR r IN
SELECT c.relname AS tab, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid
JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_proc p ON p.oid=t.tgfoid
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.attach_notif_triggers(p_schema text)
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
specs jsonb := jsonb_build_array(
jsonb_build_object('tab','agenda_eventos','name','trg_notify_on_session_status','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status()'),
jsonb_build_object('tab','agenda_eventos','name','trg_cancel_notifs_on_session_cancel','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.status IS DISTINCT FROM old.status) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel()'),
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_status_notify','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change()'),
jsonb_build_object('tab','conversation_messages','name','trg_fanout_inbound_to_notifications','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications()'),
jsonb_build_object('tab','notification_preferences','name','trg_cancel_notifs_on_opt_out','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out()')
);
el jsonb; v_count int := 0; v_target text;
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
v_target := format('%I.%I', p_schema, el->>'tab');
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec','__T__',v_target);
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END $$;
DO $$
DECLARE r record; v int;
BEGIN
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
v := public.attach_notif_triggers(r.schema_name);
RAISE NOTICE 'F6.2C %: % notif triggers', r.schema_name, v;
END LOOP;
END $$;
COMMIT;
@@ -0,0 +1,412 @@
-- =============================================================================
-- F6.2 Lote D — RPCs user-facing roteadas pro schema do tenant
--
-- ⚠️ APLICAR COMO supabase_admin (mix de funções owned postgres/supabase_admin).
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql
--
-- Padrão: valida is_tenant_member(p_tenant_id) + set_config search_path pro
-- schema do tenant; remove `WHERE tenant_id=` e tenant_id de inserts; unqualify
-- tabelas tenant; %ROWTYPE→RECORD; RETURNS <tabela_tenant>→jsonb.
-- Tabelas que FICAM em public (audit_logs global; patient_intake_requests,
-- document_share_links F1b) seguem com `public.` + filtro tenant_id.
--
-- list_my_signatures é CROSS-TENANT (assinante em vários tenants) → Lote F.
-- =============================================================================
BEGIN;
-- helper: valida acesso e RETORNA o schema do tenant. NÃO seta search_path
-- (set_config feito dentro de helper com SET search_path próprio seria revertido
-- na saída do helper). Cada RPC faz: PERFORM set_config('search_path',
-- public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
CREATE OR REPLACE FUNCTION public._tenant_route(p_tenant_id uuid)
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_schema text;
BEGIN
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Sem permissão no tenant %', p_tenant_id USING ERRCODE='42501';
END IF;
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
RETURN v_schema;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- GRUPO 1 — já têm p_tenant_id, RETURNS jsonb/void (CREATE OR REPLACE)
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found'; END IF;
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
IF v_parent <> 1 THEN RAISE EXCEPTION 'Parent not deleted'; END IF;
RETURN jsonb_build_object('ok',true,'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
END $$;
CREATE OR REPLACE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found for tenant'; END IF;
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
IF v_parent <> 1 THEN RAISE EXCEPTION 'Delete did not remove the commitment'; END IF;
RETURN jsonb_build_object('ok',true,'tenant_id',p_tenant_id,'commitment_id',p_commitment_id,
'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
END $$;
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_owner_id uuid; v_schema text;
BEGIN
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF; -- schema ainda não existe (chamado antes do clone): no-op
SELECT user_id INTO v_owner_id FROM public.tenant_members
WHERE tenant_id = p_tenant_id AND role='tenant_admin' AND status='active' LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
INSERT INTO patient_groups (owner_id, nome, cor, is_system)
VALUES (v_owner_id,'Crianças','#60a5fa',true),
(v_owner_id,'Adolescentes','#a78bfa',true),
(v_owner_id,'Idosos','#34d399',true)
ON CONFLICT (owner_id, nome) DO NOTHING;
END $$;
-- seed_determined_commitments: idêntico em estrutura, sem tenant_id nos inserts.
-- Recriado integralmente.
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_id uuid; v_schema text;
BEGIN
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='session') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'session',true,true,'Sessão','Sessão com paciente');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='reading') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'reading',false,true,'Leitura','Praticar leitura');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='supervision') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'supervision',false,true,'Supervisão','Supervisão');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='class') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'class',false,false,'Aula','Dar aula');
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='analysis') THEN
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
VALUES (true,'analysis',false,true,'Análise Pessoal','Minha análise pessoal');
END IF;
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='session' LIMIT 1;
IF v_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order)
VALUES (v_id,'notes','Observação','textarea',false,30);
END IF;
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='reading' LIMIT 1;
IF v_id IS NOT NULL THEN
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='book') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'book','Livro','text',false,10);
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='author') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'author','Autor','text',false,20);
END IF;
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'notes','Observação','textarea',false,30);
END IF;
END IF;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- GRUPO 2 — novo p_tenant_id (1º param), RETURNS scalar/jsonb (DROP+CREATE)
-- ───────────────────────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.cancel_recurrence_from(uuid, date);
CREATE FUNCTION public.cancel_recurrence_from(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
UPDATE recurrence_rules
SET end_date = p_from_date - INTERVAL '1 day', open_ended = false,
status = CASE WHEN p_from_date <= start_date THEN 'cancelado' ELSE status END,
updated_at = now()
WHERE id = p_recurrence_id;
END $$;
DROP FUNCTION IF EXISTS public.cancelar_eventos_serie(uuid, timestamptz);
CREATE FUNCTION public.cancelar_eventos_serie(p_tenant_id uuid, p_serie_id uuid, p_a_partir_de timestamptz DEFAULT now())
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_count integer;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
UPDATE agenda_eventos SET status='cancelado', updated_at=now()
WHERE serie_id = p_serie_id AND inicio_em >= p_a_partir_de AND status NOT IN ('realizado','cancelado');
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END $$;
DROP FUNCTION IF EXISTS public.split_recurrence_at(uuid, date);
CREATE FUNCTION public.split_recurrence_at(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_old RECORD; v_new_id uuid;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_old FROM recurrence_rules WHERE id = p_recurrence_id;
IF NOT FOUND THEN RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id; END IF;
UPDATE recurrence_rules SET end_date = p_from_date - INTERVAL '1 day', open_ended=false, updated_at=now()
WHERE id = p_recurrence_id;
INSERT INTO recurrence_rules (owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min, start_date, end_date, max_occurrences, open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status)
SELECT owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min, p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status
FROM recurrence_rules WHERE id = p_recurrence_id
RETURNING id INTO v_new_id;
RETURN v_new_id;
END $$;
-- can_delete_patient: SQL sem SET search_path → herda o do chamador (schema).
-- Unqualified pra resolver no schema do tenant ativo.
CREATE OR REPLACE FUNCTION public.can_delete_patient(p_patient_id uuid)
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT NOT EXISTS (
SELECT 1 FROM agenda_eventos WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM recurrence_rules WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM billing_contracts WHERE patient_id = p_patient_id
);
$$;
DROP FUNCTION IF EXISTS public.safe_delete_patient(uuid);
CREATE FUNCTION public.safe_delete_patient(p_tenant_id uuid, p_patient_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
IF NOT public.can_delete_patient(p_patient_id) THEN
RETURN jsonb_build_object('ok',false,'error','has_history',
'message','Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.');
END IF;
-- ownership: owner_id direto ou responsible_member do caller (tenant_members é GLOBAL)
IF NOT EXISTS (SELECT 1 FROM patients
WHERE id = p_patient_id AND (owner_id = auth.uid()
OR responsible_member_id IN (SELECT id FROM public.tenant_members WHERE user_id = auth.uid()))) THEN
RETURN jsonb_build_object('ok',false,'error','forbidden','message','Sem permissão para excluir este paciente.');
END IF;
DELETE FROM patients WHERE id = p_patient_id;
RETURN jsonb_build_object('ok',true);
END $$;
DROP FUNCTION IF EXISTS public.export_patient_data(uuid);
CREATE FUNCTION public.export_patient_data(p_tenant_id uuid, p_patient_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_patient RECORD; v_caller uuid; v_result jsonb;
BEGIN
v_caller := auth.uid();
IF v_caller IS NULL THEN RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE='28000'; END IF;
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_patient FROM patients WHERE id = p_patient_id;
IF NOT FOUND THEN RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE='P0002'; END IF;
v_result := jsonb_build_object(
'export_metadata', jsonb_build_object('generated_at', now(), 'generated_by', v_caller,
'tenant_id', p_tenant_id, 'patient_id', p_patient_id,
'lgpd_basis','Art. 18, II - portabilidade dos dados do titular',
'controller','AgenciaPSI - Clinica responsavel','format_version','1.0'),
'paciente', to_jsonb(v_patient),
'contatos', COALESCE((SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at) FROM patient_contacts pc WHERE pc.patient_id = p_patient_id),'[]'::jsonb),
'contatos_apoio', COALESCE((SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at) FROM patient_support_contacts psc WHERE psc.patient_id = p_patient_id),'[]'::jsonb),
'historico_status', COALESCE((SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em) FROM patient_status_history psh WHERE psh.patient_id = p_patient_id),'[]'::jsonb),
'timeline', COALESCE((SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em) FROM patient_timeline pt WHERE pt.patient_id = p_patient_id),'[]'::jsonb),
'descontos', COALESCE((SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at) FROM patient_discounts pd WHERE pd.patient_id = p_patient_id),'[]'::jsonb),
'eventos_agenda', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',ae.id,'tipo',ae.tipo,'status',ae.status,'inicio_em',ae.inicio_em,'fim_em',ae.fim_em) ORDER BY ae.inicio_em) FROM agenda_eventos ae WHERE ae.patient_id = p_patient_id),'[]'::jsonb),
'documentos', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',d.id,'nome',d.nome_original,'tipo',d.tipo_documento,'criado_em',d.created_at) ORDER BY d.created_at) FROM documents d WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL),'[]'::jsonb),
'financeiro', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',fr.id,'tipo',fr.type,'valor',fr.final_amount,'status',fr.status,'vencimento',fr.due_date) ORDER BY fr.created_at) FROM financial_records fr WHERE fr.patient_id = p_patient_id AND fr.deleted_at IS NULL),'[]'::jsonb)
);
RETURN v_result;
END $$;
DROP FUNCTION IF EXISTS public.search_global(text, text[], integer);
CREATE FUNCTION public.search_global(p_tenant_id uuid, p_q text, p_scope text[] DEFAULT NULL, p_limit integer DEFAULT 8)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_q text; v_pattern text; v_limit int;
v_patients jsonb:='[]'::jsonb; v_appointments jsonb:='[]'::jsonb; v_documents jsonb:='[]'::jsonb;
v_services jsonb:='[]'::jsonb; v_intakes jsonb:='[]'::jsonb;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
v_q := nullif(btrim(coalesce(p_q,'')),'');
IF v_q IS NULL OR length(v_q) < 2 THEN
RETURN jsonb_build_object('patients','[]'::jsonb,'appointments','[]'::jsonb,'documents','[]'::jsonb,'services','[]'::jsonb,'intakes','[]'::jsonb);
END IF;
v_q := left(v_q,80); v_pattern := '%'||v_q||'%'; v_limit := GREATEST(1, LEAST(coalesce(p_limit,8),20));
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_completo,
'sublabel',coalesce(nullif(email_principal,''),nullif(telefone,''),''),'avatar_url',avatar_url,
'deeplink','/therapist/patients/cadastro/'||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_patients
FROM (SELECT p.id,p.nome_completo,p.email_principal,p.telefone,p.avatar_url,
GREATEST(similarity(coalesce(p.nome_completo,''),v_q),similarity(coalesce(p.email_principal,''),v_q)*0.7,
similarity(coalesce(p.telefone,''),v_q)*0.5,similarity(coalesce(p.cpf,''),v_q)*0.6) AS score
FROM patients p WHERE p.nome_completo ILIKE v_pattern OR p.email_principal ILIKE v_pattern OR p.telefone ILIKE v_pattern OR p.cpf ILIKE v_pattern
ORDER BY score DESC, p.nome_completo ASC LIMIT v_limit) ranked;
END IF;
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',label,
'sublabel',trim(both ' · ' from coalesce(patient_name,')')||' · '||to_char(inicio_em,'DD/MM/YYYY HH24:MI')),
'deeplink','/therapist/agenda?event='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_appointments
FROM (SELECT e.id, coalesce(nullif(e.titulo_custom,''),nullif(e.titulo,''),'Sessão') AS label, e.inicio_em, pat.nome_completo AS patient_name,
GREATEST(similarity(coalesce(e.titulo,''),v_q),similarity(coalesce(e.titulo_custom,''),v_q),similarity(coalesce(pat.nome_completo,''),v_q)*0.9) AS score
FROM agenda_eventos e LEFT JOIN patients pat ON pat.id = e.patient_id
WHERE e.titulo ILIKE v_pattern OR e.titulo_custom ILIKE v_pattern OR pat.nome_completo ILIKE v_pattern
ORDER BY score DESC, e.inicio_em DESC LIMIT v_limit) ranked;
END IF;
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_original,
'sublabel',trim(both ' · ' from coalesce(patient_name,'')||' · '||coalesce(tipo_documento,'')),
'deeplink','/therapist/patients/'||patient_id::text||'/documents','score',round(score::numeric,3))),'[]'::jsonb) INTO v_documents
FROM (SELECT d.id,d.patient_id,d.nome_original,d.tipo_documento,pat.nome_completo AS patient_name,
GREATEST(similarity(coalesce(d.nome_original,''),v_q),similarity(coalesce(d.descricao,''),v_q)*0.7) AS score
FROM documents d LEFT JOIN patients pat ON pat.id = d.patient_id
WHERE d.nome_original ILIKE v_pattern OR d.descricao ILIKE v_pattern
ORDER BY score DESC, d.nome_original ASC LIMIT v_limit) ranked;
END IF;
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',name,
'sublabel',trim(both ' · ' from 'R$ '||to_char(price,'FM999G999G990D00')||' · '||coalesce(duration_min::text||' min','')),
'deeplink','/configuracoes/precificacao','score',round(score::numeric,3))),'[]'::jsonb) INTO v_services
FROM (SELECT s.id,s.name,s.price,s.duration_min,
GREATEST(similarity(coalesce(s.name,''),v_q),similarity(coalesce(s.description,''),v_q)*0.7) AS score
FROM services s WHERE s.active IS TRUE AND (s.name ILIKE v_pattern OR s.description ILIKE v_pattern)
ORDER BY score DESC, s.name ASC LIMIT v_limit) ranked;
END IF;
-- intakes: patient_intake_requests FICA em public (F1b) → qualifica + filtra tenant_id
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,
'label',coalesce(nullif(trim(nome_completo),''),'(sem nome)'),
'sublabel',trim(both ' · ' from coalesce(nullif(email_principal,''),nullif(telefone,''),'')||' · '||'recebido '||to_char(created_at,'DD/MM/YYYY')),
'deeplink','/therapist/patients/cadastro/recebidos?id='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_intakes
FROM (SELECT r.id,r.nome_completo,r.email_principal,r.telefone,r.created_at,
GREATEST(similarity(coalesce(r.nome_completo,''),v_q),similarity(coalesce(r.email_principal,''),v_q)*0.7,similarity(coalesce(r.telefone,''),v_q)*0.5) AS score
FROM public.patient_intake_requests r
WHERE r.tenant_id = p_tenant_id AND r.status='new'
AND (r.nome_completo ILIKE v_pattern OR r.email_principal ILIKE v_pattern OR r.telefone ILIKE v_pattern)
ORDER BY score DESC, r.created_at DESC LIMIT v_limit) ranked;
END IF;
RETURN jsonb_build_object('patients',v_patients,'appointments',v_appointments,'documents',v_documents,'services',v_services,'intakes',v_intakes);
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- GRUPO 3 — RETURNS <tabela_tenant> → jsonb (ripple no FE)
-- ───────────────────────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.mark_as_paid(uuid, text);
CREATE FUNCTION public.mark_as_paid(p_tenant_id uuid, p_financial_record_id uuid, p_payment_method text)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_record RECORD;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_record FROM financial_records WHERE id = p_financial_record_id AND owner_id = auth.uid() AND deleted_at IS NULL;
IF NOT FOUND THEN RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.'; END IF;
IF v_record.status NOT IN ('pending','overdue') THEN RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.'; END IF;
UPDATE financial_records SET status='paid', paid_at=now(), payment_method=p_payment_method, updated_at=now()
WHERE id = p_financial_record_id RETURNING * INTO v_record;
RETURN to_jsonb(v_record);
END $$;
DROP FUNCTION IF EXISTS public.create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date);
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_existing RECORD; v_new RECORD;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_existing FROM financial_records WHERE agenda_evento_id = p_agenda_evento_id AND deleted_at IS NULL AND status != 'cancelled' LIMIT 1;
IF FOUND THEN RETURN to_jsonb(v_existing); END IF;
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, amount, discount_amount, final_amount, status, due_date)
VALUES (p_owner_id, p_patient_id, p_agenda_evento_id, p_amount, 0, p_amount, 'pending', p_due_date)
RETURNING * INTO v_new;
UPDATE agenda_eventos SET billed = TRUE WHERE id = p_agenda_evento_id;
RETURN to_jsonb(v_new);
END $$;
DROP FUNCTION IF EXISTS public.mark_payout_as_paid(uuid);
CREATE FUNCTION public.mark_payout_as_paid(p_tenant_id uuid, p_payout_id uuid)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_payout RECORD;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT * INTO v_payout FROM therapist_payouts WHERE id = p_payout_id;
IF NOT FOUND THEN RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id; END IF;
IF NOT public.is_tenant_admin(p_tenant_id) THEN RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.'; END IF;
IF v_payout.status <> 'pending' THEN RAISE EXCEPTION 'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.', v_payout.status; END IF;
UPDATE therapist_payouts SET status='paid', paid_at=now(), updated_at=now() WHERE id = p_payout_id RETURNING * INTO v_payout;
RETURN to_jsonb(v_payout);
END $$;
DROP FUNCTION IF EXISTS public.create_therapist_payout(uuid, uuid, date, date);
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_payout RECORD; v_total int; v_gross numeric(10,2); v_clinic_fee numeric(10,2); v_net numeric(10,2);
BEGIN
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.';
END IF;
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
IF EXISTS (SELECT 1 FROM therapist_payouts WHERE owner_id=p_therapist_id AND period_start=p_period_start AND period_end=p_period_end AND status<>'cancelled') THEN
RAISE EXCEPTION 'Já existe um repasse ativo para o período % a % deste terapeuta.', p_period_start, p_period_end;
END IF;
SELECT COUNT(*), COALESCE(SUM(amount),0), COALESCE(SUM(clinic_fee_amount),0), COALESCE(SUM(net_amount),0)
INTO v_total, v_gross, v_clinic_fee, v_net
FROM financial_records fr
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
IF v_total = 0 THEN RAISE EXCEPTION 'Nenhum registro financeiro elegível encontrado para o período % a %.', p_period_start, p_period_end; END IF;
INSERT INTO therapist_payouts (owner_id, period_start, period_end, total_sessions, gross_amount, clinic_fee_total, net_amount, status)
VALUES (p_therapist_id, p_period_start, p_period_end, v_total, v_gross, v_clinic_fee, v_net, 'pending')
RETURNING * INTO v_payout;
INSERT INTO therapist_payout_records (payout_id, financial_record_id)
SELECT v_payout.id, fr.id FROM financial_records fr
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
RETURN to_jsonb(v_payout);
END $$;
COMMIT;
@@ -0,0 +1,308 @@
-- =============================================================================
-- F6.2 Lote E — RPCs de cron/global roteadas/loopadas por tenant
--
-- ⚠️ APLICAR COMO supabase_admin.
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql
--
-- E1: chamadas per-tenant pelas edge crons → p_tenant_id + set_config search_path
-- (helper public._tenant_route do Lote D). Edge ajustada (admin.rpc + p_tenant_id).
-- E2: crons sem-arg que varrem TODOS os tenants → loop FROM tenant_schemas.
-- =============================================================================
BEGIN;
-- helper SEM checagem de auth: resolve schema pra RPCs de SERVIÇO (chamadas por
-- service_role/edge, que não é tenant_member). Protegido por REVOKE das RPCs de
-- anon/authenticated (só service_role/postgres chamam).
CREATE OR REPLACE FUNCTION public._tenant_schema_unchecked(p_tenant_id uuid)
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_schema text;
BEGIN
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
v_schema := public.tenant_schema_for(p_tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
RETURN v_schema;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- E2 — crons globais: varrem todos os schemas
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.cleanup_notification_queue()
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int; v_total int := 0;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('DELETE FROM %I.notification_queue WHERE status IN (''enviado'',''cancelado'',''ignorado'') AND created_at < now() - interval ''90 days''', t.schema_name);
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
END LOOP;
RETURN v_total;
END $$;
CREATE OR REPLACE FUNCTION public.unstick_notification_queue()
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int; v_total int := 0;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_queue SET status=''pendente'', attempts=attempts+1, last_error=''Timeout: preso em processando por >10min'', next_retry_at=now()+interval ''2 minutes'' WHERE status=''processando'' AND updated_at < now() - interval ''10 minutes''', t.schema_name);
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
END LOOP;
RETURN v_total;
END $$;
CREATE OR REPLACE FUNCTION public.sync_overdue_financial_records()
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int; v_total int := 0;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.financial_records SET status=''overdue'', updated_at=now() WHERE status=''pending'' AND due_date IS NOT NULL AND due_date < CURRENT_DATE AND deleted_at IS NULL', t.schema_name);
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
END LOOP;
RETURN v_total;
END $$;
-- populate: complexo (multi-tabela). set_config search_path por tenant; profiles
-- é GLOBAL → qualificado. Remove tenant_id do INSERT e do SELECT.
CREATE OR REPLACE FUNCTION public.populate_notification_queue()
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
PERFORM set_config('search_path', t.schema_name || ',public,pg_temp', true);
INSERT INTO notification_queue (
owner_id, agenda_evento_id, patient_id, channel, template_key, schedule_key,
resolved_vars, recipient_address, scheduled_at, idempotency_key)
SELECT
ae.owner_id, ae.id, ae.patient_id, ch.channel,
'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
ns.schedule_key,
jsonb_build_object('nome_paciente', COALESCE(p.nome_completo,'Paciente'),
'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','DD/MM/YYYY'),
'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','HH24:MI'),
'nome_terapeuta', COALESCE(prof.full_name,'Terapeuta'),
'modalidade', COALESCE(ae.modalidade,'Presencial'),
'titulo', COALESCE(ae.titulo,'Sessão')),
CASE ch.channel WHEN 'whatsapp' THEN COALESCE(p.telefone,'') WHEN 'sms' THEN COALESCE(p.telefone,'') WHEN 'email' THEN COALESCE(p.email_principal,'') END,
CASE
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time < ns.allowed_time_start
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time > ns.allowed_time_end
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
ELSE ae.inicio_em - (ns.offset_minutes||' minutes')::interval END,
ae.id::text||':'||ns.schedule_key||':'||ch.channel||':'||ae.inicio_em::date::text
FROM agenda_eventos ae
JOIN patients p ON p.id = ae.patient_id
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id -- GLOBAL
JOIN notification_schedules ns ON ns.owner_id = ae.owner_id AND ns.is_active=true AND ns.deleted_at IS NULL AND ns.trigger_type='before_event' AND ns.event_type='lembrete_sessao'
JOIN notification_channels nc ON nc.owner_id = ae.owner_id AND nc.is_active=true AND nc.deleted_at IS NULL
CROSS JOIN LATERAL (
SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel='whatsapp'
UNION ALL SELECT 'email' WHERE ns.email_enabled AND nc.channel='email'
UNION ALL SELECT 'sms' WHERE ns.sms_enabled AND nc.channel='sms') ch
LEFT JOIN notification_preferences np ON np.patient_id = ae.patient_id AND np.owner_id = ae.owner_id AND np.deleted_at IS NULL
WHERE ae.tipo = 'sessao' AND ae.status NOT IN ('cancelado','realizado') AND ae.inicio_em > now()
AND (ae.inicio_em - (ns.offset_minutes||' minutes')::interval) > now()
ON CONFLICT (idempotency_key) DO NOTHING;
END LOOP;
END $$;
-- ───────────────────────────────────────────────────────────────────────────
-- E1 — chamadas per-tenant (p_tenant_id + route)
-- ───────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamptz, p_threshold_minutes integer)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_existing_id uuid; v_new_id uuid;
BEGIN
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN RAISE EXCEPTION 'tenant_and_thread_required'; END IF;
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
SELECT id INTO v_existing_id FROM conversation_sla_breaches WHERE thread_key = p_thread_key AND resolved_at IS NULL;
IF FOUND THEN
UPDATE conversation_sla_breaches SET assigned_to = COALESCE(p_assigned_to, assigned_to), last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at) WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
INSERT INTO conversation_sla_breaches (thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
VALUES (p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes) RETURNING id INTO v_new_id;
RETURN v_new_id;
END $$;
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid);
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid,uuid);
CREATE FUNCTION public.sla_mark_notified(p_tenant_id uuid, p_breach_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
UPDATE conversation_sla_breaches SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_breach_id;
END $$;
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, text, text, jsonb);
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, uuid, text, text, jsonb);
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_tenant_id uuid, p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL, p_details jsonb DEFAULT NULL)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_provider text; v_existing_id uuid; v_new_id uuid;
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
SELECT provider INTO v_provider FROM notification_channels WHERE id = p_channel_id AND deleted_at IS NULL;
IF NOT FOUND THEN RAISE EXCEPTION 'channel_not_found'; END IF;
IF p_kind NOT IN ('disconnected','error','qr_pending','connecting','unknown') THEN RAISE EXCEPTION 'invalid_kind: %', p_kind; END IF;
SELECT id INTO v_existing_id FROM whatsapp_connection_incidents WHERE channel_id = p_channel_id AND resolved_at IS NULL;
IF FOUND THEN
UPDATE whatsapp_connection_incidents SET last_state = COALESCE(p_last_state, last_state), details = COALESCE(p_details, details), kind = p_kind WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
INSERT INTO whatsapp_connection_incidents (channel_id, provider, kind, last_state, details)
VALUES (p_channel_id, v_provider, p_kind, p_last_state, p_details) RETURNING id INTO v_new_id;
RETURN v_new_id;
END $$;
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid);
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid,uuid);
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_tenant_id uuid, p_incident_id uuid)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
UPDATE whatsapp_connection_incidents SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_incident_id;
END $$;
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid);
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid,uuid);
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_tenant_id uuid, p_channel_id uuid)
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_count int := 0;
BEGIN
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
UPDATE whatsapp_connection_incidents SET resolved_at = now(), duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
WHERE channel_id = p_channel_id AND resolved_at IS NULL;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END $$;
-- convert_abandoned_intake_to_lead: resolve o tenant INTERNAMENTE (intake.owner_id
-- -> tenant_members). patient_intake_requests FICA em public (F1b). Writes de
-- conversation_messages/notes vão pro schema do tenant resolvido.
CREATE OR REPLACE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_intake RECORD; v_tenant_id uuid; v_schema text; v_thread_key text; v_phone text;
v_note_body text; v_admin_id uuid;
BEGIN
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::uuid; END IF;
SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_intake.owner_id
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END LIMIT 1;
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
v_schema := public.tenant_schema_for(v_tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema_not_found'; END IF;
v_phone := regexp_replace(COALESCE(v_intake.telefone,''),'\D','','g');
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55'||v_phone; END IF;
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
v_thread_key := 'anon:'||v_phone;
v_note_body := format('📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
E'\n',E'\n', COALESCE(v_intake.nome_completo,''), E'\n', COALESCE(v_intake.telefone,''), E'\n',
COALESCE(v_intake.email_principal,''), E'\n', COALESCE(v_intake.onde_nos_conheceu,''), E'\n',E'\n',
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'),
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'));
SELECT user_id INTO v_admin_id FROM public.tenant_members
WHERE tenant_id = v_tenant_id AND role IN ('tenant_admin','clinic_admin') AND status='active' LIMIT 1;
IF v_admin_id IS NULL THEN v_admin_id := v_intake.owner_id; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
INSERT INTO conversation_messages (channel, direction, from_number, to_number, body, provider, provider_raw, kanban_status)
VALUES ('whatsapp','inbound', CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, NULL,
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.', COALESCE(v_intake.nome_completo,'Visitante')),
'system', jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id), 'awaiting_us');
INSERT INTO conversation_notes (thread_key, contact_number, body, created_by)
VALUES (v_thread_key, CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, v_note_body, v_admin_id);
UPDATE public.patient_intake_requests SET status='abandoned_lead', lead_thread_key=v_thread_key, updated_at=now() WHERE id = p_intake_id;
RETURN p_intake_id;
END $$;
-- first_response analytics: routam pelo p_tenant_id (cada função seta o seu próprio
-- search_path — _first_response_runs tem SET search_path próprio que resetaria).
CREATE OR REPLACE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamptz, p_to timestamptz)
RETURNS TABLE(thread_key text, inbound_started_at timestamptz, responded_at timestamptz, response_seconds integer, responder_id uuid)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
WITH base AS (
SELECT COALESCE(m.patient_id::text, 'anon:' || COALESCE(CASE WHEN m.direction='inbound' THEN m.from_number ELSE m.to_number END, 'unknown')) AS thread_key,
m.direction, m.created_at
FROM conversation_messages m
WHERE m.created_at >= p_from AND m.created_at < p_to
),
inbound AS (
SELECT b.thread_key AS tk, min(b.created_at) AS inbound_started_at
FROM base b WHERE b.direction='inbound' GROUP BY b.thread_key
)
SELECT i.tk, i.inbound_started_at,
(SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) AS responded_at,
EXTRACT(EPOCH FROM ((SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) - i.inbound_started_at))::int AS response_seconds,
a.assigned_to AS responder_id
FROM inbound i
LEFT JOIN conversation_assignments a ON a.thread_key = i.tk;
END $$;
CREATE OR REPLACE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamptz DEFAULT (now() - interval '30 days'), p_to timestamptz DEFAULT now(), p_therapist_id uuid DEFAULT NULL)
RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_threshold_min integer;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT threshold_minutes INTO v_threshold_min FROM conversation_sla_rules LIMIT 1;
v_threshold_min := COALESCE(v_threshold_min, 30);
RETURN QUERY
WITH runs AS (
SELECT r.response_seconds FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE r.responded_at IS NOT NULL AND (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
)
SELECT count(*)::int,
COALESCE(avg(response_seconds),0)::int,
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY response_seconds),0)::int,
COALESCE(min(response_seconds),0)::int,
COALESCE(max(response_seconds),0)::int,
(v_threshold_min*60)::int,
count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)::int,
CASE WHEN count(*)=0 THEN 0 ELSE round(100.0*count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)/count(*),1) END
FROM runs;
END $$;
-- RPCs de serviço (cron/edge): só service_role/postgres. Sem checagem de membership.
DO $g$
DECLARE fn text;
BEGIN
FOREACH fn IN ARRAY ARRAY[
'sla_open_breach(uuid,text,uuid,timestamptz,integer)',
'sla_mark_notified(uuid,uuid)',
'whatsapp_heartbeat_open_incident(uuid,uuid,text,text,jsonb)',
'whatsapp_heartbeat_mark_notified(uuid,uuid)',
'whatsapp_heartbeat_resolve_open_incidents(uuid,uuid)',
'convert_abandoned_intake_to_lead(uuid)',
'cleanup_notification_queue()','unstick_notification_queue()',
'sync_overdue_financial_records()','populate_notification_queue()'
] LOOP
EXECUTE format('REVOKE ALL ON FUNCTION public.%s FROM PUBLIC, anon, authenticated', fn);
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO service_role', fn);
END LOOP;
END $g$;
COMMIT;
@@ -0,0 +1,247 @@
-- =============================================================================
-- F6.2 Lote F — RPCs anon/token: resolvem tenant por token/slug e roteiam
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- Visitante anon não está logado → cada RPC resolve o tenant a partir do
-- token/slug do registro que VIVE em public (F1b: document_share_links,
-- agendador_configuracoes — ambos têm tenant_id), depois set_config search_path
-- pro schema só pras tabelas tenant (documents, document_signatures,
-- document_access_logs, patients, agenda_*, recurrence_*).
-- Tabelas que ficam em public seguem qualificadas (document_share_links,
-- agendador_configuracoes/solicitacoes).
-- %ROWTYPE de tabelas tenant → RECORD; RETURNS document_signatures → jsonb.
-- list_my_signatures é cross-tenant (assinante em vários tenants) → fan-out.
-- =============================================================================
BEGIN;
-- ── Documentos: tenant via document_share_links.tenant_id (public) ──────────
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE sl public.document_share_links%ROWTYPE; v_doc RECORD; v_token text; v_schema text;
BEGIN
v_token := nullif(btrim(coalesce(p_token,'')),'');
IF v_token IS NULL THEN RAISE EXCEPTION 'token obrigatório' USING ERRCODE='22023'; END IF;
SELECT * INTO sl FROM public.document_share_links WHERE token = v_token LIMIT 1;
IF NOT FOUND THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='28000'; END IF;
IF sl.ativo IS NOT TRUE THEN RAISE EXCEPTION 'Link desativado' USING ERRCODE='28000'; END IF;
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN RAISE EXCEPTION 'Link expirado' USING ERRCODE='28000'; END IF;
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE='28000'; END IF;
v_schema := public.tenant_schema_for(sl.tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='28000'; END IF;
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = sl.id;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
BEGIN
INSERT INTO document_access_logs (documento_id, action, share_link_id)
VALUES (sl.documento_id, 'shared_link_access', sl.id);
EXCEPTION WHEN OTHERS THEN NULL; END;
SELECT * INTO v_doc FROM documents WHERE id = sl.documento_id;
RETURN jsonb_build_object('document_id', sl.documento_id, 'bucket', v_doc.storage_bucket,
'bucket_path', v_doc.bucket_path, 'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type, 'tamanho_bytes', v_doc.tamanho_bytes);
END $$;
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(p_token text)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_link public.document_share_links%ROWTYPE; v_doc RECORD; v_sigs jsonb; v_schema text;
BEGIN
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
IF v_link.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid'); END IF;
v_schema := public.tenant_schema_for(v_link.tenant_id);
IF v_schema IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'tenant_invalid'); END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
SELECT * INTO v_doc FROM documents WHERE id = v_link.documento_id AND deleted_at IS NULL LIMIT 1;
IF v_doc.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'document_not_found'); END IF;
SELECT jsonb_agg(jsonb_build_object('id',s.id,'signatario_tipo',s.signatario_tipo,'signatario_nome',s.signatario_nome,
'signatario_email',s.signatario_email,'ordem',s.ordem,'status',s.status,'assinado_em',s.assinado_em) ORDER BY s.ordem) INTO v_sigs
FROM document_signatures s WHERE s.documento_id = v_doc.id;
RETURN jsonb_build_object('valid', true,
'document', jsonb_build_object('id',v_doc.id,'nome_original',v_doc.nome_original,'mime_type',v_doc.mime_type,
'tamanho_bytes',v_doc.tamanho_bytes,'bucket_path',v_doc.bucket_path,'storage_bucket',v_doc.storage_bucket,'tipo_documento',v_doc.tipo_documento),
'signatures', COALESCE(v_sigs,'[]'::jsonb), 'expira_em', v_link.expira_em, 'usos_restantes', v_link.usos_max - v_link.usos);
END $$;
DROP FUNCTION IF EXISTS public.sign_document_by_token(text, uuid, text);
CREATE FUNCTION public.sign_document_by_token(p_token text, p_signature_id uuid DEFAULT NULL, p_hash_documento text DEFAULT NULL)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_link public.document_share_links%ROWTYPE; v_sig RECORD; v_ip inet; v_ua text; v_schema text;
BEGIN
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
IF v_link.id IS NULL THEN RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE='P0002'; END IF;
v_schema := public.tenant_schema_for(v_link.tenant_id);
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='P0002'; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
IF p_signature_id IS NOT NULL THEN
SELECT * INTO v_sig FROM document_signatures WHERE id = p_signature_id AND documento_id = v_link.documento_id AND status IN ('pendente','enviado') LIMIT 1;
ELSE
SELECT * INTO v_sig FROM document_signatures WHERE documento_id = v_link.documento_id AND status IN ('pendente','enviado') ORDER BY ordem ASC, criado_em ASC LIMIT 1;
END IF;
IF v_sig.id IS NULL THEN RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE='P0002'; END IF;
v_ip := inet_client_addr();
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
WHERE id = v_sig.id RETURNING * INTO v_sig;
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = v_link.id;
RETURN to_jsonb(v_sig);
END $$;
-- sign_document_by_signature_id: assinante LOGADO (paciente OU therapist) via
-- portal. Paciente NÃO é tenant_member → routing UNCHECKED (p_tenant_id vem do
-- FE, da própria assinatura listada). Autorização é por LINHA: só assina se for
-- o signatário (signatario_id = uid OU email do uid).
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, text);
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, uuid, text);
CREATE FUNCTION public.sign_document_by_signature_id(p_tenant_id uuid, p_signature_id uuid, p_hash_documento text DEFAULT NULL)
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_row RECORD; v_ip inet; v_ua text; v_uid uuid; v_email text;
BEGIN
IF p_signature_id IS NULL THEN RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE='22023'; END IF;
v_uid := auth.uid();
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
SELECT email INTO v_email FROM auth.users WHERE id = v_uid;
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
v_ip := inet_client_addr();
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
WHERE id = p_signature_id AND status IN ('pendente','enviado')
AND (signatario_id = v_uid OR signatario_email = v_email
OR documento_id IN (SELECT d.id FROM documents d JOIN patients p ON p.id = d.patient_id WHERE p.user_id = v_uid))
RETURNING * INTO v_row;
IF v_row.id IS NULL THEN RAISE EXCEPTION 'Assinatura não encontrada, já processada, ou sem permissão' USING ERRCODE='P0002'; END IF;
RETURN to_jsonb(v_row);
END $$;
-- list_my_signatures: cross-tenant. Fan-out por schema (tenant_id injetado do loop).
-- document_share_links é GLOBAL (public). Ordenação global é aproximada (por schema).
CREATE OR REPLACE FUNCTION public.list_my_signatures(p_status text[] DEFAULT NULL)
RETURNS TABLE(signature_id uuid, documento_id uuid, tenant_id uuid, signatario_tipo text, status text, ordem smallint, assinado_em timestamptz, criado_em timestamptz, nome_original text, tipo_documento text, mime_type text, share_token text, share_expira_em timestamptz)
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_uid uuid; t record;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
FOR t IN SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts LOOP
RETURN QUERY EXECUTE format(
'SELECT s.id, s.documento_id, $2::uuid, s.signatario_tipo, s.status, s.ordem, s.assinado_em, s.criado_em, '
|| 'd.nome_original, d.tipo_documento, d.mime_type, sl.token, sl.expira_em '
|| 'FROM %1$I.document_signatures s '
|| 'JOIN %1$I.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL '
|| 'LEFT JOIN LATERAL (SELECT token, expira_em FROM public.document_share_links WHERE documento_id = d.id AND ativo=true AND expira_em > now() AND usos < usos_max ORDER BY criado_em DESC LIMIT 1) sl ON true '
|| 'WHERE (s.signatario_id = $1 OR s.signatario_email = (SELECT email FROM auth.users WHERE id=$1) '
|| 'OR d.patient_id IN (SELECT p.id FROM %1$I.patients p WHERE p.user_id = $1)) '
|| 'AND ($3::text[] IS NULL OR s.status = ANY($3))',
t.schema_name)
USING v_uid, t.tenant_id, p_status;
END LOOP;
END $$;
-- ── match_patient_by_phone: service (edge), p_tenant_id → unchecked ──────────
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id uuid, p_phone text)
RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_normalized text; v_patient_id uuid;
BEGIN
v_normalized := public.normalize_phone_br(p_phone);
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN RETURN NULL; END IF;
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone) = v_normalized LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_alternativo) = v_normalized LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_responsavel) = v_normalized LIMIT 1;
RETURN v_patient_id;
END $$;
-- ── Agendador público: tenant via agendador_configuracoes.tenant_id (public) ─
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer)
RETURNS TABLE(data date, tem_slots boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_antecedencia int; v_agora timestamptz;
v_data date; v_data_inicio date; v_data_fim date; v_db_dow int; v_tem_slot boolean; v_bloqueado boolean;
BEGIN
SELECT c.owner_id, c.tenant_id, c.antecedencia_minima_horas INTO v_owner_id, v_tenant_id, v_antecedencia
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_schema := public.tenant_schema_for(v_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
v_agora := now(); v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date; v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
v_db_dow := extract(dow from v_data::timestamp)::int;
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=v_data AND COALESCE(b.data_fim,v_data)>=v_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_bloqueado;
IF v_bloqueado THEN v_data := v_data + 1; CONTINUE; END IF;
SELECT EXISTS (SELECT 1 FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true
AND (v_data::text||' '||s.time::text)::timestamp AT TIME ZONE 'America/Sao_Paulo' >= v_agora + (v_antecedencia||' hours')::interval) INTO v_tem_slot;
IF v_tem_slot THEN data := v_data; tem_slots := true; RETURN NEXT; END IF;
v_data := v_data + 1;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date)
RETURNS TABLE(hora time without time zone, disponivel boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_duracao int; v_antecedencia int; v_agora timestamptz;
v_db_dow int; v_slot time; v_slot_fim time; v_slot_ts timestamptz; v_ocupado boolean;
v_rule RECORD; v_rule_start_dow int; v_first_occ date; v_day_diff int; v_ex_type text;
BEGIN
SELECT c.owner_id, c.tenant_id, c.duracao_sessao_min, c.antecedencia_minima_horas
INTO v_owner_id, v_tenant_id, v_duracao, v_antecedencia
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_schema := public.tenant_schema_for(v_tenant_id);
IF v_schema IS NULL THEN RETURN; END IF;
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
v_agora := now(); v_db_dow := extract(dow from p_data::timestamp)::int;
IF EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) THEN RETURN; END IF;
FOR v_slot IN SELECT s.time FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true ORDER BY s.time LOOP
v_slot_fim := v_slot + (v_duracao||' minutes')::interval; v_ocupado := false;
v_slot_ts := (p_data::text||' '||v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia||' hours')::interval THEN v_ocupado := true; END IF;
IF NOT v_ocupado THEN
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NOT NULL AND b.hora_inicio<v_slot_fim AND b.hora_fim>v_slot AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_ocupado;
END IF;
IF NOT v_ocupado THEN
SELECT EXISTS (SELECT 1 FROM agenda_eventos e WHERE e.owner_id=v_owner_id AND e.status::text NOT IN ('cancelado','faltou') AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date=p_data AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time<v_slot_fim AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time>v_slot) INTO v_ocupado;
END IF;
IF NOT v_ocupado THEN
FOR v_rule IN SELECT r.id, r.start_date::date AS start_date, r.end_date::date AS end_date, r.start_time::time AS start_time, r.end_time::time AS end_time, COALESCE(r.interval,1)::int AS interval
FROM recurrence_rules r WHERE r.owner_id=v_owner_id AND r.status='ativo' AND p_data>=r.start_date::date AND (r.end_date IS NULL OR p_data<=r.end_date::date) AND v_db_dow=ANY(r.weekdays) AND r.start_time::time<v_slot_fim AND r.end_time::time>v_slot LOOP
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
v_first_occ := v_rule.start_date + (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
v_day_diff := (p_data - v_first_occ)::int;
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
v_ex_type := NULL;
SELECT ex.type INTO v_ex_type FROM recurrence_exceptions ex WHERE ex.recurrence_id=v_rule.id AND ex.original_date=p_data LIMIT 1;
IF v_ex_type IS NULL OR v_ex_type NOT IN ('cancel_session','patient_missed','therapist_canceled','holiday_block','reschedule_session') THEN v_ocupado := true; EXIT; END IF;
END IF;
END LOOP;
END IF;
IF NOT v_ocupado THEN
SELECT EXISTS (SELECT 1 FROM recurrence_exceptions ex JOIN recurrence_rules r ON r.id=ex.recurrence_id WHERE r.owner_id=v_owner_id AND r.status='ativo' AND ex.type='reschedule_session' AND ex.new_date=p_data AND COALESCE(ex.new_start_time,r.start_time)::time<v_slot_fim AND COALESCE(ex.new_end_time,r.end_time)::time>v_slot) INTO v_ocupado;
END IF;
IF NOT v_ocupado THEN
-- agendador_solicitacoes FICA em public (F1b)
SELECT EXISTS (SELECT 1 FROM public.agendador_solicitacoes sol WHERE sol.owner_id=v_owner_id AND sol.status='pendente' AND sol.data_solicitada=p_data AND sol.hora_solicitada=v_slot AND (sol.reservado_ate IS NULL OR sol.reservado_ate>v_agora)) INTO v_ocupado;
END IF;
hora := v_slot; disponivel := NOT v_ocupado; RETURN NEXT;
END LOOP;
END $$;
-- match_patient_by_phone só pra service_role (edge)
REVOKE ALL ON FUNCTION public.match_patient_by_phone(uuid, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.match_patient_by_phone(uuid, text) TO service_role;
COMMIT;
@@ -0,0 +1,126 @@
-- =============================================================================
-- F6.2 Lote G — funções SQL puras → plpgsql + roteamento por tenant
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- SQL puro não permite set_config dinâmico do search_path (limitação 3 do
-- blueprint) → converter pra plpgsql. Adicionam p_tenant_id + _tenant_route.
-- RETURNS SETOF <tabela_tenant> → jsonb. get_entity_primary_phone (interno,
-- 0 callers) herda search_path do chamador (sem SET, unqualified).
-- =============================================================================
BEGIN;
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, integer, integer);
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, uuid, integer, integer);
CREATE FUNCTION public.get_financial_summary(p_tenant_id uuid, p_owner_id uuid, p_year integer, p_month integer)
RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
SELECT
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0),
COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
COALESCE(SUM(amount) FILTER (WHERE status IN ('pending','overdue')), 0),
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0) - COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
COALESCE(SUM(clinic_fee_amount) FILTER (WHERE type='receita' AND status='paid'), 0),
COUNT(*) FILTER (WHERE type='receita' AND deleted_at IS NULL),
COUNT(*) FILTER (WHERE type='despesa' AND deleted_at IS NULL)
FROM financial_records
WHERE owner_id = p_owner_id AND deleted_at IS NULL
AND EXTRACT(YEAR FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_year
AND EXTRACT(MONTH FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_month;
END $$;
-- list_financial_records: RETURNS SETOF financial_records → jsonb (array)
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, integer, integer, text, text, uuid, integer, integer);
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, uuid, integer, integer, text, text, uuid, integer, integer);
CREATE FUNCTION public.list_financial_records(p_tenant_id uuid, p_owner_id uuid, p_year integer DEFAULT NULL, p_month integer DEFAULT NULL, p_type text DEFAULT NULL, p_status text DEFAULT NULL, p_patient_id uuid DEFAULT NULL, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0)
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v_result jsonb;
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
SELECT COALESCE(jsonb_agg(row_json), '[]'::jsonb) INTO v_result FROM (
SELECT to_jsonb(fr) AS row_json
FROM financial_records fr
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
AND (p_type IS NULL OR fr.type::text = p_type)
AND (p_status IS NULL OR fr.status = p_status)
AND (p_patient_id IS NULL OR fr.patient_id = p_patient_id)
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_year)
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_month)
ORDER BY COALESCE(fr.paid_at, fr.due_date::timestamptz, fr.created_at) DESC
LIMIT p_limit OFFSET p_offset
) sub;
RETURN v_result;
END $$;
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid[]);
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid, uuid[]);
CREATE FUNCTION public.get_patient_session_counts(p_tenant_id uuid, p_patient_ids uuid[])
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
SELECT ae.patient_id, COUNT(*)::int, MAX(ae.inicio_em)
FROM agenda_eventos ae
WHERE ae.patient_id = ANY(p_patient_ids)
GROUP BY ae.patient_id;
END $$;
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, date, date, text);
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, uuid, date, date, text);
CREATE FUNCTION public.get_financial_report(p_tenant_id uuid, p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month')
RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
RETURN QUERY
WITH base AS (
SELECT fr.type, fr.amount, fr.final_amount, fr.status, fr.deleted_at,
CASE p_group_by
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
WHEN 'category' THEN COALESCE(fr.category_id::text, fr.category, 'sem_categoria')
WHEN 'patient' THEN COALESCE(fr.patient_id::text, 'sem_paciente')
ELSE NULL END AS gkey,
CASE p_group_by
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::text, 'Sem paciente')
ELSE NULL END AS glabel
FROM financial_records fr
LEFT JOIN financial_categories fc ON fc.id = fr.category_id
LEFT JOIN patients p ON p.id = fr.patient_id
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
AND COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date) BETWEEN p_start_date AND p_end_date
)
SELECT gkey, glabel,
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0),
COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0) - COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
COALESCE(SUM(final_amount) FILTER (WHERE status='pending'),0),
COALESCE(SUM(final_amount) FILTER (WHERE status='overdue'),0),
COUNT(*)
FROM base WHERE gkey IS NOT NULL GROUP BY gkey, glabel ORDER BY gkey ASC;
END $$;
-- get_entity_primary_phone: interno (0 callers). Sem SET search_path → herda do
-- chamador (que roteia pro schema). Unqualified. Mantém assinatura.
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid)
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT number FROM contact_phones
WHERE entity_type = p_entity_type AND entity_id = p_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
$$;
COMMIT;
@@ -0,0 +1,106 @@
-- =============================================================================
-- F6.2 (wiring) — tenants NOVOS nascem com todos os triggers de negócio
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- Até aqui os 9 schemas existentes ganharam os triggers via backfills (Lotes
-- A/B/C). Um tenant NOVO (clone_tenant_template) só ganhava channel-routing +
-- RLS. Este wiring:
-- 1. attach_agnostic_triggers → SELF-CONTAINED (dirigido por colunas, não lê
-- public; sobrevive ao DROP da F6.3).
-- 2. trigger AFTER INSERT em tenant_schemas dispara os 3 attach (agnostic +
-- schema_aware + notif) pro schema novo — clone_tenant_template não precisa
-- ser tocado (ele insere em tenant_schemas).
-- 3. provision_account_tenant: clone ANTES do seed (seed é no-op se o schema
-- não existe; precisa do schema criado primeiro).
-- =============================================================================
BEGIN;
-- 1) attach_agnostic_triggers self-contained ---------------------------------
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE r record; v_count int := 0;
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
-- set_updated_at em toda tabela do schema que tem coluna updated_at
FOR r IN
SELECT c.relname AS tab
FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid
WHERE c.relnamespace = p_schema::regnamespace AND c.relkind = 'r'
AND a.attname = 'updated_at' AND NOT a.attisdropped AND c.relname NOT LIKE '\_%'
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS set_updated_at ON %I.%I', p_schema, r.tab);
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()', p_schema, r.tab);
v_count := v_count + 1;
END LOOP;
-- prevent_* em patient_groups
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = p_schema AND table_name = 'patient_groups') THEN
EXECUTE format('DROP TRIGGER IF EXISTS prevent_promoting_to_system ON %I.patient_groups', p_schema);
EXECUTE format('CREATE TRIGGER prevent_promoting_to_system BEFORE UPDATE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_promoting_to_system()', p_schema);
EXECUTE format('DROP TRIGGER IF EXISTS prevent_system_group_changes ON %I.patient_groups', p_schema);
EXECUTE format('CREATE TRIGGER prevent_system_group_changes BEFORE UPDATE OR DELETE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_system_group_changes()', p_schema);
v_count := v_count + 2;
END IF;
RETURN v_count;
END $$;
-- 2) trigger de wiring em tenant_schemas -------------------------------------
GRANT EXECUTE ON FUNCTION public.attach_agnostic_triggers(text) TO postgres, service_role;
GRANT EXECUTE ON FUNCTION public.attach_schema_aware_triggers(text) TO postgres, service_role;
GRANT EXECUTE ON FUNCTION public.attach_notif_triggers(text) TO postgres, service_role;
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
PERFORM public.attach_notif_triggers(NEW.schema_name);
RETURN NULL;
END $$;
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
DROP TRIGGER IF EXISTS trg_tenant_schemas_attach ON public.tenant_schemas;
CREATE TRIGGER trg_tenant_schemas_attach
AFTER INSERT ON public.tenant_schemas
FOR EACH ROW EXECUTE FUNCTION public.trg_attach_business_triggers();
-- 3) provision_account_tenant: clone ANTES do seed ---------------------------
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_tenant_id uuid;
v_account_type text;
v_name text;
BEGIN
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind USING ERRCODE = 'P0001';
END IF;
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
IF EXISTS (
SELECT 1 FROM public.tenant_members tm JOIN public.tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = p_user_id AND tm.role = 'tenant_admin' AND tm.status = 'active' AND t.kind = p_kind
) THEN
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind USING ERRCODE = 'P0001';
END IF;
v_name := COALESCE(NULLIF(TRIM(p_name), ''),
(SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
FROM public.profiles pr JOIN auth.users au ON au.id = pr.id WHERE pr.id = p_user_id),
'Conta');
INSERT INTO public.tenants (name, kind, created_at) VALUES (v_name, p_kind, now()) RETURNING id INTO v_tenant_id;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
UPDATE public.profiles SET account_type = v_account_type WHERE id = p_user_id;
-- F6 wiring: clone PRIMEIRO (cria o schema), seed DEPOIS (escreve no schema)
PERFORM public.clone_tenant_template(v_tenant_id);
PERFORM public.seed_determined_commitments(v_tenant_id);
RETURN v_tenant_id;
END $$;
COMMIT;
+99
View File
@@ -0,0 +1,99 @@
# F6.3 — Rollback da migração schema-per-tenant
> Como voltar atrás. Lê isto ANTES de aplicar a F6.3 (o DROP). A regra de ouro:
> **enquanto a F6.3 NÃO foi aplicada, o rollback é trivial e sem perda.**
## Princípio: a branch é a rede de segurança
A migração inteira (F3→F6.4) vive na branch `feat/schema-per-tenant`. A `main`
**nunca mudou** o modelo antigo — só recebeu F0/F1/F2, que são **aditivas**
(criam `tenants.slug`, o schema `_tenant_template`, helpers e o gatilho de
provisionamento; não removem nem alteram nada que o app antigo usa). Ou seja:
**`git checkout main` te devolve o app funcionando no modelo public**, desde que
o banco também volte (ver abaixo).
O único passo IRREVERSÍVEL por si só é o **DROP** (F6.3). Tudo antes dele é
reversível porque os dados continuam **espelhados em public** (a F6.1 COPIA, não
move). Por isso o checkpoint é parar ANTES do DROP.
---
## Cenário 1 — ANTES de aplicar a F6.3 (estado atual) — rollback trivial
Nada destrutivo foi feito. Public tem todas as tabelas e dados originais. Para
abandonar a migração:
```bash
# 1. código volta pro modelo antigo
git checkout main
# 2. banco: derruba os schemas tenant + artefatos da migração
docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
psql -U supabase_admin -h 127.0.0.1 -d postgres <<'SQL'
DO $$ DECLARE r record; BEGIN
FOR r IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\_%' OR nspname='_tenant_template' LOOP
EXECUTE format('DROP SCHEMA %I CASCADE', r.nspname);
END LOOP;
END $$;
-- limpa o registro + config do PostgREST
DELETE FROM public.tenant_schemas;
ALTER ROLE authenticator SET pgrst.db_schemas = 'public, graphql_public';
NOTIFY pgrst, 'reload config';
SQL
```
⚠️ As FUNÇÕES em public foram reescritas (F6.2) na branch, mas essas mudanças
**não foram aplicadas via migration em `main`** — elas vieram dos arquivos
`manual/f6_2*.sql` aplicados como supabase_admin no banco LOCAL. Se você quer o
banco local 100% igual ao `main`, restaure as funções originais do backup:
```bash
# restaura o estado public pré-migração (funções, triggers, tudo)
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
< database-novo/backups/pre-F6/public.sql # ou o backup mais antigo (pré-F1)
```
Como na prática o banco é LOCAL e descartável, o caminho mais limpo do Cenário 1
é: **`git checkout main` + `node db.cjs reset` (reinstala schema+seeds do zero)**.
---
## Cenário 2 — DEPOIS de aplicar a F6.3 (DROP já feito) — recuperável, com cuidado
O DROP removeu as 78 tabelas tenant de public. Os dados VIVOS estão nos schemas
`tenant_<slug>`. Há duas situações:
### 2a) Rollback rápido (logo após o DROP, app quase não rodou nos schemas)
Os dados em public estavam atualizados até o **backup pré-F6.3**. Se quase nada
foi escrito nos schemas depois do DROP, restaure public do backup e volte o código:
```bash
git checkout main
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
< database-novo/backups/pre-F6.3/public.sql # backup TIRADO antes do DROP
# + derrubar schemas tenant (bloco SQL do Cenário 1)
```
PERDE: qualquer escrita feita NOS SCHEMAS entre o DROP e o rollback.
### 2b) Rollback com dados atualizados (app rodou e acumulou dados nos schemas)
Aí os schemas têm a verdade mais nova. Precisa de uma **migração reversa**
(schema → public, o inverso da F6.1), re-adicionando `tenant_id`. Roteiro:
1. Restaure a ESTRUTURA das 78 tabelas em public (do backup pré-F6.3, sem os
dados, ou recriando via o schema dump).
2. Para cada tenant, `INSERT INTO public.<tab> (cols + tenant_id) SELECT cols,
'<tenant_id>' FROM tenant_<slug>.<tab>` — o inverso exato do
`manual/f6_1_migrate_data.supabase_admin.sql` (trocar origem/destino e
RE-ADICIONAR a coluna tenant_id com o id do tenant do schema).
3. Resetar sequences, recriar as 9 views + 2 FKs, voltar o código (`git checkout main`).
Esse caminho é trabalhoso — por isso a recomendação forte: **só aplique a F6.3
depois de validar o app**, e mantenha o backup pré-F6.3. O DROP é a única coisa
que transforma "trivial" em "trabalhoso".
---
## Checklist antes de aplicar a F6.3 (resumo)
- [ ] App testado no browser (fluxos autenticados sem erro PGRST/4xx).
- [ ] Backup FRESCO: `pg_dump --schema=public > backups/pre-F6.3/public.sql`.
- [ ] Branch commitada (rollback de código = `git checkout main`).
- [ ] Ciente: pós-DROP, public some; a verdade passa a ser os schemas.
@@ -0,0 +1,107 @@
-- =============================================================================
-- F6.3 — DROP das tabelas tenant em public (PONTO DE NÃO-RETORNO)
--
-- 🛑 NÃO APLICAR AINDA. Este arquivo está PREPARADO para revisão. Aplicar só
-- depois de:
-- (a) Leonardo testar o app no branch e validar os fluxos;
-- (b) os ITENS EM ABERTO abaixo resolvidos;
-- (c) backup fresco confirmado.
--
-- ⚠️ APLICAR COMO supabase_admin (DROP CASCADE; tabelas documents/document_* são
-- owned por supabase_admin).
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
-- < database-novo/manual/f6_3_drop_public_tenant_tables.supabase_admin.sql
--
-- BACKUP ANTES (obrigatório):
-- docker exec supabase_db_agenciapsi-primesakai pg_dump -U postgres -d postgres \
-- --schema=public --no-owner --no-acl > database-novo/backups/pre-F6.3/public.sql
--
-- ─────────────────────────────────────────────────────────────────────────────
-- ✅ ITENS EM ABERTO — RESOLVIDOS (F6.4, commit dc2363b):
-- 1. v_twilio_whatsapp_overview / getAllChannels → RPC saas_list_all_whatsapp_
-- channels (fan-out). ✓
-- 2. SaasFeriadosPage / SaasNotificationTemplatesPage / SaasWhatsappPage →
-- RPCs saas_*_default + supabase.schema(tenant_<slug>). ✓
-- 3. notification-webhook (Meta) → confirmado: usa tdb/schema (F4). ✓
-- 4. AgendadorPublicoPage → RPCs roteadas. ✓
-- Varredura FE confirma ZERO supabase.from('<tabela_tenant>') público restante.
-- PRÉ-REQUISITO FINAL: Leonardo testar o app + backup fresco.
-- ─────────────────────────────────────────────────────────────────────────────
-- =============================================================================
\set ON_ERROR_STOP on
BEGIN;
-- ── 0) PRÉ-FLIGHT: aborta se algo essencial não bate ────────────────────────
DO $$
DECLARE v_tenants int; v_schemas int; v_drop int;
BEGIN
SELECT count(*) INTO v_tenants FROM public.tenants;
SELECT count(*) INTO v_schemas FROM public.tenant_schemas;
IF v_tenants <> v_schemas THEN
RAISE EXCEPTION 'F6.3 ABORT: % tenants mas % schemas provisionados — nem todos migrados', v_tenants, v_schemas;
END IF;
SELECT count(*) INTO v_drop FROM information_schema.tables
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%';
IF v_drop <> 78 THEN
RAISE EXCEPTION 'F6.3 ABORT: _tenant_template tem % tabelas, esperava 78', v_drop;
END IF;
-- guarda: nenhuma das 6 anon-facing pode estar na lista de drop
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='_tenant_template'
AND table_name IN ('patient_intake_requests','patient_invites','patient_invite_attempts',
'document_share_links','agendador_configuracoes','agendador_solicitacoes')) THEN
RAISE EXCEPTION 'F6.3 ABORT: tabela anon-facing presente no template (não deveria sair de public)';
END IF;
RAISE NOTICE 'F6.3 pré-flight OK: % tenants = % schemas, 78 tabelas a dropar', v_tenants, v_schemas;
END $$;
-- ── 1) FKs de tabelas que FICAM → tabelas que SAEM: viram coluna solta ──────
-- (validação fica no app/RPC via token; alvo vai pro schema do tenant)
ALTER TABLE public.document_share_links DROP CONSTRAINT IF EXISTS document_share_links_documento_id_fkey;
ALTER TABLE public.whatsapp_credits_transactions DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_conversation_message_id_fkey;
-- ── 2) Views public que referenciam tabelas tenant ──────────────────────────
-- As 6 do template são recriadas POR SCHEMA (F1). v_patient_engajamento e
-- v_patients_risco são dead code (0 uso no FE). v_twilio_whatsapp_overview:
-- ⚠️ ver ITEM EM ABERTO #1 — só dropar após reescrever getAllChannels.
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
DROP VIEW IF EXISTS public.v_cashflow_projection CASCADE;
DROP VIEW IF EXISTS public.v_commitment_totals CASCADE;
DROP VIEW IF EXISTS public.v_patient_groups_with_counts CASCADE;
DROP VIEW IF EXISTS public.v_tag_patient_counts CASCADE;
DROP VIEW IF EXISTS public.v_patient_engajamento CASCADE; -- dead code
DROP VIEW IF EXISTS public.v_patients_risco CASCADE; -- dead code
DROP VIEW IF EXISTS public.v_twilio_whatsapp_overview CASCADE; -- ⚠️ item #1
-- ── 3) DROP das 78 tabelas tenant em public (derivado de _tenant_template) ──
-- CASCADE leva junto triggers das tabelas + FKs intra-tenant. Dados já estão
-- nos schemas (F6.1) — estas são as cópias velhas.
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT table_name FROM information_schema.tables
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%'
ORDER BY table_name
LOOP
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=r.table_name) THEN
EXECUTE format('DROP TABLE public.%I CASCADE', r.table_name);
RAISE NOTICE 'F6.3 dropou public.%', r.table_name;
END IF;
END LOOP;
END $$;
-- ── 4) Limpeza de funções obsoletas pós-drop ────────────────────────────────
-- financial_records_inject_tenant só fazia sentido em public.financial_records
-- (já dropada); não está anexado em nenhum schema.
DROP FUNCTION IF EXISTS public.financial_records_inject_tenant() CASCADE;
COMMIT;
-- ── 5) PÓS-DROP: verificações manuais (rodar separado) ───────────────────────
-- SELECT 'tabelas tenant restantes em public: ' || count(*) FROM information_schema.tables
-- WHERE table_schema='public' AND table_name IN (SELECT table_name FROM _tenant_template.information... );
-- NOTIFY pgrst, 'reload schema';
-- Conferir app: nenhuma query 4xx/PGRST200 no console.
@@ -0,0 +1,181 @@
-- =============================================================================
-- F6.4 — RPCs SaaS-admin: defaults do sistema (template + fan-out) e views
-- cross-tenant (fan-out). Destrava o F6.3 (páginas SaaS deixam de ler
-- public.<tabela_tenant>).
--
-- ⚠️ APLICAR COMO supabase_admin.
--
-- Defaults (feriados nacionais, notification_templates is_default): editados
-- pelo SaaS no _tenant_template (fonte da verdade, propaga p/ tenants NOVOS no
-- clone) E fan-out pros schemas EXISTENTES. Só toca cópias-default (owner_id
-- NULL / is_default), preserva overrides do tenant (owner_id próprio).
-- Todas gated por is_saas_admin().
-- =============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public._assert_saas_admin()
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$ BEGIN
IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Apenas SaaS admin' USING ERRCODE='42501'; END IF;
END $$;
-- ── FERIADOS (defaults nacionais) ───────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.saas_list_default_feriados(p_ano integer)
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v jsonb;
BEGIN
PERFORM public._assert_saas_admin();
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',id,'data',data,'nome',nome,'tipo',tipo,'bloqueia_sessoes',bloqueia_sessoes) ORDER BY data),'[]'::jsonb)
INTO v FROM _tenant_template.feriados WHERE EXTRACT(YEAR FROM data) = p_ano;
RETURN v;
END $$;
CREATE OR REPLACE FUNCTION public.saas_add_default_feriado(p_data date, p_nome text, p_tipo text DEFAULT 'municipal')
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_owner uuid := auth.uid();
BEGIN
PERFORM public._assert_saas_admin();
INSERT INTO _tenant_template.feriados (owner_id, tipo, nome, data, bloqueia_sessoes)
VALUES (v_owner, p_tipo, p_nome, p_data, false) ON CONFLICT (data, nome) DO NOTHING;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('INSERT INTO %I.feriados (owner_id, tipo, nome, data, bloqueia_sessoes) VALUES ($1,$2,$3,$4,false) ON CONFLICT (data, nome) DO NOTHING', t.schema_name)
USING v_owner, p_tipo, p_nome, p_data;
END LOOP;
RETURN jsonb_build_object('data', p_data, 'nome', p_nome, 'tipo', p_tipo);
END $$;
CREATE OR REPLACE FUNCTION public.saas_remove_default_feriado(p_data date, p_nome text)
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int := 0;
BEGIN
PERFORM public._assert_saas_admin();
DELETE FROM _tenant_template.feriados WHERE data = p_data AND nome = p_nome;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('DELETE FROM %I.feriados WHERE data = $1 AND nome = $2', t.schema_name) USING p_data, p_nome;
v_n := v_n + 1;
END LOOP;
RETURN v_n;
END $$;
-- ── NOTIFICATION_TEMPLATES (defaults) ───────────────────────────────────────
CREATE OR REPLACE FUNCTION public.saas_list_default_notif_templates()
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE v jsonb;
BEGIN
PERFORM public._assert_saas_admin();
SELECT COALESCE(jsonb_agg(to_jsonb(nt) ORDER BY nt.domain, nt.event_type),'[]'::jsonb)
INTO v FROM _tenant_template.notification_templates nt WHERE nt.is_default = true AND nt.deleted_at IS NULL;
RETURN v;
END $$;
-- upsert por key (defaults têm owner_id NULL). Cria/atualiza no template + schemas.
CREATE OR REPLACE FUNCTION public.saas_upsert_default_notif_template(p_payload jsonb)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_key text := p_payload->>'key'; v_exists boolean;
v_domain text := p_payload->>'domain'; v_channel text := p_payload->>'channel';
v_event text := p_payload->>'event_type'; v_body text := p_payload->>'body_text';
v_vars jsonb := COALESCE(p_payload->'variables','[]'::jsonb);
v_active boolean := COALESCE((p_payload->>'is_active')::boolean, true);
BEGIN
PERFORM public._assert_saas_admin();
IF v_key IS NULL THEN RAISE EXCEPTION 'key obrigatório'; END IF;
-- template
SELECT EXISTS(SELECT 1 FROM _tenant_template.notification_templates WHERE key=v_key AND owner_id IS NULL AND is_default=true) INTO v_exists;
IF v_exists THEN
UPDATE _tenant_template.notification_templates SET body_text=v_body, domain=v_domain, channel=v_channel,
event_type=v_event, variables=v_vars, is_active=v_active WHERE key=v_key AND owner_id IS NULL AND is_default=true;
ELSE
INSERT INTO _tenant_template.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES (NULL, v_key, v_domain, v_channel, v_event, v_body, v_vars, true, v_active);
END IF;
-- fan-out schemas (só a cópia-default; preserva overrides do tenant owner_id<>NULL)
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format(
'INSERT INTO %I.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active) '
|| 'VALUES (NULL,$1,$2,$3,$4,$5,$6,true,$7) '
|| 'ON CONFLICT (owner_id, key, deleted_at) DO UPDATE SET body_text=EXCLUDED.body_text, domain=EXCLUDED.domain, '
|| 'channel=EXCLUDED.channel, event_type=EXCLUDED.event_type, variables=EXCLUDED.variables, is_active=EXCLUDED.is_active',
t.schema_name) USING v_key, v_domain, v_channel, v_event, v_body, v_vars, v_active;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.saas_set_default_notif_template_active(p_key text, p_active boolean)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
PERFORM public._assert_saas_admin();
UPDATE _tenant_template.notification_templates SET is_active=p_active WHERE key=p_key AND owner_id IS NULL AND is_default=true;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_templates SET is_active=$1 WHERE key=$2 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_active, p_key;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION public.saas_delete_default_notif_template(p_key text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record;
BEGIN
PERFORM public._assert_saas_admin();
UPDATE _tenant_template.notification_templates SET deleted_at=now() WHERE key=p_key AND owner_id IS NULL AND is_default=true;
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('UPDATE %I.notification_templates SET deleted_at=now() WHERE key=$1 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_key;
END LOOP;
END $$;
-- quantos tenants têm override (tenant_id<>NULL no modelo antigo = owner_id<>NULL aqui) por key
CREATE OR REPLACE FUNCTION public.saas_count_notif_template_overrides(p_key text)
RETURNS integer LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_n int := 0; v_has int;
BEGIN
PERFORM public._assert_saas_admin();
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
EXECUTE format('SELECT count(*) FROM %I.notification_templates WHERE key=$1 AND owner_id IS NOT NULL AND is_active=true AND deleted_at IS NULL', t.schema_name) INTO v_has USING p_key;
v_n := v_n + v_has;
END LOOP;
RETURN v_n;
END $$;
-- ── WHATSAPP admin (cross-tenant) — substitui v_twilio_whatsapp_overview ─────
CREATE OR REPLACE FUNCTION public.saas_list_all_whatsapp_channels()
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE t record; v_rows jsonb := '[]'::jsonb; v_part jsonb;
BEGIN
PERFORM public._assert_saas_admin();
FOR t IN SELECT ts.tenant_id, ts.schema_name, tn.name AS tenant_name
FROM public.tenant_schemas ts JOIN public.tenants tn ON tn.id = ts.tenant_id LOOP
EXECUTE format(
'SELECT COALESCE(jsonb_agg(jsonb_build_object('
|| '''id'',nc.id, ''tenant_id'',$1::uuid, ''tenant_name'',$2::text, ''owner_id'',nc.owner_id,'
|| '''provider'',nc.provider, ''is_active'',nc.is_active, ''connection_status'',nc.connection_status,'
|| '''sender_address'',nc.sender_address, ''twilio_phone_number'',nc.twilio_phone_number,'
|| '''last_health_check'',nc.last_health_check,'
|| '''open_incident'',(SELECT i.kind FROM %1$I.whatsapp_connection_incidents i WHERE i.channel_id=nc.id AND i.resolved_at IS NULL LIMIT 1)'
|| ')),''[]''::jsonb) FROM %1$I.notification_channels nc WHERE nc.channel=''whatsapp'' AND nc.deleted_at IS NULL',
t.schema_name) INTO v_part USING t.tenant_id, t.tenant_name;
v_rows := v_rows || v_part;
END LOOP;
RETURN v_rows;
END $$;
-- grants: gated por is_saas_admin internamente, mas exposto a authenticated
DO $g$ DECLARE fn text; BEGIN
FOREACH fn IN ARRAY ARRAY[
'saas_list_default_feriados(integer)','saas_add_default_feriado(date,text,text)','saas_remove_default_feriado(date,text)',
'saas_list_default_notif_templates()','saas_upsert_default_notif_template(jsonb)',
'saas_set_default_notif_template_active(text,boolean)','saas_delete_default_notif_template(text)',
'saas_count_notif_template_overrides(text)','saas_list_all_whatsapp_channels()'
] LOOP
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO authenticated', fn);
END LOOP;
END $g$;
COMMIT;
@@ -0,0 +1,147 @@
-- =============================================================================
-- Freemium F1 — Enforcement de limite de plano (pacientes), schema-per-tenant
--
-- ⚠️ APLICAR COMO supabase_admin (anexa triggers em tabelas tenant + a função de
-- wiring trg_attach_business_triggers é owned por supabase_admin).
--
-- Trigger genérico BEFORE INSERT em <schema>.patients que:
-- 1. resolve o tenant pelo NOME DO SCHEMA (TG_TABLE_SCHEMA → tenant_schemas);
-- 2. resolve o plano ATIVO do tenant em runtime (clínica via tenant_id;
-- pessoal/terapeuta via owner user_id — as 6 subs pessoais têm tenant_id NULL);
-- 3. lê o limite max_patients de plan_features.limits EM RUNTIME (mudar o número
-- no painel passa a valer sem deploy);
-- 4. conta pacientes vivos (status <> 'Arquivado') e dá RAISE parseável
-- 'PLAN_LIMIT_REACHED|patients|<limite>' quando já atingiu.
--
-- Sem plano ativo OU sem limite definido (planos PRO) ⇒ não bloqueia.
-- Idempotente (CREATE OR REPLACE + DROP TRIGGER IF EXISTS). Tudo em public
-- (subscriptions/plan_features/tenant_schemas são globais) ⇒ sobrevive ao DROP F6.3.
-- =============================================================================
BEGIN;
-- 1) Resolve o plano ativo de um tenant (clínica OU pessoal) ------------------
CREATE OR REPLACE FUNCTION public.tenant_active_plan_id(p_tenant_id uuid)
RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
SELECT COALESCE(
-- clínica: subscription chaveada por tenant_id
(SELECT vas.plan_id
FROM public.v_tenant_active_subscription vas
WHERE vas.tenant_id = p_tenant_id),
-- pessoal: subscription chaveada pelo owner (user_id), tenant_id NULL
(SELECT s.plan_id
FROM public.subscriptions s
JOIN public.tenant_members tm
ON tm.user_id = s.user_id
AND tm.tenant_id = p_tenant_id
AND tm.status = 'active'
WHERE s.status = 'active'
AND s.tenant_id IS NULL
AND (s.current_period_end IS NULL OR s.current_period_end > now())
ORDER BY s.created_at DESC
LIMIT 1)
);
$$;
ALTER FUNCTION public.tenant_active_plan_id(uuid) OWNER TO supabase_admin;
-- 2) Lê um limite numérico do plano (busca a chave em qualquer feature) -------
-- Ex.: clinic_free guarda max_patients sob clinic_calendar; therapist_free
-- sob patients.manage. Retorna o MIN (mais restritivo) se houver mais de um.
CREATE OR REPLACE FUNCTION public.plan_feature_limit(p_plan_id uuid, p_limit_key text)
RETURNS int
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
SELECT min((pf.limits->>p_limit_key)::int)
FROM public.plan_features pf
WHERE pf.plan_id = p_plan_id
AND pf.enabled
AND pf.limits ? p_limit_key
AND (pf.limits->>p_limit_key) ~ '^[0-9]+$';
$$;
ALTER FUNCTION public.plan_feature_limit(uuid, text) OWNER TO supabase_admin;
-- 3) Trigger function de enforcement -----------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_patient_plan_limit()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_tenant uuid;
v_plan uuid;
v_limit int;
v_count int;
BEGIN
SELECT tenant_id INTO v_tenant
FROM public.tenant_schemas
WHERE schema_name = TG_TABLE_SCHEMA;
IF v_tenant IS NULL THEN RETURN NEW; END IF; -- schema não-tenant: ignora
v_plan := public.tenant_active_plan_id(v_tenant);
IF v_plan IS NULL THEN RETURN NEW; END IF; -- sem plano ativo: não bloqueia
v_limit := public.plan_feature_limit(v_plan, 'max_patients');
IF v_limit IS NULL THEN RETURN NEW; END IF; -- plano sem limite (PRO): ilimitado
EXECUTE format(
'SELECT count(*) FROM %I.patients WHERE status IS DISTINCT FROM %L',
TG_TABLE_SCHEMA, 'Arquivado'
) INTO v_count;
IF v_count >= v_limit THEN
RAISE EXCEPTION 'PLAN_LIMIT_REACHED|patients|%', v_limit USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END $$;
ALTER FUNCTION public.enforce_patient_plan_limit() OWNER TO supabase_admin;
-- 4) Attach helper (pendura o trigger no patients de um schema) ---------------
CREATE OR REPLACE FUNCTION public.attach_plan_limit_triggers(p_schema text)
RETURNS int
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
IF p_schema NOT LIKE 'tenant\_%' THEN
RAISE EXCEPTION 'schema inválido %', p_schema;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = p_schema AND table_name = 'patients'
) THEN
EXECUTE format('DROP TRIGGER IF EXISTS enforce_patient_plan_limit ON %I.patients', p_schema);
EXECUTE format(
'CREATE TRIGGER enforce_patient_plan_limit BEFORE INSERT ON %I.patients '
'FOR EACH ROW EXECUTE FUNCTION public.enforce_patient_plan_limit()', p_schema);
RETURN 1;
END IF;
RETURN 0;
END $$;
ALTER FUNCTION public.attach_plan_limit_triggers(text) OWNER TO supabase_admin;
GRANT EXECUTE ON FUNCTION public.attach_plan_limit_triggers(text) TO postgres, service_role;
-- 5) Wiring: tenants NOVOS ganham o trigger de limite no clone ----------------
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
BEGIN
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
PERFORM public.attach_notif_triggers(NEW.schema_name);
PERFORM public.attach_plan_limit_triggers(NEW.schema_name);
RETURN NULL;
END $$;
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
-- 6) Backfill: os 9 schemas já existentes ganham o trigger agora -------------
DO $$
DECLARE r record; n int := 0;
BEGIN
FOR r IN SELECT schema_name FROM public.tenant_schemas LOOP
n := n + public.attach_plan_limit_triggers(r.schema_name);
END LOOP;
RAISE NOTICE 'enforce_patient_plan_limit anexado em % schemas', n;
END $$;
COMMIT;
@@ -0,0 +1,238 @@
-- =============================================================================
-- Freemium F2 — RPCs idempotentes do self-service (schema-per-tenant)
--
-- ⚠️ APLICAR COMO supabase_admin (auto_provision insere em tenants/members/
-- subscriptions/profiles + roda clone_tenant_template).
--
-- Com confirmação de e-mail LIGADA, o signup NÃO tem sessão — então nada que
-- dependa de auth.uid() roda no signup. A escolha do usuário (nome, slug, plano,
-- intervalo, kind) é gravada no raw_user_meta_data do signUp e PROCESSADA aqui,
-- no 1º login pós-confirmação:
-- • slug_disponivel(p_slug) → {ok, motivo} (chamável por anon no signup)
-- • auto_provision_free_tenant(...) → cria tenant + clone + master + sub free
-- • processar_pos_signup() → cria a intenção SÓ pro caminho pago
--
-- Todas idempotentes. Não há tabela de aceite legal neste sistema (pulado).
-- =============================================================================
BEGIN;
-- 1) slug_disponivel ---------------------------------------------------------
-- Valida formato (mesma regra do generate_tenant_slug), reservados e uso.
-- Chamável por ANON (signup acontece antes do login). Não vaza dados de
-- tenant além do fato "slug em uso".
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v text := lower(trim(coalesce(p_slug, '')));
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
BEGIN
IF length(v) < 3 THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'curto');
END IF;
IF length(v) > 48 THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'longo');
END IF;
-- começa com letra, só [a-z0-9_]
IF v !~ '^[a-z][a-z0-9_]*$' THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'invalido');
END IF;
IF v = ANY(v_reservados) THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'reservado');
END IF;
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso');
END IF;
-- (F3) blacklist de slug integra aqui via motivo 'bloqueado'
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
END $$;
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
-- 2) auto_provision_free_tenant ----------------------------------------------
-- Idempotente: se o usuário já tem tenant ativo, retorna esse. Senão lê o
-- raw_user_meta_data, cria o tenant (slug escolhido OU auto), vira master,
-- clona o schema e cria a subscription gratuita ativa (XOR conforme target).
-- p_slug_override permite a tela /onboarding reescolher o slug se colidiu.
CREATE OR REPLACE FUNCTION public.auto_provision_free_tenant(p_slug_override text DEFAULT NULL)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_uid uuid := auth.uid();
v_meta jsonb;
v_email text;
v_kind text;
v_acct text;
v_name text;
v_slug text;
v_display text;
v_tenant_id uuid;
v_plan_key text;
v_plan_id uuid;
v_target text;
v_existing uuid;
BEGIN
IF v_uid IS NULL THEN
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
END IF;
-- idempotência: já tem tenant ativo?
SELECT tm.tenant_id INTO v_existing
FROM public.tenant_members tm
WHERE tm.user_id = v_uid AND tm.status = 'active'
ORDER BY tm.created_at ASC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('status', 'exists', 'tenant_id', v_existing,
'slug', (SELECT slug FROM public.tenants WHERE id = v_existing));
END IF;
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
FROM auth.users au WHERE au.id = v_uid;
v_meta := COALESCE(v_meta, '{}'::jsonb);
-- kind: do metadata, default therapist (maioria). Valida contra os aceitos.
v_kind := lower(coalesce(nullif(trim(v_meta->>'account_kind'), ''), 'therapist'));
IF v_kind NOT IN ('therapist','clinic_coworking','clinic_reception','clinic_full') THEN
v_kind := 'therapist';
END IF;
v_acct := CASE WHEN v_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
v_display := nullif(trim(v_meta->>'display_name'), '');
v_name := coalesce(
nullif(trim(v_meta->>'tenant_name'), ''),
v_display,
split_part(coalesce(v_email, 'conta'), '@', 1),
'Conta');
-- slug: override > metadata > NULL (trigger auto-gera). Valida disponibilidade.
v_slug := lower(trim(coalesce(p_slug_override, v_meta->>'tenant_slug', '')));
IF v_slug = '' THEN
v_slug := NULL;
ELSE
IF NOT (public.slug_disponivel(v_slug)->>'ok')::boolean THEN
RAISE EXCEPTION 'SLUG_TAKEN|%', v_slug USING ERRCODE = 'P0001';
END IF;
END IF;
-- cria tenant (trg_tenants_slug respeita slug fornecido; gera se NULL)
INSERT INTO public.tenants (name, kind, slug, created_at)
VALUES (v_name, v_kind, v_slug, now())
RETURNING id, slug INTO v_tenant_id, v_slug;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (v_tenant_id, v_uid, 'tenant_admin', 'active', now());
UPDATE public.profiles
SET account_type = v_acct,
full_name = COALESCE(full_name, v_display)
WHERE id = v_uid;
-- provisiona o schema físico + seed
PERFORM public.clone_tenant_template(v_tenant_id);
PERFORM public.seed_determined_commitments(v_tenant_id);
-- subscription gratuita ativa (XOR: clinic→tenant_id; therapist→user_id)
v_plan_key := CASE WHEN v_acct = 'therapist' THEN 'therapist_free' ELSE 'clinic_free' END;
SELECT id, lower(target) INTO v_plan_id, v_target FROM public.plans WHERE key = v_plan_key;
INSERT INTO public.subscriptions (plan_id, plan_key, status, interval, source,
tenant_id, user_id, started_at, activated_at, current_period_start)
VALUES (v_plan_id, v_plan_key, 'active', 'month', 'auto_free',
CASE WHEN v_target = 'clinic' THEN v_tenant_id ELSE NULL END,
CASE WHEN v_target = 'clinic' THEN NULL ELSE v_uid END,
now(), now(), now());
RETURN jsonb_build_object('status', 'provisioned', 'tenant_id', v_tenant_id,
'slug', v_slug, 'kind', v_kind, 'plan_key', v_plan_key);
END $$;
ALTER FUNCTION public.auto_provision_free_tenant(text) OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.auto_provision_free_tenant(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.auto_provision_free_tenant(text) TO authenticated, service_role;
-- 3) processar_pos_signup ----------------------------------------------------
-- Caminho PAGO: se o usuário escolheu um plano PRO no signup (metadata),
-- registra a intenção (idempotente — uma por usuário+plano 'new'). O caminho
-- gratuito não gera intenção. Sem tabela de aceite legal (pulado).
CREATE OR REPLACE FUNCTION public.processar_pos_signup()
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_uid uuid := auth.uid();
v_meta jsonb;
v_email text;
v_plan_key text;
v_interval text;
v_plan record;
v_tenant uuid;
v_amount int;
BEGIN
IF v_uid IS NULL THEN
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
END IF;
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
FROM auth.users au WHERE au.id = v_uid;
v_meta := COALESCE(v_meta, '{}'::jsonb);
v_plan_key := nullif(trim(v_meta->>'plan_key'), '');
v_interval := lower(coalesce(nullif(trim(v_meta->>'billing_interval'), ''), 'month'));
IF v_interval NOT IN ('month','year') THEN v_interval := 'month'; END IF;
-- sem plano escolhido OU plano gratuito → nada a fazer
IF v_plan_key IS NULL OR v_plan_key LIKE '%\_free' THEN
RETURN jsonb_build_object('status', 'no_intent');
END IF;
SELECT * INTO v_plan FROM public.plans WHERE key = v_plan_key AND is_active;
IF NOT FOUND THEN
RETURN jsonb_build_object('status', 'plan_not_found', 'plan_key', v_plan_key);
END IF;
-- idempotência: já existe intent 'new' desse usuário+plano?
IF EXISTS (
SELECT 1 FROM public.subscription_intents
WHERE created_by_user_id = v_uid AND plan_key = v_plan_key AND status = 'new'
) THEN
RETURN jsonb_build_object('status', 'intent_exists', 'plan_key', v_plan_key);
END IF;
SELECT tm.tenant_id INTO v_tenant
FROM public.tenant_members tm WHERE tm.user_id = v_uid AND tm.status = 'active'
ORDER BY tm.created_at ASC LIMIT 1;
v_amount := CASE WHEN v_interval = 'year'
THEN COALESCE(v_plan.price_cents, 0) * 12
ELSE COALESCE(v_plan.price_cents, 0) END;
-- escreve direto na tabela real (a view subscription_intents tem INSTEAD OF
-- trigger que não propaga user_id pra _tenant; o serviço do front também
-- escreve nas tabelas reais por target).
IF lower(v_plan.target) = 'clinic' THEN
INSERT INTO public.subscription_intents_tenant
(tenant_id, user_id, created_by_user_id, email, plan_id, plan_key,
interval, amount_cents, currency, status, source)
VALUES
(v_tenant, v_uid, v_uid, v_email, v_plan.id, v_plan_key,
v_interval, v_amount, 'BRL', 'new', 'signup');
ELSE
INSERT INTO public.subscription_intents_personal
(user_id, created_by_user_id, email, plan_id, plan_key,
interval, amount_cents, currency, status, source)
VALUES
(v_uid, v_uid, v_email, v_plan.id, v_plan_key,
v_interval, v_amount, 'BRL', 'new', 'signup');
END IF;
RETURN jsonb_build_object('status', 'intent_created', 'plan_key', v_plan_key, 'interval', v_interval);
END $$;
ALTER FUNCTION public.processar_pos_signup() OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.processar_pos_signup() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.processar_pos_signup() TO authenticated, service_role;
COMMIT;
@@ -0,0 +1,209 @@
-- ==========================================================================
-- Agencia PSI — Migracao: campo "Observacao" nativo no commitment Sessao
-- ==========================================================================
-- Antes: o commitment determinado 'Sessao' (is_native=true, native_key='session')
-- nao tinha campos extras default. Os outros nativos (Leitura, Supervisao, Aula,
-- Analise Pessoal) ja vinham com 'notes' (Observacao, textarea) — Sessao era
-- a unica excecao.
--
-- O AgendaEventDialog tinha uma textarea hard-coded "Observacao" no form, fora
-- do mecanismo de extra_fields. Pra padronizar (e pra que a Observacao da
-- sessao siga o mesmo storage que os outros commitments: agenda_eventos.extra_fields),
-- a textarea hardcoded foi removida do .vue e Sessao agora ganha 'notes' como
-- campo extra default.
--
-- Esta migracao:
-- 1. Adiciona 'notes' (Observacao, textarea) em TODOS os commitments Sessao
-- existentes (idempotente — so insere se ainda nao houver).
-- 2. Atualiza a funcao seed_determined_commitments pra que novos tenants criados
-- daqui pra frente ja venham com 'notes' no Sessao por padrao.
-- ==========================================================================
-- ──────────────────────────────────────────────────────────────────────────
-- 1. Backfill — adiciona 'notes' nos commitments Sessao ja existentes
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
SELECT dc.tenant_id, dc.id, 'notes', 'Observação', 'textarea', false, 30
FROM public.determined_commitments dc
WHERE dc.is_native = true
AND dc.native_key = 'session'
AND NOT EXISTS (
SELECT 1 FROM public.determined_commitment_fields f
WHERE f.commitment_id = dc.id AND f.key = 'notes'
);
-- ──────────────────────────────────────────────────────────────────────────
-- 2. Forward-fix — funcao seed_determined_commitments inclui 'notes' em Sessao
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_id uuid;
begin
-- Sessão (locked + sempre ativa)
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
end if;
-- Leitura
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if;
-- Supervisão
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
end if;
-- Aula
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if;
-- Análise pessoal
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
end if;
-- -------------------------------------------------------
-- Campos padrão (idempotentes por (commitment_id, key))
-- -------------------------------------------------------
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Leitura
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'book') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'book', 'Livro', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'author') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'author', 'Autor', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Supervisão
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'supervisor') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'supervisor', 'Supervisor', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'topic') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'topic', 'Assunto', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Aula
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'theme') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'theme', 'Tema', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'group') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'group', 'Turma', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Análise
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'analyst') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'analyst', 'Analista', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'focus') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'focus', 'Foco', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
end;
$$;
@@ -0,0 +1,32 @@
-- ============================================================================
-- Drop agenda_excecoes (tabela órfã) + tipos relacionados
-- ----------------------------------------------------------------------------
-- A tabela `public.agenda_excecoes` foi criada num design anterior pra
-- representar "exceções no horário de trabalho" (almoço extra, atendimento
-- fora do padrão, etc) mas nunca foi integrada à UI. Auditoria em
-- 2026-05-13 confirmou 0 referências em src/. As funcionalidades equivalentes
-- vivem em:
-- - public.agenda_bloqueios — bloqueios (período, dia, horário, feriado)
-- - public.agenda_configuracoes.pausas_semanais (jsonb) — pausas semanais
-- - public.feriados — feriados nacionais/municipais
--
-- Esta migration:
-- 1) Dropa o trigger tg_agenda_excecoes_updated_at
-- 2) Dropa a tabela public.agenda_excecoes (CASCADE pra cair policies)
-- 3) Dropa os enums tipo_excecao_agenda e status_excecao_agenda
-- (verificados: usados APENAS por agenda_excecoes)
-- ============================================================================
BEGIN;
-- 1. Trigger (idempotente)
DROP TRIGGER IF EXISTS tg_agenda_excecoes_updated_at ON public.agenda_excecoes;
-- 2. Tabela (CASCADE leva policies junto)
DROP TABLE IF EXISTS public.agenda_excecoes CASCADE;
-- 3. Enums órfãos
DROP TYPE IF EXISTS public.tipo_excecao_agenda;
DROP TYPE IF EXISTS public.status_excecao_agenda;
COMMIT;
@@ -0,0 +1,24 @@
-- ============================================================================
-- Adiciona coluna payment_link em financial_records
-- ----------------------------------------------------------------------------
-- Quando a cobrança for paga via gateway externo (Asaas, Stripe, Mercado Pago)
-- e o terapeuta escolher "Enviar link de pagamento" no AgendaEventDialog, o
-- link de cobrança gerado pelo gateway é salvo aqui. UI da lista do Financeiro
-- usa esse campo pra exibir ícone clicável (external-link).
--
-- Campo nullable: registros sem integração de gateway (PIX manual, dinheiro,
-- depósito, cartão maquininha) ficam com payment_link = NULL.
--
-- Preparação pra Fase 7 (Pagamento como entidade separada) — quando a
-- integração Asaas estiver completa, o webhook vai preencher esse campo.
-- ============================================================================
BEGIN;
ALTER TABLE public.financial_records
ADD COLUMN IF NOT EXISTS payment_link text;
COMMENT ON COLUMN public.financial_records.payment_link IS
'URL externa de cobrança (Asaas/Stripe/etc) quando payment_method indica gateway. Null em pagamentos manuais.';
COMMIT;
@@ -0,0 +1,22 @@
-- ============================================================================
-- Adiciona coluna default_consume_on_miss em financial_exceptions
-- ----------------------------------------------------------------------------
-- Define o comportamento padrão pro saldo de pacote quando o status muda
-- pra "faltou" ou "cancelado":
-- true → desconta 1 sessão do pacote (sessions_used += 1) por padrão
-- false → não consome saldo (sessão fica disponível pra remarcar)
--
-- O dialog de confirmação que aparece ao mudar status sugere essa decisão
-- mas o terapeuta pode override caso a caso. Padrão começa false (mais
-- benevolente ao paciente).
-- ============================================================================
BEGIN;
ALTER TABLE public.financial_exceptions
ADD COLUMN IF NOT EXISTS default_consume_on_miss boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN public.financial_exceptions.default_consume_on_miss IS
'Default pro toggle "Descontar do saldo" no dialog de status change. false = não consome (paciente pode remarcar); true = consome (sessão perdida).';
COMMIT;
@@ -0,0 +1,31 @@
-- ============================================================================
-- Adiciona coluna charging_style em billing_contracts
-- ----------------------------------------------------------------------------
-- Identifica como o pacote foi cobrado na criação:
-- 'upfront' → 1 financial_record total criado na hora; sessões só
-- consomem saldo, não geram nova cobrança
-- 'saldo' → sem financial_record na criação; cada sessão realizada
-- gera 1 cobrança individual e incrementa sessions_used
-- 'per_session'→ N financial_records já criados na materialização da série
-- (chargeMode='per_session' do AgendaEventDialog)
--
-- Sem esse campo, o handler de status change não saberia distinguir entre
-- "já tudo pago, só atualizar status" vs "criar cobrança nova".
-- ============================================================================
BEGIN;
ALTER TABLE public.billing_contracts
ADD COLUMN IF NOT EXISTS charging_style text DEFAULT 'saldo';
-- Constraint pra restringir aos 3 valores válidos
ALTER TABLE public.billing_contracts
DROP CONSTRAINT IF EXISTS billing_contracts_charging_style_chk;
ALTER TABLE public.billing_contracts
ADD CONSTRAINT billing_contracts_charging_style_chk
CHECK (charging_style = ANY (ARRAY['upfront'::text, 'saldo'::text, 'per_session'::text]));
COMMENT ON COLUMN public.billing_contracts.charging_style IS
'Estilo de cobrança: upfront (1 record total no início), saldo (cobra por sessão realizada), per_session (N records já criados).';
COMMIT;
@@ -0,0 +1,86 @@
-- ============================================================================
-- create_financial_record_for_session: idempotência ignora cancelled
-- ----------------------------------------------------------------------------
-- Bug: a função recusava criar um novo financial_record quando já existia
-- um record cancelled pro mesmo agenda_evento_id, porque a checagem de
-- idempotência só filtrava `deleted_at IS NULL` (e cancel preserva
-- deleted_at = NULL pra manter auditoria).
--
-- Consequência: user cancelava cobrança sem querer e ficava preso — todo
-- "Gerar cobrança" subsequente retornava o registro cancelado sem inserir
-- nova linha (frontend recebia data, achava sucesso, mas DB ficava como
-- estava).
--
-- Fix: adiciona `AND status != 'cancelled'` na checagem. Cancelled passa a
-- ser tratado como "sem cobrança ativa" pra idempotência. Audit history
-- continua preservado (rows cancelled permanecem na tabela).
--
-- Idempotente: CREATE OR REPLACE substitui a função existente.
-- ============================================================================
BEGIN;
CREATE OR REPLACE 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 SETOF public.financial_records
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_existing public.financial_records%ROWTYPE;
v_new public.financial_records%ROWTYPE;
BEGIN
-- Idempotência: retorna o registro existente se já foi criado.
-- Ignora cancelled (treat as "no active record") pra permitir regenerar
-- cobrança após cancelamento.
SELECT * INTO v_existing
FROM public.financial_records
WHERE agenda_evento_id = p_agenda_evento_id
AND deleted_at IS NULL
AND status != 'cancelled'
LIMIT 1;
IF FOUND THEN
RETURN NEXT v_existing;
RETURN;
END IF;
-- Cria o novo registro
INSERT INTO public.financial_records (
tenant_id,
owner_id,
patient_id,
agenda_evento_id,
amount,
discount_amount,
final_amount,
status,
due_date
) VALUES (
p_tenant_id,
p_owner_id,
p_patient_id,
p_agenda_evento_id,
p_amount,
0,
p_amount,
'pending',
p_due_date
)
RETURNING * INTO v_new;
-- Marca o evento da agenda como billed = true
UPDATE public.agenda_eventos
SET billed = TRUE
WHERE id = p_agenda_evento_id;
RETURN NEXT v_new;
END;
$$;
COMMIT;
@@ -0,0 +1,165 @@
-- ============================================================================
-- Cria tabelas do prontuário clínico
-- ----------------------------------------------------------------------------
-- Núcleo do prontuário: notas clínicas (anamnese, evolução, plano), com
-- versionamento (audit trail) e templates (SOAP/DAP/BIRP/livre).
--
-- Decisões (sessão de modelagem 2026-05-20):
-- • Tabela única `clinical_notes` discriminada por `note_type` (não 1 tabela
-- por tipo). Templates customizáveis exigem flexibilidade.
-- • `content_text` (livre) + `content_structured` (jsonb) coexistem na mesma
-- row — UI prioriza conforme template; busca/edit rápido sempre tem text.
-- • Versionamento via snapshot completo (não diff) em `clinical_note_versions`
-- — restore trivial e audit visualization friendly. Trigger de versionamento
-- criado em migration separada.
-- • Instrumentos de avaliação (GAD-7, PHQ-9, etc) ficam pra Fase 2.
-- • RLS: owner-only (terapeuta responsável). Sem clinic-wide read — CFP exige
-- sigilo entre profissionais. Policies em migration separada.
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. clinical_notes — núcleo do prontuário
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL, -- terapeuta responsável
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE RESTRICT,
session_event_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
note_type text NOT NULL,
template_id uuid, -- FK adicionada após criar templates
title text,
content_text text,
content_structured jsonb,
pinned boolean DEFAULT false NOT NULL,
is_draft boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid NOT NULL,
updated_by uuid,
deleted_at timestamp with time zone,
deleted_by uuid,
CONSTRAINT clinical_notes_note_type_check CHECK (note_type IN (
'anamnese',
'evolucao_sessao',
'plano_terapeutico',
'observacao_livre',
'resumo_caso'
)),
CONSTRAINT clinical_notes_content_present_check CHECK (
content_text IS NOT NULL OR content_structured IS NOT NULL
)
);
COMMENT ON TABLE public.clinical_notes IS
'Notas clínicas do prontuário (anamnese, evolução de sessão, plano, observações). Owner-only via RLS — CFP exige sigilo.';
COMMENT ON COLUMN public.clinical_notes.session_event_id IS
'Sessão associada (quando aplicável). Anamnese/plano/resumo podem ter NULL.';
COMMENT ON COLUMN public.clinical_notes.content_text IS
'Conteúdo em texto livre (sempre disponível pra busca/edit rápido).';
COMMENT ON COLUMN public.clinical_notes.content_structured IS
'Conteúdo em formato estruturado quando há template ativo (jsonb dos campos preenchidos).';
CREATE INDEX IF NOT EXISTS idx_clinical_notes_patient_recent
ON public.clinical_notes (tenant_id, patient_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_owner
ON public.clinical_notes (owner_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_session
ON public.clinical_notes (session_event_id)
WHERE session_event_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_type
ON public.clinical_notes (tenant_id, patient_id, note_type)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_pinned
ON public.clinical_notes (tenant_id, patient_id)
WHERE pinned = true AND deleted_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 2. clinical_note_versions — audit trail (snapshot completo)
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_note_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
note_id uuid NOT NULL REFERENCES public.clinical_notes(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
version_number integer NOT NULL,
title text,
content_text text,
content_structured jsonb,
change_reason text, -- 'criacao' | 'edicao' | livre
created_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid NOT NULL,
CONSTRAINT clinical_note_versions_unique UNIQUE (note_id, version_number)
);
COMMENT ON TABLE public.clinical_note_versions IS
'Snapshot completo de cada versão de clinical_notes. Criado via trigger AFTER INSERT OR UPDATE.';
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_recent
ON public.clinical_note_versions (note_id, version_number DESC);
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_audit
ON public.clinical_note_versions (created_by, created_at DESC);
-- ──────────────────────────────────────────────────────────────────────────
-- 3. clinical_note_templates — templates SOAP/DAP/BIRP/anamnese padrão
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_note_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid, -- NULL = template global do sistema
owner_id uuid, -- NULL = template do tenant inteiro
key text NOT NULL, -- 'soap', 'dap', 'birp', 'anamnese_padrao', ...
name text NOT NULL,
note_type text NOT NULL,
description text,
structure jsonb NOT NULL, -- [{key, label, type, required, hint}]
is_system boolean DEFAULT false NOT NULL,
is_global boolean DEFAULT false NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT clinical_note_templates_note_type_check CHECK (note_type IN (
'anamnese',
'evolucao_sessao',
'plano_terapeutico',
'observacao_livre',
'resumo_caso'
)),
CONSTRAINT clinical_note_templates_scope_check CHECK (
-- Sistema: ambos NULL e is_system=true
-- Tenant-wide: tenant_id presente, owner_id NULL
-- Owner: ambos presentes
(is_system = true AND tenant_id IS NULL AND owner_id IS NULL)
OR (is_system = false AND tenant_id IS NOT NULL)
)
);
COMMENT ON TABLE public.clinical_note_templates IS
'Templates de notas clínicas. Escopo: sistema (is_system, sem tenant), tenant-wide (tenant_id sem owner), owner (ambos).';
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_active
ON public.clinical_note_templates (note_type)
WHERE active = true;
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_tenant
ON public.clinical_note_templates (tenant_id, note_type)
WHERE tenant_id IS NOT NULL AND active = true;
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_owner
ON public.clinical_note_templates (owner_id, note_type)
WHERE owner_id IS NOT NULL AND active = true;
-- ──────────────────────────────────────────────────────────────────────────
-- 4. FK de clinical_notes.template_id (criada agora que templates existe)
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_notes
ADD CONSTRAINT clinical_notes_template_fkey
FOREIGN KEY (template_id)
REFERENCES public.clinical_note_templates(id)
ON DELETE SET NULL;
COMMIT;
@@ -0,0 +1,111 @@
-- ============================================================================
-- RLS policies do prontuário clínico
-- ----------------------------------------------------------------------------
-- Padrão MAIS RESTRITIVO que agenda — CFP exige sigilo profissional entre
-- terapeutas do mesmo tenant. Default: APENAS o owner (terapeuta responsável)
-- lê e escreve. Sem clinic-wide read.
--
-- Compartilhamento com supervisor / outro terapeuta vai requerer policy
-- específica baseada em tabela `clinical_note_shares` (Fase 2).
--
-- Templates seguem regra mais aberta:
-- • Sistema (is_system): todos authenticated leem
-- • Tenant-wide (tenant_id): membros do tenant leem; tenant_admin edita
-- • Owner: só o owner lê/edita
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_notes — owner only
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY clinical_notes_owner_select
ON public.clinical_notes FOR SELECT TO authenticated
USING (owner_id = auth.uid() AND deleted_at IS NULL);
CREATE POLICY clinical_notes_owner_insert
ON public.clinical_notes FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND public.is_tenant_member(tenant_id)
);
CREATE POLICY clinical_notes_owner_update
ON public.clinical_notes FOR UPDATE TO authenticated
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- DELETE só por soft-delete (UPDATE deleted_at). Hard delete bloqueado em RLS.
-- Backup/admin pode dropar via psql -U supabase_admin se preciso.
CREATE POLICY clinical_notes_no_hard_delete
ON public.clinical_notes FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_note_versions — read-only pelo owner da nota
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_note_versions ENABLE ROW LEVEL SECURITY;
CREATE POLICY clinical_note_versions_owner_select
ON public.clinical_note_versions FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.clinical_notes cn
WHERE cn.id = clinical_note_versions.note_id
AND cn.owner_id = auth.uid()
)
);
-- INSERT só via trigger (SECURITY DEFINER). Sem policy de UPDATE/DELETE —
-- versões são imutáveis. Trigger usa role bypass.
CREATE POLICY clinical_note_versions_no_write
ON public.clinical_note_versions FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY clinical_note_versions_no_update
ON public.clinical_note_versions FOR UPDATE TO authenticated
USING (false);
CREATE POLICY clinical_note_versions_no_delete
ON public.clinical_note_versions FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_note_templates — escopo escalonado
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_note_templates ENABLE ROW LEVEL SECURITY;
-- SELECT: sistema (qualquer authenticated) + tenant-wide (membros) + owner (próprio)
CREATE POLICY clinical_note_templates_select
ON public.clinical_note_templates FOR SELECT TO authenticated
USING (
active = true
AND (
is_system = true
OR (tenant_id IS NOT NULL AND public.is_tenant_member(tenant_id))
)
);
-- INSERT/UPDATE/DELETE: só owner ou tenant_admin do tenant
-- Templates do sistema (is_system) nunca alteráveis via UI — só via seed/migration.
CREATE POLICY clinical_note_templates_owner_write
ON public.clinical_note_templates TO authenticated
USING (
is_system = false
AND (
owner_id = auth.uid()
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
)
)
WITH CHECK (
is_system = false
AND (
owner_id = auth.uid()
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
)
);
COMMIT;
@@ -0,0 +1,117 @@
-- ============================================================================
-- Trigger de versionamento automático de clinical_notes
-- ----------------------------------------------------------------------------
-- A cada INSERT ou UPDATE relevante em clinical_notes, cria snapshot completo
-- em clinical_note_versions. Função é SECURITY DEFINER pra bypassar a RLS
-- (que bloqueia INSERT direto em clinical_note_versions).
--
-- Versionamento dispara em:
-- • INSERT — registra criação (version_number = 1)
-- • UPDATE em content_text, content_structured ou title — registra edição
--
-- Mudanças em pinned/is_draft NÃO disparam versionamento (mudança de UI/state,
-- não de conteúdo).
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
next_version integer;
reason text;
BEGIN
SELECT COALESCE(MAX(version_number), 0) + 1
INTO next_version
FROM public.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 public.clinical_note_versions (
note_id,
tenant_id,
version_number,
title,
content_text,
content_structured,
change_reason,
created_at,
created_by
) VALUES (
NEW.id,
NEW.tenant_id,
next_version,
NEW.title,
NEW.content_text,
NEW.content_structured,
reason,
now(),
COALESCE(NEW.updated_by, NEW.created_by)
);
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION public.fn_clinical_note_version() IS
'Snapshot completo de clinical_notes a cada INSERT/UPDATE relevante. SECURITY DEFINER bypassa RLS pra escrever em clinical_note_versions (que bloqueia INSERT direto).';
CREATE TRIGGER trg_clinical_notes_version_insert
AFTER INSERT ON public.clinical_notes
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_note_version();
CREATE TRIGGER trg_clinical_notes_version_update
AFTER UPDATE OF content_text, content_structured, title, deleted_at
ON public.clinical_notes
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();
-- ──────────────────────────────────────────────────────────────────────────
-- Trigger para updated_at automático
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.fn_clinical_notes_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_clinical_notes_updated_at
BEFORE UPDATE ON public.clinical_notes
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
CREATE TRIGGER trg_clinical_note_templates_updated_at
BEFORE UPDATE ON public.clinical_note_templates
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
COMMIT;
@@ -0,0 +1,46 @@
-- ============================================================================
-- Liga documents a clinical_notes (preenche FK órfã)
-- ----------------------------------------------------------------------------
-- A coluna `documents.session_note_id` existia desde antes apontando pra uma
-- tabela `session_notes` que nunca foi criada. Agora que `clinical_notes`
-- existe e abrange anamnese/evolução/plano (não só sessão), renomeia pra
-- `clinical_note_id` e adiciona FK constraint.
--
-- PRÉ-CHECK: a query abaixo deve retornar 0 antes de rodar esta migration.
-- SELECT count(*) FROM public.documents WHERE session_note_id IS NOT NULL;
-- Se houver dados, eles são órfãos (referenciam tabela inexistente) — limpar
-- antes de adicionar a FK constraint, ou ela falha.
-- ============================================================================
BEGIN;
-- 1. Limpa eventuais órfãos (FK nunca foi enforced, mas valor pode ter sido
-- setado por código no front antes da migration). Defesa em profundidade.
UPDATE public.documents
SET session_note_id = NULL
WHERE session_note_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.clinical_notes cn
WHERE cn.id = documents.session_note_id
);
-- 2. Rename
ALTER TABLE public.documents
RENAME COLUMN session_note_id TO clinical_note_id;
-- 3. FK constraint
ALTER TABLE public.documents
ADD CONSTRAINT documents_clinical_note_fkey
FOREIGN KEY (clinical_note_id)
REFERENCES public.clinical_notes(id)
ON DELETE SET NULL;
-- 4. Index pra reverse lookup (documentos de uma nota)
CREATE INDEX IF NOT EXISTS idx_documents_clinical_note
ON public.documents (clinical_note_id)
WHERE clinical_note_id IS NOT NULL AND deleted_at IS NULL;
COMMENT ON COLUMN public.documents.clinical_note_id IS
'Vínculo opcional a uma nota clínica (anexar PDF a anamnese/evolução). Renomeado de session_note_id em 2026-05-20.';
COMMIT;
@@ -0,0 +1,95 @@
-- ============================================================================
-- RPC accept_tenant_invite — destrava o fluxo de aceitar convite
-- ----------------------------------------------------------------------------
-- Recebe o token UUID do invite. Em uma transação (SECURITY DEFINER):
-- 1. Lê invite ATIVO (não accepted, não revoked, não expired)
-- 2. INSERT em tenant_members com role do invite + user_id = auth.uid()
-- 3. UPDATE invite com accepted_at + accepted_by
--
-- Retorna jsonb { ok, tenant_id, role } em sucesso ou throw com mensagem PT-BR.
--
-- Chamada pelo features/tenantship/services/tenantInvitesRepository.acceptInvite().
-- Stub anterior tava jogando erro PT-BR explicando isso. Agora funciona.
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.accept_tenant_invite(p_token uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_uid uuid;
v_invite record;
v_existing_member record;
BEGIN
-- Quem está aceitando — auth.uid() pega do JWT
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Sessão inválida (sem user autenticado).';
END IF;
-- 1. Lê invite ativo. Lock via FOR UPDATE pra evitar race.
SELECT id, tenant_id, email, role, accepted_at, revoked_at, expires_at
INTO v_invite
FROM public.tenant_invites
WHERE token = p_token
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Convite não encontrado. Verifique o link.';
END IF;
IF v_invite.revoked_at IS NOT NULL THEN
RAISE EXCEPTION 'Convite revogado pelo administrador.';
END IF;
IF v_invite.accepted_at IS NOT NULL THEN
RAISE EXCEPTION 'Convite já foi aceito anteriormente.';
END IF;
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
RAISE EXCEPTION 'Convite expirado. Peça um novo ao administrador.';
END IF;
-- 2. Idempotência: se já é membro do tenant, só marca invite aceito.
SELECT id, role, status
INTO v_existing_member
FROM public.tenant_members
WHERE tenant_id = v_invite.tenant_id
AND user_id = v_uid
LIMIT 1;
IF v_existing_member.id IS NULL THEN
INSERT INTO public.tenant_members (tenant_id, user_id, role, status)
VALUES (v_invite.tenant_id, v_uid, v_invite.role, 'active');
ELSIF v_existing_member.status <> 'active' THEN
UPDATE public.tenant_members
SET status = 'active', role = v_invite.role
WHERE id = v_existing_member.id;
END IF;
-- (se já está ativo, deixa como tá — convite aceito não rebaixa)
-- 3. Marca invite como aceito
UPDATE public.tenant_invites
SET accepted_at = now(), accepted_by = v_uid
WHERE id = v_invite.id;
RETURN jsonb_build_object(
'ok', true,
'tenant_id', v_invite.tenant_id,
'role', v_invite.role
);
END;
$$;
COMMENT ON FUNCTION public.accept_tenant_invite(uuid) IS
'Aceita convite de membership. SECURITY DEFINER pra criar tenant_members em nome do user logado. Lock FOR UPDATE no invite previne race condition.';
-- Permite que qualquer authenticated chame (precisa do token UUID válido pra entrar).
REVOKE ALL ON FUNCTION public.accept_tenant_invite(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.accept_tenant_invite(uuid) TO authenticated;
COMMIT;
@@ -0,0 +1,138 @@
-- ============================================================================
-- Asaas Gateway — Tier 1 (cobrança de paciente) — schema foundation
-- ----------------------------------------------------------------------------
-- Cria 3 tabelas novas + adiciona 4 colunas em payment_settings.
-- Schema preparado pra Fase 3 do ROADMAP (gateway de pagamento).
--
-- ⚠️ Não habilita o gateway sozinho. Requer:
-- - Edge Functions deployadas
-- - API keys configuradas em payment_settings
-- - Webhook setado no dashboard Asaas
--
-- Ver: development/02-auditoria/DESIGN_ASAAS_GATEWAY.md
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. asaas_customers — mapping patient ↔ Asaas customer
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.asaas_customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
asaas_customer_id text NOT NULL,
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
-- dados cacheados (sincronizados quando atualizar patient)
name text NOT NULL,
email text,
cpf_cnpj text,
phone text,
address jsonb,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
deleted_at timestamptz,
CONSTRAINT asaas_customers_unique_per_env UNIQUE (tenant_id, patient_id, environment)
);
COMMENT ON TABLE public.asaas_customers IS
'Mapping de pacientes para Asaas customers (1:1 por environment). Cacheado pra evitar re-criação a cada cobrança.';
CREATE INDEX IF NOT EXISTS idx_asaas_customers_lookup
ON public.asaas_customers (tenant_id, patient_id)
WHERE deleted_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 2. asaas_payments — 1 row por cobrança gerada
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.asaas_payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
financial_record_id uuid NOT NULL REFERENCES public.financial_records(id) ON DELETE CASCADE,
asaas_customer_id uuid REFERENCES public.asaas_customers(id),
asaas_payment_id text NOT NULL,
asaas_invoice_id text,
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
billing_type text NOT NULL CHECK (billing_type IN ('PIX', 'BOLETO', 'CREDIT_CARD', 'UNDEFINED')),
status text NOT NULL,
value numeric(10, 2) NOT NULL,
net_value numeric(10, 2),
due_date date NOT NULL,
payment_date timestamptz,
invoice_url text,
payment_url text,
bank_slip_url text,
pix_qr_code text,
pix_copy_paste text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
cancelled_at timestamptz,
CONSTRAINT asaas_payments_unique_per_env UNIQUE (asaas_payment_id, environment)
);
COMMENT ON TABLE public.asaas_payments IS
'Cobranças geradas no Asaas. Status raw do Asaas; mapeamento pra financial_records.status acontece no JS.';
CREATE INDEX IF NOT EXISTS idx_asaas_payments_record
ON public.asaas_payments (financial_record_id);
CREATE INDEX IF NOT EXISTS idx_asaas_payments_lookup
ON public.asaas_payments (tenant_id, status, due_date)
WHERE cancelled_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 3. asaas_webhook_events — idempotência + audit
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.asaas_webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id text,
event_type text NOT NULL,
asaas_payment_id text,
payload jsonb NOT NULL,
processed_at timestamptz,
processing_error text,
received_at timestamptz DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.asaas_webhook_events IS
'Audit + idempotência de webhooks Asaas. event_id usado pra dedupe (Asaas faz retry).';
-- event_id UNIQUE quando preenchido (Asaas nem sempre manda)
CREATE UNIQUE INDEX IF NOT EXISTS idx_asaas_webhook_events_event_id
ON public.asaas_webhook_events (event_id)
WHERE event_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_payment
ON public.asaas_webhook_events (asaas_payment_id);
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_unprocessed
ON public.asaas_webhook_events (received_at)
WHERE processed_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 4. payment_settings — colunas pra config Asaas por tenant
-- ──────────────────────────────────────────────────────────────────────────
-- API keys plaintext nesta migration. Em produção, mover pra pgsodium/Vault.
ALTER TABLE public.payment_settings
ADD COLUMN IF NOT EXISTS asaas_api_key_sandbox text,
ADD COLUMN IF NOT EXISTS asaas_api_key_prod text,
ADD COLUMN IF NOT EXISTS asaas_environment text DEFAULT 'sandbox'
CHECK (asaas_environment IN ('sandbox', 'prod')),
ADD COLUMN IF NOT EXISTS asaas_webhook_token text,
ADD COLUMN IF NOT EXISTS asaas_enabled boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN public.payment_settings.asaas_api_key_sandbox IS
'API key Asaas SANDBOX. PLAINTEXT por enquanto — migrar pra Vault em prod.';
COMMENT ON COLUMN public.payment_settings.asaas_api_key_prod IS
'API key Asaas PRODUÇÃO. PLAINTEXT por enquanto — migrar pra Vault em prod.';
COMMENT ON COLUMN public.payment_settings.asaas_environment IS
'Qual key usar: sandbox (testes) ou prod (real). Default sandbox por segurança.';
COMMENT ON COLUMN public.payment_settings.asaas_webhook_token IS
'Token customizado pra webhook receiver validar. Setar mesmo valor no dashboard Asaas.';
COMMENT ON COLUMN public.payment_settings.asaas_enabled IS
'Flag que controla se gateway Asaas está habilitado pro tenant. Default false (opt-in).';
COMMIT;
@@ -0,0 +1,72 @@
-- ============================================================================
-- Asaas Gateway — RLS policies
-- ----------------------------------------------------------------------------
-- Owner-scoped: cada terapeuta vê só os customers/payments do seu tenant.
-- INSERT/UPDATE bloqueado client-side — só Edge Functions (service role)
-- podem escrever. Browser só lê (pra exibir QR code, status, etc).
--
-- API keys em payment_settings: já tem RLS (não duplica).
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- asaas_customers
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.asaas_customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY asaas_customers_member_select
ON public.asaas_customers FOR SELECT TO authenticated
USING (public.is_tenant_member(tenant_id));
-- INSERT/UPDATE/DELETE bloqueados — Edge Functions usam service_role que bypassa RLS
CREATE POLICY asaas_customers_no_client_write
ON public.asaas_customers FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY asaas_customers_no_client_update
ON public.asaas_customers FOR UPDATE TO authenticated
USING (false);
CREATE POLICY asaas_customers_no_client_delete
ON public.asaas_customers FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- asaas_payments
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.asaas_payments ENABLE ROW LEVEL SECURITY;
CREATE POLICY asaas_payments_member_select
ON public.asaas_payments FOR SELECT TO authenticated
USING (public.is_tenant_member(tenant_id));
CREATE POLICY asaas_payments_no_client_write
ON public.asaas_payments FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY asaas_payments_no_client_update
ON public.asaas_payments FOR UPDATE TO authenticated
USING (false);
CREATE POLICY asaas_payments_no_client_delete
ON public.asaas_payments FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- asaas_webhook_events
-- ──────────────────────────────────────────────────────────────────────────
-- Audit table — saas_admin lê pra debug. Members não veem.
ALTER TABLE public.asaas_webhook_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY asaas_webhook_events_saas_admin_select
ON public.asaas_webhook_events FOR SELECT TO authenticated
USING (public.is_saas_admin());
CREATE POLICY asaas_webhook_events_no_client_write
ON public.asaas_webhook_events FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY asaas_webhook_events_no_update
ON public.asaas_webhook_events FOR UPDATE TO authenticated
USING (false);
CREATE POLICY asaas_webhook_events_no_delete
ON public.asaas_webhook_events FOR DELETE TO authenticated
USING (false);
COMMIT;
@@ -0,0 +1,71 @@
-- ============================================================================
-- Compliance CFP — Tipo de registro profissional (ROADMAP item #5)
-- ----------------------------------------------------------------------------
-- Adiciona campos de registro profissional ao perfil. Necessário pra emissão
-- de recibos/laudos válidos (CFP exige tipo, número e UF do conselho).
--
-- Conselhos comuns no Brasil:
-- CRP — Psicólogo
-- CRM — Médico
-- CRFa — Fonoaudiólogo
-- CREFITO — Fisioterapeuta / Terapeuta Ocupacional
-- CRESS — Assistente Social
-- CRN — Nutricionista
-- RMS — Residência Multiprofissional (Saúde)
-- outro — Catch-all (campo livre na UI)
-- ============================================================================
BEGIN;
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS professional_registration_type text,
ADD COLUMN IF NOT EXISTS professional_registration_number text,
ADD COLUMN IF NOT EXISTS professional_registration_uf text;
-- CHECK não pode ser ADD IF NOT EXISTS — guard com DO block
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'profiles_registration_type_check'
) THEN
ALTER TABLE public.profiles
ADD CONSTRAINT profiles_registration_type_check CHECK (
professional_registration_type IS NULL
OR professional_registration_type = ANY (ARRAY[
'CRP',
'CRM',
'CRFa',
'CREFITO',
'CRESS',
'CRN',
'RMS',
'outro'
])
);
END IF;
END $$;
-- UF check (regex pra 2 chars uppercase ou NULL)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'profiles_registration_uf_check'
) THEN
ALTER TABLE public.profiles
ADD CONSTRAINT profiles_registration_uf_check CHECK (
professional_registration_uf IS NULL
OR professional_registration_uf ~ '^[A-Z]{2}$'
);
END IF;
END $$;
COMMENT ON COLUMN public.profiles.professional_registration_type IS
'Tipo de registro profissional. Obrigatório pra emitir recibos/laudos. ROADMAP item #5.';
COMMENT ON COLUMN public.profiles.professional_registration_number IS
'Número do registro (ex: 06/12345 ou 123456). Formato livre — UI ajuda com mask se relevante.';
COMMENT ON COLUMN public.profiles.professional_registration_uf IS
'UF do conselho (2 chars uppercase). Alguns conselhos exigem regionalização (CRP 06/SP, CRP 03/BA).';
COMMIT;
@@ -0,0 +1,79 @@
-- ============================================================================
-- Compliance CFP — Especialidades do profissional (ROADMAP item #9)
-- ----------------------------------------------------------------------------
-- Catálogo de especialidades/abordagens + join many-to-many com profiles.
-- Profissional pode ter múltiplas especialidades (clínica + jurídica, etc).
-- ============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS public.specialties (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key text UNIQUE NOT NULL,
name text NOT NULL,
category text,
is_system boolean DEFAULT false NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.specialties IS
'Catálogo global de especialidades/abordagens psicológicas (ROADMAP item #9). is_system=true pra entries seedadas.';
CREATE INDEX IF NOT EXISTS idx_specialties_active ON public.specialties (active, category, name);
CREATE TABLE IF NOT EXISTS public.profile_specialties (
profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
specialty_id uuid NOT NULL REFERENCES public.specialties(id) ON DELETE RESTRICT,
other_label text,
created_at timestamptz DEFAULT now() NOT NULL,
PRIMARY KEY (profile_id, specialty_id)
);
COMMENT ON TABLE public.profile_specialties IS
'M:N entre profile e specialty. other_label preenchido só quando specialty.key=outra (custom user-defined).';
CREATE INDEX IF NOT EXISTS idx_profile_specialties_profile ON public.profile_specialties (profile_id);
-- ──────────────────────────────────────────────────────────────────────────
-- RLS
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.specialties ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profile_specialties ENABLE ROW LEVEL SECURITY;
-- specialties: read-only pra todos authenticated (catálogo público); só saas_admin escreve
CREATE POLICY specialties_authenticated_read
ON public.specialties FOR SELECT TO authenticated
USING (active = true);
CREATE POLICY specialties_saas_admin_write
ON public.specialties TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- profile_specialties: cada user gerencia o próprio
CREATE POLICY profile_specialties_owner_select
ON public.profile_specialties FOR SELECT TO authenticated
USING (profile_id = auth.uid());
CREATE POLICY profile_specialties_owner_insert
ON public.profile_specialties FOR INSERT TO authenticated
WITH CHECK (profile_id = auth.uid());
CREATE POLICY profile_specialties_owner_delete
ON public.profile_specialties FOR DELETE TO authenticated
USING (profile_id = auth.uid());
-- Tenant_admin pode VER specialties dos membros (pra cards públicos / perfil clínica)
CREATE POLICY profile_specialties_tenant_admin_read
ON public.profile_specialties FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = profile_specialties.profile_id
AND public.is_tenant_admin(tm.tenant_id)
)
);
COMMIT;
@@ -0,0 +1,44 @@
-- ============================================================================
-- Compliance CFP #6 — Tipos de consent form (LGPD + Gravação)
-- ----------------------------------------------------------------------------
-- Estende o CHECK constraint de document_templates.tipo para acomodar dois
-- novos tipos de consent form exigidos pela LGPD e pela prática clínica:
-- • termo_lgpd — Consentimento de tratamento de dados pessoais
-- • autorizacao_gravacao — Autorização de gravação de sessão (áudio/vídeo)
--
-- ROADMAP item #1.2 #6 (Biblioteca de consent forms editáveis).
-- ============================================================================
BEGIN;
ALTER TABLE public.document_templates
DROP CONSTRAINT IF EXISTS dt_tipo_check;
ALTER TABLE public.document_templates
ADD CONSTRAINT dt_tipo_check CHECK (
tipo = ANY (ARRAY[
'declaracao_comparecimento',
'atestado_psicologico',
'relatorio_acompanhamento',
'recibo_pagamento',
'termo_consentimento',
'encaminhamento',
'contrato_servicos',
'tcle',
'autorizacao_menor',
'laudo_psicologico',
'parecer_psicologico',
'termo_sigilo',
'declaracao_inicio_tratamento',
'termo_alta',
'tcle_online',
'termo_lgpd',
'autorizacao_gravacao',
'outro'
])
);
COMMENT ON COLUMN public.document_templates.tipo IS
'Tipo do template. Inclui consent forms (tcle, tcle_online, autorizacao_menor, termo_sigilo, termo_lgpd, autorizacao_gravacao).';
COMMIT;
@@ -0,0 +1,251 @@
-- ============================================================================
-- Compliance CFP #7 — RPCs de assinatura eletrônica
-- ----------------------------------------------------------------------------
-- Cria 2 RPCs que registram assinatura capturando IP server-side (anti-spoof)
-- via inet_client_addr() e user-agent via request headers do Supabase.
--
-- • sign_document_by_signature_id — paciente logado assina via portal
-- • sign_document_by_token — terceiro assina via share link público
--
-- ROADMAP item #1.2 #7 (Assinatura eletrônica pelo paciente no portal,
-- simples, com IP+timestamp). Não usa ICP-Brasil — é assinatura simples
-- com audit trail (IP, UA, timestamp, hash SHA-256 do documento).
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. sign_document_by_signature_id
-- ----------------------------------------------------------------------------
-- Para signatários LOGADOS no portal/sistema. SECURITY INVOKER — a RLS de
-- document_signatures continua aplicando (signatario_id = auth.uid() ou
-- tenant_members). RPC só serve pra centralizar captura de IP + UA + hash.
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sign_document_by_signature_id(
p_signature_id uuid,
p_hash_documento text DEFAULT NULL
) RETURNS public.document_signatures
LANGUAGE plpgsql
SECURITY INVOKER
AS $$
DECLARE
v_row public.document_signatures;
v_ip inet;
v_ua text;
BEGIN
IF p_signature_id IS NULL THEN
RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE = '22023';
END IF;
-- Captura IP e UA do request (best-effort — pode vir NULL em alguns ambientes)
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 public.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')
RETURNING * INTO v_row;
IF v_row.id IS NULL THEN
RAISE EXCEPTION 'Assinatura não encontrada ou já processada' USING ERRCODE = 'P0002';
END IF;
RETURN v_row;
END;
$$;
COMMENT ON FUNCTION public.sign_document_by_signature_id(uuid, text) IS
'Assinatura via portal logado. Captura IP/UA server-side. RLS aplica (SECURITY INVOKER).';
GRANT EXECUTE ON FUNCTION public.sign_document_by_signature_id(uuid, text) TO authenticated;
-- ──────────────────────────────────────────────────────────────────────────
-- 2. sign_document_by_token
-- ----------------------------------------------------------------------------
-- Para signatários NÃO LOGADOS via share link público. SECURITY DEFINER —
-- bypassa RLS. Valida o share_link (token, ativo, expira_em, usos_max),
-- localiza o signatário PENDENTE associado ao documento (signatario_email
-- opcional p/ desambiguar quando há múltiplos), assina, incrementa usos.
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sign_document_by_token(
p_token text,
p_signature_id uuid DEFAULT NULL,
p_hash_documento text DEFAULT NULL
) RETURNS public.document_signatures
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_link public.document_share_links;
v_sig public.document_signatures;
v_ip inet;
v_ua text;
BEGIN
IF p_token IS NULL OR length(p_token) < 32 THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
END IF;
-- Valida share_link
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;
-- Localiza a signature pendente do documento. Se p_signature_id veio,
-- é desambiguação (multi-signatário); senão pega a primeira pendente
-- por ordem.
IF p_signature_id IS NOT NULL THEN
SELECT * INTO v_sig
FROM public.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 public.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;
-- Captura IP/UA
v_ip := inet_client_addr();
BEGIN
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
EXCEPTION WHEN OTHERS THEN
v_ua := NULL;
END;
-- Assina
UPDATE public.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;
-- Incrementa contador de usos do share_link
UPDATE public.document_share_links
SET usos = usos + 1
WHERE id = v_link.id;
RETURN v_sig;
END;
$$;
COMMENT ON FUNCTION public.sign_document_by_token(text, uuid, text) IS
'Assinatura via share link público. SECURITY DEFINER — valida token, captura IP/UA, incrementa usos. p_signature_id é opcional pra desambiguar multi-signatário.';
GRANT EXECUTE ON FUNCTION public.sign_document_by_token(text, uuid, text) TO anon, authenticated;
-- ──────────────────────────────────────────────────────────────────────────
-- 3. get_signable_document_by_token
-- ----------------------------------------------------------------------------
-- View helper que retorna info do documento + signatários pendentes via token,
-- sem assinar. Permite a página pública renderizar antes do click.
-- SECURITY DEFINER porque share_link tem RLS pública mas documents+signatures
-- têm RLS por owner/tenant.
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(
p_token text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_link public.document_share_links;
v_doc public.documents;
v_sigs jsonb;
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;
SELECT * INTO v_doc
FROM public.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 public.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;
$$;
COMMENT ON FUNCTION public.get_signable_document_by_token(text) IS
'Retorna documento + signatários pendentes via token. Usado pela página pública antes de assinar.';
GRANT EXECUTE ON FUNCTION public.get_signable_document_by_token(text) TO anon, authenticated;
COMMIT;
@@ -0,0 +1,102 @@
-- ============================================================================
-- Compliance CFP #7 — RPC list_my_signatures (portal do paciente)
-- ----------------------------------------------------------------------------
-- Retorna as solicitações de assinatura do paciente logado (auth.uid()
-- associado a patients.user_id). SECURITY DEFINER pra bypassar a RLS de
-- document_signatures (que hoje só libera pra tenant_members).
--
-- Cada item já vem com o share_link.token associado, pra que o portal
-- aponte direto pra /shared/document/:token onde o usuário vai assinar.
-- O link público é gerado quando o terapeuta solicita a assinatura.
-- ============================================================================
BEGIN;
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,
-- Documento
nome_original text,
tipo_documento text,
mime_type text,
-- Share link (primeiro válido encontrado pro doc)
share_token text,
share_expira_em timestamptz
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_uid uuid;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Sessão inválida' USING ERRCODE = '28000';
END IF;
RETURN QUERY
SELECT
s.id AS signature_id,
s.documento_id AS documento_id,
s.tenant_id AS tenant_id,
s.signatario_tipo AS signatario_tipo,
s.status AS status,
s.ordem AS ordem,
s.assinado_em AS assinado_em,
s.criado_em AS criado_em,
d.nome_original AS nome_original,
d.tipo_documento AS tipo_documento,
d.mime_type AS mime_type,
sl.token AS share_token,
sl.expira_em AS share_expira_em
FROM public.document_signatures s
JOIN public.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 (
-- signatario_id direto (quando registrado)
s.signatario_id = v_uid
OR
-- Fallback: paciente pelo email (quando signatario_id veio NULL)
s.signatario_email = (SELECT email FROM auth.users WHERE id = v_uid)
OR
-- Fallback: paciente pelo patient_id (documents.patient_id -> patients.user_id)
d.patient_id IN (SELECT p.id FROM public.patients p WHERE p.user_id = v_uid)
)
AND (p_status IS NULL OR s.status = ANY (p_status))
ORDER BY
CASE s.status
WHEN 'pendente' THEN 0
WHEN 'enviado' THEN 1
WHEN 'assinado' THEN 2
WHEN 'recusado' THEN 3
WHEN 'expirado' THEN 4
ELSE 99
END,
s.criado_em DESC;
END;
$$;
COMMENT ON FUNCTION public.list_my_signatures(text[]) IS
'Lista signatures do paciente logado (auth.uid()) cruzando por signatario_id, email ou patient.user_id. Inclui share_token pra link de assinatura.';
GRANT EXECUTE ON FUNCTION public.list_my_signatures(text[]) TO authenticated;
COMMIT;
@@ -0,0 +1,35 @@
-- ============================================================================
-- ROADMAP #1.4 #14 — Recibo profissional usa terapeuta_registro genérico
-- ----------------------------------------------------------------------------
-- O template recibo_pagamento (seed_015) usa "Psicólogo(a) — CRP {{terapeuta_crp}}".
-- Como agora suportamos múltiplos conselhos (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS)
-- via #5 (migration 20260521000003), o recibo precisa ser CFP-agnóstico.
--
-- Esta migration substitui no recibo_pagamento:
-- "Psicólogo(a) — CRP {{terapeuta_crp}}" → "{{terapeuta_registro}}"
-- e atualiza variaveis[] removendo terapeuta_crp + adicionando terapeuta_registro.
--
-- {{terapeuta_registro}} é auto-formatado server-side como "CRP 12345/SP",
-- "CRM 12345/SP" etc, então não precisa de "Psicólogo(a) —" hardcoded.
-- ============================================================================
BEGIN;
UPDATE public.document_templates
SET corpo_html = REPLACE(
corpo_html,
'Psicólogo(a) — CRP {{terapeuta_crp}}',
'{{terapeuta_registro}}'
),
variaveis = ARRAY(
SELECT DISTINCT v FROM (
SELECT unnest(variaveis) v
UNION ALL
SELECT 'terapeuta_registro'
) sub
WHERE v <> 'terapeuta_crp'
),
updated_at = now()
WHERE tipo = 'recibo_pagamento' AND is_global = true;
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,34 @@
-- =============================================================================
-- Freemium F1 — limite de pacientes do plano therapist_free
--
-- clinic_free já traz max_patients=30 (em plan_features.limits da feature
-- clinic_calendar, semeado). O therapist_free não tinha limite de pacientes.
-- Pendura max_patients=20 na feature 'patients.manage' (a que o therapist_free
-- já possui, enabled).
--
-- REGRA DE OURO: referenciar plano/feature POR KEY via subquery, nunca por uuid
-- hardcoded (uuids divergem entre ambientes). Idempotente (merge no jsonb).
-- O enforcement em runtime (trigger) está em manual/freemium_f1_plan_limits.
-- =============================================================================
BEGIN;
UPDATE public.plan_features pf
SET limits = COALESCE(pf.limits, '{}'::jsonb) || jsonb_build_object('max_patients', 20)
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
-- Sanidade: garante que o limite ficou gravado (1 linha afetada esperada).
DO $$
DECLARE v int;
BEGIN
SELECT (pf.limits->>'max_patients')::int INTO v
FROM public.plan_features pf
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
IF v IS DISTINCT FROM 20 THEN
RAISE EXCEPTION 'therapist_free max_patients esperado 20, obtido %', v;
END IF;
END $$;
COMMIT;
@@ -0,0 +1,53 @@
-- =============================================================================
-- Fix (regressão schema-per-tenant): log_audit_change quebra INSERT em tabelas
-- GLOBAIS auditadas.
--
-- log_audit_change deriva o tenant via tenant_id_for_schema(TG_TABLE_SCHEMA).
-- Para tabelas em tenant_<slug> isso resolve certo. Mas o trigger também está
-- em public.tenant_members (tabela global) — e tenant_id_for_schema('public')
-- retorna NULL, violando audit_logs.tenant_id (NOT NULL). Resultado: QUALQUER
-- INSERT em tenant_members falhava (provisionamento, aceite de convite).
--
-- Fix: quando o schema não resolve um tenant (tabela global), usa o tenant_id
-- da própria linha (tenant_members.tenant_id). Se ainda assim for NULL, não
-- audita — mas NUNCA quebra a operação de negócio.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $function$
DECLARE
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
BEGIN
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
-- tabela global (public.*): cai no tenant_id da própria linha, se existir
IF v_tenant_id IS NULL THEN
v_tenant_id := NULLIF(to_jsonb(COALESCE(NEW, OLD)) ->> 'tenant_id', '')::uuid;
END IF;
-- sem tenant resolvível → não audita, mas não quebra a operação
IF v_tenant_id IS NULL THEN
RETURN COALESCE(NEW, OLD);
END IF;
IF TG_OP = 'DELETE' THEN
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
ELSE
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
IF v_changed IS NULL THEN RETURN NEW; END IF;
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
END IF;
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
RETURN COALESCE(NEW, OLD);
END $function$;
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
-- Extensions
-- Gerado automaticamente em 2026-04-21T23:16:33.041Z
-- Gerado automaticamente em 2026-05-11T16:53:49.849Z
-- Total: 10
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
+780 -19
View File
@@ -1,6 +1,6 @@
-- All Functions
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
-- Total: 192
-- Gerado automaticamente em 2026-05-11T16:53:50.921Z
-- Total: 211
CREATE FUNCTION auth.email() RETURNS text
LANGUAGE sql STABLE
@@ -287,6 +287,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
select 'ok'::text;
$$;
CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
WITH msgs AS (
SELECT
m.id,
m.tenant_id,
m.direction,
m.created_at,
m.patient_id,
m.from_number,
m.to_number,
-- mesma logica da view conversation_threads
COALESCE(
m.patient_id::text,
'anon:' || COALESCE(
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
'unknown'
)
) AS tk
FROM public.conversation_messages m
WHERE m.tenant_id = p_tenant_id
AND m.direction IN ('inbound', 'outbound')
AND m.created_at >= p_from
AND m.created_at <= p_to
),
with_prev AS (
SELECT *,
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
FROM msgs
),
run_starts AS (
-- Primeira mensagem de cada "run inbound"
SELECT tk, tenant_id, created_at AS inbound_started_at
FROM with_prev
WHERE direction = 'inbound'
AND (prev_direction IS NULL OR prev_direction = 'outbound')
)
SELECT
r.tk AS thread_key,
r.inbound_started_at,
o.created_at AS responded_at,
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
a.assigned_to AS responder_id
FROM run_starts r
LEFT JOIN LATERAL (
SELECT created_at
FROM public.conversation_messages m2
WHERE m2.tenant_id = r.tenant_id
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
AND m2.direction = 'outbound'
AND m2.created_at > r.inbound_started_at
ORDER BY m2.created_at
LIMIT 1
) o ON true
LEFT JOIN public.conversation_assignments a
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
$$;
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -451,6 +513,95 @@ BEGIN
END;
$$;
CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_new_balance INT;
v_current_balance INT;
v_topup_net INT;
v_usage_total INT;
v_removable INT;
v_clean_note TEXT;
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_required';
END IF;
IF p_amount IS NULL OR p_amount = 0 THEN
RAISE EXCEPTION 'amount_required';
END IF;
IF ABS(p_amount) > 1000 THEN
RAISE EXCEPTION 'amount_exceeds_limit_1000';
END IF;
IF p_admin_id IS NULL THEN
RAISE EXCEPTION 'admin_id_required';
END IF;
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
IF v_clean_note IS NOT NULL THEN
v_clean_note := LEFT(v_clean_note, 500);
END IF;
IF p_amount > 0 THEN
-- ADICIONAR
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
VALUES (p_tenant_id, p_amount)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
low_balance_alerted_at = NULL
RETURNING balance INTO v_new_balance;
ELSE
-- REMOVER (amount < 0)
SELECT balance INTO v_current_balance
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'tenant_has_no_balance';
END IF;
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind IN ('topup_manual', 'adjustment', 'refund');
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind = 'usage';
v_removable := GREATEST(0, v_topup_net - v_usage_total);
v_removable := LEAST(v_removable, v_current_balance);
IF ABS(p_amount) > v_removable THEN
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance + p_amount -- p_amount ja e negativo
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
END IF;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, admin_id, note)
VALUES
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
RETURN v_new_balance;
END;
$$;
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -1053,9 +1204,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status IN ('cancelado', 'excluido')
AND OLD.status NOT IN ('cancelado', 'excluido')
THEN
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id
);
@@ -1429,6 +1578,101 @@ BEGIN
END;
$$;
CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_intake RECORD;
v_tenant_id UUID;
v_thread_key TEXT;
v_phone TEXT;
v_note_body TEXT;
v_admin_id UUID;
v_msg_id BIGINT;
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;
-- Tenant_id vem via owner_id (tenant_members)
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;
-- Normaliza telefone pra thread_key
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;
-- Nota com dados coletados
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')
);
-- Pega 1 admin do tenant pra preencher created_by da nota
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;
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
INSERT INTO public.conversation_messages
(tenant_id, channel, direction, from_number, to_number, body, provider,
provider_raw, kanban_status)
VALUES (
v_tenant_id, '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'
) RETURNING id INTO v_msg_id;
-- Cria nota interna
INSERT INTO public.conversation_notes
(tenant_id, thread_key, contact_number, body, created_by)
VALUES (
v_tenant_id, v_thread_key,
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
v_note_body, v_admin_id
);
-- Atualiza intake
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;
$$;
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -3032,6 +3276,84 @@ BEGIN
END;
$$;
CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT
r.responder_id AS therapist_id,
COUNT(*)::INT AS runs_count,
AVG(r.response_seconds)::INT AS avg_seconds,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE r.responder_id IS NOT NULL
GROUP BY r.responder_id
ORDER BY avg_seconds ASC;
$$;
CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
WITH runs AS (
SELECT r.inbound_started_at, r.response_seconds
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
),
bucketed AS (
SELECT
-- Janela alinhada a p_from: bucket_index * N dias + p_from
p_from + (
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
* p_bucket_days * interval '1 day'
) AS bucket_start,
response_seconds
FROM runs
)
SELECT
bucket_start,
COUNT(*)::INT AS runs_count,
AVG(response_seconds)::INT AS avg_seconds
FROM bucketed
GROUP BY bucket_start
ORDER BY bucket_start;
$$;
CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) 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'
AS $$
DECLARE
v_threshold_seconds INT;
BEGIN
-- Pega threshold do SLA (se habilitado)
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
INTO v_threshold_seconds
FROM public.conversation_sla_rules
WHERE tenant_id = p_tenant_id;
RETURN QUERY
WITH runs AS (
SELECT r.response_seconds, r.responder_id
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
)
SELECT
COUNT(*)::INT AS runs_count,
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
COALESCE(MIN(response_seconds), 0) AS min_seconds,
COALESCE(MAX(response_seconds), 0) AS max_seconds,
v_threshold_seconds AS sla_threshold_seconds,
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
CASE
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
END AS sla_compliance_rate
FROM runs;
END;
$$;
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -3138,6 +3460,146 @@ BEGIN
END;
$$;
CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_url TEXT;
v_key TEXT;
BEGIN
-- So dispara se status realmente mudou
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
RETURN NEW;
END IF;
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
RETURN NEW;
END IF;
-- Precisa de paciente vinculado (senao nao tem telefone)
IF NEW.patient_id IS NULL THEN
RETURN NEW;
END IF;
-- Busca settings
BEGIN
v_url := current_setting('app.settings.supabase_url', true);
v_key := current_setting('app.settings.service_role_key', true);
EXCEPTION WHEN OTHERS THEN
-- Settings nao configuradas — silencioso
RETURN NEW;
END;
IF v_url IS NULL OR v_key IS NULL THEN
RETURN NEW;
END IF;
-- Fire and forget (pg_net)
PERFORM net.http_post(
url := v_url || '/functions/v1/send-session-status-notification',
headers := jsonb_build_object(
'Authorization', 'Bearer ' || v_key,
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'event_id', NEW.id,
'old_status', OLD.status,
'new_status', NEW.status
)
);
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_thread_key TEXT;
BEGIN
-- So processa outbound
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
-- Calcula thread_key no mesmo padrao da view conversation_threads
v_thread_key := COALESCE(
NEW.patient_id::text,
'anon:' || COALESCE(NEW.to_number, 'unknown')
);
UPDATE public.conversation_sla_breaches
SET resolved_at = now(),
resolved_by_message_id = NEW.id
WHERE tenant_id = NEW.tenant_id
AND thread_key = v_thread_key
AND resolved_at IS NULL;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_detail TEXT;
BEGIN
-- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
IF NEW.balance < NEW.low_balance_threshold
AND NEW.low_balance_alerted_at IS NULL THEN
v_detail := format(
'Saldo atual: %s credito(s). Alerta configurado em %s. '
'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
NEW.balance,
NEW.low_balance_threshold
);
-- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
INSERT INTO public.notifications
(owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT
u.user_id,
NEW.tenant_id,
'system_alert',
NEW.tenant_id,
'whatsapp_credits_balance',
jsonb_build_object(
'title', 'Saldo de WhatsApp baixo',
'detail', v_detail,
'severity', 'warn',
'deeplink', '/configuracoes/creditos-whatsapp'
)
FROM (
SELECT owner_id AS user_id
FROM public.notification_channels
WHERE tenant_id = NEW.tenant_id
AND channel = 'whatsapp'
AND is_active = true
AND deleted_at IS NULL
UNION
SELECT user_id
FROM public.tenant_members
WHERE tenant_id = NEW.tenant_id
AND role IN ('clinic_admin', 'tenant_admin')
AND status = 'active'
) u
WHERE u.user_id IS NOT NULL;
-- Anti-spam: so alerta de novo depois que add_whatsapp_credits
-- reseta alerted_at pra NULL (acontece em purchase/topup)
NEW.low_balance_alerted_at := now();
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -3456,6 +3918,48 @@ BEGIN
END;
$$;
CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_balance INT := 0;
v_topup_net INT := 0;
v_usage_total INT := 0;
v_removable INT := 0;
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
SELECT COALESCE(b.balance, 0) INTO v_balance
FROM public.whatsapp_credits_balance b
WHERE b.tenant_id = p_tenant_id;
v_balance := COALESCE(v_balance, 0);
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind IN ('topup_manual', 'adjustment', 'refund');
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind = 'usage';
v_removable := GREATEST(0, v_topup_net - v_usage_total);
v_removable := LEAST(v_removable, v_balance);
RETURN QUERY SELECT
v_balance,
v_removable,
GREATEST(0, v_balance - v_removable),
v_topup_net,
v_usage_total;
END;
$$;
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -4689,6 +5193,120 @@ begin
end;
$$;
CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
WITH purchases AS (
SELECT p.paid_at, p.amount_brl
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to
),
bucketed AS (
SELECT
p_from + (
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
* p_bucket_days * interval '1 day'
) AS bucket_start,
amount_brl
FROM purchases
)
SELECT
bucket_start,
COUNT(*)::INT AS purchases_count,
SUM(amount_brl)::NUMERIC AS revenue_brl
FROM bucketed
GROUP BY bucket_start
ORDER BY bucket_start;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
COUNT(*)::INT AS purchases_count,
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
END AS avg_ticket_brl
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
p.package_id,
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
-- atual pra consolidar pacotes renomeados
COALESCE(
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
p.package_name
) AS package_name,
COUNT(*)::INT AS purchases_count,
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
SUM(p.credits)::INT AS credits_sold
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to
GROUP BY p.package_id, p.package_name
ORDER BY revenue_brl DESC
LIMIT 10;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
COALESCE(SUM(balance), 0)::INT AS current_balance,
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
END AS usage_rate,
COUNT(*)::INT AS tenants_with_balance
FROM public.whatsapp_credits_balance;
END;
$$;
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -5006,7 +5624,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
declare
v_id uuid;
begin
-- Sess??o (locked + sempre ativa)
-- Sessão (locked + sempre ativa)
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
@@ -5014,7 +5632,7 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'session', true, true, 'Sess??o', 'Sess??o com paciente');
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
end if;
-- Leitura
@@ -5028,7 +5646,7 @@ begin
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if;
-- Supervis??o
-- Supervisão
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -5036,10 +5654,10 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'supervision', false, true, 'Supervis??o', 'Supervis??o');
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
end if;
-- Aula ??? (corrigido)
-- Aula
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
@@ -5050,7 +5668,7 @@ begin
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if;
-- An??lise pessoal
-- Análise pessoal
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -5058,13 +5676,26 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'analysis', false, true, 'An??lise Pessoal', 'Minha an??lise pessoal');
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
end if;
-- -------------------------------------------------------
-- Campos padr??o (idempotentes por (commitment_id, key))
-- Campos padrão (idempotentes por (commitment_id, key))
-- -------------------------------------------------------
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Leitura
select id into v_id
from public.determined_commitments
@@ -5084,11 +5715,11 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Supervis??o
-- Supervisão
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -5107,7 +5738,7 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
@@ -5130,11 +5761,11 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- An??lise
-- Análise
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -5153,7 +5784,7 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
end;
@@ -5335,6 +5966,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;
CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.conversation_sla_breaches
SET notified_at = now(),
notification_count = notification_count + 1
WHERE id = p_breach_id;
END;
$$;
CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
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;
-- Ja tem aberto? Retorna o mesmo id (idempotente)
SELECT id INTO v_existing_id
FROM public.conversation_sla_breaches
WHERE tenant_id = p_tenant_id
AND thread_key = p_thread_key
AND resolved_at IS NULL;
IF FOUND THEN
UPDATE public.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 public.conversation_sla_breaches
(tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
VALUES
(p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -6698,6 +7378,87 @@ BEGIN
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.whatsapp_connection_incidents
SET notified_at = now(),
notification_count = notification_count + 1
WHERE id = p_incident_id;
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tenant_id UUID;
v_provider TEXT;
v_existing_id UUID;
v_new_id UUID;
BEGIN
-- Busca tenant/provider do channel
SELECT tenant_id, provider INTO v_tenant_id, v_provider
FROM public.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;
-- Ja tem aberto? Retorna o mesmo id (idempotente)
SELECT id INTO v_existing_id
FROM public.whatsapp_connection_incidents
WHERE channel_id = p_channel_id
AND resolved_at IS NULL;
IF FOUND THEN
-- Atualiza o incident existente com detalhes frescos
UPDATE public.whatsapp_connection_incidents
SET last_state = COALESCE(p_last_state, last_state),
details = COALESCE(p_details, details),
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
-- Abre novo
INSERT INTO public.whatsapp_connection_incidents
(tenant_id, channel_id, provider, kind, last_state, details)
VALUES
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_count INT := 0;
BEGIN
UPDATE public.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;
$$;
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
LANGUAGE sql STABLE
AS $$
+1 -1
View File
@@ -1,5 +1,5 @@
-- Functions: auth
-- Gerado automaticamente em 2026-04-21T23:16:34.941Z
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
-- Total: 4
CREATE FUNCTION auth.email() RETURNS text
@@ -1,5 +1,5 @@
-- Functions: extensions
-- Gerado automaticamente em 2026-04-21T23:16:34.942Z
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
-- Total: 6
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
@@ -1,5 +1,5 @@
-- Functions: pgbouncer
-- Gerado automaticamente em 2026-04-21T23:16:34.943Z
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
-- Total: 1
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
+780 -19
View File
@@ -1,6 +1,6 @@
-- Functions: public
-- Gerado automaticamente em 2026-04-21T23:16:34.944Z
-- Total: 153
-- Gerado automaticamente em 2026-05-11T16:53:50.918Z
-- Total: 172
CREATE FUNCTION public.__rls_ping() RETURNS text
LANGUAGE sql STABLE
@@ -8,6 +8,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
select 'ok'::text;
$$;
CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
WITH msgs AS (
SELECT
m.id,
m.tenant_id,
m.direction,
m.created_at,
m.patient_id,
m.from_number,
m.to_number,
-- mesma logica da view conversation_threads
COALESCE(
m.patient_id::text,
'anon:' || COALESCE(
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
'unknown'
)
) AS tk
FROM public.conversation_messages m
WHERE m.tenant_id = p_tenant_id
AND m.direction IN ('inbound', 'outbound')
AND m.created_at >= p_from
AND m.created_at <= p_to
),
with_prev AS (
SELECT *,
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
FROM msgs
),
run_starts AS (
-- Primeira mensagem de cada "run inbound"
SELECT tk, tenant_id, created_at AS inbound_started_at
FROM with_prev
WHERE direction = 'inbound'
AND (prev_direction IS NULL OR prev_direction = 'outbound')
)
SELECT
r.tk AS thread_key,
r.inbound_started_at,
o.created_at AS responded_at,
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
a.assigned_to AS responder_id
FROM run_starts r
LEFT JOIN LATERAL (
SELECT created_at
FROM public.conversation_messages m2
WHERE m2.tenant_id = r.tenant_id
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
AND m2.direction = 'outbound'
AND m2.created_at > r.inbound_started_at
ORDER BY m2.created_at
LIMIT 1
) o ON true
LEFT JOIN public.conversation_assignments a
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
$$;
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -172,6 +234,95 @@ BEGIN
END;
$$;
CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_new_balance INT;
v_current_balance INT;
v_topup_net INT;
v_usage_total INT;
v_removable INT;
v_clean_note TEXT;
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_required';
END IF;
IF p_amount IS NULL OR p_amount = 0 THEN
RAISE EXCEPTION 'amount_required';
END IF;
IF ABS(p_amount) > 1000 THEN
RAISE EXCEPTION 'amount_exceeds_limit_1000';
END IF;
IF p_admin_id IS NULL THEN
RAISE EXCEPTION 'admin_id_required';
END IF;
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
IF v_clean_note IS NOT NULL THEN
v_clean_note := LEFT(v_clean_note, 500);
END IF;
IF p_amount > 0 THEN
-- ADICIONAR
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
VALUES (p_tenant_id, p_amount)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
low_balance_alerted_at = NULL
RETURNING balance INTO v_new_balance;
ELSE
-- REMOVER (amount < 0)
SELECT balance INTO v_current_balance
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'tenant_has_no_balance';
END IF;
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind IN ('topup_manual', 'adjustment', 'refund');
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind = 'usage';
v_removable := GREATEST(0, v_topup_net - v_usage_total);
v_removable := LEAST(v_removable, v_current_balance);
IF ABS(p_amount) > v_removable THEN
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance + p_amount -- p_amount ja e negativo
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
END IF;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, admin_id, note)
VALUES
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
RETURN v_new_balance;
END;
$$;
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -774,9 +925,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status IN ('cancelado', 'excluido')
AND OLD.status NOT IN ('cancelado', 'excluido')
THEN
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id
);
@@ -1150,6 +1299,101 @@ BEGIN
END;
$$;
CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_intake RECORD;
v_tenant_id UUID;
v_thread_key TEXT;
v_phone TEXT;
v_note_body TEXT;
v_admin_id UUID;
v_msg_id BIGINT;
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;
-- Tenant_id vem via owner_id (tenant_members)
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;
-- Normaliza telefone pra thread_key
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;
-- Nota com dados coletados
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')
);
-- Pega 1 admin do tenant pra preencher created_by da nota
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;
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
INSERT INTO public.conversation_messages
(tenant_id, channel, direction, from_number, to_number, body, provider,
provider_raw, kanban_status)
VALUES (
v_tenant_id, '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'
) RETURNING id INTO v_msg_id;
-- Cria nota interna
INSERT INTO public.conversation_notes
(tenant_id, thread_key, contact_number, body, created_by)
VALUES (
v_tenant_id, v_thread_key,
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
v_note_body, v_admin_id
);
-- Atualiza intake
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;
$$;
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -2753,6 +2997,84 @@ BEGIN
END;
$$;
CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT
r.responder_id AS therapist_id,
COUNT(*)::INT AS runs_count,
AVG(r.response_seconds)::INT AS avg_seconds,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE r.responder_id IS NOT NULL
GROUP BY r.responder_id
ORDER BY avg_seconds ASC;
$$;
CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
WITH runs AS (
SELECT r.inbound_started_at, r.response_seconds
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
),
bucketed AS (
SELECT
-- Janela alinhada a p_from: bucket_index * N dias + p_from
p_from + (
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
* p_bucket_days * interval '1 day'
) AS bucket_start,
response_seconds
FROM runs
)
SELECT
bucket_start,
COUNT(*)::INT AS runs_count,
AVG(response_seconds)::INT AS avg_seconds
FROM bucketed
GROUP BY bucket_start
ORDER BY bucket_start;
$$;
CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) 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'
AS $$
DECLARE
v_threshold_seconds INT;
BEGIN
-- Pega threshold do SLA (se habilitado)
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
INTO v_threshold_seconds
FROM public.conversation_sla_rules
WHERE tenant_id = p_tenant_id;
RETURN QUERY
WITH runs AS (
SELECT r.response_seconds, r.responder_id
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
)
SELECT
COUNT(*)::INT AS runs_count,
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
COALESCE(MIN(response_seconds), 0) AS min_seconds,
COALESCE(MAX(response_seconds), 0) AS max_seconds,
v_threshold_seconds AS sla_threshold_seconds,
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
CASE
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
END AS sla_compliance_rate
FROM runs;
END;
$$;
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -2859,6 +3181,146 @@ BEGIN
END;
$$;
CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_url TEXT;
v_key TEXT;
BEGIN
-- So dispara se status realmente mudou
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
RETURN NEW;
END IF;
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
RETURN NEW;
END IF;
-- Precisa de paciente vinculado (senao nao tem telefone)
IF NEW.patient_id IS NULL THEN
RETURN NEW;
END IF;
-- Busca settings
BEGIN
v_url := current_setting('app.settings.supabase_url', true);
v_key := current_setting('app.settings.service_role_key', true);
EXCEPTION WHEN OTHERS THEN
-- Settings nao configuradas — silencioso
RETURN NEW;
END;
IF v_url IS NULL OR v_key IS NULL THEN
RETURN NEW;
END IF;
-- Fire and forget (pg_net)
PERFORM net.http_post(
url := v_url || '/functions/v1/send-session-status-notification',
headers := jsonb_build_object(
'Authorization', 'Bearer ' || v_key,
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'event_id', NEW.id,
'old_status', OLD.status,
'new_status', NEW.status
)
);
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_thread_key TEXT;
BEGIN
-- So processa outbound
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
-- Calcula thread_key no mesmo padrao da view conversation_threads
v_thread_key := COALESCE(
NEW.patient_id::text,
'anon:' || COALESCE(NEW.to_number, 'unknown')
);
UPDATE public.conversation_sla_breaches
SET resolved_at = now(),
resolved_by_message_id = NEW.id
WHERE tenant_id = NEW.tenant_id
AND thread_key = v_thread_key
AND resolved_at IS NULL;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_detail TEXT;
BEGIN
-- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
IF NEW.balance < NEW.low_balance_threshold
AND NEW.low_balance_alerted_at IS NULL THEN
v_detail := format(
'Saldo atual: %s credito(s). Alerta configurado em %s. '
'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
NEW.balance,
NEW.low_balance_threshold
);
-- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
INSERT INTO public.notifications
(owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT
u.user_id,
NEW.tenant_id,
'system_alert',
NEW.tenant_id,
'whatsapp_credits_balance',
jsonb_build_object(
'title', 'Saldo de WhatsApp baixo',
'detail', v_detail,
'severity', 'warn',
'deeplink', '/configuracoes/creditos-whatsapp'
)
FROM (
SELECT owner_id AS user_id
FROM public.notification_channels
WHERE tenant_id = NEW.tenant_id
AND channel = 'whatsapp'
AND is_active = true
AND deleted_at IS NULL
UNION
SELECT user_id
FROM public.tenant_members
WHERE tenant_id = NEW.tenant_id
AND role IN ('clinic_admin', 'tenant_admin')
AND status = 'active'
) u
WHERE u.user_id IS NOT NULL;
-- Anti-spam: so alerta de novo depois que add_whatsapp_credits
-- reseta alerted_at pra NULL (acontece em purchase/topup)
NEW.low_balance_alerted_at := now();
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -3177,6 +3639,48 @@ BEGIN
END;
$$;
CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_balance INT := 0;
v_topup_net INT := 0;
v_usage_total INT := 0;
v_removable INT := 0;
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
SELECT COALESCE(b.balance, 0) INTO v_balance
FROM public.whatsapp_credits_balance b
WHERE b.tenant_id = p_tenant_id;
v_balance := COALESCE(v_balance, 0);
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind IN ('topup_manual', 'adjustment', 'refund');
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind = 'usage';
v_removable := GREATEST(0, v_topup_net - v_usage_total);
v_removable := LEAST(v_removable, v_balance);
RETURN QUERY SELECT
v_balance,
v_removable,
GREATEST(0, v_balance - v_removable),
v_topup_net,
v_usage_total;
END;
$$;
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -4410,6 +4914,120 @@ begin
end;
$$;
CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
WITH purchases AS (
SELECT p.paid_at, p.amount_brl
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to
),
bucketed AS (
SELECT
p_from + (
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
* p_bucket_days * interval '1 day'
) AS bucket_start,
amount_brl
FROM purchases
)
SELECT
bucket_start,
COUNT(*)::INT AS purchases_count,
SUM(amount_brl)::NUMERIC AS revenue_brl
FROM bucketed
GROUP BY bucket_start
ORDER BY bucket_start;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
COUNT(*)::INT AS purchases_count,
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
END AS avg_ticket_brl
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
p.package_id,
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
-- atual pra consolidar pacotes renomeados
COALESCE(
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
p.package_name
) AS package_name,
COUNT(*)::INT AS purchases_count,
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
SUM(p.credits)::INT AS credits_sold
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to
GROUP BY p.package_id, p.package_name
ORDER BY revenue_brl DESC
LIMIT 10;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
COALESCE(SUM(balance), 0)::INT AS current_balance,
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
END AS usage_rate,
COUNT(*)::INT AS tenants_with_balance
FROM public.whatsapp_credits_balance;
END;
$$;
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -4727,7 +5345,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
declare
v_id uuid;
begin
-- Sess??o (locked + sempre ativa)
-- Sessão (locked + sempre ativa)
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
@@ -4735,7 +5353,7 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'session', true, true, 'Sess??o', 'Sess??o com paciente');
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
end if;
-- Leitura
@@ -4749,7 +5367,7 @@ begin
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if;
-- Supervis??o
-- Supervisão
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -4757,10 +5375,10 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'supervision', false, true, 'Supervis??o', 'Supervis??o');
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
end if;
-- Aula ??? (corrigido)
-- Aula
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
@@ -4771,7 +5389,7 @@ begin
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if;
-- An??lise pessoal
-- Análise pessoal
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -4779,13 +5397,26 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'analysis', false, true, 'An??lise Pessoal', 'Minha an??lise pessoal');
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
end if;
-- -------------------------------------------------------
-- Campos padr??o (idempotentes por (commitment_id, key))
-- Campos padrão (idempotentes por (commitment_id, key))
-- -------------------------------------------------------
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Leitura
select id into v_id
from public.determined_commitments
@@ -4805,11 +5436,11 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Supervis??o
-- Supervisão
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -4828,7 +5459,7 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
@@ -4851,11 +5482,11 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- An??lise
-- Análise
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -4874,7 +5505,7 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
end;
@@ -5056,6 +5687,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;
CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.conversation_sla_breaches
SET notified_at = now(),
notification_count = notification_count + 1
WHERE id = p_breach_id;
END;
$$;
CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
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;
-- Ja tem aberto? Retorna o mesmo id (idempotente)
SELECT id INTO v_existing_id
FROM public.conversation_sla_breaches
WHERE tenant_id = p_tenant_id
AND thread_key = p_thread_key
AND resolved_at IS NULL;
IF FOUND THEN
UPDATE public.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 public.conversation_sla_breaches
(tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
VALUES
(p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -6419,6 +7099,87 @@ BEGIN
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.whatsapp_connection_incidents
SET notified_at = now(),
notification_count = notification_count + 1
WHERE id = p_incident_id;
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tenant_id UUID;
v_provider TEXT;
v_existing_id UUID;
v_new_id UUID;
BEGIN
-- Busca tenant/provider do channel
SELECT tenant_id, provider INTO v_tenant_id, v_provider
FROM public.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;
-- Ja tem aberto? Retorna o mesmo id (idempotente)
SELECT id INTO v_existing_id
FROM public.whatsapp_connection_incidents
WHERE channel_id = p_channel_id
AND resolved_at IS NULL;
IF FOUND THEN
-- Atualiza o incident existente com detalhes frescos
UPDATE public.whatsapp_connection_incidents
SET last_state = COALESCE(p_last_state, last_state),
details = COALESCE(p_details, details),
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
-- Abre novo
INSERT INTO public.whatsapp_connection_incidents
(tenant_id, channel_id, provider, kind, last_state, details)
VALUES
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_count INT := 0;
BEGIN
UPDATE public.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;
$$;
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
LANGUAGE sql STABLE
AS $$
@@ -1,5 +1,5 @@
-- Functions: realtime
-- Gerado automaticamente em 2026-04-21T23:16:34.949Z
-- Gerado automaticamente em 2026-05-11T16:53:50.919Z
-- Total: 12
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
@@ -1,5 +1,5 @@
-- Functions: storage
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
-- Gerado automaticamente em 2026-05-11T16:53:50.920Z
-- Total: 15
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
@@ -1,5 +1,5 @@
-- Functions: supabase_functions
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
-- Gerado automaticamente em 2026-05-11T16:53:50.920Z
-- Total: 1
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
@@ -1,5 +1,5 @@
-- Tables: Addons / Créditos
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Gerado automaticamente em 2026-05-11T16:53:50.927Z
-- Total: 7
CREATE TABLE public.addon_credits (
@@ -1,5 +1,5 @@
-- Tables: Agenda / Agendamento
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Gerado automaticamente em 2026-05-11T16:53:50.927Z
-- Total: 10
CREATE TABLE public.agenda_bloqueios (
@@ -1,5 +1,5 @@
-- Tables: Central SaaS (docs/FAQ)
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
-- Gerado automaticamente em 2026-05-11T16:53:50.931Z
-- Total: 4
CREATE TABLE public.saas_doc_votos (
@@ -1,6 +1,6 @@
-- Tables: Comunicação / Notificações
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 14
-- Gerado automaticamente em 2026-05-11T16:53:50.929Z
-- Total: 15
CREATE TABLE public.notification_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
@@ -260,7 +260,23 @@ CREATE TABLE public.notifications (
read_at timestamp with time zone,
archived boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text, 'inbound_message'::text])))
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text, 'inbound_message'::text, 'system_alert'::text])))
);
CREATE TABLE public.saas_twilio_config (
id boolean DEFAULT true NOT NULL,
account_sid text,
whatsapp_webhook_url text,
usd_brl_rate numeric(10,4) DEFAULT 5.5 NOT NULL,
margin_multiplier numeric(10,4) DEFAULT 1.4 NOT NULL,
notes text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_twilio_config_mult_chk CHECK (((margin_multiplier >= (1)::numeric) AND (margin_multiplier <= (10)::numeric))),
CONSTRAINT saas_twilio_config_rate_chk CHECK (((usd_brl_rate > (0)::numeric) AND (usd_brl_rate < (100)::numeric))),
CONSTRAINT saas_twilio_config_sid_chk CHECK (((account_sid IS NULL) OR (account_sid ~ '^AC[a-zA-Z0-9]{32}$'::text))),
CONSTRAINT saas_twilio_config_singleton CHECK ((id = true)),
CONSTRAINT saas_twilio_config_url_chk CHECK (((whatsapp_webhook_url IS NULL) OR (whatsapp_webhook_url ~ '^https?://'::text)))
);
CREATE TABLE public.twilio_subaccount_usage (
@@ -1,6 +1,18 @@
-- Tables: CRM Conversas (WhatsApp)
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 10
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
-- Total: 16
CREATE TABLE public.conversation_assignments (
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
patient_id uuid,
contact_number text,
assigned_to uuid,
assigned_by uuid NOT NULL,
assigned_at timestamp with time zone DEFAULT now() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.conversation_autoreply_log (
id bigint NOT NULL,
@@ -25,6 +37,39 @@ CREATE TABLE public.conversation_autoreply_settings (
CONSTRAINT conversation_autoreply_settings_schedule_mode_check CHECK ((schedule_mode = ANY (ARRAY['agenda'::text, 'business_hours'::text, 'custom'::text])))
);
CREATE TABLE public.conversation_bot_sessions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
contact_number text,
current_step integer DEFAULT 0 NOT NULL,
collected_data jsonb DEFAULT '{}'::jsonb NOT NULL,
status text DEFAULT 'active'::text NOT NULL,
started_at timestamp with time zone DEFAULT now() NOT NULL,
last_advance_at timestamp with time zone DEFAULT now() NOT NULL,
completed_at timestamp with time zone,
abandoned_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_bot_sessions_status_check CHECK ((status = ANY (ARRAY['active'::text, 'completed'::text, 'abandoned_idle'::text, 'abandoned_manual'::text, 'opted_out'::text])))
);
CREATE TABLE public.conversation_bots (
tenant_id uuid NOT NULL,
enabled boolean DEFAULT false NOT NULL,
greeting_message text DEFAULT 'Olá! 👋 Sou o assistente virtual. Vou te fazer algumas perguntas rápidas pra a equipe preparar seu atendimento.'::text NOT NULL,
closing_message text DEFAULT 'Obrigado! Recebemos suas informações e a equipe entrará em contato em breve. 💙'::text NOT NULL,
steps jsonb DEFAULT jsonb_build_array(jsonb_build_object('prompt', 'Qual seu nome completo?', 'variable', 'nome_completo', 'type', 'text'), jsonb_build_object('prompt', 'O que te levou a buscar atendimento? Pode me contar brevemente.', 'variable', 'motivo', 'type', 'text'), jsonb_build_object('prompt', 'Prefere atendimento online ou presencial?', 'variable', 'modalidade', 'type', 'text'), jsonb_build_object('prompt', 'Qual o melhor dia e horário pra você? (Ex: terça à tarde)', 'variable', 'horario_preferido', 'type', 'text')) NOT NULL,
trigger_mode text DEFAULT 'new_contact'::text NOT NULL,
trigger_keywords text[] DEFAULT ARRAY[]::text[] NOT NULL,
idle_timeout_minutes integer DEFAULT 30 NOT NULL,
respect_optout boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_bots_idle_timeout_minutes_check CHECK (((idle_timeout_minutes >= 5) AND (idle_timeout_minutes <= 1440))),
CONSTRAINT conversation_bots_trigger_mode_check CHECK ((trigger_mode = ANY (ARRAY['new_contact'::text, 'all_unassigned'::text, 'keyword'::text])))
);
CREATE TABLE public.conversation_messages (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
@@ -99,6 +144,39 @@ CREATE TABLE public.conversation_optouts (
CONSTRAINT conversation_optouts_source_check CHECK ((source = ANY (ARRAY['keyword'::text, 'manual'::text])))
);
CREATE TABLE public.conversation_sla_breaches (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
assigned_to uuid,
last_inbound_at timestamp with time zone NOT NULL,
threshold_minutes_at_breach integer NOT NULL,
breached_at timestamp with time zone DEFAULT now() NOT NULL,
resolved_at timestamp with time zone,
resolved_by_message_id bigint,
notified_at timestamp with time zone,
notification_count integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.conversation_sla_rules (
tenant_id uuid NOT NULL,
enabled boolean DEFAULT false NOT NULL,
threshold_minutes integer DEFAULT 60 NOT NULL,
respect_business_hours boolean DEFAULT true NOT NULL,
business_hours_start time without time zone DEFAULT '08:00:00'::time without time zone NOT NULL,
business_hours_end time without time zone DEFAULT '18:00:00'::time without time zone NOT NULL,
business_days smallint[] DEFAULT ARRAY[(1)::smallint, (2)::smallint, (3)::smallint, (4)::smallint, (5)::smallint] NOT NULL,
alert_scope text DEFAULT 'assigned_only'::text NOT NULL,
notify_admin_on_breach boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_sla_rules_alert_scope_check CHECK ((alert_scope = ANY (ARRAY['assigned_only'::text, 'all'::text]))),
CONSTRAINT conversation_sla_rules_business_days_check CHECK (((array_length(business_days, 1) >= 1) AND (array_length(business_days, 1) <= 7))),
CONSTRAINT conversation_sla_rules_threshold_minutes_check CHECK (((threshold_minutes >= 1) AND (threshold_minutes <= 1440)))
);
CREATE TABLE public.conversation_tags (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
@@ -134,7 +212,7 @@ CREATE TABLE public.session_reminder_logs (
to_phone text,
provider_message_id text,
conversation_message_id bigint,
CONSTRAINT session_reminder_logs_reminder_type_check CHECK ((reminder_type = ANY (ARRAY['24h'::text, '2h'::text])))
CONSTRAINT session_reminder_logs_reminder_type_check CHECK ((reminder_type = ANY (ARRAY['24h'::text, '2h'::text, 'manual'::text])))
);
CREATE TABLE public.session_reminder_settings (
@@ -153,3 +231,22 @@ CREATE TABLE public.session_reminder_settings (
CONSTRAINT session_reminder_settings_template_24h_check CHECK (((length(template_24h) > 0) AND (length(template_24h) <= 2000))),
CONSTRAINT session_reminder_settings_template_2h_check CHECK (((length(template_2h) > 0) AND (length(template_2h) <= 2000)))
);
CREATE TABLE public.whatsapp_connection_incidents (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
channel_id uuid NOT NULL,
provider text NOT NULL,
kind text NOT NULL,
last_state text,
details jsonb,
started_at timestamp with time zone DEFAULT now() NOT NULL,
resolved_at timestamp with time zone,
duration_seconds integer,
notified_at timestamp with time zone,
notification_count integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_connection_incidents_kind_check CHECK ((kind = ANY (ARRAY['disconnected'::text, 'error'::text, 'qr_pending'::text, 'connecting'::text, 'unknown'::text]))),
CONSTRAINT whatsapp_connection_incidents_provider_check CHECK ((provider = ANY (ARRAY['evolution_api'::text, 'twilio'::text])))
);
@@ -0,0 +1,179 @@
-- Tables: Dev / Tracking
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
-- Total: 10
CREATE TABLE public.dev_auditoria_items (
id bigint NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao_problema text,
solucao text,
severidade character varying(20),
status character varying(20) DEFAULT 'aberto'::character varying NOT NULL,
resolvido_em date,
sessao_resolucao character varying(160),
arquivo_afetado text,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_auditoria_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_auditoria_items_status_check CHECK (((status)::text = ANY ((ARRAY['aberto'::character varying, 'em_analise'::character varying, 'resolvido'::character varying, 'wontfix'::character varying, 'duplicado'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_competitor_status (
id bigint NOT NULL,
comparison_id bigint NOT NULL,
competitor_id bigint NOT NULL,
status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nota text,
fonte character varying(20),
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_competitor_status_fonte_check CHECK (((fonte IS NULL) OR ((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))),
CONSTRAINT dev_comparison_competitor_status_status_check CHECK (((status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_matrix (
id bigint NOT NULL,
dominio character varying(120),
feature text NOT NULL,
nosso_status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nossa_nota text,
importancia character varying(20),
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_matrix_importancia_check CHECK (((importancia IS NULL) OR ((importancia)::text = ANY ((ARRAY['alta'::character varying, 'media'::character varying, 'baixa'::character varying])::text[])))),
CONSTRAINT dev_comparison_matrix_nosso_status_check CHECK (((nosso_status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_competitor_features (
id bigint NOT NULL,
competitor_id bigint NOT NULL,
categoria character varying(120),
nome text NOT NULL,
descricao text,
fonte character varying(20) DEFAULT 'publico'::character varying NOT NULL,
fonte_url text,
data_fonte date,
destaque boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_competitor_features_fonte_check CHECK (((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))
);
CREATE TABLE public.dev_competitors (
id bigint NOT NULL,
slug character varying(80) NOT NULL,
nome character varying(160) NOT NULL,
pais character varying(40),
foco character varying(160),
pricing text,
posicionamento text,
url text,
ultima_pesquisa date,
notas text,
ativo boolean DEFAULT true NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_generation_log (
id bigint NOT NULL,
tipo character varying(40) NOT NULL,
comando text,
sucesso boolean DEFAULT false NOT NULL,
stdout text,
stderr text,
duration_ms integer,
metadata jsonb DEFAULT '{}'::jsonb,
trigger_user_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_roadmap_items (
id bigint NOT NULL,
phase_id bigint NOT NULL,
numero integer,
bloco character varying(160),
feature text NOT NULL,
descricao text,
esforco character varying(4),
prioridade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
notas text,
assignee character varying(120),
data_inicio date,
data_conclusao date,
ordem integer DEFAULT 0 NOT NULL,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_items_esforco_check CHECK (((esforco IS NULL) OR ((esforco)::text = ANY ((ARRAY['S'::character varying, 'M'::character varying, 'L'::character varying, 'XL'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_prioridade_check CHECK (((prioridade IS NULL) OR ((prioridade)::text = ANY ((ARRAY['bloqueador'::character varying, 'alta'::character varying, 'media'::character varying, 'diferencial'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'em_andamento'::character varying, 'concluido'::character varying, 'cancelado'::character varying, 'bloqueado'::character varying])::text[])))
);
CREATE TABLE public.dev_roadmap_phases (
id bigint NOT NULL,
numero integer NOT NULL,
nome character varying(160) NOT NULL,
objetivo text,
timeline_sugerida character varying(160),
criterio_saida text,
status character varying(20) DEFAULT 'planejada'::character varying NOT NULL,
data_inicio date,
data_fim date,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_phases_status_check CHECK (((status)::text = ANY ((ARRAY['planejada'::character varying, 'em_andamento'::character varying, 'concluida'::character varying, 'arquivada'::character varying])::text[])))
);
CREATE TABLE public.dev_test_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
arquivo text,
descricao text,
total_tests integer DEFAULT 0,
passing integer DEFAULT 0,
failing integer DEFAULT 0,
skipped integer DEFAULT 0,
cobertura_pct numeric(5,2),
status character varying(20) DEFAULT 'ok'::character varying NOT NULL,
last_run_at timestamp with time zone,
sessao_criacao character varying(160),
notas text,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_test_items_status_check CHECK (((status)::text = ANY ((ARRAY['ok'::character varying, 'falhando'::character varying, 'pendente'::character varying, 'obsoleto'::character varying, 'a_escrever'::character varying])::text[])))
);
CREATE TABLE public.dev_verificacoes_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao text,
resultado text,
acao_sugerida text,
severidade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
verificado_em date,
sessao_verificacao character varying(160),
arquivo_afetado text,
auditoria_item_id bigint,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_verificacoes_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_verificacoes_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'verificando'::character varying, 'ok'::character varying, 'problema'::character varying, 'corrigido'::character varying, 'wontfix'::character varying])::text[])))
);
@@ -1,5 +1,5 @@
-- Tables: Documentos
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Gerado automaticamente em 2026-05-11T16:53:50.928Z
-- Total: 6
CREATE TABLE public.document_access_logs (
@@ -1,5 +1,5 @@
-- Tables: Estrutura / Calendário
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
-- Total: 1
CREATE TABLE public.feriados (
@@ -1,5 +1,5 @@
-- Tables: Financeiro
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Gerado automaticamente em 2026-05-11T16:53:50.926Z
-- Total: 10
CREATE TABLE public.financial_records (
+2 -258
View File
@@ -1,6 +1,6 @@
-- Tables: outros
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Total: 17
-- Gerado automaticamente em 2026-05-11T16:53:50.926Z
-- Total: 1
CREATE TABLE public._db_migrations (
id integer NOT NULL,
@@ -9,259 +9,3 @@ CREATE TABLE public._db_migrations (
category text DEFAULT 'migration'::text NOT NULL,
applied_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.audit_logs (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
user_id uuid,
entity_type text NOT NULL,
entity_id text,
action text NOT NULL,
old_values jsonb,
new_values jsonb,
changed_fields text[],
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT audit_logs_action_check CHECK ((action = ANY (ARRAY['insert'::text, 'update'::text, 'delete'::text])))
);
CREATE TABLE public.dev_auditoria_items (
id bigint NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao_problema text,
solucao text,
severidade character varying(20),
status character varying(20) DEFAULT 'aberto'::character varying NOT NULL,
resolvido_em date,
sessao_resolucao character varying(160),
arquivo_afetado text,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_auditoria_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_auditoria_items_status_check CHECK (((status)::text = ANY ((ARRAY['aberto'::character varying, 'em_analise'::character varying, 'resolvido'::character varying, 'wontfix'::character varying, 'duplicado'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_competitor_status (
id bigint NOT NULL,
comparison_id bigint NOT NULL,
competitor_id bigint NOT NULL,
status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nota text,
fonte character varying(20),
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_competitor_status_fonte_check CHECK (((fonte IS NULL) OR ((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))),
CONSTRAINT dev_comparison_competitor_status_status_check CHECK (((status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_matrix (
id bigint NOT NULL,
dominio character varying(120),
feature text NOT NULL,
nosso_status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nossa_nota text,
importancia character varying(20),
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_matrix_importancia_check CHECK (((importancia IS NULL) OR ((importancia)::text = ANY ((ARRAY['alta'::character varying, 'media'::character varying, 'baixa'::character varying])::text[])))),
CONSTRAINT dev_comparison_matrix_nosso_status_check CHECK (((nosso_status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_competitor_features (
id bigint NOT NULL,
competitor_id bigint NOT NULL,
categoria character varying(120),
nome text NOT NULL,
descricao text,
fonte character varying(20) DEFAULT 'publico'::character varying NOT NULL,
fonte_url text,
data_fonte date,
destaque boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_competitor_features_fonte_check CHECK (((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))
);
CREATE TABLE public.dev_competitors (
id bigint NOT NULL,
slug character varying(80) NOT NULL,
nome character varying(160) NOT NULL,
pais character varying(40),
foco character varying(160),
pricing text,
posicionamento text,
url text,
ultima_pesquisa date,
notas text,
ativo boolean DEFAULT true NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_generation_log (
id bigint NOT NULL,
tipo character varying(40) NOT NULL,
comando text,
sucesso boolean DEFAULT false NOT NULL,
stdout text,
stderr text,
duration_ms integer,
metadata jsonb DEFAULT '{}'::jsonb,
trigger_user_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_roadmap_items (
id bigint NOT NULL,
phase_id bigint NOT NULL,
numero integer,
bloco character varying(160),
feature text NOT NULL,
descricao text,
esforco character varying(4),
prioridade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
notas text,
assignee character varying(120),
data_inicio date,
data_conclusao date,
ordem integer DEFAULT 0 NOT NULL,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_items_esforco_check CHECK (((esforco IS NULL) OR ((esforco)::text = ANY ((ARRAY['S'::character varying, 'M'::character varying, 'L'::character varying, 'XL'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_prioridade_check CHECK (((prioridade IS NULL) OR ((prioridade)::text = ANY ((ARRAY['bloqueador'::character varying, 'alta'::character varying, 'media'::character varying, 'diferencial'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'em_andamento'::character varying, 'concluido'::character varying, 'cancelado'::character varying, 'bloqueado'::character varying])::text[])))
);
CREATE TABLE public.dev_roadmap_phases (
id bigint NOT NULL,
numero integer NOT NULL,
nome character varying(160) NOT NULL,
objetivo text,
timeline_sugerida character varying(160),
criterio_saida text,
status character varying(20) DEFAULT 'planejada'::character varying NOT NULL,
data_inicio date,
data_fim date,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_phases_status_check CHECK (((status)::text = ANY ((ARRAY['planejada'::character varying, 'em_andamento'::character varying, 'concluida'::character varying, 'arquivada'::character varying])::text[])))
);
CREATE TABLE public.dev_test_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
arquivo text,
descricao text,
total_tests integer DEFAULT 0,
passing integer DEFAULT 0,
failing integer DEFAULT 0,
skipped integer DEFAULT 0,
cobertura_pct numeric(5,2),
status character varying(20) DEFAULT 'ok'::character varying NOT NULL,
last_run_at timestamp with time zone,
sessao_criacao character varying(160),
notas text,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_test_items_status_check CHECK (((status)::text = ANY ((ARRAY['ok'::character varying, 'falhando'::character varying, 'pendente'::character varying, 'obsoleto'::character varying, 'a_escrever'::character varying])::text[])))
);
CREATE TABLE public.dev_verificacoes_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao text,
resultado text,
acao_sugerida text,
severidade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
verificado_em date,
sessao_verificacao character varying(160),
arquivo_afetado text,
auditoria_item_id bigint,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_verificacoes_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_verificacoes_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'verificando'::character varying, 'ok'::character varying, 'problema'::character varying, 'corrigido'::character varying, 'wontfix'::character varying])::text[])))
);
CREATE TABLE public.math_challenges (
id uuid DEFAULT gen_random_uuid() NOT NULL,
question text NOT NULL,
answer integer NOT NULL,
used boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone DEFAULT (now() + '00:05:00'::interval) NOT NULL
);
CREATE TABLE public.patient_invite_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
token text NOT NULL,
ok boolean NOT NULL,
error_code text,
error_msg text,
client_info text,
owner_id uuid,
tenant_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.public_submission_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text,
user_agent text,
metadata jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.saas_security_config (
id boolean DEFAULT true NOT NULL,
honeypot_enabled boolean DEFAULT true NOT NULL,
rate_limit_enabled boolean DEFAULT true NOT NULL,
rate_limit_window_min integer DEFAULT 10 NOT NULL,
rate_limit_max_attempts integer DEFAULT 5 NOT NULL,
captcha_after_failures integer DEFAULT 3 NOT NULL,
captcha_required_globally boolean DEFAULT false NOT NULL,
block_duration_min integer DEFAULT 30 NOT NULL,
captcha_required_window_min integer DEFAULT 60 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK ((id = true))
);
CREATE TABLE public.saas_twilio_config (
id boolean DEFAULT true NOT NULL,
account_sid text,
whatsapp_webhook_url text,
usd_brl_rate numeric(10,4) DEFAULT 5.5 NOT NULL,
margin_multiplier numeric(10,4) DEFAULT 1.4 NOT NULL,
notes text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_twilio_config_mult_chk CHECK (((margin_multiplier >= (1)::numeric) AND (margin_multiplier <= (10)::numeric))),
CONSTRAINT saas_twilio_config_rate_chk CHECK (((usd_brl_rate > (0)::numeric) AND (usd_brl_rate < (100)::numeric))),
CONSTRAINT saas_twilio_config_sid_chk CHECK (((account_sid IS NULL) OR (account_sid ~ '^AC[a-zA-Z0-9]{32}$'::text))),
CONSTRAINT saas_twilio_config_singleton CHECK ((id = true)),
CONSTRAINT saas_twilio_config_url_chk CHECK (((whatsapp_webhook_url IS NULL) OR (whatsapp_webhook_url ~ '^https?://'::text)))
);
+4 -2
View File
@@ -1,5 +1,5 @@
-- Tables: Pacientes
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Gerado automaticamente em 2026-05-11T16:53:50.929Z
-- Total: 16
CREATE TABLE public.patient_status_history (
@@ -247,7 +247,9 @@ CREATE TABLE public.patient_intake_requests (
nacionalidade text,
avatar_url text,
tenant_id uuid,
CONSTRAINT chk_intakes_status CHECK ((status = ANY (ARRAY['new'::text, 'converted'::text, 'rejected'::text])))
last_progress_at timestamp with time zone,
lead_thread_key text,
CONSTRAINT chk_intakes_status CHECK ((status = ANY (ARRAY['new'::text, 'in_progress'::text, 'converted'::text, 'rejected'::text, 'abandoned_lead'::text])))
);
CREATE TABLE public.patient_invites (
@@ -1,5 +1,5 @@
-- Tables: SaaS / Planos
-- Gerado automaticamente em 2026-04-21T23:16:34.953Z
-- Gerado automaticamente em 2026-05-11T16:53:50.925Z
-- Total: 18
CREATE TABLE public.subscriptions (
@@ -0,0 +1,79 @@
-- Tables: Segurança / Auditoria
-- Gerado automaticamente em 2026-05-11T16:53:50.928Z
-- Total: 6
CREATE TABLE public.audit_logs (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
user_id uuid,
entity_type text NOT NULL,
entity_id text,
action text NOT NULL,
old_values jsonb,
new_values jsonb,
changed_fields text[],
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT audit_logs_action_check CHECK ((action = ANY (ARRAY['insert'::text, 'update'::text, 'delete'::text])))
);
CREATE TABLE public.math_challenges (
id uuid DEFAULT gen_random_uuid() NOT NULL,
question text NOT NULL,
answer integer NOT NULL,
used boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone DEFAULT (now() + '00:05:00'::interval) NOT NULL
);
CREATE TABLE public.patient_invite_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
token text NOT NULL,
ok boolean NOT NULL,
error_code text,
error_msg text,
client_info text,
owner_id uuid,
tenant_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.public_submission_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text,
user_agent text,
metadata jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.saas_security_config (
id boolean DEFAULT true NOT NULL,
honeypot_enabled boolean DEFAULT true NOT NULL,
rate_limit_enabled boolean DEFAULT true NOT NULL,
rate_limit_window_min integer DEFAULT 10 NOT NULL,
rate_limit_max_attempts integer DEFAULT 5 NOT NULL,
captcha_after_failures integer DEFAULT 3 NOT NULL,
captcha_required_globally boolean DEFAULT false NOT NULL,
block_duration_min integer DEFAULT 30 NOT NULL,
captcha_required_window_min integer DEFAULT 60 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK ((id = true))
);
CREATE TABLE public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer DEFAULT 0 NOT NULL,
fail_count integer DEFAULT 0 NOT NULL,
window_start timestamp with time zone DEFAULT now() NOT NULL,
blocked_until timestamp with time zone,
requires_captcha_until timestamp with time zone,
last_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
@@ -1,15 +0,0 @@
-- Tables: Segurança / Rate limiting
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
-- Total: 1
CREATE TABLE public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer DEFAULT 0 NOT NULL,
fail_count integer DEFAULT 0 NOT NULL,
window_start timestamp with time zone DEFAULT now() NOT NULL,
blocked_until timestamp with time zone,
requires_captcha_until timestamp with time zone,
last_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
@@ -1,5 +1,5 @@
-- Tables: Serviços / Prontuários
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
-- Total: 8
CREATE TABLE public.commitment_services (
@@ -1,5 +1,5 @@
-- Tables: Tenants / Multi-tenant
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Gerado automaticamente em 2026-05-11T16:53:50.926Z
-- Total: 10
CREATE TABLE public.tenant_members (
@@ -119,6 +119,8 @@ CREATE TABLE public.tenants (
created_at timestamp with time zone DEFAULT now() NOT NULL,
kind text DEFAULT 'saas'::text NOT NULL,
papel_timbrado jsonb DEFAULT '{"footer": {"slots": {"left": null, "right": null, "center": {"type": "custom-text", "content": ""}}, "height": 40, "preset": "text-center", "divider": {"show": true, "color": "#cccccc", "style": "solid"}, "enabled": true, "showPageNumber": false}, "header": {"slots": {"left": {"size": "medium", "type": "logo"}, "right": {"type": "institution-data", "fields": ["nome", "cnpj", "endereco_linha"]}, "center": null}, "height": 80, "preset": "logo-left-text-right", "divider": {"show": true, "color": "#cccccc", "style": "solid"}, "enabled": true}, "margins": {"top": 20, "left": 25, "right": 25, "bottom": 20}}'::jsonb,
cpf_cnpj text,
CONSTRAINT tenants_cpf_cnpj_format CHECK (((cpf_cnpj IS NULL) OR (cpf_cnpj ~ '^[0-9]{11}$'::text) OR (cpf_cnpj ~ '^[0-9]{14}$'::text))),
CONSTRAINT tenants_kind_check CHECK ((kind = ANY (ARRAY['therapist'::text, 'clinic_coworking'::text, 'clinic_reception'::text, 'clinic_full'::text, 'clinic'::text, 'saas'::text, 'supervisor'::text])))
);
+7 -4
View File
@@ -1,5 +1,5 @@
-- Views
-- Gerado automaticamente em 2026-04-21T23:16:34.958Z
-- Gerado automaticamente em 2026-05-11T16:53:50.932Z
-- Total: 29
CREATE VIEW public.audit_log_unified WITH (security_invoker='true') AS
@@ -133,10 +133,13 @@ CREATE VIEW public.conversation_threads WITH (security_invoker='true') AS
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status
FROM ((latest l
l.kanban_status,
ca.assigned_to,
ca.assigned_at
FROM (((latest l
JOIN counts c ON (((c.tenant_id = l.tenant_id) AND (c.thread_key = l.thread_key))))
LEFT JOIN public.patients p ON ((p.id = l.patient_id)));
LEFT JOIN public.patients p ON ((p.id = l.patient_id)))
LEFT JOIN public.conversation_assignments ca ON (((ca.tenant_id = l.tenant_id) AND (ca.thread_key = l.thread_key))));
CREATE VIEW public.current_tenant_id AS
SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting;
+24 -2
View File
@@ -1,6 +1,6 @@
-- Indexes
-- Gerado automaticamente em 2026-04-21T23:16:34.961Z
-- Total: 361
-- Gerado automaticamente em 2026-05-11T16:53:50.934Z
-- Total: 372
CREATE INDEX agenda_bloqueios_owner_data_idx ON public.agenda_bloqueios USING btree (owner_id, data_inicio, data_fim);
@@ -184,6 +184,8 @@ CREATE INDEX idx_audit_logs_user_created ON public.audit_logs USING btree (user_
CREATE INDEX idx_autoreply_log_cooldown ON public.conversation_autoreply_log USING btree (tenant_id, thread_key, sent_at DESC);
CREATE INDEX idx_bot_sessions_tenant_status ON public.conversation_bot_sessions USING btree (tenant_id, status, started_at DESC);
CREATE INDEX idx_contact_email_types_tenant ON public.contact_email_types USING btree (tenant_id, "position");
CREATE INDEX idx_contact_emails_email ON public.contact_emails USING btree (tenant_id, email);
@@ -196,6 +198,10 @@ CREATE INDEX idx_contact_phones_number ON public.contact_phones USING btree (ten
CREATE INDEX idx_contact_types_tenant ON public.contact_types USING btree (tenant_id, "position");
CREATE INDEX idx_conv_assign_patient ON public.conversation_assignments USING btree (patient_id) WHERE (patient_id IS NOT NULL);
CREATE INDEX idx_conv_assign_tenant_user ON public.conversation_assignments USING btree (tenant_id, assigned_to) WHERE (assigned_to IS NOT NULL);
CREATE INDEX idx_conv_msg_delivery_status ON public.conversation_messages USING btree (tenant_id, delivery_status) WHERE (direction = 'outbound'::text);
CREATE INDEX idx_conv_msg_from_number ON public.conversation_messages USING btree (tenant_id, from_number);
@@ -332,6 +338,8 @@ CREATE INDEX idx_intakes_owner_created ON public.patient_intake_requests USING b
CREATE INDEX idx_intakes_owner_status_created ON public.patient_intake_requests USING btree (owner_id, status, created_at DESC);
CREATE INDEX idx_intakes_progress_pending ON public.patient_intake_requests USING btree (last_progress_at) WHERE (status = 'in_progress'::text);
CREATE INDEX idx_intakes_status_created ON public.patient_intake_requests USING btree (status, created_at DESC);
CREATE INDEX idx_mc_expires ON public.math_challenges USING btree (expires_at);
@@ -460,6 +468,10 @@ CREATE INDEX idx_services_name_trgm ON public.services USING gin (name public.gi
CREATE INDEX idx_session_reminder_tenant_sent ON public.session_reminder_logs USING btree (tenant_id, sent_at DESC);
CREATE INDEX idx_sla_breaches_open ON public.conversation_sla_breaches USING btree (resolved_at) WHERE (resolved_at IS NULL);
CREATE INDEX idx_sla_breaches_tenant_breached ON public.conversation_sla_breaches USING btree (tenant_id, breached_at DESC);
CREATE INDEX idx_slots_bloq_owner_dia ON public.agenda_slots_bloqueados_semanais USING btree (owner_id, dia_semana);
CREATE INDEX idx_srl_blocked_until ON public.submission_rate_limits USING btree (blocked_until) WHERE (blocked_until IS NOT NULL);
@@ -506,6 +518,10 @@ CREATE INDEX idx_wa_credits_tx_kind ON public.whatsapp_credits_transactions USIN
CREATE INDEX idx_wa_credits_tx_tenant_created ON public.whatsapp_credits_transactions USING btree (tenant_id, created_at DESC);
CREATE INDEX idx_wa_incidents_open ON public.whatsapp_connection_incidents USING btree (resolved_at) WHERE (resolved_at IS NULL);
CREATE INDEX idx_wa_incidents_tenant_started ON public.whatsapp_connection_incidents USING btree (tenant_id, started_at DESC);
CREATE INDEX insurance_plans_owner_idx ON public.insurance_plans USING btree (owner_id);
CREATE INDEX insurance_plans_tenant_idx ON public.insurance_plans USING btree (tenant_id);
@@ -684,6 +700,8 @@ CREATE INDEX tenant_modules_owner_idx ON public.tenant_modules USING btree (owne
CREATE UNIQUE INDEX unique_member_per_tenant ON public.tenant_members USING btree (tenant_id, user_id);
CREATE UNIQUE INDEX uq_bot_sessions_active_per_thread ON public.conversation_bot_sessions USING btree (tenant_id, thread_key) WHERE (status = 'active'::text);
CREATE UNIQUE INDEX uq_contact_email_types_system_slug ON public.contact_email_types USING btree (slug) WHERE (tenant_id IS NULL);
CREATE UNIQUE INDEX uq_contact_email_types_tenant_slug ON public.contact_email_types USING btree (tenant_id, slug) WHERE (tenant_id IS NOT NULL);
@@ -712,6 +730,8 @@ CREATE UNIQUE INDEX uq_plan_prices_active ON public.plan_prices USING btree (pla
CREATE UNIQUE INDEX uq_session_reminder_event_type ON public.session_reminder_logs USING btree (event_id, reminder_type);
CREATE UNIQUE INDEX uq_sla_breaches_open_per_thread ON public.conversation_sla_breaches USING btree (tenant_id, thread_key) WHERE (resolved_at IS NULL);
CREATE UNIQUE INDEX uq_subscriptions_active_by_tenant ON public.subscriptions USING btree (tenant_id) WHERE ((tenant_id IS NOT NULL) AND (status = 'active'::text));
CREATE UNIQUE INDEX uq_subscriptions_active_personal_by_user ON public.subscriptions USING btree (user_id) WHERE ((tenant_id IS NULL) AND (status = 'active'::text));
@@ -720,6 +740,8 @@ CREATE UNIQUE INDEX uq_tenant_invites_pending ON public.tenant_invites USING btr
CREATE UNIQUE INDEX uq_tenant_members_tenant_user ON public.tenant_members USING btree (tenant_id, user_id);
CREATE UNIQUE INDEX uq_wa_incidents_open_per_channel ON public.whatsapp_connection_incidents USING btree (channel_id) WHERE (resolved_at IS NULL);
CREATE UNIQUE INDEX ux_subscriptions_active_per_personal_user ON public.subscriptions USING btree (user_id) WHERE ((status = 'active'::text) AND (tenant_id IS NULL));
CREATE UNIQUE INDEX ux_subscriptions_active_per_tenant ON public.subscriptions USING btree (tenant_id) WHERE ((status = 'active'::text) AND (tenant_id IS NOT NULL));
@@ -1,6 +1,6 @@
-- Constraints (PK, FK, UNIQUE, CHECK)
-- Gerado automaticamente em 2026-04-21T23:16:34.963Z
-- Total: 353
-- Gerado automaticamente em 2026-05-11T16:53:50.935Z
-- Total: 369
ALTER TABLE ONLY public._db_migrations
ADD CONSTRAINT _db_migrations_filename_key UNIQUE (filename);
@@ -95,12 +95,21 @@ ALTER TABLE ONLY public.contact_phones
ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_assignments
ADD CONSTRAINT conversation_assignments_pkey PRIMARY KEY (tenant_id, thread_key);
ALTER TABLE ONLY public.conversation_autoreply_log
ADD CONSTRAINT conversation_autoreply_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_autoreply_settings
ADD CONSTRAINT conversation_autoreply_settings_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.conversation_bot_sessions
ADD CONSTRAINT conversation_bot_sessions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_bots
ADD CONSTRAINT conversation_bots_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_pkey PRIMARY KEY (id);
@@ -113,6 +122,12 @@ ALTER TABLE ONLY public.conversation_optout_keywords
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_sla_breaches
ADD CONSTRAINT conversation_sla_breaches_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_sla_rules
ADD CONSTRAINT conversation_sla_rules_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_pkey PRIMARY KEY (id);
@@ -500,6 +515,9 @@ ALTER TABLE ONLY public.notification_templates
ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY public.whatsapp_connection_incidents
ADD CONSTRAINT whatsapp_connection_incidents_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.whatsapp_credit_packages
ADD CONSTRAINT whatsapp_credit_packages_pkey PRIMARY KEY (id);
@@ -638,12 +656,30 @@ ALTER TABLE ONLY public.contact_phones
ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_assignments
ADD CONSTRAINT conversation_assignments_assigned_by_fkey FOREIGN KEY (assigned_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_assignments
ADD CONSTRAINT conversation_assignments_assigned_to_fkey FOREIGN KEY (assigned_to) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_assignments
ADD CONSTRAINT conversation_assignments_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_assignments
ADD CONSTRAINT conversation_assignments_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_autoreply_log
ADD CONSTRAINT conversation_autoreply_log_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_autoreply_settings
ADD CONSTRAINT conversation_autoreply_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_bot_sessions
ADD CONSTRAINT conversation_bot_sessions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_bots
ADD CONSTRAINT conversation_bots_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
@@ -671,6 +707,12 @@ ALTER TABLE ONLY public.conversation_optouts
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_sla_breaches
ADD CONSTRAINT conversation_sla_breaches_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_sla_rules
ADD CONSTRAINT conversation_sla_rules_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
@@ -1037,6 +1079,12 @@ ALTER TABLE ONLY public.twilio_subaccount_usage
ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_connection_incidents
ADD CONSTRAINT whatsapp_connection_incidents_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_connection_incidents
ADD CONSTRAINT whatsapp_connection_incidents_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL;
+20 -2
View File
@@ -1,6 +1,6 @@
-- Triggers
-- Gerado automaticamente em 2026-04-21T23:16:34.965Z
-- Total: 111
-- Gerado automaticamente em 2026-05-11T16:53:50.937Z
-- Total: 120
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
@@ -40,6 +40,8 @@ CREATE TRIGGER trg_agenda_eventos_busy_mirror_upd AFTER UPDATE ON public.agenda_
CREATE TRIGGER trg_agenda_regras_semanais_no_overlap BEFORE INSERT OR UPDATE ON public.agenda_regras_semanais FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap();
CREATE TRIGGER trg_agenda_status_notify AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change();
CREATE TRIGGER trg_audit_agenda_eventos AFTER INSERT OR DELETE OR UPDATE ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_audit_documents AFTER INSERT OR DELETE OR UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
@@ -52,6 +54,8 @@ CREATE TRIGGER trg_audit_tenant_members AFTER INSERT OR DELETE OR UPDATE ON publ
CREATE TRIGGER trg_auto_financial_from_session AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session();
CREATE TRIGGER trg_bot_sessions_updated_at BEFORE UPDATE ON public.conversation_bot_sessions FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_cancel_notifs_on_opt_out AFTER UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out();
CREATE TRIGGER trg_cancel_notifs_on_session_cancel AFTER UPDATE ON public.agenda_eventos FOR EACH ROW WHEN ((new.status IS DISTINCT FROM old.status)) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel();
@@ -70,8 +74,12 @@ CREATE TRIGGER trg_contact_phones_updated_at BEFORE UPDATE ON public.contact_pho
CREATE TRIGGER trg_contact_types_updated_at BEFORE UPDATE ON public.contact_types FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_assign_updated_at BEFORE UPDATE ON public.conversation_assignments FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_autoreply_settings_updated_at BEFORE UPDATE ON public.conversation_autoreply_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_bots_updated_at BEFORE UPDATE ON public.conversation_bots FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_messages_updated_at BEFORE UPDATE ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_notes_updated_at BEFORE UPDATE ON public.conversation_notes FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
@@ -194,6 +202,12 @@ CREATE TRIGGER trg_services_updated_at BEFORE UPDATE ON public.services FOR EACH
CREATE TRIGGER trg_session_reminder_settings_updated_at BEFORE UPDATE ON public.session_reminder_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_sla_breaches_updated_at BEFORE UPDATE ON public.conversation_sla_breaches FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_sla_resolve_on_outbound AFTER INSERT ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound();
CREATE TRIGGER trg_sla_rules_updated_at BEFORE UPDATE ON public.conversation_sla_rules FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_subscription_intents_view_insert INSTEAD OF INSERT ON public.subscription_intents FOR EACH ROW EXECUTE FUNCTION public.subscription_intents_view_insert();
CREATE TRIGGER trg_subscriptions_validate_scope BEFORE INSERT OR UPDATE ON public.subscriptions FOR EACH ROW EXECUTE FUNCTION public.subscriptions_validate_scope();
@@ -214,6 +228,10 @@ CREATE TRIGGER trg_wa_credit_purchases_updated_at BEFORE UPDATE ON public.whatsa
CREATE TRIGGER trg_wa_credits_balance_updated_at BEFORE UPDATE ON public.whatsapp_credits_balance FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_wa_incidents_updated_at BEFORE UPDATE ON public.whatsapp_connection_incidents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_whatsapp_low_balance_notify BEFORE UPDATE ON public.whatsapp_credits_balance FOR EACH ROW EXECUTE FUNCTION public.fn_whatsapp_low_balance_notify();
CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters();
CREATE TRIGGER enforce_bucket_name_length_trigger BEFORE INSERT OR UPDATE OF name ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.enforce_bucket_name_length();
+65 -5
View File
@@ -1,7 +1,7 @@
-- RLS Policies
-- Gerado automaticamente em 2026-04-21T23:16:34.967Z
-- Enable RLS: 131 tabelas
-- Policies: 344
-- Gerado automaticamente em 2026-05-11T16:53:50.939Z
-- Enable RLS: 137 tabelas
-- Policies: 357
-- Enable RLS
ALTER TABLE public.addon_credits ENABLE ROW LEVEL SECURITY;
@@ -26,12 +26,17 @@ ALTER TABLE public.contact_email_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_emails ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_phones ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_assignments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_bot_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_bots ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_sla_breaches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_sla_rules ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.determined_commitment_fields ENABLE ROW LEVEL SECURITY;
@@ -131,6 +136,7 @@ ALTER TABLE public.therapist_payout_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.therapist_payouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_connection_incidents ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
@@ -273,6 +279,12 @@ CREATE POLICY bloqueios_select_own ON public.agenda_bloqueios FOR SELECT TO auth
CREATE POLICY bloqueios_update ON public.agenda_bloqueios FOR UPDATE TO authenticated USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "bot_sessions: select membros" ON public.conversation_bot_sessions FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_bot_sessions.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "bot_sessions: write service_role" ON public.conversation_bot_sessions TO service_role USING (true) WITH CHECK (true);
CREATE POLICY clinic_admin_read_all_docs ON public.saas_docs FOR SELECT TO authenticated USING (((ativo = true) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])))))));
@@ -335,6 +347,32 @@ CREATE POLICY "contact_types: select" ON public.contact_types FOR SELECT TO auth
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_assign: insert tenant" ON public.conversation_assignments FOR INSERT TO authenticated WITH CHECK (((assigned_by = auth.uid()) AND (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_assignments.tenant_id) AND (tm.status = 'active'::text)))) AND ((assigned_to IS NULL) OR (EXISTS ( SELECT 1
FROM public.tenant_members tm2
WHERE ((tm2.user_id = conversation_assignments.assigned_to) AND (tm2.tenant_id = conversation_assignments.tenant_id) AND (tm2.status = 'active'::text)))))));
CREATE POLICY "conv_assign: select tenant" ON public.conversation_assignments FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_assignments.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_assign: update tenant" ON public.conversation_assignments FOR UPDATE TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_assignments.tenant_id) AND (tm.status = 'active'::text))))) WITH CHECK (((assigned_by = auth.uid()) AND ((assigned_to IS NULL) OR (EXISTS ( SELECT 1
FROM public.tenant_members tm2
WHERE ((tm2.user_id = conversation_assignments.assigned_to) AND (tm2.tenant_id = conversation_assignments.tenant_id) AND (tm2.status = 'active'::text)))))));
CREATE POLICY "conv_bots: select membros" ON public.conversation_bots FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_bots.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_bots: write admins" ON public.conversation_bots TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_bots.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_bots.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages FOR DELETE TO authenticated USING (false);
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages FOR INSERT TO authenticated WITH CHECK (false);
@@ -677,9 +715,9 @@ CREATE POLICY notif_channels_insert ON public.notification_channels FOR INSERT T
CREATE POLICY notif_channels_modify ON public.notification_channels FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY notif_channels_select ON public.notification_channels FOR SELECT TO authenticated USING (((deleted_at IS NULL) AND (public.is_saas_admin() OR (owner_id = auth.uid()) OR (tenant_id IN ( SELECT tm.tenant_id
CREATE POLICY notif_channels_select ON public.notification_channels FOR SELECT USING ((public.is_saas_admin() OR (owner_id = auth.uid()) OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY notif_logs_owner ON public.notification_logs FOR SELECT USING ((owner_id = auth.uid()));
@@ -933,6 +971,22 @@ CREATE POLICY "services: select" ON public.services FOR SELECT TO authenticated
CREATE POLICY "services: update" ON public.services FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "sla_breaches: select membros/admin" ON public.conversation_sla_breaches FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_sla_breaches.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "sla_breaches: write service_role" ON public.conversation_sla_breaches TO service_role USING (true) WITH CHECK (true);
CREATE POLICY "sla_rules: select membros/admin" ON public.conversation_sla_rules FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_sla_rules.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "sla_rules: write admins" ON public.conversation_sla_rules TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_sla_rules.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = conversation_sla_rules.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text))))));
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits FOR SELECT TO authenticated USING (public.is_saas_admin());
CREATE POLICY subscription_events_read_saas ON public.subscription_events FOR SELECT USING (public.is_saas_admin());
@@ -1067,6 +1121,12 @@ CREATE POLICY "wa_credits_tx: select tenant" ON public.whatsapp_credits_transact
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_transactions.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "wa_incidents: select membros/admin" ON public.whatsapp_connection_incidents FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = whatsapp_connection_incidents.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "wa_incidents: write service_role" ON public.whatsapp_connection_incidents TO service_role USING (true) WITH CHECK (true);
CREATE POLICY "wa_packages: manage saas admin" ON public.whatsapp_credit_packages TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY "wa_packages: select active" ON public.whatsapp_credit_packages FOR SELECT TO authenticated USING (((is_active = true) OR public.is_saas_admin()));
@@ -0,0 +1,151 @@
-- ============================================================================
-- Seed dos templates do sistema de prontuário clínico
-- ----------------------------------------------------------------------------
-- Templates is_system=true, sem tenant_id, sem owner_id.
-- Cobrem os 4 tipos mais comuns de nota clínica em psicologia:
-- • Anamnese padrão CFP-style
-- • Evolução: SOAP / DAP / BIRP
-- • Plano terapêutico padrão
--
-- structure jsonb segue schema:
-- [
-- { key, label, type, required?, hint?, options? },
-- ...
-- ]
-- type: 'text' | 'textarea' | 'select' | 'date' | 'multiselect'
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. Anamnese padrão (CFP)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'anamnese_padrao',
'Anamnese Padrão',
'anamnese',
'Estrutura padrão de anamnese clínica em psicologia. Pode ser preenchida em 1-3 sessões iniciais.',
'[
{"key": "queixa_principal", "label": "Queixa principal", "type": "textarea", "required": true, "hint": "O que trouxe o paciente à terapia"},
{"key": "historia_queixa", "label": "História da queixa", "type": "textarea", "hint": "Quando começou, evolução, fatores agravantes/atenuantes"},
{"key": "historia_vida", "label": "História de vida", "type": "textarea", "hint": "Infância, adolescência, eventos marcantes"},
{"key": "antecedentes_psicologicos", "label": "Antecedentes psicológicos", "type": "textarea", "hint": "Tratamentos anteriores, medicações, internações"},
{"key": "antecedentes_medicos", "label": "Antecedentes médicos", "type": "textarea", "hint": "Doenças, cirurgias, medicações em uso"},
{"key": "antecedentes_familiares", "label": "Antecedentes familiares", "type": "textarea", "hint": "Histórico familiar de transtornos psicológicos/psiquiátricos"},
{"key": "vida_atual_relacionamentos", "label": "Relacionamentos atuais", "type": "textarea"},
{"key": "vida_atual_trabalho_estudo", "label": "Trabalho / estudo atual", "type": "textarea"},
{"key": "hipoteses_iniciais", "label": "Hipóteses iniciais", "type": "textarea", "hint": "Hipóteses do terapeuta — não compartilhar com paciente"},
{"key": "plano_inicial", "label": "Plano terapêutico inicial", "type": "textarea"}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 2. Evolução SOAP (Subjective, Objective, Assessment, Plan)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'soap',
'Evolução SOAP',
'evolucao_sessao',
'Padrão internacional: Subjetivo (relato do paciente), Objetivo (observações), Avaliação (análise), Plano (próximos passos).',
'[
{"key": "subjetivo", "label": "S — Subjetivo", "type": "textarea", "required": true, "hint": "O que o paciente relatou; humor; queixas verbalizadas"},
{"key": "objetivo", "label": "O — Objetivo", "type": "textarea", "hint": "Observações do terapeuta: comportamento, afeto, aparência, postura"},
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true, "hint": "Análise clínica, hipóteses, evolução"},
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true, "hint": "Intervenções planejadas, tarefas, foco da próxima sessão"}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 3. Evolução DAP (Data, Assessment, Plan)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'dap',
'Evolução DAP',
'evolucao_sessao',
'Mais conciso que SOAP: Dados (relato + observações), Avaliação, Plano.',
'[
{"key": "dados", "label": "D — Dados", "type": "textarea", "required": true, "hint": "Relato + observações em texto único"},
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true},
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 4. Evolução BIRP (Behavior, Intervention, Response, Plan)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'birp',
'Evolução BIRP',
'evolucao_sessao',
'Foco em intervenção: Comportamento observado, Intervenção aplicada, Resposta do paciente, Plano.',
'[
{"key": "behavior", "label": "B — Comportamento", "type": "textarea", "required": true, "hint": "Comportamento/queixa observada na sessão"},
{"key": "intervention", "label": "I — Intervenção", "type": "textarea", "required": true, "hint": "Técnicas ou abordagens aplicadas pelo terapeuta"},
{"key": "response", "label": "R — Resposta", "type": "textarea", "required": true, "hint": "Como o paciente respondeu à intervenção"},
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 5. Evolução livre (CFP-style — texto único)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'evolucao_livre',
'Evolução Livre',
'evolucao_sessao',
'Texto único, sem estrutura — pra quem prefere prosa contínua estilo CFP tradicional.',
'[
{"key": "evolucao", "label": "Evolução", "type": "textarea", "required": true, "hint": "Texto único descrevendo a sessão"}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 6. Plano terapêutico padrão
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'plano_terapeutico_padrao',
'Plano Terapêutico Padrão',
'plano_terapeutico',
'Estrutura básica de plano: objetivos, estratégia, recursos, prazo estimado.',
'[
{"key": "objetivos_gerais", "label": "Objetivos gerais", "type": "textarea", "required": true, "hint": "O que o paciente quer alcançar"},
{"key": "objetivos_especificos", "label": "Objetivos específicos / metas", "type": "textarea", "hint": "Metas mensuráveis"},
{"key": "estrategia_terapeutica", "label": "Estratégia terapêutica", "type": "textarea", "required": true, "hint": "Abordagem teórica, técnicas previstas"},
{"key": "recursos_indicados", "label": "Recursos / intervenções indicadas", "type": "textarea"},
{"key": "duracao_estimada", "label": "Duração estimada", "type": "text", "hint": "Ex: 6 meses, indeterminado"},
{"key": "criterios_alta", "label": "Critérios de alta", "type": "textarea"},
{"key": "encaminhamentos", "label": "Encaminhamentos paralelos", "type": "textarea", "hint": "Psiquiatria, médico, outras especialidades"}
]'::jsonb,
true,
true,
true
);
COMMIT;
@@ -0,0 +1,57 @@
-- ============================================================================
-- Seed: Especialidades do sistema (ROADMAP item #9)
-- ----------------------------------------------------------------------------
-- Lista canônica de especialidades + abordagens psicológicas no Brasil.
-- is_system=true; usuário escolhe múltiplas; 'outra' permite custom via
-- profile_specialties.other_label.
-- ============================================================================
BEGIN;
INSERT INTO public.specialties (key, name, category, is_system, active) VALUES
-- Especialidades CFP (psicologia)
('psicologia_clinica', 'Psicologia Clínica', 'psicologia', true, true),
('psicologia_hospitalar', 'Psicologia Hospitalar', 'psicologia', true, true),
('neuropsicologia', 'Neuropsicologia', 'psicologia', true, true),
('psicologia_organizacional', 'Psicologia Organizacional e do Trabalho', 'psicologia', true, true),
('psicologia_escolar', 'Psicologia Escolar e Educacional', 'psicologia', true, true),
('psicologia_juridica', 'Psicologia Jurídica', 'psicologia', true, true),
('psicologia_esporte', 'Psicologia do Esporte', 'psicologia', true, true),
('psicologia_social', 'Psicologia Social', 'psicologia', true, true),
('psicologia_transito', 'Psicologia do Trânsito', 'psicologia', true, true),
-- Abordagens teóricas
('psicanalise', 'Psicanálise', 'abordagem', true, true),
('tcc', 'Terapia Cognitivo-Comportamental (TCC)', 'abordagem', true, true),
('psicodrama', 'Psicodrama', 'abordagem', true, true),
('gestalt_terapia', 'Gestalt-terapia', 'abordagem', true, true),
('analise_comportamento', 'Análise do Comportamento (ABA)', 'abordagem', true, true),
('humanista', 'Abordagem Humanista (Rogers)', 'abordagem', true, true),
('sistemica_familiar', 'Terapia Sistêmica Familiar', 'abordagem', true, true),
('logoterapia', 'Logoterapia (Frankl)', 'abordagem', true, true),
('analitica_jung', 'Psicologia Analítica (Jung)', 'abordagem', true, true),
-- Públicos
('infantil', 'Atendimento Infantil', 'publico', true, true),
('adolescentes', 'Atendimento de Adolescentes', 'publico', true, true),
('casais', 'Terapia de Casal', 'publico', true, true),
('familia', 'Terapia Familiar', 'publico', true, true),
('grupos', 'Atendimento de Grupos', 'publico', true, true),
('idosos', 'Atendimento de Idosos / Gerontologia', 'publico', true, true),
('lgbtqia', 'Atendimento LGBTQIA+', 'publico', true, true),
-- Temas
('ansiedade', 'Transtornos de Ansiedade', 'tema', true, true),
('depressao', 'Depressão', 'tema', true, true),
('tdah', 'TDAH', 'tema', true, true),
('autismo', 'Transtorno do Espectro Autista', 'tema', true, true),
('luto', 'Luto e Perdas', 'tema', true, true),
('dependencia_quimica', 'Dependência Química', 'tema', true, true),
('transtornos_alimentares', 'Transtornos Alimentares', 'tema', true, true),
('trauma', 'Trauma e Estresse Pós-Traumático', 'tema', true, true),
-- Catch-all
('outra', 'Outra', 'outro', true, true)
ON CONFLICT (key) DO NOTHING;
COMMIT;
@@ -0,0 +1,74 @@
-- ============================================================================
-- Compliance CFP #6 — Consent forms extra (LGPD + Gravação) + LGPD amend
-- ----------------------------------------------------------------------------
-- Adiciona 2 templates globais novos exigidos pra completar a biblioteca de
-- consent forms do ROADMAP item #1.2 #6:
-- • termo_lgpd — Consentimento LGPD (tratamento de dados pessoais)
-- • autorizacao_gravacao — Autorização de gravação de sessão
--
-- Também atualiza o template tcle_online existente pra incluir cláusula
-- explícita de LGPD (estava mencionando criptografia mas não a Lei 13.709/2018
-- nem direitos do titular).
--
-- Pré-requisito: migration 20260521000005_document_templates_consent_types.sql
-- já aplicada (adiciona 'termo_lgpd' e 'autorizacao_gravacao' ao CHECK).
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. Termo de Consentimento LGPD (tratamento de dados pessoais)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.document_templates (
id, tenant_id, owner_id, nome_template, tipo, descricao,
corpo_html, cabecalho_html, rodape_html,
variaveis, is_global, ativo
) VALUES (
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento LGPD',
'termo_lgpd',
'Consentimento específico para tratamento de dados pessoais conforme Lei nº 13.709/2018 (LGPD).',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO PARA TRATAMENTO DE DADOS PESSOAIS</h2>\n\n<p>Em conformidade com a <strong>Lei Geral de Proteção de Dados Pessoais — Lei nº 13.709/2018 (LGPD)</strong>, eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido informado(a) e <strong>consinto livremente</strong> com o tratamento dos meus dados pessoais nos termos abaixo.</p>\n\n<h3>1. Controlador dos dados</h3>\n<p><strong>{{terapeuta_nome}}</strong>, Psicólogo(a) — CRP <strong>{{terapeuta_crp}}</strong>, atuando em <strong>{{clinica_nome}}</strong>, com endereço em <strong>{{clinica_endereco}}</strong>, atua como controlador dos dados pessoais coletados.</p>\n\n<h3>2. Dados tratados</h3>\n<p>Serão coletados e tratados os seguintes dados:</p>\n<ul>\n <li><strong>Identificação:</strong> nome, CPF, RG, data de nascimento, endereço, telefone, e-mail;</li>\n <li><strong>Dados sensíveis de saúde:</strong> histórico clínico, hipóteses diagnósticas, evolução terapêutica, prescrições, encaminhamentos (Art. 11 LGPD);</li>\n <li><strong>Dados de pagamento:</strong> valores, formas de pagamento, recibos emitidos;</li>\n <li><strong>Registros de atendimento:</strong> data, horário, modalidade e duração das sessões.</li>\n</ul>\n\n<h3>3. Finalidade e base legal</h3>\n<p>Os dados serão utilizados exclusivamente para:</p>\n<ul>\n <li><strong>Execução do contrato</strong> de prestação de serviços psicológicos (Art. 7º, V e Art. 11, II, "a" da LGPD);</li>\n <li>Cumprimento de <strong>obrigações legais e regulatórias</strong> (Resoluções CFP, retenção de prontuário por 5 anos — Art. 1º Res. CFP 001/2009);</li>\n <li>Proteção da <strong>vida e incolumidade física</strong> do titular ou de terceiros, quando necessário (Art. 11, II, "f" LGPD);</li>\n <li>Emissão de documentos solicitados (recibos, atestados, declarações).</li>\n</ul>\n\n<h3>4. Compartilhamento</h3>\n<p>Os dados <strong>não serão compartilhados</strong> com terceiros, exceto:</p>\n<ul>\n <li>Mediante <strong>autorização expressa</strong> do titular (ex: encaminhamentos);</li>\n <li>Por <strong>determinação judicial</strong> ou requisição legal de autoridade competente;</li>\n <li>Para <strong>processadores</strong> contratados (plataforma de prontuário eletrônico, serviços de armazenamento em nuvem), com cláusulas de confidencialidade e proteção equivalentes.</li>\n</ul>\n\n<h3>5. Armazenamento e retenção</h3>\n<p>Os dados serão mantidos pelo prazo mínimo de <strong>5 anos</strong> após o término do acompanhamento, conforme exigência do CFP (Resolução nº 001/2009), em ambiente eletrônico criptografado com controle de acesso restrito ao profissional responsável.</p>\n\n<h3>6. Direitos do titular (Art. 18 LGPD)</h3>\n<p>O(A) titular pode, a qualquer momento, solicitar ao controlador:</p>\n<ul>\n <li>Confirmação da existência de tratamento;</li>\n <li>Acesso aos seus dados;</li>\n <li>Correção de dados incompletos, inexatos ou desatualizados;</li>\n <li>Anonimização, bloqueio ou eliminação de dados desnecessários ou tratados em desconformidade;</li>\n <li>Portabilidade dos dados;</li>\n <li>Revogação deste consentimento, nos termos do §5º do Art. 8º (sem prejuízo do tratamento legalmente exigido).</li>\n</ul>\n\n<h3>7. Contato</h3>\n<p>Para exercer seus direitos ou esclarecer dúvidas: <strong>{{terapeuta_email}}</strong> · <strong>{{terapeuta_telefone}}</strong>.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e consinto</strong> livremente com o tratamento dos meus dados pessoais conforme descrito acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n Titular dos dados — CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Controlador — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Documento regido pela Lei nº 13.709/2018 (LGPD) e pelo Código de Ética Profissional do Psicólogo.\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','terapeuta_email','terapeuta_telefone','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 2. Autorização para Gravação de Sessão
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.document_templates (
id, tenant_id, owner_id, nome_template, tipo, descricao,
corpo_html, cabecalho_html, rodape_html,
variaveis, is_global, ativo
) VALUES (
gen_random_uuid(), NULL, NULL,
'Autorização para Gravação de Sessão',
'autorizacao_gravacao',
'Autorização específica do paciente para gravação de áudio/vídeo das sessões (supervisão, ensino, registro clínico).',
E'<h2 style="text-align:center; margin-bottom:30px;">AUTORIZAÇÃO PARA GRAVAÇÃO DE SESSÃO</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido devidamente informado(a) pelo(a) psicólogo(a) <strong>{{terapeuta_nome}}</strong>, CRP <strong>{{terapeuta_crp}}</strong>, e <strong>AUTORIZO</strong> a gravação das sessões de atendimento psicológico nas condições abaixo.</p>\n\n<h3>1. Tipo de gravação</h3>\n<p>Modalidade autorizada: <strong>{{tipo_gravacao}}</strong> (áudio, vídeo ou ambos).</p>\n\n<h3>2. Finalidade</h3>\n<p>As gravações serão utilizadas exclusivamente para:</p>\n<ul>\n <li><strong>{{finalidade_gravacao}}</strong></li>\n</ul>\n<p>Finalidades comuns: registro clínico para análise posterior do profissional; supervisão técnica com supervisor identificado; uso didático em formação (com anonimização); pesquisa científica (mediante consentimento adicional específico).</p>\n\n<h3>3. Compartilhamento</h3>\n<p>As gravações são <strong>confidenciais</strong>. Não serão compartilhadas com terceiros, exceto quando:</p>\n<ul>\n <li>Houver autorização expressa e por escrito do(a) titular;</li>\n <li>Para fins de supervisão técnica, com o(a) supervisor(a) identificado(a) — <strong>{{supervisor_nome}}</strong> (quando aplicável);</li>\n <li>Anonimizadas, para fins didáticos ou de pesquisa (com novo consentimento específico).</li>\n</ul>\n\n<h3>4. Armazenamento e descarte</h3>\n<p>As gravações serão armazenadas em ambiente criptografado, com acesso restrito ao(à) profissional responsável, pelo prazo de <strong>{{prazo_retencao}}</strong>, após o qual serão definitivamente eliminadas, conforme a LGPD (Lei nº 13.709/2018).</p>\n\n<h3>5. Direitos do(a) paciente</h3>\n<ul>\n <li>Revogar esta autorização a qualquer tempo, com efeito sobre gravações futuras;</li>\n <li>Solicitar a eliminação imediata de gravações específicas;</li>\n <li>Solicitar cópia da gravação para fins próprios;</li>\n <li>Ser informado(a) sobre cada utilização (supervisão, pesquisa).</li>\n</ul>\n\n<h3>6. Considerações éticas</h3>\n<p>A presente autorização está em conformidade com o Código de Ética Profissional do Psicólogo, com a Resolução CFP nº 010/2005 (sigilo profissional) e com a Lei nº 13.709/2018 (LGPD). A negativa de gravação <strong>não prejudica</strong> o atendimento psicológico, que prosseguirá normalmente.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e autorizo</strong> a gravação das sessões nos termos acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Autorização regida pelo Código de Ética do Psicólogo (CFP 010/2005) e pela Lei nº 13.709/2018 (LGPD).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','tipo_gravacao','finalidade_gravacao','supervisor_nome','prazo_retencao','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 3. Amend tcle_online com cláusula LGPD explícita
-- ----------------------------------------------------------------------------
-- O template original (seed_015) menciona criptografia mas não cita a LGPD
-- explicitamente nem os direitos do titular. Acrescenta uma seção 5.
-- ──────────────────────────────────────────────────────────────────────────
UPDATE public.document_templates
SET corpo_html = REPLACE(
corpo_html,
'<h3>4. Limitações</h3>',
E'<h3>5. Proteção de Dados (LGPD)</h3>\n<p>O atendimento online é regido pela <strong>Lei Geral de Proteção de Dados — Lei nº 13.709/2018 (LGPD)</strong>. Você tem direito a (Art. 18 LGPD): confirmar a existência de tratamento dos seus dados; acessar seus dados; corrigir dados incompletos ou inexatos; solicitar eliminação dos dados após o término do tratamento (resguardados os prazos legais de retenção do CFP); e revogar este consentimento a qualquer momento. Para exercê-los, contate <strong>{{terapeuta_email}}</strong>.</p>\n\n<h3>6. Limitações</h3>'
),
variaveis = ARRAY['paciente_nome','paciente_cpf','plataforma_online','terapeuta_nome','terapeuta_crp','terapeuta_email','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
descricao = 'Consentimento específico para atendimento psicológico por meios tecnológicos (Resolução CFP nº 11/2018) + cláusula LGPD.',
updated_at = now()
WHERE tipo = 'tcle_online' AND is_global = true;
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;

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