243 Commits

Author SHA1 Message Date
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
Leonardo 558922d1a5 log: sessao Melissa cfg-* nativas + temas + cronometro DB
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:00:24 -03:00
Leonardo 9966b5f175 Melissa: paginas nativas cfg-* + temas + textos com fundo + drawer WA
CHROME COMPARTILHADO + 18 PAGINAS NATIVAS
- MelissaConfigPage: chrome unico (header, drawer mobile, sidebar com Configuracoes
  + FAQ slot, main com Suspense). Replica fake-dialog right rule e fica flush
  com o config-aside global.
- 18 wrappers finos (~25 linhas cada): cfg-precificacao, cfg-descontos,
  cfg-excecoes, cfg-convenios, cfg-wa, cfg-wa-pessoal, cfg-wa-oficial,
  cfg-wa-templates, cfg-conversas-tags/autoreply/optouts/sla/bots,
  cfg-lembretes, cfg-creditos-wa, cfg-sms, cfg-email-templates,
  cfg-recursos-extras, cfg-recursos-extras-extrato, cfg-auditoria.
- Cada wrapper usa defineAsyncComponent + Suspense pra evitar race com
  tenantStore no boot (loading travado em alguns chooser-style pages).
- MelissaLayout: imports + SECOES + MELISSA_NON_CONFIG_SLUGS + render
  conditions atualizados pra cobrir os 18 slugs.

PAGINAS LEGADAS DETECTAM CONTEXTO MELISSA
- ConfiguracoesWhatsappChooserPage, WhatsappPage, TwilioWhatsappPage,
  SmsPage, RecursosExtrasPage, EmailTemplatesPage, AddonsExtratoPage,
  AgendadorPage, ConversasAutoreplyPage: route.startsWith('/melissa')
  decide se router.push vai pro slug Melissa ou /configuracoes legado.
- Anchors <a href="/configuracoes/..."> (que recarregavam pagina e
  vazavam o usuario do Melissa) trocados por RouterLink context-aware.
- MelissaAgenda.goSettings agora vai pra /melissa/agenda-config.

PERSONALIZAR > TEMAS
- melissaThemes.js: catalogo Freud/Klein/Jung (wallpaper + cor primaria
  + preset Lara/Nora + surface).
- Toggle de tema aplica tudo de uma vez; persistido em melissa_prefs.themeName.
- Boot resolve themeName -> imagem via fetch + data URL (sem guardar
  data URL gigante no DB).
- onCustomFileChange/onClearBg invalidam themeName quando user mexe no bg.

PERSONALIZAR > FUNDO NOS TEXTOS
- Pref textBgEnabled em melissa_prefs.
- MelissaHeroClock: prop textBg envolve relogio/data/saudacao/resumo
  em <span class="hero-text"> que ganha bg branco/preto 60% + borda
  + padding + radius quando o toggle esta on.
- Vars --m-hero-text-bg / --m-hero-text-border flipam com light/dark.

TOP + DOCK COM GRADIENT HORIZONTAL
- Var --m-band: preto 80% (dark) / branco 80% (light).
- .melissa-topbar-band: gradiente cor->transparente (right->left) atras
  dos botoes do topo.
- .melissa-dock: gradiente cor->transparente (left->right) atras dos pins.

MELISSANEGOCIO ABSORVE MINHA EMPRESA
- Adiciona logo upload + preview "cartao de visita" (computeds
  enderecoLinhas/redesValidas/temDados/logoDisplay + redeIcon helper).
- Normaliza dados legados do cfg-empresa: redes_sociais.{rede} virou {name}.
- Preview teleporta entre 3 destinos baseado no viewport:
  mobile -> drawer; mid-desktop -> sidebar; wide-desktop (>=1340px) ->
  painel flutuante FORA do fake dialog (ancora no right edge + 14px gap,
  altura segue conteudo, header alinhado com header do dialog).
- Remove cfg-empresa de melissaConfigGrupos.js + COMPONENT_MAP do
  MelissaConfiguracoes; grupo "Empresa & Plataforma" -> "Plataforma".

CRONOMETRO -> SESSAO AGENDADA
- MelissaCronometro emite session-end ao parar com paciente selecionado
  (threshold 5s pra ignorar start/stop acidental).
- MelissaLayout.onCronometroSessionEnd busca agenda_eventos do paciente
  no dia (tipo='sessao'), pega o mais recente e grava em
  extra_fields.cronometro_duracao_seg + cronometro_parado_em.
- Toast: sucesso ("X min salvos") ou warn ("sessao nao encontrada").

CONVERSATIONDRAWER WHATSAPP-LIKE
- Nova imagem whatsapp-bg.jpg (renomeada de hash random) usada como
  tile (380px) no .cd-msgs.
- Light: bege #efeae2 + multiply blend.
- Dark: #0b141a + camada 78% sobre o doodle.
- Bubbles WA-style (verde out / branco-dark in com tails) ja existiam.

EXTRATO RECURSOS EXTRAS
- Filtros 2-por-linha em Melissa (vs 1/4 no /configuracoes).
- Cards de Resumo teleportam pro #cfg-page-side em Melissa
  (1-col empilhado no drawer; 4-col inline no /configuracoes).
- Botoes de exportar com flex-1 distribuidos em uma unica linha em
  desktop, wrap no mobile.
- DataTable scrollable em ambos os layouts.

OUTROS AJUSTES MENORES
- Cfg-conversas-autoreply: dias semana 4-cols em Melissa (vs 7-cols
  no /configuracoes).
- Cfg-creditos-wa: 1/2 por linha (vs 1/2/4) em Melissa.
- Cfg-recursos-extras: pacotes 1/2 (vs 1/2/4); "Em breve" 1-col.
- WhatsAppPage aba Templates: guia de formatacao teleporta pro side
  drawer em Melissa, deixando textareas full-width.
- ConfigPage chrome agora tem #cfg-page-actions target pros Teleport
  de acoes (refresh button etc).
- Imagens renomeadas em src/assets/themes/ (freudwebp/melainewebp/
  jungwebp.webp) e src/assets/whatsapp-bg.jpg.
- JoditTextEditor.vue novo (wrapper Jodit generico, sem features de email).
- MelissaConfigList.vue novo (lista compartilhada de configs pro drawer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:48:18 -03:00
Leonardo cc7841bd1f MelissaConversas: a11y + perf tagsForThread + DRY (channelMeta + KANBAN_COLUMNS shared)
A11y no parent:
- aria-label em botoes icon-only do header (Recarregar dinamico, Buscar
  compact, Close); tooltip vira title que SR ignora
- aria-hidden=true em icones decorativos (header title, search input,
  subheader info-circle, kanban col head, empty state, button icons)
- aria-busy reativo no mw-col__body durante loading
- aria-label dinamico no count do kanban ("3 conversas em Urgente")
- aria-expanded + aria-controls no menu mobile button
- aria-label no input de busca
- role=note no subheader explicativo
- :inert="(drawerOpen && isMobile) || null" no <section class="mw-page">
  — focus trap real: drawer aberto torna conteudo de fundo inerte
  (boolean attr via || null pra Vue 3.4 serializar correto)

A11y no Sidebar:
- aria-hidden=true em todos icones decorativos restantes (filter title
  icons, list/bell/user/user-minus, channel icons, filter-slash, etc)

Perf — tagsForThread cacheado:
- Antes era chamado in-template (2x por card, recriava array a cada
  render). Agora tagsByThreadKey computed Map: lookup O(1) por card,
  recompute so quando threadTagsMap ou tagById muda. EMPTY_TAGS frozen
  evita criar arrays novos pra threads sem tags.

DRY — channelMeta + KANBAN_COLUMNS shared:
- src/utils/channelMeta.js (novo): CHANNEL_OPTIONS frozen + channelIcon
  + channelLabel. Antes channelIcon estava em 3 lugares (parent, Sidebar,
  Card); CHANNEL_OPTIONS em 2 (parent, Sidebar). Agora 1.
- useConversations.js: exporta KANBAN_COLUMNS frozen (metadata canonica:
  key + label + icon + color). Antes parent+Sidebar tinham copias locais
  de 8 linhas cada + composable tinha KANBAN_ORDER separado. Agora
  KANBAN_ORDER deriva de KANBAN_COLUMNS.

Drift eliminado: 3 fontes -> 1 pra channelIcon, 2 -> 1 pra
CHANNEL_OPTIONS, 2 -> 1 pra KANBAN_COLUMNS (KANBAN_ORDER ainda interno
ao composable mas derivado).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:37:46 -03:00
Leonardo 250e946084 MelissaConversas: refator + extracoes + migracao Tailwind
useConversations: debounce 300ms no realtime load (sem isso, clinica
ativa fazia SELECT 500 por mensagem); expose currentUserId no return
(antes SFC + composable faziam 2 round-trips a auth.getUser); cleanup
do timer no unsubscribeRealtime.

MelissaConversas: bug fixes de loading
- reloadThreadTags lê de threads.value (universal, nao filtered) — antes
  tags piscavam a cada flick de filtro
- watch(threads) com debounce 200ms substitui watch(filteredThreads.length)
  — antes recarregava todas as tags em cada char digitado
- Promise.all no mount sem race com currentUserId (reloadThreadTags
  removido daqui — vem via watch automatic)
- watch drawer.isOpen: await load() antes (antes load+reload em paralelo
  liam threads velhas)
- watch tenantStore com token monotonico (race A→B→A)
- supabase.auth.getUser local removido (usa currentUserId do composable)

Extracoes:
- MelissaConversasSidebar.vue: aside col-1 (alerta unlinked + 4 grupos
  de filtros + footer "Limpar filtros" com Vue Transition). filters
  passado como prop e mutado direto. KANBAN_COLUMNS/CHANNEL_OPTIONS/
  channelIcon/hasActiveFilters/clearAllFilters movidos pra dentro.
  Tailwind nas bases; state modifiers .is-active/.is-warn/.is-danger/
  .is-{red,amber,blue,emerald} ficam scoped (cores fixas por status).
- MelissaConversasCard.vue: card do kanban (head/msg/tags/foot).
  channelIcon/truncate/contactLabel/fmtRelative/assigneeLabel movidos.
  aria-label, aria-pressed, aria-hidden em icones decorativos.
  Tailwind no template; .is-mine do assignee fica scoped.

Tailwind no resto do parent: containers (.mw-page + animation), header
(.mw-page__head/title/count/unread/actions), search (.mw-search* +
--xl-only via max-[1279px]:hidden), close/head-btn/menu-btn (incluindo
--compact-only e --mobile-only via hidden + max-[XXX]:grid/inline-flex),
subheader, body/main/kanban/col/col__head/title/count/body/empty,
mobile drawer + backdrop. 2 media queries inteiras eliminadas
(@media max-width 1279/1023). State modifiers de kanban color
(.mw-col.is-{color}) ficam scoped — 12 regras com cores fixas RGB
seriam ruidosas inline. Cross-teleport :deep(.mw-side*) preservado.

MelissaConversas: 1293 -> 465 linhas (-828, -64%)
  script: 198 -> 195 (logica essencial preservada)
  template: 278 -> 143 (49% reducao via componentizacao)
  style: 761 -> 99 (87% reducao — so keyframes, kanban color states,
                     scrollbars, cross-teleport :deep, Vue Transitions)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:51:05 -03:00
Leonardo ef3e160b36 MelissaAgenda: migra CSS pra Tailwind + extrai SearchPopover/ActionsPopover + fixes de smell
CSS migration (Tailwind v4 com max-[1023px]:/max-[1279px]: arbitrarios pra preservar pixel-perfect):
- Containers/layout: ma-page, ma-page__head/__title/__actions, ma-close, ma-head-btn, ma-body, ma-menu-btn, ma-mobile-drawer*
- Mini-calendar: weekdays/grid/day/dots/dot bases (state modifiers .is-feriado--* ficam em CSS por usarem color-mix por tipo)
- Aside: ma-side, ma-search*, ma-pat* (avatar/info/name/sub/novo/kebab), ma-act-btn*, paginator
- Toolbar: ma-cal*, ma-cal__nav*, ma-cal__btn (versao ghost + bug pre-existente da definicao duplicada preservado), ma-cal__icon, ma-cal__view*
- Stats/Sessions/Filter chip/Loading/Dock actions: bases full-Tailwind, state modifiers ficam em CSS
- Patient banner + All sessions: bases + grid responsivo via max-[640px]:[grid-column:N] arbitrarios

Extracoes:
- MelissaAgendaSearchPopover.vue: Cmd+K busca de toolbar (datas + paciente). Token monotonico + invalidacao em early returns + cleanup no @hide do Popover.
- MelissaAgendaActionsPopover.vue: popover Acoes mobile (<xl) com SelectButtons + 4 botoes de bloqueio. v-model:calendar-view/only-sessions/time-mode + emit bloqueio.

Fixes acionaveis (smells previamente listados):
- #2 null-safety em M.feriados/workRules (fallbacks pra modo standalone)
- #3 race em searchEventosByText (token monotonico contra out-of-order resolution)
- #5 cleanup _patClickTimer em onBeforeUnmount
- onHistoricoOpen valida inicio_em/fim_em antes de construir startH/endH (evita NaN propagation)
- onPacientesPageChange ignora clicks durante loading (evita resolucao fora de ordem)
- ESC no search popover limpa state via @hide handler centralizado
- fcEvents em 1 passada (for loop) em vez de filter().filter().filter().map() — 4x mais rapido em listView mensal
- pacientesIndex Map O(1) substitui 2 .find() sequenciais + cache extra pra fetch on-demand de patient (resolve dock/banner sumindo silenciosamente em clinicas >1k)
- Bloqueio buttons :disabled quando M=null (standalone)
- Teleport to=.melissa-dock guard com v-if M (evita warn em standalone)
- Dedup em flight de getSessionCounts (Set _sessionFetchInflight)

MelissaAgenda: 4181 -> 2851 linhas (-1330, -32%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:37:59 -03:00
Leonardo 95b2535d3d MelissaLayout: extrai Settings/Hero/Timeline + composables wallpaper/toques + push-back veil perf
- MelissaSettingsPanel.vue: painel Personalizar (Plano de Fundo, Relogio & Som, Tema com preset Lara/Nora)
- MelissaHeroClock.vue: relogio gigante + saudacao + cronometro + resumo do dia
- MelissaTimelineHoje.vue: timeline horizontal (lg+) e vertical (mobile) com eco/cursor agora
- useMelissaWallpaper(): bgUrl/overlayOpacity/bgImageOpacity + onFileChange/clearBg + photoStyle/defaultBgStyle
- useMelissaToques(): toqueTermino + testarToque (preferencia, nao instance state do cronometro)
- Push-back perf: filter:blur animado no .win11-summary substituido por veil unico com backdrop-filter
  (1 backdrop pass por frame em vez de N glass-panels re-blurados; will-change + contain:strict +
  transform/opacity GPU-friendly; 60fps em mobile)

MelissaLayout: 4114 -> 2861 linhas (-1253, -30%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:37:24 -03:00
Leonardo 63340d1226 MelissaMenu: label Conta + remove modo escuro + preset migra pra Personalizar
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:37:05 -03:00
Leonardo 27b5bbed6f MelissaPerfil mobile: drawer "Menu Perfil" ganha o menu de configs no topo
Refator (entendi corretamente agora): o drawer da pagina deve
PERSISTIR em mobile (botao "Menu Perfil" abre um slide-in da
esquerda com a info contextual). O que muda e que dentro do drawer,
no topo, tambem aparece o MENU GLOBAL DE CONFIGURACOES — em vez
de ficar fixado na lateral em desktop e desaparecer em mobile.

MelissaLayout:
- @media (max-width: 1023px) esconde .melissa-config-aside-host
- Reseta --m-config-aside-left pra 6px em mobile (pagina vira full-width)

MelissaPerfil:
- Restaura o drawer slide-in (drawerOpen + toggle/fechar)
- Restaura botao "Menu Perfil" no header mobile
- Drawer scroll wrapper agora tem dois filhos:
  1. .mpr-mobile-drawer__configs com `<MelissaConfigSidebar>`
  2. .mpr-mobile-drawer__contextual com Teleport target da
     sidebar contextual (Sua evolucao + Avatar + Sair)
- Removido o trecho de menu inline no body (que era o approach errado)

Em desktop nada muda — a sidebar global do MelissaLayout continua
fixa na esquerda (296px de left no inset das paginas).

Pendente: aplicar o mesmo pattern (Teleport do MelissaConfigSidebar
pro drawer da pagina, acima do contextual) nas outras 8 paginas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:07:25 -03:00
Leonardo d1dced242f MelissaPerfil mobile: menu de configs inline no topo (sem drawer)
Refator do menu mobile so no Perfil (validacao do padrao):
- Remove o drawer slide-in da esquerda + backdrop + botao "Menu"/
  "Configuracoes" no header
- Renderiza <MelissaConfigSidebar> INLINE no topo do .mpr-body em
  mobile (.mpr-mobile-config-menu)
- A sidebar contextual (Sua evolucao + Avatar) tambem renderiza
  inline em sequencia, abaixo do menu global
- O main com o form fica abaixo de tudo
- Body em mobile vira flex column + overflow-y: auto (scroll externo
  unico pra toda a pagina)

Drawer state (drawerOpen/toggle/fechar) e Teleport removidos do
JS+template. Em desktop nada muda — MelissaLayout segue renderizando
a sidebar global fixa na esquerda.

Pendente: aplicar o mesmo pattern nas outras 8 paginas de config se
o usuario validar este formato.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:01:34 -03:00
Leonardo 989c5330f8 MelissaLayout: sidebar global de configs em qualquer rota de config
Antes cada pagina nativa de config tinha seu proprio chrome 2-col, e
quando o usuario navegava entre Perfil/Plano/Negocio/Seguranca/Agenda
Config/Bloqueios/Agendador/Pagamento, perdia o contexto do menu.

Agora:
- Catalogo unico em composables/melissaConfigGrupos.js (MELISSA_CONFIG_
  GRUPOS + isMelissaConfigSlug helper)
- MelissaConfigSidebar.vue componente standalone com accordion +
  navegacao via router.push + destaque do item ativo
- MelissaLayout renderiza `<MelissaConfigSidebar>` em qualquer slug
  que esteja em MELISSA_CONFIG_GRUPOS (computed showConfigSidebar)
- CSS var --m-config-aside-left no .win11-root: 296px quando sidebar
  visivel, 6px caso contrario
- Todas as 9 paginas nativas (Perfil, Plano, AlterarPlano, Negocio,
  Seguranca, Bloqueios, AgendaConfig, Agendador, Pagamento) +
  MelissaConfiguracoes ajustam left do inset usando a var

Sidebar tem entrada animada (lift + slide) e usa o pattern do .mcfg-
accordion (head com icone primary + label + desc 2-linhas + badge;
items com hover/active color-mix primary 12-16%).

Proximo passo: limpar o aside redundante interno do MelissaConfiguracoes
+ ajustar MelissaSeguranca pra considerar o aside no min-width 1000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:50:03 -03:00
Leonardo 7d2307dcf0 MelissaPagamento: pagina nativa 2-col com 6 cards de metodos
Tira "Pagamento" do MelissaConfiguracoes (era embed cfg-pagamento ->
ConfiguracoesPagamentoPage.vue, 580 linhas). Cria a /melissa/pagamento
nativa Melissa.

Sidebar (mpg-side):
- Card "Resumo" — 5 mini stats coloridos por metodo (Pix verde,
  Deposito azul, Dinheiro amarelo, Cartao roxo, Convenio teal),
  com label "Ativo" ou "Inativo" + cor da borda quando ativo
- Card "Como funciona" — FAQ (Agendador / Cobranca WhatsApp / Obs)

Main (1-col, sem grid pq cards expandem dinamicamente quando ativos):
- Pix: tipo de chave (Select 5 opcoes) + chave + nome titular
- Deposito/TED: banco (Select 16 bancos BR) + tipo conta + agencia
  + conta + titular + CPF/CNPJ
- Dinheiro: toggle simples
- Cartao: toggle + instrucao opcional
- Convenio: toggle + lista de convenios (Textarea)
- Observacoes: Textarea livre

Cada card com toggle no head + body que expande quando ativo +
botao "Salvar" proprio (saveCard build payload do subset). Quando
inativo, mostra so "Salvar como inativo" pra persistir o desligar.
flex-shrink: 0 nos cards (mesmo padrao do AgendaConfig — conteudo
varia muito).

Logica espelhada do ConfiguracoesPagamentoPage (tabela payment_settings).
Compativel com /configuracoes/pagamento.

Wire-up:
- MelissaLayout: import + render `<MelissaPagamento>` quando
  secaoAberta === 'pagamento'
- 'pagamento' adicionado em SECOES + MELISSA_NON_CONFIG_SLUGS
- MelissaConfiguracoes: cfg-pagamento removido de COMPONENT_MAP +
  item do grupo Financeiro re-rotulado pra slug 'pagamento' (atalho
  pra pagina nativa)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:31:28 -03:00
Leonardo 6cc094d252 MelissaAgendaConfig: cards com flex-shrink: 0 (altura por conteudo)
Os cards estavam comprimindo (ficando "pequenos") quando o total
ultrapassava a altura do .mac-main. Causa: flex-shrink: 1 (default)
deixa o flex column comprimir antes de engatar overflow-y: auto.

Fix: flex-shrink: 0 nos .mac-w (main e sidebar). Agora cada card
mantem a altura natural do seu conteudo e o .mac-main scrolla
externamente quando o total passa da viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:53:12 -03:00
Leonardo 11201e1e5d MelissaAgendaConfig: cards crescem com conteudo, sem scroll interno
Remove min-height/max-height/overflow-y das cards (sidebar e main)
em desktop. Agora cada card cresce naturalmente conforme o conteudo
muda (alternar "Diferente por dia" na Jornada, expandir slots no
Online, etc). O scroll externo do .mac-main/.mac-side__scroll cuida
da pagina inteira.

Decisao especifica desta tela — Perfil/Negocio/Plano/AlterarPlano/
Bloqueios/Agendador continuam com cap 300+scroll porque o conteudo
deles e mais homogeneo. Aqui o conteudo da Jornada varia bastante.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:51:36 -03:00
Leonardo d49248979a MelissaAgendaConfig: cards do main com altura por conteudo + scroll interno
Aplica o mesmo pattern dos outros (Perfil/Negocio/Plano):
- Em desktop, cada .mac-main > .mac-w ganha min-height: 300px +
  max-height: 100% (do .mac-main).
- Body com flex: 1 + min-height: 0 + overflow-y: auto.

Quando o usuario alterna pra "Diferente por dia" (Jornada), pra
"Personalizar" (Ritmo) ou expande slots (Online), o card cresce ate
o teto do main e depois passa a scrollar internamente em vez de
empurrar os cards seguintes pra fora.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:50:04 -03:00
Leonardo 48a9700aea MelissaConfiguracoes: restaura grupos Conta + Agenda como atalhos pras paginas nativas
Remover os grupos inteiros tirava o caminho de descoberta via sidebar
de Configuracoes. Agora os items continuam listados, mas as keys
apontam pros slugs nativos (perfil/plano/negocio/seguranca + agenda-
config/bloqueios/online-scheduling).

Quando o user clica, selecionar() empurra a rota /melissa/<slug> e o
MelissaLayout troca pra renderizar a pagina nativa correspondente —
MelissaConfiguracoes desmonta porque isMelissaConfigRoute(slug) retorna
false (slug esta em MELISSA_NON_CONFIG_SLUGS).

Resultado: o user encontra os items no Configuracoes (descoberta) e
ao clicar abre a pagina nativa (UX melhor que embed antigo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:47:36 -03:00
Leonardo cf1fa7e361 MelissaAgendaConfig: pagina nativa 2-col com Jornada + Ritmo + Online
Tira "Agenda" do MelissaConfiguracoes (era embed cfg-agenda ->
ConfiguracoesAgendaPage.vue, 1714 linhas). Cria a /melissa/agenda-config
nativa Melissa.

Sidebar (mac-side):
- Card "Status do setup" — 3 status items clicaveis (Jornada/Ritmo/
  Online) com icone verde se OK ou amber se pendente + resumo
  dinamico + chevron pra ancora
- Card "Como funciona" — FAQ 3-passos (1/2/3) explicando o fluxo

Main (1-col stacked, denso demais pra 50/50):
- Jornada: fuso (timezone Select) + dias da semana (chips toggleaveis)
  + modo igual/diferente (toggle bonito) + horarios (DatePickers timeOnly,
  weekdays + sab + dom separados em modo igual; por dia em modo
  diferente) + pausas (PausasChipsEditor reaproveitado, globais ou
  por dia)
- Ritmo: 5 presets de duracao (30/45/50/60/90 min com gap) + custom
  collapse com 2 DatePickers (duracao + intervalo)
- Online: aviso de slots orfaos (dias com slots mas sem jornada) +
  toggle ativo + tabs de dia + periodos quick actions (Manha/Tarde/
  Noite/Todos/Limpar) + slot chips individuais + info contagem

Cada card com Salvar proprio (saveJornada/saveRitmo/saveOnline). DB:
agenda_configuracoes + agenda_regras_semanais + agenda_online_slots.
Logica de igual/diferente com snapshot preservation, geracao de slots
respeitando jornada+pausas, limpeza automatica de slots orfaos ao
salvar jornada — tudo espelhado do original.

SKIPPED: FullCalendar preview (visite /melissa/agenda real).

Wire-up:
- MelissaLayout: import + render `<MelissaAgendaConfig>` quando
  secaoAberta === 'agenda-config'
- 'agenda-config' adicionado em SECOES + MELISSA_NON_CONFIG_SLUGS
- MelissaConfiguracoes: cfg-agenda removido de COMPONENT_MAP +
  grupo Agenda inteiro removido (Agenda/Bloqueios/Agendador todos
  viraram nativos agora)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:41:26 -03:00
Leonardo 85ebbf334d MelissaAgendador: pagina nativa 2-col com 6 secoes de config
Tira "Agendador Online" do MelissaConfiguracoes (era embed
cfg-agendador -> ConfiguracoesAgendadorPage.vue) e tambem do
MelissaEmbed (era 'online-scheduling' em MELISSA_EMBED_KEYS).
Cria a /melissa/online-scheduling como pagina nativa Melissa.

Sidebar (mag-side):
- Card "Status" — toggle ativo + tag PRO + link publico (com input
  selecionavel + copy + open) + slug personalizado (se entitlement)
  ou upgrade hint
- Card "Configuracoes" — 6 atalhos clicaveis com icones coloridos
  + resumo dinamico de cada secao (scroll suave pra ancora)

Main (50/50 desktop, Textos full-width):
- Identidade Visual: nome + cor (ColorPicker) + 3 uploads
  (logomarca/header/fundo) com auto-save apos upload
- Perfil Publico: endereco + botao "Como chegar" toggle + maps_url
- Fluxo: modo aprovacao (radio cards) + prazo resposta + modalidade
  (SelectButton) + tipos (chips) + duracao + antecedencia
- Pagamento: 3 modos (radio) + metodos visiveis (com payment_settings
  sync) + Pix config + reserva
- Triagem & LGPD: 4 toggles (motivo + origem + verificacao email + lgpd)
- Textos: 3 Editors PrimeVue (boas-vindas + como_se_preparar +
  termos_lgpd condicional) — em row full-width pq sao mais altos

Cada card tem botao "Salvar" proprio (saveCard build payload do
respectivo subset). Aplicam o pattern: min-h 300, max-h 100%, body
overflow-y: auto.

Logica espelhada do ConfiguracoesAgendadorPage (agendador_configuracoes
+ bucket agendador + entitlements). Compativel com /configuracoes/agendador.

Wire-up:
- MelissaLayout: import + render `<MelissaAgendador>` quando
  secaoAberta === 'online-scheduling'
- MELISSA_EMBED_KEYS agora vazio (Melissa nao tem mais embeds)
- 'online-scheduling' adicionado explicitamente em
  MELISSA_NON_CONFIG_SLUGS
- SECOES['online-scheduling'].descricao atualizada
- MelissaConfiguracoes: cfg-agendador removido de COMPONENT_MAP e
  do grupo Agenda (resta so cfg-agenda)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:31:29 -03:00
Leonardo 25444c1f5f MelissaBloqueios: pagina nativa 2-col com nacionais + municipais + bloqueios
Tira "Bloqueios" do MelissaConfiguracoes (era embed cfg-bloqueios ->
BloqueiosPage.vue) e cria a /melissa/bloqueios nativa Melissa.

Sidebar (mbq-side):
- Card "Resumo" — 3 mini-stats coloridos (Nacionais blue, Municipais
  orange, Bloqueios red) + nav de ano (chevron < ANO > )
- Card "Adicionar" — 2 CTAs (Feriado municipal secundario + Novo
  bloqueio primary)
- Card "Como funciona" — FAQ 3-bullets explicando os tipos

Main (50/50 desktop, com Bloqueios full-row abaixo):
- Card Nacionais — read-only (gerado automaticamente), tags Movel
- Card Municipais — CRUD via dialog (nome + data + observacao)
- Card Bloqueios — CRUD via dialog (titulo + datas + horas + obs +
  recorrente). Border-left colorido por tipo (azul/laranja/vermelho).
- Items com data + titulo + observacao inline + acoes (edit/trash)

Cards aplicam o pattern dos anteriores: min-h 300, max-h 100%, body
overflow-y: auto. Bloqueios spans 2-col com .mbq-w--full.

Logica espelhada do BloqueiosPage (composable useFeriados +
agenda_bloqueios). Compativel com /configuracoes/bloqueios legacy.

Wire-up:
- MelissaLayout: import + render `<MelissaBloqueios>` quando
  secaoAberta === 'bloqueios'
- 'bloqueios' sai de MELISSA_CONFIG_ALIASES, entra em
  MELISSA_NON_CONFIG_SLUGS
- SECOES.bloqueios adicionado (icone pi-ban)
- MelissaConfiguracoes: cfg-bloqueios removido de COMPONENT_MAP,
  ROUTE_ALIASES e do grupo Agenda (resta cfg-agenda + cfg-agendador)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:21:44 -03:00
Leonardo 33370018b5 MelissaSeguranca: breakpoint mobile <768px (era <1024px)
Como a pagina e enxuta (so 1 form pequeno + sidebar com info), cabe
bem em tablet portrait. Reduzi o breakpoint mobile pra 768px e
adaptei a formula da largura:

- 768-1012px : full-width (right: 6px)
- 1012-2012px: width fixo 1000px
- >= 2012px  : ~50% do viewport (right: 50%)

Formula: right = max(6px, min(50%, calc(100% - 1006px)))

JS _mqMobile tambem atualizado pra (max-width: 767px).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:10:03 -03:00
Leonardo 3549a977cc MelissaSeguranca: pagina min 1000px de largura no desktop
Antes era right: 50% fixo, o que dava ~512px em viewports 1024-1100
(cramped p/ um form de senha + sidebar 320px).

Agora right = min(50%, calc(100% - 1006px)):
- viewport <  2012: page fixa em 1000px (nao shrink)
- viewport >= 2012: page = 50% (sobra mais espaco no lado direito)

Mobile (<1024px) continua full-width via media query existente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:07:03 -03:00
Leonardo df7ab9c5a8 MelissaSeguranca: pagina 50% alinhada a esquerda
Troca a centralizacao (left: 25% + right: 25%) por alinhamento a
esquerda: left fica em 6px (do inset base) e right: 50% (push do
edge direito pro meio). Pagina passa a ocupar a metade esquerda
do viewport, encostada na borda esquerda.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:04:10 -03:00
Leonardo a89745f668 MelissaSeguranca: pagina 50% de largura em desktop
Em desktop a pagina ocupa apenas 50% da largura util (left: 25% +
right: 25%), centralizada. Como Seguranca so tem 1 form pequeno
(Trocar senha + sidebar de info), nao precisa de toda a largura
do viewport. Em mobile mantem 100% (override so na media query
>= 1024px).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:03:25 -03:00
Leonardo c605a4f1a2 MelissaSeguranca: card Trocar senha altura por conteudo + scroll y auto
Em desktop o card .mse-w do main (Trocar senha) ganha:
- max-height: 100% (do .mse-main) — nao passa do viewport
- SEM min-height — altura natural por conteudo
- body com flex: 1 + min-height: 0 + overflow-y: auto pra scroll
  interno quando o conteudo (3 inputs + barra de forca + match +
  warning + 2 botoes) precisar de mais espaco que o disponivel.

Diferente da sidebar (que mantem min-h 300 pq pode haver pouca info).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:59:26 -03:00
Leonardo 2ca9cde2ea MelissaSeguranca: pagina nativa 2-col + grupo "Conta" sai inteiro de Configuracoes
Tira "Seguranca" do MelissaConfiguracoes (era embed cfg-seguranca ->
SecurityPage.vue) e cria a /melissa/seguranca nativa Melissa.

Sidebar (mse-side):
- Card "Estado da conta" — email (mono) + tag "Sessão Ativa" pulsando
  + warning amber sobre desconectar todos dispositivos
- Card "Boas praticas" — 4 dicas com bullet colorido (8+ chars,
  evite obvio, encerre sessao publica, nao reuse senhas)

Main:
- Card "Trocar senha" — 3 Password inputs (atual + nova + confirmar)
  + barra de forca 4-segmentos (Muito fraca/Fraca/Boa/Forte) +
  match indicator (check verde / x amber) + warning + 2 botoes:
  "Enviar link por e-mail" (reset por email) + primary "Trocar senha"
- Estado "concluido" com check verde + redirect pro login

Logica espelhada do SecurityPage:
- changePassword: signInWithPassword pra reautenticar + updateUser +
  hardLogout (signOut global + clear sb-* tokens) apos 2.6s
- sendResetEmail: resetPasswordForEmail com redirectTo /auth/reset-password

Wire-up:
- MelissaLayout: import + render `<MelissaSeguranca v-if=secaoAberta=='seguranca'>`
- 'seguranca' sai de MELISSA_CONFIG_ALIASES, entra em MELISSA_NON_CONFIG_SLUGS
- SECOES.seguranca atualizado (label + descricao + duplicado removido)
- MelissaConfiguracoes: cfg-seguranca removido de COMPONENT_MAP +
  ROUTE_ALIASES; grupo "Conta" inteiro removido (Perfil/Plano/Negocio/
  Seguranca todos viraram nativos agora)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:57:27 -03:00
Leonardo 7c0c1b3528 MelissaPlano/AlterarPlano desktop: cards min-h 300 + body scroll
Aplica o mesmo fix do MelissaPerfil/Negocio:
- align-items: start no grid (cells nao stretch p/ row height)
- Cards min-height 300px + max-height 100% (do container)
- .mpl-w__body / .map-w__body com flex: 1 + min-height: 0 +
  overflow-y: auto (scrollbar fina)

MelissaPlano: vale pros 2 cards do main (Recursos / Historico) e
pros 2 cards da sidebar (Plano atual / Resumo) — quando o user
tem features longas ou muitos eventos, scroll interno engata.

MelissaAlterarPlano: aplicado SO na sidebar (Plano atual / Filtros).
Os plan cards do main (.map-plan) ficam fora — sao product cards
com layout proprio (preco grande + 3 CTAs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:51:47 -03:00
Leonardo 5db6000c2c MelissaPerfil/Negocio desktop: cards min-h 300, max-h 100%, body scroll
Em desktop os cards ficavam com altura natural do conteudo, e o
.mpr-main / .mng-main scrollava externamente. Quando o usuario
adicionava redes sociais (ou customs), o card crescia, empurrava a
row do grid e o conteudo novo ficava fora do viewport sem feedback
de scroll claro.

Fix:
- align-items: start no grid (cells nao stretch p/ row height)
- Cada card min-height: 300px + max-height: 100% (do container)
- .mpr-w__body / .mng-w__body com flex: 1 + min-height: 0 +
  overflow-y: auto (scrollbar fina)
- Vale tanto pros cards do main (Identidade/Contato/Bio/Redes etc)
  quanto pros da sidebar (Sua evolucao/presenca + Avatar/Logomarca)
  que tinham o mesmo problema com badges/dicas

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:49:30 -03:00
Leonardo 5a2d24dd99 MelissaAlterarPlano: pagina nativa pra escolha de plano
Substitui o redirect pra /therapist/upgrade (que sai do overlay
Melissa) por uma pagina nativa em /melissa/alterar-plano com o
mesmo chrome 2-col das outras.

Sidebar (map-side):
- Card "Plano atual" — nome destacado em primary box + key + valor
  + status; ou empty state se nao tem plano pessoal
- Card "Filtros" — busca por nome/key/desc + chips Mensal/Anual
- Footer: botao "Voltar pro Meu Plano"

Main:
- Grid responsivo 1/2/3 cols (mobile/md/xl) de plan cards
- Cada card: nome + key (mono) + tag "Atual" se for o plano atual,
  descricao, preco grande (do interval selecionado), CTA primario
  "Escolher mensal/anual" + 2 botoes secundarios (Mensal | Anual)
  cada um mostrando seu preco abaixo do label
- Card destacado com border primary se for o plano atual
- Empty state: filtro vazio com botao "Limpar busca"

Logica:
- preflight: valida sessao + plano + interval + preco ativo + nao ja
  estar nesse plano/intervalo
- choosePlan: se ja tem subscription -> RPC change_subscription_plan
  + update do interval; se nao tem -> insert manual em subscriptions.
  Apos sucesso, emit('goto', 'plano') volta pro MelissaPlano com
  estado fresh.

Wire-up:
- MelissaLayout: import + render `<MelissaAlterarPlano>` com
  @goto="abrirSecao"
- 'alterar-plano' adicionado em SECOES + MELISSA_NON_CONFIG_SLUGS
- MelissaPlano.goUpgrade() agora router.push pra Melissa(secao=alterar-plano)
  em vez de /therapist/upgrade

Espelha o TherapistUpgradePage.vue (subscriptions + plans target=therapist
+ plan_prices + RPC change_subscription_plan), compativel com fluxo legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:42:45 -03:00
Leonardo 0c88cc2e72 MelissaNegocio: pagina nativa 2-col com gamificacao + logomarca
Tira "Meu Negocio" do MelissaConfiguracoes (era embed cfg-negocio
-> Negociopage.vue) e cria a /melissa/negocio nativa Melissa.

Sidebar (mng-side):
- Card "Sua presenca" — gamificacao 7 niveis (Cadastro Basico ->
  Excelencia) + barra de progresso + 7 badges (Nomeado, Categorizado,
  Regularizado, Localizado, Acessivel, Identificado, Online) +
  dicas do que falta. Badges/dicas com ancora pra sessao.
- Card "Logomarca" — preview 96x96 (object-fit: contain) + upload/
  remover (bucket 'logos', max 2MB, PNG/SVG/JPG/WebP)

Main (50/50 desktop):
- Identidade: nome_fantasia* + razao_social + tipo_empresa* (Select 8 opcoes)
- Dados Fiscais: cnpj (mask 99.999.999/9999-99) + IE + IM
- Endereco: cep (mask + ViaCEP autofill onBlur) + logradouro + numero
  + complemento + bairro + cidade + estado (Select UFs BR)
- Contato: telefone (mask) + email (placeholder=" ") + site
- Redes Sociais: array com add/remove (name + url)

Validacao: nome_fantasia + tipo_empresa obrigatorios.
URL: /melissa/negocio. Compativel com /account/negocio (mesma tabela
company_profiles + bucket logos).

Wire-up:
- MelissaLayout: import + render
- 'negocio' sai de MELISSA_CONFIG_ALIASES, entra em MELISSA_NON_CONFIG_SLUGS
- SECOES.negocio descricao atualizada
- MelissaConfiguracoes: cfg-negocio removido de COMPONENT_MAP,
  ROUTE_ALIASES e do grupo "Conta" (so resta Seguranca agora)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:38:48 -03:00
Leonardo 6395c4c0b6 MelissaPlano: pagina nativa 2-col com assinatura + recursos + historico
Tira "Meu Plano" do MelissaConfiguracoes (era embed cfg-plano ->
TherapistMeuPlanoPage.vue) e cria a /melissa/plano como pagina
nativa Melissa no padrao das outras 2-col.

Sidebar (mpl-side):
- Card "Plano atual" — nome destacado + valor + status + ciclo
  + proxima renovacao (com badge cancelamento agendado vs auto) +
  descricao do plano
- Card "Resumo" — mini-stats: Recursos / Eventos / Renova-Encerra +
  ID da assinatura
- Footer: botao "Alterar plano" (router.push /therapist/upgrade)

Main:
- Card "Seu plano inclui" — features agrupadas por modulo (a partir
  do prefix antes do . ou _ na key), cada item com check verde +
  key + descricao em 1 linha (ellipsis), grid 1-2 cols
- Card "Historico" — subscription_events com tag de tipo + before
  -> after dos plan_ids + autor (profiles join) + reason + metadata
  (max 50 eventos)

Estados:
- Loading: skeletons na sidebar + main
- Sem assinatura: empty state grande no main com CTA "Ver planos",
  empty compacto na sidebar
- Erro: toast (mesma logica do TherapistMeuPlanoPage)

Wire-up:
- MelissaLayout: import + render `<MelissaPlano v-if=secaoAberta=='plano'>`
- 'plano' sai de MELISSA_CONFIG_ALIASES, entra em MELISSA_NON_CONFIG_SLUGS
- SECOES.plano descricao atualizada (Assinatura, recursos, historico)
- MelissaConfiguracoes: cfg-plano removido de COMPONENT_MAP,
  ROUTE_ALIASES e do grupo "Conta" (continua com Negocio + Seguranca)

Logica de fetch espelhada do TherapistMeuPlanoPage (subscriptions +
plans + plan_prices + plan_features + features + subscription_events
+ profiles), compativel com a /therapist/meu-plano legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:31:50 -03:00
Leonardo 56d30b4285 MelissaPerfil: cards Sua evolucao e Avatar com bg --m-bg-medium
Espelha o pattern do MelissaPacientes (.mp-w dentro de .mp-side):
sidebar tem bg --m-bg-soft e os cards .mpr-w--side ganham bg
--m-bg-medium pra destacar com contraste sutil. Antes ambos eram
soft e os cards sumiam contra o fundo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:26:05 -03:00
Leonardo dc57caf534 MelissaPerfil: botao Salvar mostra o texto em mobile
Remove o display: none do span do .mpr-act-btn--primary em mobile.
Salvar e acao primaria importante o suficiente pra ocupar espaco
do label + icone no header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:24:52 -03:00
Leonardo 9e4421b7ff MelissaPerfil: cards mobile com altura por conteudo
Em mobile cada .mpr-w no main vira flex: 0 0 auto + height: auto +
align-self: stretch. Garante que cada card ocupa exatamente a
altura do seu conteudo sem ser comprimido nem esticado pelo flex
column do .mpr-main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:24:33 -03:00
Leonardo dad1fd72c2 MelissaPerfil: cards com chrome estilo financeiro (icon-box + sub + body)
Cada card .mpr-w agora segue o mesmo padrao visual do MelissaFinanceiro:
- Head: icon-box 36x36 com bg primary-tint + titulo + subtitulo
- Border-bottom separando head do body
- Body wrappado em .mpr-w__body com padding 14px e gap 12px
- Box-shadow elevando o card sobre o bg da sidebar/main

Subtitulos novos por card:
- Sua evolucao  : Nivel, conquistas e pendencias
- Avatar        : Foto exibida no menu e cabecalho
- Identidade    : Nome, apelido e descricao profissional
- Contato       : WhatsApp e e-mail de login
- Bio           : Apresentacao curta para o seu perfil publico
- Sites e Redes : Site, redes sociais e links customizados

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:22:22 -03:00
Leonardo f2fd2e4722 MelissaPerfil: scroll mobile + ancoras nos badges + email placeholder + 50/50 desktop
1. Mobile scroll fix: .mpr-main ganha min-height: 0 (faltava pra
   permitir shrink dentro do flex column do .mpr-body em mobile, sem
   isso o overflow-y: auto nao engatava).

2. Badges e dicas viraram <button> com @click que rola pra sessao
   correspondente do form (Identidade / Avatar / Bio / Contato /
   Redes). Em mobile o drawer fecha antes do scroll (exceto Avatar,
   que vive na propria sidebar). Cada card .mpr-w ganhou id pra
   ancora (mpr-sec-*).

3. Email readonly recebe placeholder=" " (espaco) — sem ele o
   FloatLabel variant=on ficava em cima do email enquanto o user
   nao tinha foco, pq :placeholder-shown nao aplica.

4. Desktop (>=1024px): .mpr-main vira grid 2 colunas (50/50). Os 4
   cards (Identidade, Contato, Bio, Redes) ficam lado a lado em
   pares. Internal .mpr-grid colapsa pra 1 col nesse modo, e
   .mpr-field--half passa a span 1/-1 — evita ficar cramped em
   metade da largura.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:14:49 -03:00
Leonardo abd4f8f34c MelissaPerfil: pagina nativa 2-col com gamificacao no aside
Tira "Meu Perfil" do MelissaConfiguracoes (era embed cfg-perfil ->
ProfilePage.vue) e cria a /melissa/perfil como pagina nativa Melissa
no padrao das outras 2-col (sidebar + main).

Sidebar (mpr-side):
- Card "Sua evolucao" — gamificacao: nivel atual + barra de progresso
  + XP-to-next + 7 badges (earned/locked) + dicas do que falta
- Card "Avatar" — preview + upload (5MB max) + remover
- Footer: botao "Sair da conta" (com Confirm dialog)

Main:
- Card Identidade: full_name + nickname + work_description (+ outro)
- Card Contato: phone (mask BR) + email read-only
- Card Bio: textarea
- Card Sites e Redes: site/IG/YT/FB/X + customSocials (add/remove)

O que ficou de fora (vs ProfilePage.vue):
- Aparencia (tema/cores) — vive no MelissaConfiguracoes Layout Melissa
- Layout Variant (Rail/Classic/Melissa) — irrelevante dentro do Melissa
- Trocar senha — empurrado pro cfg-seguranca
- Preferencias (idioma/timezone/notifs) — fora de escopo do MVP

Wire-up:
- MelissaLayout: import + render `<MelissaPerfil v-if=secaoAberta=='perfil'>`
- 'perfil' sai de MELISSA_CONFIG_ALIASES, entra em MELISSA_NON_CONFIG_SLUGS
- SECOES.perfil descricao atualizada
- MelissaConfiguracoes: cfg-perfil removido de COMPONENT_MAP, ROUTE_ALIASES
  e do grupo "Conta" (continua com Plano + Negocio + Seguranca)

Logica de load/save espelhada do ProfilePage (auth.user_metadata +
profiles + storage avatars), compativel com a /account/perfil legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:06:22 -03:00
Leonardo 44135a961f MelissaConfiguracoes: gap entre os panels do accordion
Adiciona display: flex + flex-direction: column + gap: 8px no
.p-accordion pra os AccordionPanels nao colarem um no outro
quando todos estao fechados.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:53:56 -03:00
Leonardo ac8308f45b MelissaConfiguracoes: desc pode quebrar em ate 2 linhas
Volta -webkit-line-clamp: 2 no .mcfg-grp-desc e
.mcfg-nav-item__desc — descricao quebra em 2 linhas se precisar
ao inves de cortar com ellipsis em 1 linha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:53:16 -03:00
Leonardo 1b5214c90b MelissaConfiguracoes: titulo + subtitulo de volta no accordion
Restaura label + desc tanto no header do grupo quanto no sub-item,
com altura confortavel (min-height 44px no item). Optimizacoes
mantidas pra evitar lag:

- Sem -webkit-line-clamp (custoso com o backdrop-filter do parent);
  desc 1 linha com text-overflow: ellipsis
- Icone inline (16-18px, sem caixa 32x32 + box-shadow transition)
- Sem transition de color-mix em hover

Hover/active continuam com cor primary (12-16%). Desc no hover/active
ganha tom misturado primary 70% pra ficar legivel sem competir com
o label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:52:46 -03:00
Leonardo a0948919ef MelissaConfiguracoes: accordion enxuto pra tirar o lag
Continua usando o <Accordion> do PrimeVue, mas DOM/CSS bem mais leves:

- Header: icone inline + label + badge (era icone 36x36 caixa
  centralizada + label + desc 2-linhas + badge)
- Sub-itens: botao denso 1-linha (icone + label, desc vai pro
  title="" como tooltip nativo)
- Removidas transitions de box-shadow e color-mix nos hovers
  (eram custosas com o backdrop-filter blur do parent)
- Removidas as line-clamp 2-linhas em cada item (~30 itens
  abertos = ~30 layouts caros)
- Hover/active dos sub-itens com color-mix(--p-primary-color),
  alinhado com o padrao do MelissaMenu (.mm-foot-item)
- Padding compactado, gap entre items 1px (era 6px)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:49:38 -03:00
Leonardo e912558769 MelissaConfiguracoes: Layout Melissa unificado em 1 pagina
Antes a sidebar tinha 4 items (Aparencia / Plano de fundo / Relogio /
Cronometro), cada um abria uma sessao separada. Agora vira 1 unico
item "Layout Melissa" com os 4 cards stackeados em uma tela so.

- grupos[0].items reduzido pra 1 entrada (key: aparencia, label:
  Layout Melissa)
- INLINE_KEYS so tem 'aparencia' agora
- DEPRECATED_ALIASES adicionado: /melissa/fundo, /melissa/relogio,
  /melissa/cronometro -> 'aparencia' (URLs antigas continuam abrindo
  a tela unificada)
- Template: 4 v-if/v-else-if -> 1 <template v-if> com os 4 .mcfg-w
  como siblings
- MelissaLayout SECOES.aparencia label: "Configuracoes do Melissa"
  -> "Layout Melissa" (icon palette)
- MelissaMenu CATEGORIAS aparencia label idem

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:44:24 -03:00
Leonardo 66441c1744 MelissaMenu: busca tambem casa pelo nome da categoria
Se o termo bate com o label da categoria (ex: "financeiro"),
inclui todos os sub-itens dessa categoria nos resultados. Antes
so casava por label do sub-item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:38:05 -03:00
Leonardo 9c6d77ec56 MelissaMenu: busca no topo do mm-side (estilo rail)
Input com pi-search a esquerda + botao limpar a direita. Quando
query tem texto, substitui a lista de categorias por uma lista
flat de sub-itens que casam (com nome da categoria a direita
como breadcrumb). Click no resultado dispara clicarSubItem (mesma
logica de navegacao) e limpa o termo. Empty state pra "nenhum
resultado".

Visual segue mm-aside: bg --m-bg-soft, border --m-border, focus
border --p-primary-color. Hover dos resultados usa color-mix
primary 12% (mesmo pattern do .mm-foot-item).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:33:58 -03:00
Leonardo 0dd070c6a5 Revert: divisor degrade entre .mm-cat
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:32:09 -03:00
Leonardo 7572cb3295 MelissaMenu: divisor com degrade entre categorias
Cada .mm-cat (exceto a ultima) ganha um ::after de 1px que sai
colado na borda esquerda e some no meio via linear-gradient
(--m-border-strong -> transparent). Da uma separacao visual
sutil sem precisar de border-bottom solido.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:30:04 -03:00
Leonardo 72f989f23c MelissaMenu: icones do .mm-aside na cor primary
.mm-sub__icon e .mm-link-row__icon agora usam --p-primary-color
em vez de --m-text-muted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:28:27 -03:00
Leonardo d8968d9aec MelissaMenu: hover/active primary nos itens do rodape
Hover e is-active dos .mm-foot-item agora usam --p-primary-color (text +
icone + bg color-mix 12-16%). Trocado "Meus Planos" -> "Meu Plano"
(singular). Cada item ganha is-active baseado em props.secaoAtiva
(perfil/plano/negocio/seguranca), Modo escuro fica is-active quando
isDarkTheme = true, Cores do Tema mantem is-active baseado em
themeViewActive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:24:53 -03:00
Leonardo 684f673cc1 MelissaMenu: switch on/off no Modo escuro
Substitui o pill "Ligado/Desligado" por um switch CSS visual (track
+ thumb que desliza). Usa --p-primary-color quando ligado. O button
mantem o toggleDarkAndPersist no click + aria-pressed pra leitor de
tela. Switch tem aria-hidden pq a semantica vive no botao.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:16:01 -03:00
Leonardo e344661d4d MelissaMenu: categoria abre alinhada com a secao ativa
Antes o menu sempre abria em "Agenda e Pacientes" (CATEGORIAS[0]).
Agora resolve qual categoria contem o sub-item secaoAtiva e abre
direto nela: ex. estando em /melissa/financeiro-lancamentos, o menu
abre ja em "Financeiro". Sem secaoAtiva (resumo), mantem default
agenda-pacientes.

Watcher em props.secaoAtiva sincroniza o destaque se o user troca de
sessao com o menu aberto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:14:28 -03:00
Leonardo 6495cefb7e MelissaFinanceiro mobile: .mf-card 300px fixo + scroll interno
Em mobile cada .mf-card tem min/max-height 300px com scroll no body
(flex: 1 + min-height: 0 + overflow-y: auto). Garante que o "Ultimos
lancamentos" fique visivel e cada card tenha altura previsivel sem
consumir toda a altura do .mf-body. Chart wrap vira flex: 1 dentro
do body de 300px - head.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:11:46 -03:00
Leonardo 0a24fd6233 MelissaFinanceiro: fix mobile - lancamentos sumindo
Em mobile reseta flex: 1 dos cards do row 50/50 (chart + projecao)
pra altura natural. O flex: 1 do desktop fazia os cards consumirem
toda a altura do .mf-body e empurrar o "Ultimos lancamentos" pra
fora do scroll visivel. Agora todos sizam por content em mobile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:07:32 -03:00
Leonardo ef4c4d0fac MelissaFinanceiro: chart + projecao 50/50, lancamentos full-width
Wrappa "Receita x Despesa" e "Projecao de Caixa" em .mf-cards-row
flex 50/50 lado a lado em desktop (alturas iguais via align-items:
stretch + body flex: 1 com scroll interno). "Ultimos lancamentos"
fica full-width abaixo.

Mobile empilha (column), chart wrap volta pra altura fixa 240px,
body sem scroll interno (.mf-body ja scrolla).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:04:59 -03:00
Leonardo d8ce33f74f MelissaRelatorios: tabela primeiro, chart depois
Inverte a ordem dentro do .mr-charts-row via flex order — "Sessoes no
periodo" (tabela) em 1o lugar, "Sessoes por mes/semana" (chart) em 2o.
Vale pra desktop (esquerda/direita) e mobile (em cima/embaixo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:58:17 -03:00
Leonardo 6d693a0a3b MelissaRelatorios: chart + tabela 50/50 + min-height mobile
Wrappa "Sessoes por mes/semana" e "Sessoes no periodo" em .mr-charts-row
flex 50/50 lado a lado em desktop. Mobile empilha (column) e a tabela
ganha min-height: 360px pra nao colapsar pra ~50px em telas curtas.
Chart wrap/skel viram flex: 1 dentro do card-chart pra acompanhar a
altura compartilhada da row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:56:32 -03:00
Leonardo bad828cab3 Melissa pages: esconde page-title-icon em mobile
No mobile o botao "Menu Lancamentos/Notificacoes/etc" ja indica a sessao,
entao o pi-list/pi-bell ao lado do contador era redundante. Adiciona
.<prefix>-page__title-icon { display: none; } no @media max-width: 1023px.
Em MelissaConversas usa > i:first-child (icone nao tem classe dedicada).

Pages: FinanceiroLancamentos, Compromissos, Documentos, CadastrosRecebidos,
Conversas, AgendamentosRecebidos, Financeiro, Grupos, Notificacoes, Tags,
Medicos, Relatorios, Recorrencias.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:54:28 -03:00
Leonardo 02af119dc6 Melissa drawers: footer colado no bottom (pattern AppMenu)
Refator do mobile drawer em todas as Melissa Pages com sidebar:
scroll move pra dentro de .xx-side__scroll (flex: 1 + min-height: 0)
e o __footer vira flex-shrink: 0 last child de flex column. Espelha
o pattern do AppMenu/layout-sidebar Rail. Substitui o sticky/margin:auto
que falhava quando o conteudo era pequeno (deixava espaco vazio sob
o "Limpar filtros").

Pages: Compromissos, Conversas, Documentos, FinanceiroLancamentos,
Grupos, Medicos, Notificacoes, Pacientes, Recorrencias, Relatorios, Tags.

Pacientes (caso especial): mp-quick fixo no topo (max-height: 50%)
+ mp-side flex: 1 com scroll/footer interno.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:30:52 -03:00
Leonardo 48bf2726a5 Drawer mobile + footer colado + Menu nomeado + tenant ensureLoaded
Tres ajustes globais nas Melissa Pages com sidebar:

1) FOOTER "Limpar filtros" colado no bottom do drawer mobile

   Problema: o sticky bottom precisa que algum container parent
   tenha altura definida e overflow. No drawer, o `.xx-side` tinha
   `height: auto` — entao o footer ficava no fluxo natural (logo
   apos os cards) mesmo com pouco conteudo, em vez de empurrado pro
   bottom do drawer.

   Fix: `.xx-mobile-drawer__scroll .xx-side` ganha
   `flex: 1; min-height: 0; display: flex; flex-direction: column`
   pra ocupar altura disponivel; o `.xx-side__footer` ganha
   `margin: auto -12px -24px` (margin-top: auto empurra pro fim).
   Sticky bottom continua pro caso de scroll com muito conteudo.

   Aplicado em: Compromissos, Grupos, Tags, Medicos, Conversas,
   Recorrencias, Pacientes (caso especial — separa .mp-side de
   .mp-quick), Cadastros Recebidos, FinanceiroLancamentos.

2) DRAWER MOBILE adicionado em Notificacoes, Documentos e
   Relatorios (estavam com sidebar virando topo via max-height
   50vh — faltava o pattern oficial das demais Melissa Pages).

   Pattern aplicado:
   - Aside host com id="<prefix>-mobile-drawer-target" + Transition
     backdrop com fade
   - Botao "Menu <Secao>" no header (esquerda do titulo)
   - <Teleport :disabled="!isMobile"> envolvendo a sidebar
   - Script: drawerOpen + isMobile + matchMedia listener registrado
     no onMounted, removido no onBeforeUnmount
   - CSS completo: .xx-mobile-drawer (fixed, transform translateX),
     __scroll (overflow + padding), __backdrop (rgba 0.45 + blur),
     overrides quando teleportada (sidebar perde bg/border-right,
     footer vira sticky bottom com margin-top auto)

3) Botao "Menu" passa a ter sufixo da pagina:
   - "Menu Lancamentos" (FinanceiroLancamentos)
   - "Menu Notificacoes" (Notificacoes)
   - "Menu Documentos" (Documentos)
   - "Menu Relatorios" (Relatorios)
   - "Menu Agendamentos" (AgendamentosRecebidos — corrigido tambem)

4) Bug de "lista vazia ao carregar via URL direto":

   FinanceiroLancamentos e Relatorios usam composables que dependem
   de tenantStore.activeTenantId. Quando aberta direto via URL
   (sem navegar pelo menu), o tenantStore pode nao estar inicializado
   ainda — entao fetchRecords() / loadSessions() retornam vazio.

   Fix: adicionar `await tenantStore.ensureLoaded()` no onMounted
   antes do fetch. Ja era pattern usado em outras Melissa Pages
   (Compromissos, etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:40:07 -03:00
Leonardo 532204708e Documentos + Templates + Relatorios nativas (so resta online-scheduling)
Promove '/melissa/documentos', '/melissa/documentos-templates' e
'/melissa/relatorios' do embed pra paginas nativas Melissa.

MelissaDocumentos (~700L):
- Sidebar com stats (Total / Tamanho / Tipos / Pendentes amber) +
  filtro Tipo (Select com TIPOS_DOCUMENTO 11 opcoes) + filtro Tag
  (Select dinamico com usedTags) + footer fixo Limpar filtros
- Main: toolbar busca + lista de DocumentCard (componente reusado)
- Modo "todos os pacientes" — patientId null. Upload/Gerar exigem
  abrir paciente especifico no prontuario (botoes nao aparecem).
- Dialogs reusados: PreviewDialog + SignatureDialog + ShareDialog +
  ConfirmDialog (delete).

MelissaDocumentosTemplates (~700L):
- Layout 1-col empilhado, 3 views: list / create / edit
- Header com botao "Novo template" (list) ou "Cancelar/Salvar"
  (create/edit) + back button
- 2 sections distintas: "Templates padrao do sistema" (info-blue,
  click duplica) e "Meus templates" (accent, click edita + menu de
  acoes Duplicar/Editar/Desativar)
- Cards em grid responsivo (auto-fill 280px), com badge "padrao"/
  "inativo" e count de variaveis
- DocumentTemplateEditor reusado pra create/edit
- ConfirmDialog reusado

MelissaRelatorios (~1100L):
- Sidebar com 6 stats (Total / Realizadas verde / Faltas red /
  Canceladas warn / Agendadas info / Taxa realizacao) + filtro
  Periodo (button list: semana/mes/3meses/6meses) + filtro Status
  (Realizadas/Faltas/Canceladas/Agendadas com cores) + footer
  Limpar filtros
- Main: card Grafico (Chart.js stacked bar agrupado por
  semana/mes) + card DataTable de sessoes filtradas (Data/Hora
  sortable / Paciente / Sessao / Modalidade / Status)
- Empty states distintos: sem sessoes no periodo / sem resultado
  do filtro

Logica preservada das paginas originais. Composables/services nao
foram tocados — apenas adaptacao do chrome pra blueprint Melissa.

DocumentsListPage / DocumentTemplatesPage / RelatoriosPage
continuam intactas no layout Rail (/therapist/*, /admin/*).

Wire-up MelissaLayout: imports + 3 render blocks + 'documentos',
'documentos-templates', 'relatorios' literais em NON_CONFIG_SLUGS;
removidos de MELISSA_EMBED_KEYS. Entries removidos do EMBED_MAP em
MelissaEmbed (resta apenas 'online-scheduling').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:14:16 -03:00
Leonardo 387043b3b2 MelissaFinanceiro + MelissaFinanceiroLancamentos nativas
Promove '/melissa/financeiro' e '/melissa/financeiro-lancamentos' do
embed pra paginas nativas, eliminando o triplo header.

MelissaFinanceiro (dashboard, ~700L):
- Layout 1-col empilhado (sem sidebar — so cards de resumo)
- Header com ícone wallet + titulo + badge mes corrente +
  botao "Ver lancamentos" + Recarregar + Voltar
- Subheader explicativo
- 4 cards empilhados:
  1. Quick stats grid (Recebido verde / Pendente amber / Vencido red /
     Despesas neutral)
  2. Card Grafico Receita x Despesa (Chart.js bar, 6 meses)
  3. Card Projecao de Caixa (cobrancas em aberto, proximos 6 meses
     com receita/despesa/saldo + count badge)
  4. Card Ultimos lancamentos (DataTable 5 mais recentes)
- Click "Ver lancamentos" / "Ver todos" navega pra
  /melissa/financeiro-lancamentos

MelissaFinanceiroLancamentos (lista, ~1100L):
- Blueprint tabular Melissa completo
- Header com botao "Lancamento manual" + Recarregar + Voltar
- Subheader
- Sidebar com __scroll + __footer fixo:
  - Stats (Pendente amber / Vencido red / Pago verde / Total)
  - Filtro Status (button list: Pendentes amber / Vencidos red /
    Pagos green / Cancelados neutral) + X inline
  - Filtro Tipo (Receita green / Despesa red) + X inline
  - Filtro Paciente (Select com filter + identification_color dot)
    + X inline
  - Filtro Periodo (DatePicker range vencimento) + X inline
  - Footer fixo "Limpar filtros" (Transition fade+collapse)
- Main: DataTable lazy + paginator com 7 colunas (Paciente +
  avatar / Sessao / Tipo / Valor + desconto / Vencimento / Status /
  Acoes). Row overdue com bg vermelho tinted.
- Acoes por status:
  - pending/overdue: botoes "Receber" (abre dialog pagamento) +
    "Cancelar" (Confirm)
  - paid: badge "metodo + data"
  - cancelled: travessao
- Mobile: sidebar vira topo (max-height 50vh)

Dialogs preservados:
- Registrar pagamento (5 metodos com icones: pix/deposito/dinheiro/
  cartao/convenio)
- Lancamento manual (Paciente opcional + Valor + Desconto + Valor
  final read-only + Data vencimento + Metodo opcional + Obs)

Logica preservada do composable useFinancialRecords + RPCs
(get_financial_summary, list_financial_records, view
v_cashflow_projection, mark_as_paid, cancel_record,
create_manual_record).

FinanceiroDashboardPage e FinanceiroPage continuam intactas no
layout Rail (/admin/financeiro, /therapist/financeiro).

Wire-up: imports + render blocks + 'financeiro' e
'financeiro-lancamentos' em NON_CONFIG_SLUGS; removidos de
MELISSA_EMBED_KEYS. Entries removidos do EMBED_MAP em MelissaEmbed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:06:18 -03:00
Leonardo f9145442ae MelissaNotificacoes nativa (mesmo design do LinkExterno)
Promove '/melissa/notificacoes' do embed pra pagina nativa Melissa,
eliminando o triplo header (layout + embed + hero sticky da
NotificationsHistoryPage interna).

Layout 2-col seguindo o mesmo blueprint Melissa:

- Header: titulo + count badge + pill amber "X nao lidas" quando
  unreadCount > 0 + botao "Marcar todas lidas" (visivel se ha unread)
  + Recarregar + Voltar.
- Subheader explicativo.
- Sidebar (~280px) com __scroll + __footer fixo:
  - Stat card (Total / Nao lidas amber / Lidas verde / Arquivadas)
  - Filter card "Status" (button list: Todas / Nao lidas / Lidas /
    Arquivadas) + X inline pra voltar pro padrao 'all'
  - Filter card "Tipo" (button list: Agendamento red / Novo paciente
    sky / Recorrencia amber / Sessao orange / Mensagem emerald —
    cores espelham typeMap) + X inline
  - Footer fixo "Limpar filtros" (Transition fade+collapse) — zera
    busca + tipo + reseta status pra 'all'
- Main: toolbar com busca por titulo/descricao + lista de
  notificacoes com row design preservado:
  - Border-left colorido por tipo (--mn-row-color via inline style)
  - Icone do tipo + avatar circular primary com iniciais
  - Body: titulo + type pill colorido + arquivada pill (se aplicavel)
    + detail (2 lines clamp) + tempo relativo
  - Hover actions: marcar lida/nao lida + arquivar/desarquivar +
    remover (com ConfirmDialog)
  - is-unread: bg primary tinted; is-archived: opacity 0.7
- Mobile (<1024px): sidebar vira topo (max-height 50vh), main fica
  abaixo, actions sempre visiveis (sem hover).

Logica preservada da NotificationsHistoryPage:
- load() do supabase ('notifications' eq owner_id, limit 500)
- markRead/markUnread/archive/unarchive/remove + sync notifStore
- markAllRead em batch
- handleRowClick: inbound_message abre conversationDrawer (paciente
  ou anonimo via from_number); outras com deeplink fazem
  router.push (e fecham a Melissa pra navegar pra rota Rail);
  todas marcam como lida automaticamente.

Wire-up MelissaLayout: import + render block + 'notificacoes'
literal em NON_CONFIG_SLUGS; removido de MELISSA_EMBED_KEYS. Entry
removido do EMBED_MAP no MelissaEmbed.

NotificationsHistoryPage continua intacta — segue funcionando no
layout Rail (/therapist/notifications, /admin/notificacoes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:53:34 -03:00
Leonardo ee084c2918 Pacientes restore unificado + MelissaLinkExterno nativa
Trabalho de continuidade pós-blueprint:

A) Botao "Restaurar" visivel direto na linha da PatientsListPage
   (layout Rail) quando paciente.status === 'Arquivado' — atalho
   pra usuarios que filtram por arquivados sem precisar abrir o
   menu de "..." (que ja tinha "Reativar" via PatientActionMenu).
   Icone pi-undo + label "Restaurar" + tooltip + click chama
   reactivatePatient do usePatientLifecycle. Aplicado tanto no
   DataTable desktop quanto nos cards mobile.

B) Consolidacao: removido restorePatient do patientsRepository
   (era duplicado com reactivatePatient do usePatientLifecycle).
   MelissaPacientes agora consome reactivatePatient direto, fonte
   unica de verdade pra toda transicao de status pra 'Ativo'.

C) MelissaLinkExterno (nova pagina nativa Melissa). Substitui o
   embed via MelissaEmbed que duplicava 3 headers (layout + embed
   + hero sticky da pagina interna). Lógica preservada (RPC
   issue_patient_invite + rotate_patient_invite_token_v2 +
   copy/openLink), so o chrome muda pra casar com o blueprint
   Melissa: 1 header com status pill (Link ativo/Gerando) +
   botao "Gerar novo link" + Recarregar + Voltar; subheader
   explicativo; body 2-col (esquerda card "Seu link publico" com
   InputGroup + 2 CTAs grandes + card "Mensagem pronta"; direita
   cards "Como funciona" + "Boas praticas"); mobile vira 1-col.

   PatientsExternalLinkPage continua intacta — segue funcionando
   no layout Rail. Wire-up no MelissaLayout: import +
   render block + 'link-externo' literal em NON_CONFIG_SLUGS;
   removido de MELISSA_EMBED_KEYS. Entry removido do EMBED_MAP
   no MelissaEmbed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:38:18 -03:00
Leonardo 97b0ec1ec5 HANDOFF + log atualizados pra sessao 2026-05-06
- HANDOFF.md reescrito refletindo estado atual: working tree limpa,
  5 commits criados na sessao, resumo do que foi feito (6 Melissa Pages
  blueprint + dialogs harmonizados + ConversationDrawer WhatsApp +
  bug fix de cores no MelissaPacientes), e o que continua pendente
  (A66 V2 design aguardando feedback + restore na PatientsListPage)
- Obsidian/Brain/log.md: entrada da sessao 05-06 anexada com detalhes
  e referencias dos 5 commits

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:18:57 -03:00
Leonardo 15103eded5 Cleanup: backups antigos removidos + dashboard config + HANDOFF/log
- Remove database-novo/backups/2026-03-27 e 2026-03-29 (deveriam estar
  no gitignore, mas haviam sido tracked antes)
- Atualiza db.config.json + generate-dashboard.cjs + dashboard.html
- HANDOFF.md atualizado com estado de 05-05 (sprint blueprint tabular +
  arquivamento de pacientes)
- Obsidian/Brain/log.md: entrada da sessao 05-05 adicionada

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:15:22 -03:00
Leonardo 98f7252dcd Melissa: 6 Pages aplicando blueprint + dialogs unificados + Conversa estilo WhatsApp
Sprint F (05-06). Blueprint tabular aplicado nas 6 paginas restantes;
dialogs harmonizados (FloatLabel + IconField + variant=filled + section
dividers, espelhando PatientsCadastroPage Identidade); ConversationDrawer
repaginado pra visual estilo WhatsApp.

Pages refatoradas (cada uma com subheader, sidebar __scroll + __footer
fixo "Limpar filtros", Xs inline pra zerar filtro individual, mobile
drawer com sticky footer):

- MelissaCompromissos: blueprint mantendo row design original (color
  stripe + name + badges + descricao + meta inline). Filtros Status
  (Ativos/Inativos) + Tipo (Nativos/Meus). Coluna Acoes frozen 140px
  com toggle+pencil+trash.

- MelissaGrupos / MelissaTags: pattern completo + dialog "Pacientes
  do grupo/tag" com lista vinculada via patient_group_patient /
  patient_patient_tag. Avatar primary nos pacientes, header colorido
  com cor da entidade, X de fechar igual .mc-close. Dialog de
  criar/editar com FloatLabel + section dividers.

- MelissaMedicos: blueprint + dialog "Pacientes encaminhados" usando
  cor primary do tema (medicos nao tem cor propria); dialog de
  criar/editar com 4 secoes (Identificacao/Contato/Localizacao/Obs)
  espelhando PatientsCadastroPage. Service ja tinha
  fetchPatientsByMedicoNome (ILIKE em encaminhado_por).

- MelissaConversas: subheader, sidebar com bg-soft + border-right e
  cards com sombra (mw-w--side), Limpar filtros global no footer fixo
  (fix bug: filters era ref({...}) e eu lia filters.search direto, agora
  usa .value), alerta de unlinked movido pro topo, kanban mobile com
  min-height nas colunas pra mostrar mensagens.

- MelissaRecorrencias: subheader, button list de status (Ativas verde/
  Encerradas vermelho/Todas) substitui SelectButton, busca por nome do
  paciente, footer Limpar filtros, X inline no filtro Status.

ConversationDrawer redesign (WhatsApp-style):
- Header com avatar circular primary + iniciais + numero formatado
- Container de mensagens com bg "papel de parede" (color-mix com bege
  esverdeado WA + radial-gradient pattern)
- Bolhas com cantos certos (top-left ou top-right zerado simulando
  tail), sombra sutil, cores autenticas (#d9fdd3 light/#005c4b dark
  outbound; #fff/#202c33 inbound), detecao dark via :global
- Time HH:MM + status overlay no canto inferior direito DENTRO do
  balao; checks azuis quando lida (#53bdeb)
- Compose pill rounded-full + botao Send circular verde #00a884
- Removido fmtDateTime obsoleto (substituido por fmtTimeOnly)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:14:35 -03:00
Leonardo 269b531158 Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore
Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).

Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
  referencia canonica MelissaCadastrosRecebidos

Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
  action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
  MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
  Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
  sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
  coloridas, email/phone colunas proprias, mobile pencil+popover, fix
  scroll mobile com min-height:0 na .mp-list, view toggle persistido,
  tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes

Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
  inconsistencia ao editar paciente arquivado)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:53 -03:00
Leonardo 6d9b36d592 A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto
Sub-sessao 1 entregue (composables):
- agendaEventHelpers (262L) — utilitarios puros (date, format, parse)
- useAgendaEventComposer (485L) — montagem do form + validacao
- useAgendaEventActions (387L) — save/delete/cancel/move actions
- useAgendaEventPickerBilling (378L) — pickers (terapeuta, servico,
  convenio) + calculo de billing
- useAgendaEventLifecycle (474L) — open/close/dirty state + autosave
- 5 specs em __tests__/ (75+76+28+43+43 = 265 testes), 495/495 passing

AgendaEventDialog: 3522 -> 2632 linhas (-25%) consumindo os composables.
Backup byte-identico em AgendaEventDialog.vue.bak pra rollback.

Sub-sessao 2 entregue (esqueleto, NAO TESTADO):
- AgendaEventDialogV2 (~1100L, 3 zonas: PACIENTE/QUANDO/O QUE)
- Preview em /preview/agenda-dialog-v2 com 5 cenarios
- Rota em routes.misc.js
- User testou e nao gostou do design — aguarda feedback especifico
  pra iteracao na sub-sessao 3 (migracao nos 9 consumers).

Dialogs auxiliares novos pro AgendaEventDialog:
- InsurancePlanQuickCreateDialog (criar convenio inline)
- ServiceQuickCreateDialog (criar tipo de sessao inline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:22 -03:00
Leonardo 957e912a7f Melissa polish + Prontuario Visao Geral + agenda historico
Sprints B (05-03) e C (05-04) acumulados:

- NotificationDrawer/Item redesign (visual mais limpo, ações inline)
- Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore)
- MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico
  card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado
- useFeriados: cache opt-in pra evitar fetch redundante de feriados
- PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish
- AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes
  de paridade com Melissa
- DocumentsListPage: pequenos ajustes
- DB migration 20260504000001: fix do trigger pra status 'excluido' nas
  cancel_notifications

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:11:55 -03:00
Leonardo 86311ef305 Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas.

- MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/
  Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed.
- MelissaEmbed: wrapper generico que injeta layout-variant=melissa
  e remove cromos pra reaproveitar Pages tradicionais.
- 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes,
  Conversas, Embed, Grupos, Medicos, Recorrencias, Tags.
- Dialog blueprint atualizado: bg-gray-100 (hardcoded light) ->
  bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em
  9 arquivos. Anti-pattern documentado.
- PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name),
  toggle vertical/abas com persist localStorage, sticky margin-top.
- Surface picker no popover do MelissaLayout (8 swatches).
- useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos.
- Migration: status agenda remarcado/confirmado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:41:19 -03:00
Leonardo 269c380d9c Wiki/grafo: graphify + wiki-brain setup compartilhado pra equipe
CLAUDE.md com 3 secoes (navegacao, regras de sessao, doc pra equipe).
Vault Obsidian/Brain/ commitado pra time editar conhecimento curado.
graphify-out/ no gitignore (regeneravel via /graphify src/).
Binarios do Obsidian.exe ignorados, so vault Brain/ vai pro repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:40:59 -03:00
Leonardo b331d68572 Test: classifySendError cobre 22 casos de erro de envio WhatsApp
Exporta classifySendError (era privada do store) pra poder testar
isolada. A funcao e' deterministica e pura, entao spec direto vale
mais que stub do supabase.functions.invoke.

Cobertura:
- 5xx downstream (502/503/504) -> banner "fora do ar" com 2 CTAs
  (Configurar + Comprar creditos), incluindo o case sem code
- http_500 explicitamente NAO cai no ramo 5xx (e' catch geral, nao
  "downstream fora") — checagem de regressao
- insufficient_credits, canal nao configurado/inativo (3 variacoes
  de string), credenciais evolution/twilio incompletas
- evolution retornou X (com e sem status 5xx — confirma precedencia
  dos ramos), twilio_send_failed_<code>
- auth (sessao expirou), forbidden (sem permissao) — ambos sem CTA
- "Edge Function returned a non-2xx" wrapper do supabase-js
- Fallback generico: code desconhecido com message custom; code+message
  vazios -> mensagem padrao
- Robustez: case-insensitive (INSUFFICIENT_CREDITS -> reconhece),
  status nao-numerico -> null em vez de NaN, codes nao-string
  (undefined/number/object) nao quebram

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:28:47 -03:00
Leonardo 76b58af9a1 Melissa: promove rota oficial + redirect automatico da home
Sai do estado "preview/sandbox" e liga o Melissa como layout real
ativavel pelo user em Configuracoes -> Profile.

Mudancas:

- routes.misc.js: /preview/melissa/:secao? -> /melissa/:secao?,
  nome PreviewMelissa -> Melissa. Sem alias por compat (autorizado).

- router/index.js: novo beforeEach apos o supportGuard e antes do
  applyGuards. Quando to.name e' therapist.dashboard ou admin.dashboard
  E localStorage.layout_variant === 'melissa' E viewport >= 1280px,
  redireciona pra { name: 'Melissa' }. Le do localStorage (gravado pelo
  bootstrapUserSettings + setVariant) pra evitar esperar store do DB e
  evitar flash do shell antes do redirect. Bypassa mobile pq Melissa
  nao foi feito pra <xl e o effectiveVariant ja forca 'classic' la.

- MelissaLayout.vue: 2 chamadas router.push apontavam pra
  'PreviewMelissa', agora 'Melissa'. Header doc atualizado.

- useMelissaPacientes.js: comment doc citando /preview/melissa
  generalizado pra "sem session retorna vazio".

- ProfilePage.vue: card Melissa perde badge "Em construcao" e ganha
  badge "Beta". Texto explicativo perde "navegacao completa ainda
  nao esta integrada" e ganha "Ao salvar, sua proxima entrada na
  home cai direto no Melissa". Link /preview/melissa -> /melissa.
  Remove regra CSS .lv-card--wip orfa.

Tradeoff aceito: rotas especificas (/therapist/agenda etc.) seguem
no shell classico/rail. So a HOME do role e' interceptada pra /melissa.
Coerente com o desenho atual do MelissaLayout, que ja abre Agenda /
Pacientes / etc. como overlays internos via deep-link /melissa/<secao>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:45:53 -03:00
Leonardo 68d601e0f4 MelissaPacientes: title="" -> v-tooltip pra alinhar com MelissaAgenda
10 botoes da pagina (header, filtros side, acoes inline do card,
fechar selecao da quick view) usavam o atributo HTML title nativo,
fora da convencao do projeto. Substitui por v-tooltip do PrimeVue
(auto-registrado via PrimeVueResolver) com posicao explicita por
contexto: bottom no header, top nas acoes, left no close da detail.

Sem mudanca funcional — apenas visual e de consistencia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:31:10 -03:00
Leonardo 629e7ce18e DB: melissa_prefs em user_settings + 'melissa' como layout_variant
Migration nova (database-novo/migrations/20260427000001_*):
- ALTER TABLE user_settings ADD COLUMN melissa_prefs jsonb DEFAULT '{}'
  NOT NULL — guarda toqueTermino, overlayOpacity, bgImageOpacity, use24h,
  cardsAtivos[] e cardsLayout. Sanitizacao no client antes do upsert.
- bgUrl (data URL da foto, MBs) NAO entra aqui — segue em localStorage
  ate migrarmos pra Supabase Storage.

Schema canonico (tenants_multi_tenant.sql) atualizado em paralelo:
- mesma coluna melissa_prefs jsonb
- check de layout_variant agora aceita 'melissa' alem de 'classic' e
  'rail' (precondicao pra plugar o tema Direcao B no preference real)

Leitura/escrita no client ainda pendente — feita em sessao separada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:12:25 -03:00
Leonardo 06bce11e1c Melissa: deep-link via URL + Pacientes (WIP) + cronometro reset
Roteamento por URL (substitui o ref local secaoAberta):
- routes.misc.js: rota vira /preview/melissa/:secao? — param opcional
- MelissaLayout.vue: secaoAberta agora e computed do route.params.secao,
  validado contra SECOES (chave invalida -> null). abrirSecao/fecharSecao
  fazem router.push em vez de mutar ref. Habilita back/forward, refresh
  e deep-link tipo /preview/melissa/agenda.

Pagina Pacientes (WIP, ainda nao wireada no slot do Layout):
- src/layout/melissa/MelissaPacientes.vue (novo, ~? linhas) — fullscreen
  3-col espelhando MelissaAgenda: aside esquerda com filtros (status /
  grupos / tags), lista central com cards + busca, quick view direita
  com KPIs do paciente selecionado + acoes.
- Carrega pacientes (todos os status), grupos/tags do tenant, vinculos
  patient_groups + patient_tags + session counts em paralelo.
- Integra PatientProntuario (overlay), PatientCadastroDialog,
  PatientCreatePopover + ComponentCadastroRapido, e
  conversationDrawerStore (acao WhatsApp da quick view).

useMelissaPacientes ganha opcao { onlyActive }:
- default true (compat com cards do resumo / cronometro / eventos hoje
  — so faz sentido com ativos)
- false retorna Ativo + Inativo + Arquivado, pra uso na pagina nova
- select agora inclui data_nascimento (necessario pros KPIs da quick view)

Cronometro: zera ao parar — terminou a sessao, fica pronto pra proxima
sem precisar reabrir o popover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:12:15 -03:00
Leonardo 7b67bd083a Melissa Agenda: breakpoint compact + drawer mobile teleportado
Dois pontos de quebra agora:
- <xl (<=1279px) "compact": view-switcher (Dia/Semana/Mes/Lista) sai da
  toolbar e entra no menu "Acoes" com check icon no ativo. Filtros
  tambem migram pra dentro pra nao inflar a barra.
- <lg (<=1023px) "mobile": .ma-side e .ma-widgets viajam pra fora do
  .ma-page via Teleport, num <aside class="ma-mobile-drawer"> sempre
  presente no DOM (v-show controla display) — garante target valido
  desde o mount. Botao "Menu" mobile-only aparece a esquerda do header.
  Backdrop entre drawer e .ma-page com Transition de fade.

Bonus styles.scss: fix borda dupla do FullCalendar.
.fc-scrollgrid em light mode mantinha borda externa que somada com a
borda das celulas da ponta dava 2px na borda do calendario. Zera o
contorno do contairner — celulas (td/th) ja desenham a grade visual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:11:56 -03:00
Leonardo dac3198873 Drawer WhatsApp: banner persistente em erros de envio
friendlySendError (string única) virou classifySendError, que devolve
{ code, status, message, hint, action, secondaryAction }. UI passa a
renderizar banner persistente no chat (não só toast efêmero) com título
+ dica explicativa + CTA contextual.

Casos cobertos:
- 502/503/504 -> "Servidor de WhatsApp fora do ar" + CTA Configurar +
  CTA Comprar créditos (caso ainda não tenha contratado)
- insufficient_credits -> CTA Comprar créditos
- canal nao configurado / inativo -> CTA Configurar agora
- credenciais evolution incompletas -> CTA Configuracoes WhatsApp
- twilio credenciais incompletas -> sem CTA (fala pra contatar suporte)
- evolution retornou ... -> CTA Ver status
- twilio_send_failed... -> CTA Configuracoes WhatsApp
- auth -> "sessao expirou", sem CTA
- forbidden -> sem CTA

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:11:44 -03:00
Leonardo a57cf27a6a Fix TDZ no autosave do cadastro externo
O watch de scheduleProgressSave referenciava form.* antes da declaração
do reactive form, violando TDZ e quebrando a página inteira no load.
Move o watch pra depois da `const form = reactive(resetForm())`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:11:31 -03:00
Leonardo ffcb8b17f9 Melissa Agenda: paridade com AgendaTerapeuta + responsivo mobile
Composable useMelissaAgenda (~1150 linhas, exclusivo Melissa):
- Orquestra useAgendaEvents + useRecurrence + useDeterminedCommitments
  + useFeriados + useCommitmentServices
- 7 cases de save (avulso, recorrente C, somente_este D, este_e_seguintes E,
  todos F, todos_sem_excecao G + tratamento de exclusion constraint)
- 3 cases de delete (somente_este, este_e_seguintes, todos com encerrar série)
- onCreateEvento (botão Agendar), onSelectTime com cap de 120min,
  persistMoveOrResize com confirm dialog descritivo e bold em datas/horas
- Bloqueio: openBloqueioDialog(mode) com 4 modos

MelissaLayout:
- Provide composable via MELISSA_AGENDA_KEY (inject em MelissaAgenda)
- Renderiza AgendaEventDialog + BloqueioDialog + ConfirmDialog
- Slot #message v-html pra renderizar HTML em messages do confirm
- onEditEvento liga panel ao dialog completo (B3 não-stub)

MelissaAgenda:
- Drop useMelissaEventosRange — eventos vêm do composable injetado
- Drag/resize/select-to-create habilitados quando há composable
- Cluster Paciente + Agendar (50/50 primary)
- Toolbar: timeMode (24/12/Meu) + onlySessions + bloquear-menu (desktop)
- Header: Pacientes (mobile-only, abre drawer) + Configurações + Fechar
- Mobile <lg: aside + widgets viram drawer off-canvas (slide esquerda);
  calendar fullwidth; "Ações" menu mobile concentra timeMode/onlySessions/
  bloquear; backdrop com click-outside

MelissaEventoPanel (B3 estático-revisado):
- Substitui panel inline que crashava em campos inexistentes
- Action bar agrupada (status / paciente / geral)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:15:56 -03:00
Leonardo ff3695fbb1 HANDOFF 2026-04-27: bug Teleport + B1 toolbar + B2 stats; B3 pendente teste
Sessão de domingo curta. Bug do chip resolvido pela manhã, polimento
da Agenda à tarde (toolbar + stats interativos), à noite extração do
MelissaEventoPanel novo (não testado em browser, fica pra amanhã).

Working tree não commitado: B3 (MelissaEventoPanel novo + handlers
no MelissaLayout + patient_id no normalize + defineExpose). Ver
seção "PENDENTE DE TESTE" no HANDOFF pra plano de validação.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:41:00 -03:00
Leonardo 6a92735366 Melissa Agenda: toolbar polish + stats interativos com filtro
B1 — Toolbar
- Cluster Hoje + chevrons num pill único (mais coeso)
- Título com flex+ellipsis (some min-width:130px que truncava feio em
  view Mês/Lista)
- Botão "Hoje" disabled visual (opacity 0.45) quando hoje cai no range
  visível — antes ficava idêntico, sem affordance
- title="" → v-tooltip.top nos chevrons (memória: tooltips PrimeVue)
- focus-visible com outline accent em todos os botões da toolbar
- Visual refinado: padding/font-weight, view-btn ativo com box-shadow

B2 — Stats interativos
- Click no stat filtra fcEvents + sessoesHoje pelo predicado correspondente
  (Total/Sessões/Realizadas/Faltas — feriados continuam sempre)
- Stat ativo ganha borda accent + bg color-mix
- Stats com value=0 ficam disabled (cursor:not-allowed, opacity 0.4)
- Click no stat ativo limpa o filtro
- Chip flutuante "Filtrando: X" no canto sup direito do FC, click limpa
- Tooltip dinâmico explicando a ação esperada

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:29:59 -03:00
Leonardo f2b15ce0f7 HANDOFF + cleanup: bug Teleport resolvido, backups antigos removidos
- HANDOFF.md atualizado: bug do chip do cronômetro resolvido em 2026-04-27.
  Causa-raiz documentada (múltiplos Teleports compartilhando target +
  Transition>Element v-if gera comment placeholder VNode → emitsOptions:null
  no shouldUpdateComponent) e fix oficial (Transition envolvendo Teleport).
- Backups locais 2026-03-23 removidos do índice (já estavam .gitignored,
  apenas saneamento).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:11:28 -03:00
Leonardo 1bcb969f72 Layout Melissa (Direção B): preview, /profile, Agenda, dock, cadastro
Sandbox completo do novo layout Win11 lockscreen-style. Não troca o
AppLayout atual — Fase 5 (router wire-up) fica pra sessão dedicada.

Estrutura
- src/layout/melissa/ — MelissaLayout (bg+ψ+overlays), MelissaCronometro,
  MelissaAgenda (fullscreen), MelissaCard, MelissaMenu, MelissaBusca
- composables/useMelissaEventos.js — semana real do FC + range mensal
  pros dots do mini-cal
- composables/useMelissaPacientes.js — agora retorna created_at p/ "novo"
- melissaToques.js — toques Web Audio do término

Rota e persistência
- /preview/melissa (sem auth, sem AppLayout)
- /account/profile ganha 3º card "Melissa" com badge "Em construção"
- bootstrapUserSettings + layout composable aceitam variant='melissa'
- Migration: CHECK constraint user_settings.layout_variant aceita 'melissa'

Light mode
- Gradiente Bloom flipa via CSS vars (--bloom-c1/c2/base-1/base-2)
  Dark: 400/300/950 · Light: 200/100/0
- Cronômetro/Personalização: color: white → var(--m-text)
- Pílula psi-kbd ganha tokens --m-kbd-bg/--m-kbd-text
- Override mapeia text-X-200/300/400 → text-X-600 (17 cores Tailwind)

Agenda fullscreen
- Mini-cal funcional: click pula FC, range visível destacado, dots reais
- Feriados nacional/municipal/personalizado (rose/amber/violet)
- Dias fechados (workRules) cinza apagado, mutex feriado vence
- Card "Hoje" (stats+sessões) mesclado e movido pra sidebar esquerda
- ProximosFeriadosCard reaproveitado entre mini-cal e Hoje
- Avatar paciente: bg --m-accent-strong → --m-accent (saturado em light)
- Cores light: 12 substituições color:white → var(--m-text)

Dock taskbar Win11-style
- .melissa-dock 76px fixed bottom (CSS global, não scoped — Vue static
  hoisting perderia data-v-{hash})
- ψ centralizado vertical na faixa (bottom:10px)
- Chip cronômetro teleportado pro dock + animação minimize macOS
  (dialog encolhe + voa pro canto bottom-left, 340ms cubic-bezier)
- transform-origin: 96px calc(100% - 38px) (posição do chip no dock)

Pacientes na sidebar
- Botão fake "+" no topo abre PatientCreatePopover (rápido/completo/link)
- Reaproveita PatientCadastroDialog + ComponentCadastroRapido
- Pacientes criados nos últimos 7d sobem pro topo + badge "novo"

Dock contextual (ações do paciente selecionado)
- Avatar + nome + count + 5 ações (sessões/whatsapp/prontuário/editar/fechar)
- Teleportado pro .melissa-dock quando há paciente selecionado
- Em mobile, ações vivem em <Menu> kebab por linha
- Pattern <Transition><Teleport v-if> obrigatório (NUNCA o contrário)
  pra evitar comment placeholder + emitsOptions:null no reconciler

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:10:53 -03:00
Leonardo ab103ec88b Fix admin adjust créditos WhatsApp: clamp silencioso vira erro vermelho
- Severidade dos toasts de validação: warn → error (não selecionar tenant,
  valor < 1, > WA_ADJUST_MAX, nada removível, excede max removível)
- Remove :max do <InputNumber> no formulário — antes ele clampeava
  silenciosamente o valor digitado pro máximo permitido, escondendo o erro.
  Agora deixa o usuário digitar e estourar o toast vermelho do submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:10:12 -03:00
Leonardo 463d71ce44 HANDOFF 2026-04-23: resumo da sessão + roteiro de testes pra amanhã 2026-04-23 22:39:03 -03:00
Leonardo f1c97ee906 Dashboard SaaS ganha seção de receita de créditos WhatsApp (Asaas)
Fecha o gap de analytics que faltava: MRR/ARR de assinatura já existia,
mas não havia visão de receita dos créditos WhatsApp comprados via Asaas.

Banco (migration 20260423000011) — 4 RPCs saas_admin only:
- saas_wa_credits_revenue_stats(from, to): total arrecadado, count de
  compras, tenants únicos, créditos vendidos, ticket médio.
- saas_wa_credits_top_packages(from, to): ranking top 10 pacotes por
  revenue, consolida nome atual se pacote foi renomeado.
- saas_wa_credits_usage_summary(): snapshot atual de lifetime_purchased
  vs lifetime_used vs current_balance + taxa de consumo.
- saas_wa_credits_revenue_evolution(from, to, bucket_days): série
  temporal pra sparkline.

Todas com check is_saas_admin() no início + SECURITY DEFINER.

Frontend:
- useSaasCreditsAnalytics composable orquestra as 4 RPCs em paralelo
  com seleção de período (30d/90d/6m/12m) que ajusta bucket_days
  automaticamente.
- SaasCreditsRevenueCard.vue: 4 KPIs (receita + ticket médio, compras +
  tenants, créditos vendidos, % consumo global), sparkline SVG com
  indicador de tendência, ranking top 5 pacotes.
- Integrado no SaasDashboard logo antes da tabela "Distribuição por plano".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:31:15 -03:00
Leonardo b8ea292ef1 Grupo 8: agenda ↔ WhatsApp completo (8.2 lembrar manual, 8.3 status→msg, 8.4 lead)
=== 8.2 Botão "Lembrar paciente" na agenda ===

Edge nova send-session-reminder-manual:
- Recebe {event_id}, autoriza (member ativo do tenant), resolve template
  lembrete_sessao (custom → default global), envia via Evolution, registra
  outbound em conversation_messages + log em session_reminder_logs com
  reminder_type='manual'.
- Reusa lógica do cron reminders (sanitização, fmt datas, render template)
  mas sem janela/dedup — terapeuta pode redisparar quantas vezes quiser
  (log usa UPSERT; UNIQUE (event_id, reminder_type) sobrescreve).

Migration 20260423000008 adiciona 'manual' ao CHECK constraint de
session_reminder_logs.reminder_type.

UI: botão verde pi-whatsapp no footer do AgendaEventDialog (só em edit
de sessão com paciente vinculado). Confirm dialog + toast + erros
amigáveis (no_phone, invalid_phone, no_active_channel, template_not_found,
forbidden, send_failed).

=== 8.3 Status sessão dispara mensagem ===

Migration 20260423000009 cria trigger AFTER UPDATE OF status em
agenda_eventos: quando status muda pra cancelado/remarcado/confirmado,
dispara edge send-session-status-notification via pg_net (não bloqueia
o UPDATE). Settings app.settings.supabase_url/service_role_key reusadas.

Edge nova send-session-status-notification:
- Body {event_id, old_status, new_status}
- STATUS_TEMPLATE_MAP: cancelado→cancelamento_sessao, remarcado→
  remarcacao_sessao, confirmado→confirmacao_sessao.
- Respeita opt-out (conversation_optouts), canal ativo, template
  existente (tenant-specific → global default). Skip silencioso em
  caso de falta de config.
- Insere outbound em conversation_messages (sem log unique — múltiplas
  mudanças de status geram múltiplas mensagens por design).

=== 8.4 Intake abandonado vira lead no CRM ===

Migration 20260423000010:
- Adiciona 'in_progress' e 'abandoned_lead' ao CHECK de
  patient_intake_requests.status. Colunas last_progress_at e
  lead_thread_key.
- RPC convert_abandoned_intake_to_lead(intake_id): cria mensagem
  placeholder inbound no CRM do tenant (thread_key anon:{phone}) +
  conversation_notes com resumo dos dados coletados + marca status.

Edge save-intake-progress:
- POST {token, nome_completo?, telefone?, email_principal?, ...}
- Whitelist de campos (ALLOWED_FIELDS) pra proteger contra POST
  malicioso tentar setar status/owner/etc.
- Busca por token, set status='in_progress' se era 'new', atualiza
  campos enviados + last_progress_at.

Edge convert-abandoned-intakes (cron):
- Body opcional {idle_minutes} (default 30).
- Varre patient_intake_requests status='in_progress' + last_progress_at
  mais antigo que cutoff. Filtra só os com nome_completo OU telefone
  (contato mínimo pra valer lead). Chama RPC pra cada um.

Hook no form público CadastroPacienteExterno:
- Watch em nome_completo, telefone, email_principal, onde_nos_conheceu
  dispara scheduleProgressSave() com debounce 1.5s.
- savePartialProgress só chama a edge se tem nome OU telefone.
- Silent fail — autosave não é crítico.

Cron do convert-abandoned-intakes NÃO ativado automaticamente (igual
heartbeat/SLA). Template comentado não está na migration — admin
descomenta SELECT cron.schedule manualmente quando quiser ligar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:25:33 -03:00
Leonardo c2c42a1620 3.7 Bot auto-triagem WhatsApp: config por tenant + hook nos inbound
Bot que coleta nome, motivo de busca e preferências ANTES do paciente
entrar no fluxo humano. Terapeuta abre a conversa e já encontra
resumo em conversation_notes.

Banco (migration 20260423000007):
- conversation_bots: config 1 por tenant. enabled, greeting/closing
  messages, steps (JSONB array de {prompt, variable, type}), trigger_mode
  (new_contact | all_unassigned | keyword), trigger_keywords[],
  idle_timeout_minutes, respect_optout.
  Defaults vêm com 4 perguntas úteis: nome, motivo, modalidade,
  horário preferido.
- conversation_bot_sessions: estado por thread. current_step,
  collected_data JSONB, status (active | completed | abandoned_idle |
  abandoned_manual | opted_out). UNIQUE parcial garante 1 ativa por
  (tenant, thread).
- RLS: leitura tenant/saas_admin, escrita admins (config) + service_role
  (sessions, só edge altera).

Shared (_shared/whatsapp-hooks.ts):
- maybeProcessBot: carrega config, busca sessão ativa, avança step
  com resposta, envia próxima pergunta via SendFn. Ao esgotar steps,
  envia closing + cria conversation_notes com resumo das variáveis
  coletadas. Se humano assume (conversation_assignments preenchido),
  sessão marca 'abandoned_manual' e bot sai.
- Trigger modes:
  - 'new_contact' (default): só inicia pra thread sem histórico bot
    E sem paciente vinculado (lead real).
  - 'all_unassigned': qualquer thread sem assignee.
  - 'keyword': matched contra lista; normalizeForMatch já existe.

Integração nos inbound (ambos providers):
- evolution-whatsapp-inbound: chama maybeProcessBot após opt-in/opt-out,
  ANTES do auto-reply. Se bot processou, skip auto-reply (senão duas
  respostas sobrepostas).
- twilio-whatsapp-inbound: idem, usando makeTwilioCreditedSendFn pra
  cobrar crédito de cada mensagem enviada pelo bot.

UI (/configuracoes/conversas-bots):
- Toggle enabled + Select trigger_mode + (se keyword) chips de keywords.
- Textareas greeting/closing.
- Editor de steps: reordenar (up/down), remover, add, editor com prompt
  e variable (regex /^[a-z_][a-z0-9_]*$/).
- Botão "Padrão" restaura mensagens/steps default.
- InputNumber idle_timeout + toggle respect_optout.
- Card inferior: últimas 30 sessões (7 dias) com status, contato,
  nome coletado (primeiro campo), progresso (step X/N), início.
- Entrada na landing de configurações + rota /configuracoes/conversas-bots.

Caveat conhecido: a resolução de conversation_notes.created_by usa
o primeiro admin ativo do tenant (pickAnyAdmin). Pra uma v2 seria
ideal ter um user "bot" sintético dedicado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:54:53 -03:00
Leonardo 4e4bac622c 6.3 Reconnect automático Evolution antes de abrir incident
Fluxo novo no heartbeat-check quando threshold vence:
1. Verifica se reconnect está habilitado (metadata.heartbeat_reconnect_
   enabled, default true) E se API respondeu (sem fetchError) E se
   passou do cooldown de 10min desde a última tentativa.
2. POST /instance/restart/{instance} na Evolution.
3. Aguarda 3s pra estabilizar + rechecka connectionState.
4. Se state voltou pra 'open': restaura connected + limpa
   first_unhealthy_at + incrementa heartbeat_reconnect_count + resolve
   qualquer incident aberto. Retorna action='auto_reconnected'.
5. Senão: atualiza heartbeat_reconnect_last_at (respeita cooldown) e
   abre incident normalmente com details.reconnect_attempted=true.

Anti-loop: 1 tentativa por ciclo (não retry), cooldown de 10min/channel
pra não martelar Evolution nem gerar restart infinito. Tentativas são
contadas em metadata.heartbeat_reconnect_count (auditoria futura).

UI em /configuracoes/whatsapp-pessoal ganha novo toggle no card de
Monitoramento: "Tentar reconectar automaticamente" (default ligado)
com explicação clara. Tenant pode desligar se preferir ser alertado
imediato sem tentativa.

Summary do endpoint agora inclui auto_reconnected count — útil pra
métricas de confiabilidade da Evolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:19:52 -03:00
Leonardo 0f643817c2 Fix send-session-reminders comparava provider='evolution' mas DB guarda 'evolution_api'
Na migração do schema (20260421000007 / whatsapp_credits) o CHECK da
coluna provider de notification_channels passou a aceitar apenas
'evolution_api' (com sufixo). O send-session-reminders continuou
comparando pelo nome antigo, sempre caindo em skip 'unknown_provider'.

Fix mapeia providerKind = 'evolution' quando channel.provider é
'evolution_api' ou 'evolution' — backward compat. Aplicado também no
branch do twilio (sem mudança, só renomeia a variável).

Validado end-to-end: lembrete 2h disparado pro paciente André Green
chegou no WhatsApp do terapeuta (provider_message_id persistido em
session_reminder_logs + outbound em conversation_messages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:15:37 -03:00
Leonardo adf9208d2d Analytics 7.1: tempo médio de 1ª resposta WhatsApp no dashboard
Card novo pra clínica e terapeuta com 3 métricas + sparkline:
- Tempo médio (e mediana) de 1ª resposta no período
- Taxa de SLA cumprido — % de respostas dentro do threshold configurado
- Contagem total de respostas no período
- Sparkline da evolução com indicador de tendência (melhorando/piorando)
- Ranking top 5 terapeutas (só no ClinicDashboard)

Filtro de período: 7/30/90 dias (muda granularidade do bucket:
1/7/15 dias pra sparkline com ~5-6 pontos).

Banco (migration 20260423000006):
- Helper interno _first_response_runs: identifica "runs" de inbound
  (sequências do paciente sem outbound entre) e calcula delta até a
  próxima outbound. Evita contar múltiplas mensagens repetidas do
  paciente. responder_id vem de conversation_assignments.
- first_response_stats: agregados (count, avg, median, min, max,
  sla_compliance_rate baseado em conversation_sla_rules).
- first_response_by_therapist: ranking com avg e count por assigned_to.
- first_response_evolution: série temporal com bucket alinhado a
  p_from (p_from + bucket_index * N days). Parâmetro p_bucket_days
  deixa o frontend escolher granularidade por período.

Todas SECURITY DEFINER + GRANT authenticated/service_role. Filtro
opcional por therapist_id nas funções que aplicam.

Frontend:
- useFirstResponseAnalytics composable wraps as 3 RPCs com cache
  via Promise.all paralelo. Helper formatSeconds (Ns/Xmin/Xh).
- FirstResponseCard.vue renderiza sparkline SVG nativo
  (sem lib extra), cor da taxa SLA por threshold (verde ≥80%,
  âmbar ≥50%, vermelho).
- Integrado em ClinicDashboard (visão global) e TherapistDashboard
  (filtrado por ownerId, sem ranking).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:55 -03:00
Leonardo 36fbc02e9f Browser notification: click leva pro destino real (drawer ou rota)
Bug: onclick da Notification do browser (nativa do Chrome/Windows)
fazia window.location.pathname = payload.deeplink direto, sem resolver
alias semântico e sem abrir o drawer em alertas com thread_key. Como
praticamente todos os nossos alertas do SLA vêm com deeplink '/conversas'
(alias), o click na notificação do Chrome caía em NotFound.

Fix:
- fireBrowserNotification agora aceita um callback onClick e é exportada.
- Removido o fireBrowserNotification hardcoded do subscribeRealtime do
  store (passa a ser responsabilidade do composable useNotifications).
- useNotifications.onRealtimeNotification dispara toast + browser notif
  passando handleNotificationAction como handler.
- handleNotificationAction: se tem thread_key → abre ConversationDrawer
  global direto na thread; senão resolve alias e router.push. Mesma
  lógica que já existe no toast e no clique do NotificationItem do sino.

Agora os 3 pontos de click (toast, sininho, notificação nativa do OS)
convergem pro mesmo comportamento.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:43:29 -03:00
Leonardo 64e76343fc NotificationItem: resolve alias + botões inline "Conversa"/"Abrir"
3 melhorias no item de notificação do sininho:

1. handleRowClick: agora resolve alias (/conversas → /therapist|admin/
   conversas) baseado em tenantStore.activeRole. Antes caía em NotFound
   quando o deeplink era /conversas ou /crm/conversas.

2. Se payload tem thread_key (alertas do SLA), o clique abre o drawer
   global diretamente na thread em vez de navegar — experiência similar
   à do botão do toast. Fallback pra deeplink se a thread sumiu.

3. typeMap ganha entrada 'system_alert' (ícone pi-exclamation-circle,
   borda vermelha).

4. Botões inline "Conversa" e "Abrir" aparecem embaixo do detail quando
   o payload tem thread_key ou deeplink — atalhos pras ações mais
   comuns sem precisar clicar na área do item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:38:22 -03:00
Leonardo f646efe522 Toast SLA: botão "Abrir conversa" abre drawer direto da thread
O alerta já vem com payload.thread_key vindo do edge conversation-sla-
check. Agora o toast renderiza 2 botões lado a lado quando thread_key
existe:
- "Abrir conversa" (outlined) → abre ConversationDrawer global direto
  na thread, sem navegar de página. Usa o store global que já existe.
- "Abrir CRM →" (solid) → fallback pra lista inteira via deeplink alias.

openConversationDrawer busca o row da view conversation_threads pelo
tenant+thread_key e delega pro conversationDrawerStore.openForThread.
Se a thread sumiu (arquivada/paciente deletado), cai no fallback de
navegar pra /conversas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:33:38 -03:00
Leonardo 5f51bc068e Fix deeplink /crm/conversas não existe; alias dinâmico por role
Bug: toast do SLA tinha deeplink /crm/conversas que caía em NotFound.
As rotas reais são /therapist/conversas (terapeuta) e /admin/conversas
(clinic_admin), contextuais por role.

Fix: novo sistema de aliases em AppLayout.resolveDeeplink.
DEEPLINK_ALIASES traduz links semânticos (ex: /conversas, /crm/conversas)
pra rota real baseado em tenantStore.activeRole. Edge do SLA agora
emite /conversas (alias) em vez de path hardcoded; frontend resolve.

Padrão aplicável pras próximas features — basta registrar novo alias
aqui quando a rota depender de contexto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:22:07 -03:00
Leonardo 4026415401 Notifications: não redispara toast pra system_alert antigas após F5
Bug: a cada mount (F5, navegação), todas as system_alert não-lidas
voltavam a disparar toast mesmo que o alerta já não fizesse mais
sentido (ex: saldo baixo já restabelecido, mas notif histórica ainda
não-lida reaparecia como toast sticky vermelho a cada reload).

Fix: seed do set alertedIds marca TODAS as system_alert do load inicial
como "já vistas nesta sessão". Alertas continuam no sino/drawer — o
usuário vê que tem pendências, mas sem bombardeio de toasts repetidos.
Toast só dispara pra alertas que chegarem depois do mount — seja via
Realtime (novidade) ou via catch-up encontrando id ainda não no set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:21:12 -03:00
Leonardo 771b636cee SLA de conversas WhatsApp (Grupo 3.4): config + detecção + alerta
Completa o Grupo 3 do CRM com alerta de conversa sem resposta além
do tempo configurado — reutiliza o pipeline system_alert (toast
vermelho sticky + sininho + drawer).

Banco (migration 20260423000005):
- conversation_sla_rules: 1 linha por tenant com threshold global
  (1-1440 min), respect_business_hours, business_hours_start/end,
  business_days (ISO 1=seg..7=dom), alert_scope (assigned_only|all),
  notify_admin_on_breach. Default: enabled=false.
- conversation_sla_breaches: incidents com UNIQUE parcial
  (tenant_id, thread_key) WHERE resolved_at IS NULL — idempotência.
- Trigger AFTER INSERT em conversation_messages resolve o breach
  automaticamente quando chega nova outbound na thread.
- RPCs service_role: sla_open_breach (idempotente), sla_mark_notified.
- RLS: membros do tenant leem; clinic_admin/tenant_admin/saas_admin
  escrevem na config; service_role escreve em breaches.

Edge function conversation-sla-check (cron 5min):
- Varre tenants com enabled=true.
- Query conversation_threads onde last_message_direction='inbound'
  (+ assigned_to NOT NULL se scope='assigned_only').
- Se respect_business_hours: calcula businessMinutesElapsed em TS
  iterando dia por dia a interseção da janela [start,end] com
  [last_inbound_at, now], só em dias marcados em business_days. TZ
  fixa em America/Sao_Paulo via Intl.DateTimeFormat.
- Se elapsed >= threshold: sla_open_breach (idempotente) + notifica
  assigned_to sempre + admins se notify_admin_on_breach (deduplicado
  via Set).
- Anti-spam: só notifica 1x por incident (checa notified_at).
- Notification leva deeplink pra /crm/conversas e payload.thread_key
  pro frontend destacar a conversa (fora de escopo deste commit).

UI em /configuracoes/conversas-sla:
- Toggle enabled + InputNumber threshold com preview "≈ Xh Ymin".
- Toggle respect_business_hours → revela start/end + seletor de dias
  úteis (pills toggleáveis Seg..Dom, ISO order).
- Select scope.
- Toggle notify_admin_on_breach.
- Card abaixo com breaches dos últimos 7 dias (status aberto/resolvido,
  thread_key, limite configurado no momento do breach, duração).
- Adicionada na ConfiguracoesPage landing + rota /configuracoes/conversas-sla.

Cron template comentado no fim da migration (mesmo padrão do heartbeat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:17:41 -03:00
Leonardo 4441661f62 Toast system_alert: agregar no catch-up pra não empilhar enxurrada
Bug: acumulando N system_alert não-lidas, o refreshAndMaybeAlert
(mount / visibilitychange / polling 60s) disparava N toasts de uma vez.
Comum após recarregar a página com alertas pendentes do último teste.

Fix: no catch-up, mostra só a notif mais recente, com sufixo "+N
outros alertas no sino" no detail se houver múltiplas. As demais são
marcadas no alertedIds pra não redisparar — continuam visíveis no
sininho/drawer com badge.

Eventos novos via Realtime seguem aparecendo individualmente (fluxo
normal — o usuário está online vendo chegar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:03:24 -03:00
Leonardo 6db06abfc2 Toast system_alert ganha botão de ação com deeplink
Novo <Toast group="system-alerts"> no AppLayout com template custom
(vive no bloco global — persiste em qualquer layout/rota). Renderiza:
- Ícone de alerta + título em bold
- Detail em texto menor com opacity
- Botão com deeplink quando payload.deeplink existe, severity danger

Label do botão inferido do deeplink:
- /configuracoes/creditos-whatsapp → "Ir pra loja"
- /configuracoes/whatsapp-pessoal  → "Ver conexão"
- /configuracoes/whatsapp-oficial  → "Ver canal oficial"
- outros → "Abrir" (ou payload.actionLabel se vier explícito)

Clique navega via router.push se é path interno, senão
window.location.href. Toast continua sticky (24h) + closable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:00:18 -03:00
Leonardo 5c50db6704 Notifications: fallback de polling + catch-up ao focar a aba
Realtime em ambiente self-hosted às vezes perde eventos (WebSocket
desconecta silenciosamente, JWT expira, sleep do SO, etc). Sem fallback,
system_alert chega no DB mas toast nunca dispara — usuário só vê ao
relogar ou recarregar.

Três caminhos complementares agora:
1. Realtime (instantâneo, quando funciona)
2. visibilitychange — ao voltar pro foco da aba, recarrega notificações
   e dispara toast pras system_alert não-lidas ainda não exibidas
3. Polling a cada 60s como redundância

Set alertedIds (in-memory por sessão) evita toast duplicado quando dois
caminhos entregam a mesma notif. Seed inicial marca notifs já lidas/
arquivadas no mount pra não disparar retroativamente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:54:48 -03:00
Leonardo e409ba64ef Saldo baixo WhatsApp: trigger dispara notificação ao cruzar threshold
Fecha o loop do Marco B — tenant não zera mais saldo sem aviso.

Nova função fn_whatsapp_low_balance_notify + trigger BEFORE UPDATE em
whatsapp_credits_balance:
- Dispara quando NEW.balance < NEW.low_balance_threshold e
  NEW.low_balance_alerted_at IS NULL
- Insere system_alert pros stakeholders do tenant (owner do canal
  WhatsApp ativo + clinic_admin + tenant_admin, deduplicado via UNION)
- Deeplink direto pra /configuracoes/creditos-whatsapp
- Seta NEW.low_balance_alerted_at = now() pra anti-spam

Reset do anti-spam já existia: add_whatsapp_credits seta
low_balance_alerted_at=NULL ao creditar (purchase/topup/refund).
Assim o ciclo completo funciona: cai abaixo → alerta → compra recrédita
→ cai de novo futuramente → alerta de novo.

Toast no frontend já é sticky vermelho pra type='system_alert'
(commit anterior). Config de threshold já existia na UI em
/configuracoes/creditos-whatsapp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:25:03 -03:00
Leonardo 881fa16c27 Fluxo de reativação de canal WhatsApp + alerta toast sticky + notify owner
Cadeia de fixes descoberta ao testar o heartbeat 6.1 num tenant que migrou
de Evolution → Twilio e precisava voltar pro Evolution.

1. RLS notification_channels (migration 20260423000003)
   - Policy antiga tinha `deleted_at IS NULL` como primeira condição AND,
     bloqueando leitura de soft-deleted até pro próprio owner/saas_admin.
   - Isso fazia o chooser nunca detectar "canal antigo pra reativar".
   - Relaxada: owner/membro/saas_admin leem inclusive soft-deleted.
   - Filtro de deleted_at fica no código aplicativo (todos os queries já
     filtram explicitamente quando querem apenas ativos).

2. Edge function reactivate-notification-channel (nova)
   - Espelho da deactivate existente; service_role bypass RLS.
   - Aceita {channel_id} OU {tenant_id + provider}.
   - Autoriza saas_admin OU membro ativo do tenant.
   - Garante exclusividade: soft-deleta qualquer OUTRO canal ativo do
     mesmo tenant+channel.
   - Reseta metadata.first_unhealthy_at + connection_status=disconnected
     (heartbeat começa do zero).

3. SaasWhatsappPage (/saas/whatsapp)
   - loadChannel busca soft-deleted como fallback quando não tem ativo.
   - saveCredentials detecta soft-deleted e chama reactivate edge,
     depois atualiza credentials+display_name.
   - Banner âmbar "Canal configurado anteriormente" + botão vira
     "Reativar e salvar".

4. ConfiguracoesWhatsappPage tenant (/configuracoes/whatsapp-pessoal)
   - loadCredentials busca soft-deleted como fallback.
   - Card âmbar "WhatsApp Pessoal foi usado anteriormente" com botão
     "Reativar WhatsApp Pessoal" em vez de mostrar apenas "chame o suporte".

5. ChooserPage (/configuracoes/whatsapp)
   - Fix bug lateral: comparava activeProvider === 'evolution' (template)
     com 'evolution_api' (DB) — card nunca mostrava estado ativo. Agora
     normaliza via computed activeProviderKey.
   - softDeletedByProvider map carregado no mount; cards que têm row
     soft-deleted mostram "Reativar" em vez de "Ativar".
   - handleChoose chama reactivate edge antes de goSetup se detecta
     soft-deleted do provider escolhido.

6. whatsapp-heartbeat-check: notifica owner do channel + admins
   - notifyChannelStakeholders substitui notifyTenantAdmins.
   - Set dedupa o owner_id do channel + clinic_admin + tenant_admin.
   - Em tenant solo: 1 notificação; em clínica com canal de terapeuta
     específico: terapeuta (owner) + admin recebem; em clínica com canal
     do próprio admin: 1 (owner=admin).

7. Toast frontend para system_alert
   - notificationStore.subscribeRealtime aceita callback onInsert.
   - useNotifications registra callback que dispara toast PrimeVue
     (severity error, life 24h, closable) para type='system_alert'.
   - Usuário precisa fechar manualmente — alerta crítico de infra
     não pode sumir sozinho.

Cron heartbeat ativado em runtime local via cron.schedule()
(não vai neste commit — é config de ambiente, não migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:21:29 -03:00
Leonardo e1f756ea82 Heartbeat WhatsApp Evolution (Grupo 6.1): detecção + incident + alerta admin
Detecta celular desconectado antes de falhar envios silenciosamente.

Banco (migration 20260423000002):
- Tabela whatsapp_connection_incidents (tenant_id, channel_id, kind,
  started_at, resolved_at, duration_seconds, notified_at, details).
  UNIQUE parcial garante no máximo 1 incident aberto por channel.
- RPCs whatsapp_heartbeat_open_incident (idempotente), _resolve_open_incidents
  e _mark_notified. Service_role only.
- RLS: membros do tenant leem, saas_admin tudo.
- ALTER notifications.type pra aceitar 'system_alert' (usado pelo alerta).

Edge function whatsapp-heartbeat-check:
- Varre notification_channels provider=evolution_api e ativos.
- GET {api_url}/instance/connectionState/{instance} (timeout 8s, rewrite
  localhost → host.docker.internal pra containers).
- Mapeia state pra connection_status (open/connecting/qr_pending/
  disconnected/error), persiste + last_health_check.
- Lógica de threshold: marca first_unhealthy_at em metadata na primeira
  falha; só abre incident após heartbeat_threshold_minutes (default 5).
- Notifica admins ativos (clinic_admin/tenant_admin) do tenant via
  insert em notifications. Anti-spam: só notifica 1x por incident.
- Aceita ?channel_id=X pra check on-demand de um tenant específico.

UI tenant (/configuracoes/whatsapp-pessoal):
- Novo card "Monitoramento de conexão" com toggle alerts_enabled +
  InputNumber threshold (1-60 min). Persiste em
  notification_channels.metadata.
- Histórico últimos 7 dias: kind (tag colorida), aberto/resolvido,
  início → fim, duração formatada (Ns/Xmin Ys/Nh Xmin).

UI SaaS (/saas/whatsapp):
- Badge "N incidents abertos" no header quando há algum.
- Botão "Verificar tudo agora" invoca a edge function e atualiza a lista.
- Tabela enriquecida: coluna Status ganha pill "Incident aberto",
  colunas novas Última check e Incidents 7d (em laranja se > 0).

Cron template no final da migration (comentado — descomentar
cron.schedule pra ativar 2min periódico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:49:09 -03:00
Leonardo f76a2e3033 Admin SaaS: ajuste manual de créditos WhatsApp (+/-) com proteção de compras
Fecha polimento do Marco B (créditos/Asaas) entregue em 21/04.

Nova RPC admin_adjust_whatsapp_credits(tenant, amount_signed, admin_id, note):
- |amount| <= 1000 por operação (anti dedo-gordo). Valores maiores → repetir.
- Em remoção (amount < 0), aplica regra FIFO cortesia primeiro:
  removable = max(0, sum(topup_manual+adjustment+refund) - usage_total).
  Créditos de 'purchase' (Asaas/PIX) são intocáveis — estorno real vai pelo
  fluxo financeiro do Asaas.
- Protegida por is_saas_admin() — authenticated comum não consegue chamar.
- Registra como kind='adjustment' com amount signed (+ ou -).

Helper get_whatsapp_removable_balance(tenant) retorna {balance, removable,
protected_amount, topup_net, usage_total} pra UI mostrar breakdown.

Aba 4 (Pacotes WhatsApp):
- Desativação dispara ConfirmDialog com histórico (N compras, M tenants
  distintos) + aviso forte se é o único pacote ativo + nota que créditos já
  adquiridos continuam válidos.
- Fix visual: :key no ToggleSwitch força re-mount durante confirm pra não
  desligar visualmente antes do accept.

Aba 5 (Topup → Ajuste):
- Substituído Select de kind por SelectButton Adicionar/Remover.
- InputNumber max 1000 · label e botão dinâmicos.
- Modo Remover: card laranja com breakdown removível/protegido, botão
  vermelho, confirm obrigatório com saldo resultante.
- Error mapping friendly pt-BR pros códigos da RPC.

ConfirmDialog com v-html habilitado pra suportar <br><br> entre frases
e <strong>/cores. Inputs livres (row.name, tenantName) passam por
escapeHtml() antes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:39:41 -03:00
Leonardo 2644e60bb6 CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════

3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.

3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).

3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).

3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.

3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".

═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════

- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
  Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
  1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
  whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
  reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
  envolve envio em dedução atômica + rollback). Consumido por Evolution E
  Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
  MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand

═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════

Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
  transactions, packages, purchases) + RPCs add_whatsapp_credits e
  deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
  de 11 ou 14 dígitos)

Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
  QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
  quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance

Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
  utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
  persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
  error.context.json()

Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
  - Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
    toggle is_active inline, dialog de edição com validação
  - Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
    add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
    das últimas 20 transações topup/adjustment/refund

═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════

2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)

2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha

═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════

5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords

═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════

- contact_types + contact_phones (entity_type + entity_id) — migration
  20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
  remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
  unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
  legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
  e cria/atualiza phone como WhatsApp vinculado

═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════

- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
  .dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
  (fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
  error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
  Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
  sino + popup + browser notification)

═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════

20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj

═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════

Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound

═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════

Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits

Stores: conversationDrawerStore

Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor

Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)

Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados

═══════════════════════════════════════════════════════════════════════════
NOTAS

- Após subir, rodar supabase functions serve --no-verify-jwt
  --env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
  providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:05:24 -03:00
Leonardo 037ba3721f HANDOFF.md atualizado para Sessoes 1-10 + proxima sessao A#31-rev
Documento "ler primeiro ao voltar" reflete estado atual: 15 areas auditadas,
zero critico/alto aberto, A#31 reformulada como "Preparacao pra deploy"
(MVP nao tem cloud Supabase nem secrets reais).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:02:48 -03:00
Leonardo d6eb992f71 Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS
Continuacao de 7c20b51. Esta etapa fechou TODA revisao senior do SaaS
(15 areas auditadas) + refator parcial de pacientes.

Ver commit.md para descricao completa por sessao.

# Estado final do projeto
- A# auditoria abertos: 1 (A#31 Deploy real)
- V# verificacoes abertos: 14 (todos medios/baixos adiados com plano)
- Criticos: 0
- Altos: 0
- Vitest: 208/208 (era 192, +16 nos novos composables)
- SQL integration: 33/33
- E2E (Playwright): 5/5
- Areas auditadas: 15

# Highlights
- Documentos 100% fechado (V#50/51/52: portal-paciente policy + content_sha256 + 4 cron jobs retention)
- Tenants V#1 P0: tenant_invites com RLS off + 0 policies (mesmo padrao A#30)
- Calendario 100% fechado: feriados WITH CHECK
- Addons V#1 P0 (dinheiro): addon_transactions WITH CHECK saas_admin
- Central SaaS V#1: faq write so saas_admin (era tenant_admin)
- Servicos/Prontuarios 100% fechado: services/medicos/insurance_plans + cascades
- Pacientes V#9: 2 composables novos (useCep, usePatientSupportContacts) + repo estendido + script extraido (template intocado, fica para quando houver E2E)

# 8 migrations novas neste commit
- 20260419000011_documents_portal_patient_policy.sql
- 20260419000012_documents_content_hash.sql
- 20260419000013_cron_retention_jobs.sql
- 20260419000014_financial_security_hardening.sql
- 20260419000015_communication_security_hardening.sql
- 20260419000016_tenants_calendario_hardening.sql
- 20260419000017_addons_central_saas_hardening.sql
- 20260419000018_servicos_prontuarios_hardening.sql

Total acumulado: 18 migrations (Sessoes 1-10).

# A#31 reformulado pra proxima sessao
"Deploy real" muda escopo: como nao ha cloud Supabase nem secrets reais
ainda (MVP), proxima sessao vira "Preparacao completa pra deploy" (DEPLOY.md,
validar migrations num container limpo, audit edge functions, listar env vars,
script db.cjs deploy-check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:00:06 -03:00
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00
Leonardo d088a89fb7 Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização 2026-03-30 14:08:19 -03:00
Leonardo 0658e2e9bf Adicionada compressão Brotli/Gzip, auto-import de Vue e PrimeVue, e análise visual do bundle para otimização de produção e Remove AppLayout duplicado de cada área (therapist, admin, configuracoes, account, supervisor, billing, features) e consolida sob um único pai no router/index.js. Adiciona RouterPassthrough para grupos de rota sem layout intermediário. Remove debug ativo (console.trace em router.push e queries Supabase em todo watch de rota) que degradava performance para todos os usuários. 2026-03-25 12:14:43 -03:00
Leonardo bfe148ef12 safe point before auto-import cleanup 2026-03-25 09:11:05 -03:00
Leonardo 3f1786c9bf + Menu Hover no Layout Rail, Twilio, Sms, Email, Templates, LNovo Layout Configurações 2026-03-25 08:39:45 -03:00
Leonardo 53a4980396 Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras 2026-03-24 21:26:58 -03:00
Leonardo a89d1f5560 Copyright, Financeiro, Lançamentos, aprimoramentos de ui 2026-03-21 08:05:40 -03:00
Leonardo 29ed349cf2 Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa. 2026-03-18 15:47:37 -03:00
Leonardo d6d2fe29d1 carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline 2026-03-18 09:26:09 -03:00
Leonardo 66f67cd40f Layout 100%, Notificações, SetupWizard 2026-03-17 21:08:14 -03:00
Leonardo 84d65e49c0 Sistema de Suporte , Documentação 2026-03-16 09:41:18 -03:00
Leonardo f66f6f3fde Ajuste Layout, Dashboard Terapeuta, Timeline, Suporte técnico, Documentação e FAQ 2026-03-15 19:46:06 -03:00
Leonardo ee09b30987 Setup Wizard 2026-03-14 19:09:44 -03:00
Leonardo 587079e414 Ajuste Convenios e Particular 2026-03-13 21:09:34 -03:00
Leonardo 06fb369beb Preficicação, Convenio, Ajustes Agenda, Configurações Excessões 2026-03-13 16:03:08 -03:00
Leonardo f4b185ae17 Agenda, Agendador, Configurações 2026-03-12 08:58:36 -03:00
Leonardo f733db8436 ZERADO 2026-03-06 06:37:13 -03:00
Leonardo d58dc21297 Ajuste rotas, Menus, Layout, Permissãoes UserRoleGuard 2026-02-24 12:04:59 -03:00
Leonardo b1c0cb47c0 Ajuste usuarios - Inicio agenda 2026-02-23 18:57:40 -03:00
Leonardo 89b4ecaba1 Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda 2026-02-22 17:56:01 -03:00
Leonardo 6eff67bf22 route 2026-02-19 13:09:44 -03:00
Leonardo 62e79e243a assets 2026-02-19 13:03:15 -03:00
Leonardo 3a671b1e9e assets 2026-02-19 12:59:03 -03:00
Leonardo b3bb817e3f commit 2026-02-19 12:53:21 -03:00
Leonardo 676042268b first commit 2026-02-18 22:36:45 -03:00
Cagatay Civici ec6b6ef53a set version as 5 2026-02-02 21:49:03 +03:00
tugcekucukoglu 76a3b60333 cchore: update PrimeVue version 2026-01-30 17:51:18 +03:00
tugcekucukoglu 410c08d693 chore: layout config updates 2025-12-25 10:03:32 +03:00
tugcekucukoglu a4b2c96b0d submodule added 2025-12-25 09:09:55 +03:00
tugcekucukoglu a47200fdf7 remove assets 2025-12-25 09:07:45 +03:00
tugcekucukoglu 7c32ae1f6f chore: remove sass warnings 2025-12-09 14:05:01 +03:00
Atakan db99863fac update transitions & dependencies 2025-12-08 14:36:22 +03:00
Atakan c2ef85fcab update for tw v4 2025-12-04 12:26:25 +03:00
Atakan deea8861f8 update 2025-11-18 05:16:32 +03:00
Atakan 319f976d2b add blocks 2025-11-08 17:37:47 +03:00
Atakan 13a50a3af3 update tw 2025-11-08 17:35:43 +03:00
Cagatay Civici 23bcf922ab Set version 2025-02-26 17:40:28 +03:00
tugcekucukoglu e1ecd23050 Update package-lock.json 2025-02-25 15:31:36 +03:00
tugcekucukoglu 2f5b71a3eb Use @primeuix/themes instead of @primevue/themes 2025-02-25 15:28:20 +03:00
tugcekucukoglu 22ba8601f3 Nora added 2025-02-18 16:01:41 +03:00
tugcekucukoglu 4a8745b497 Update FooterWidget.vue 2025-02-18 16:01:32 +03:00
tugcekucukoglu d5ec7dba67 Update package-lock.json 2025-02-18 16:01:27 +03:00
tugcekucukoglu 7c54176132 Update OverlayDoc.vue 2025-02-18 16:01:24 +03:00
tugcekucukoglu 03ef1236f0 Fixed #62 2025-02-18 16:01:19 +03:00
tugcekucukoglu 7ac2ba9013 Fixed #55 2025-02-18 15:51:13 +03:00
tugcekucukoglu 5f951584c7 Add Sakai-Vue to a Nuxt Project section added 2025-01-20 14:52:48 +03:00
tugcekucukoglu 817ffa0d62 Flag fixes 2025-01-16 13:51:47 +03:00
tugcekucukoglu 9585f62298 Update MiscDoc.vue 2025-01-16 13:03:27 +03:00
tugcekucukoglu aad48fca63 Set new version 2024-12-09 10:49:37 +03:00
tugcekucukoglu 59f3ebffe7 Update CHANGELOG.md 2024-12-09 10:49:27 +03:00
tugcekucukoglu 6fd2e4d96e Layout composable changes 2024-12-06 16:04:53 +03:00
tugcekucukoglu 1c65a74541 Update CHANGELOG.md 2024-12-05 13:28:00 +03:00
tugcekucukoglu a5aafc1d34 Laanding re-implementation 2024-12-05 13:26:47 +03:00
tugcekucukoglu 0f42b3760d Dashboard re-implementation 2024-12-05 13:16:36 +03:00
tugcekucukoglu c4dec65f2a Sass warning fixes 2024-12-05 13:04:39 +03:00
tugcekucukoglu 6f85c751de Version updates 2024-12-05 13:04:19 +03:00
Cagatay Civici 411fecb517 Fixed warning 2024-09-02 15:59:14 +03:00
tugcekucukoglu 4c7b0c0f5d Refactor 2024-08-05 09:27:58 +03:00
Cagatay Civici 3ba6d75db2 Update README.md 2024-08-03 11:36:25 +03:00
940 changed files with 346031 additions and 6074 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
+4
View File
@@ -0,0 +1,4 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
VITE_QA_MODE=true
VITE_QA_PASS=123Mudar@
+42 -1
View File
@@ -6,9 +6,50 @@ coverage
.cache
.output
.env
dist
.env.local
.env.*.local
dist/
dist-*/
.DS_Store
.idea
.eslintcache
api-generator/typedoc.json
**/.DS_Store
Dev-documentacao/
supabase/*
!supabase/functions/
# Mas os .env dentro de functions NÃO vão pro git (sobrescreve a negação acima)
supabase/functions/.env
supabase/functions/.env.local
supabase/functions/.env.*
evolution-api/
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
database-novo/backups/
# Rascunhos de design locais (Melissa Direção A, etc)
layout-scratchs/
# Outputs do Playwright
test-results/
playwright-report/
# Config local do Claude Code (cada dev tem o seu)
.claude/settings.local.json
# Notas locais do dev e rascunhos de commit — não subir
informacoes Gerais.txt
pasteds.txt
commit.txt
# Graphify outputs — regeneráveis via /graphify ou graphify update
graphify-out/
**/graphify-out/
# Obsidian: ignorar binarios do app, comitar SO o vault Brain/
Obsidian/*
!Obsidian/Brain/
# Estado local do Obsidian (workspace, hot-keys do dev) — não compartilhar
Obsidian/Brain/.obsidian/workspace*.json
Obsidian/Brain/.obsidian/hotkeys.json
+15
View File
@@ -0,0 +1,15 @@
{
"extends": [
"development"
],
"hints": {
"compat-api/css": [
"default",
{
"ignore": [
"background-color: color-mix(in srgb, var(--primary-color) 50%, transparent)"
]
}
]
}
}
+31
View File
@@ -0,0 +1,31 @@
### Observação sobre `tenant_admin` com UUID coincidente
Foi identificado que o registro de `tenant_members` possui:
- `tenant_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
- `user_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
- `role = tenant_admin`
À primeira vista pode parecer inconsistência, mas não é.
Verificação realizada:
O UUID `816b24fe-a0c3-4409-b79b-c6c0a6935d03` existe em `auth.users`
(email: admin@agenciapsi.com.br).
Portanto:
- `tenant_members.user_id` referencia corretamente `auth.users.id`
- Não há violação de integridade referencial
- O registro é válido
Trata-se de um caso em que:
- O usuário administrador principal possui um UUID específico
- O tenant foi criado com o mesmo UUID
- O administrador é `tenant_admin` desse próprio tenant
Esse padrão não quebra a arquitetura multi-tenant e é funcionalmente válido.
A coincidência entre `tenant_id` e `user_id` é apenas estrutural, não conceitual.
Conclusão:
Nenhuma correção estrutural é necessária.
+130 -32
View File
@@ -1,57 +1,155 @@
# Changelog
# CHANGELOG — Banco de Dados AgênciaPsi
## 4.1.0 (2024-07-29)
Registro histórico de todas as migrations aplicadas no banco.
Formato: data | arquivo | o que mudou | por quê
- Changed menu button location at topbar
- Add border to overlay menu
- Animation for mobile mask
- Fixed chart colors
---
## 4.0.0 (2024-07-29)
## [001] — 2026-03-03
**Arquivo:** `migration_001.sql`
**Seed:** `seed_001.sql`
- Updated to PrimeVue v4
### Contexto
O schema original foi construído de forma incremental e acumulou
inconsistências no modelo de identidade. Usuários não tinham um
tipo de conta definido formalmente, tenants não distinguiam
terapeuta de clínica, e não existia suporte a paciente como
tipo de conta de plataforma.
## 3.10.0 (2024-03-11)
### O que mudou
**Migration Guide**
#### `profiles`
- ✅ Adicionada coluna `account_type text NOT NULL DEFAULT 'free'`
- Valores: `free | patient | therapist | clinic`
- Imutável após sair de `free` (trigger `trg_account_type_immutable`)
- Usuários com role=`patient` migrados para `account_type='patient'`
- Usuários com tenant `saas` ativo migrados para `account_type='therapist'`
- Update theme files.
#### `tenants`
- ✅ Novos valores aceitos em `kind`:
- `therapist` → terapeuta individual (substitui `saas`)
- `clinic_coworking` → clínica tipo 1: gestão de salas
- `clinic_reception` → clínica tipo 2: secretaria + múltiplas agendas
- `clinic_full` → clínica tipo 3: coworking + secretaria
-`kind` agora é imutável após criação (trigger `trg_tenant_kind_immutable`)
- ✅ 10 tenants `saas` órfãos (sem admin, sem subscriptions) deletados
- ✅ Tenants `saas` com admin ativo migrados para `kind='therapist'`
- ⚠️ `saas` e `clinic` (legados) mantidos no CHECK por compatibilidade.
Não criar novos tenants com esses kinds.
**Implemented New Features and Enhancements**
#### `plans`
- ✅ Adicionado `patient` como valor válido em `target`
- ✅ Inserido plano `patient_free` (gratuito, target=patient)
- Upgrade to PrimeVue 3.49.1
#### Novas funções
| Função | Descrição |
|--------|-----------|
| `provision_account_tenant(user_id, kind, name?)` | Cria tenant + membership + atualiza account_type. Chamar no onboarding. |
| `is_therapist_tenant(tenant_id)` | Retorna true se tenant é do tipo therapist |
| `is_clinic_tenant(tenant_id)` | Atualizada: inclui todos os subtipos de clínica |
| `guard_tenant_kind_immutable()` | Trigger: bloqueia alteração de tenants.kind |
| `guard_account_type_immutable()` | Trigger: bloqueia alteração de account_type após escolha |
| `guard_patient_cannot_own_tenant()` | Trigger: bloqueia paciente de ser tenant_admin/therapist |
## 3.9.0 (2023-11-01)
#### Funções atualizadas
| Função | O que mudou |
|--------|-------------|
| `handle_new_user()` | Agora insere `account_type='free'` |
| `handle_new_user_create_personal_tenant()` | Desabilitada — tenant criado no onboarding |
| `ensure_personal_tenant()` | Busca por `kind IN ('therapist','saas')` e delega para `provision_account_tenant` |
**Migration Guide**
### Regras de negócio agora garantidas no banco
1. **Paciente é para sempre paciente**`account_type` imutável após escolha
2. **Terapeuta nunca vira clínica e vice-versa**`tenants.kind` imutável
3. **Paciente não pode ter tenant** — trigger bloqueia na inserção
4. **Cada tipo de conta tem seu tipo de tenant**`provision_account_tenant` garante
- Update theme files.
### Usuários de seed (apenas dev/staging)
| Email | Tipo | Tenant |
|-------|------|--------|
| paciente@agenciapsi.com.br | patient | nenhum |
| terapeuta@agenciapsi.com.br | therapist | tenant próprio (therapist) + vinculado à Clínica 3 |
| clinica1@agenciapsi.com.br | clinic | clinic_coworking |
| clinica2@agenciapsi.com.br | clinic | clinic_reception |
| clinica3@agenciapsi.com.br | clinic | clinic_full |
| saas@agenciapsi.com.br | saas_admin | nenhum |
> Senha de todos: `Teste@123`
**Implemented New Features and Enhancements**
---
- Upgrade to PrimeVue 3.39.0
## [002] — seed_002.sql
## 3.8.0 (2023-07-24)
**Arquivo:** `Novo-DB/seed_002.sql`
**Migration Guide**
### O que cria
- Update theme files.
- Update assets style files
- Remove code highlight
#### Migration embutida
- `profiles.platform_roles text[] NOT NULL DEFAULT '{}'` — adicionada via `ADD COLUMN IF NOT EXISTS` (idempotente)
**Implemented New Features and Enhancements**
#### Usuários de teste
| Email | Senha | Papel | Tenant |
|-------|-------|-------|--------|
| `supervisor@agenciapsi.com.br` | `Teste@123` | `supervisor` em `tenant_members` | Clínica Bem Estar (Full) |
| `editor@agenciapsi.com.br` | `Teste@123` | `therapist` em `tenant_members` + `platform_roles = '{editor}'` | Clínica Bem Estar (Full) |
- Upgrade to PrimeVue 3.30.2
UUIDs reservados:
- Supervisor: `aaaaaaaa-0007-0007-0007-000000000007`
- Editor: `aaaaaaaa-0008-0008-0008-000000000008`
## 3.7.0 (2023-05-06)
---
- Upgrade to PrimeVue 3.28.0
## [PENDENTE] — Migration necessária: `platform_roles` em `profiles`
**Implemented New Features and Enhancements**
**Contexto:**
Implementação das áreas de **Supervisor** (papel de tenant) e **Editor** (papel de plataforma).
O papel de Editor é atribuído pelo `saas_admin` e armazenado diretamente no perfil do usuário,
independente de qual tenant ele pertence.
## 3.6.0 (2023-04-12)
### O que precisa ser aplicado no banco
**Implemented New Features and Enhancements**
#### `profiles`
- ⚠️ **Adicionar coluna** `platform_roles text[] NOT NULL DEFAULT '{}'`
- Armazena papéis globais de plataforma. Ex.: `'{editor}'`
- Quem pode escrever: somente `saas_admin` (via RLS ou função privilegiada)
- Quem pode ter: qualquer usuário autenticado, **exceto** `account_type = 'patient'`
- Valores previstos: `editor` (mais podem ser adicionados futuramente)
- Upgrade to PrimeVue 3.26.1
- Upgrade to vite 4.2.1
#### SQL sugerido
```sql
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS platform_roles text[] NOT NULL DEFAULT '{}';
-- Comentário descritivo
COMMENT ON COLUMN public.profiles.platform_roles IS
'Papéis globais de plataforma, independentes de tenant. Ex: editor de microlearning. Atribuído pelo saas_admin.';
-- RLS: somente saas_admin pode atualizar platform_roles (exemplo)
-- CREATE POLICY "saas_admin pode atualizar platform_roles"
-- ON public.profiles FOR UPDATE
-- USING (auth.uid() IN (SELECT id FROM public.profiles WHERE role = 'saas_admin'))
-- WITH CHECK (true);
```
#### `tenant_members` (sem alteração necessária)
- O papel `supervisor` já é suportado como valor text em `tenant_members.role`.
- Nenhuma alteração de schema é necessária — basta inserir memberships com `role = 'supervisor'`.
### Impacto se não aplicado
- Área do Editor (`/editor`) fica inacessível a todos (coluna ausente → `platform_roles` vem `null` → acesso negado).
- Área do Supervisor (`/supervisor`) funciona normalmente — não depende desta migration.
---
## Futuro — registrado mas não implementado
### Vínculo Terapeuta ↔ Clínica (a implementar)
- Terapeuta autoriza explicitamente que secretaria gerencie suas sessões
- Permissão só válida se clínica tiver `kind IN ('clinic_reception', 'clinic_full')`
- Secretaria acessa apenas sessões — não prontuário nem anotações
- Dissociação bloqueada se houver `agenda_eventos` futuros (`inicio_em > now()`)
- Após dissociação: cada parte fica com seus próprios pacientes
- Requer: coluna de permissão no vínculo + função de dissociação com validação
---
*Última atualização: 2026-03-03*
+96
View File
@@ -0,0 +1,96 @@
## Navegação de Contexto
Quando precisar entender o código, documentos ou quaisquer arquivos deste projeto:
1. SEMPRE consulte o grafo de conhecimento primeiro: `/graphify query "sua pergunta"`
2. Só leia arquivos brutos se eu disser explicitamente "leia o arquivo" ou "veja o arquivo bruto"
3. Use `graphify-out/wiki/index.md` como ponto de entrada para navegar pela estrutura
---
## Para a Equipe — Sistema de Wiki/Grafo
> Este projeto usa um grafo de conhecimento (graphify) + wiki curado (wiki-brain) pra acelerar o trabalho do Claude e da equipe. O grafo mapeia o código automaticamente; a wiki acumula decisões, gotchas e blueprints.
### Estrutura
- `graphify-out/` — gerado automaticamente. **Não commitar** (já no .gitignore). Cada dev gera o seu localmente.
- `graph.html` — visualização interativa, abre no browser
- `graph.json` — dados brutos do grafo (consultáveis via `graphify query`)
- `wiki/` — 477+ artigos auto-gerados (1 por comunidade + god nodes), cross-linkados em estilo Obsidian
- `GRAPH_REPORT.md` — relatório auditável: god nodes, comunidades, conexões surpreendentes
- `Obsidian/Brain/` — vault Obsidian compartilhado. **É commitado**.
- `wiki/` — páginas curadas pela equipe (decisões, blueprints, gotchas) — esse é o que cresce com o tempo
- `raw/` — fontes imutáveis ingeridas (PDFs, links, transcrições)
- `log.md` — registro cronológico do que foi ingerido/decidido
### Setup local (uma vez por dev)
1. Garantir Python 3.10+ instalado
2. Instalar graphify: `pip install graphifyy`
3. Verificar: `graphify --help` (deve listar comandos)
4. Pronto. As skills `/graphify` e `/wiki-brain` já vêm com o Claude Code instaladas via `~/.claude/skills/`.
### Como acessar (modos de visualização)
- **Visual rápido** (sem instalar nada): `start graphify-out\graph.html` no Windows ou `xdg-open` no Linux. Abre no browser, navegação por arrastar e clicar.
- **Wiki crawlable**: abrir Obsidian → "Open another vault" → apontar pra `agenciapsi-primesakai\graphify-out\wiki\`. Navega com cliques nos `[[wikilinks]]` e graph view nativa.
- **Wiki curado**: abrir Obsidian → "Open another vault" → apontar pra `agenciapsi-primesakai\Obsidian\Brain\`. É onde a equipe edita.
- **Via Claude**: digitar `/graphify query "sua pergunta"` em qualquer sessão — retorna BFS no grafo, citando fontes.
### Como regenerar (cada dev no seu local)
- **Full rebuild** (lê o código + LLM, ~1M tokens, demorado): `/graphify D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\src` no Claude Code. Roda quando há mudança grande de arquitetura.
- **Update incremental** (só AST, sem LLM, rápido): `graphify update D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\src` no terminal. Roda depois de commits que mudam código mas não a arquitetura.
- **Só recluster** (sem reextração): `graphify cluster-only D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\src --no-viz`
### Como contribuir pro wiki curado (`Obsidian/Brain/wiki/`)
- Tomou uma decisão arquitetural? Criou um blueprint? Achou um gotcha? Anota lá.
- Pedir pro Claude: "ingere essa decisão sobre X no wiki" — ele cria a página com cross-links e atualiza `index.md`.
- Cross-link agressivo: usar `[[Nome da Página]]` (sintaxe Obsidian). Página sem links de entrada é beco sem saída.
- Commitar quando salvar — é conhecimento compartilhado da equipe.
### Convenções importantes
- **Nunca editar `graphify-out/` à mão** — é regenerado, qualquer mudança é perdida
- **Nunca modificar `Obsidian/Brain/raw/`** — fontes são imutáveis
- **Sempre commitar mudanças em `Obsidian/Brain/wiki/`** — é onde o conhecimento composto vive
- Antes de sessão grande de Claude, considerar `graphify update src/` pra grafo atualizado
---
## Context Navigation (Wiki-Brain)
You have access to a personal wiki at `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain`. This is the user's compounding knowledge base. Use it as your primary context source.
When you need to understand the codebase, docs, past work, or any stored knowledge:
1. **ALWAYS query the knowledge graph first:** `graphify query "your question"` (run from `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain`).
2. **Use `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/wiki/index.md`** as your navigation entrypoint for browsing the wiki structure.
3. **Use `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/graphify-out/wiki/index.md`** if it exists — it's the auto-generated Graphify wiki index.
4. **Only read raw files in `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/raw/`** if the user explicitly says "read the raw file" or the graph query doesn't have the answer.
## Wiki-Brain Session Rules
**Ingesting sources.** When the user drops a file into `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/raw/` and asks you to ingest it, follow `/wiki-brain ingest` — read the source, summarize, create/update wiki pages, cross-link aggressively, update `wiki/index.md`, append to `log.md`.
**Every session must end with a log entry.** Before ending a session, append one line to `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/log.md` in this exact format:
```
## [YYYY-MM-DD HH:MM] session | <3-8 word session title>
Touched: <comma-separated wiki pages, or "none">
```
**If the session produced durable knowledge** (decisions made, things learned, project state changed, problems solved) — update or create relevant wiki pages with that knowledge before ending. Cross-link with `[[Page Name]]`. Update `wiki/index.md`.
**If the session was trivial** (one-off fix, routine task, exploratory chatter) — skip the wiki update. Just append the log line.
**Never modify files in `raw/`.** Sources are immutable.
**Claude owns `wiki/` entirely.** Update it, don't ask permission for each page — just report what changed.
**Always update `wiki/index.md`** when you create or rename a wiki page.
**Cross-link aggressively.** `[[Page Name]]` Obsidian syntax. A page with no inbound links is a dead-end.
## Wiki-Brain Commands Available
- `/wiki-brain` — status menu
- `/wiki-brain ingest <file>` — ingest a source
- `/wiki-brain query "<q>"` — query the graph + wiki
- `/wiki-brain lint` — health-check the wiki
- `/wiki-brain rebuild` — force a Graphify rebuild
- `/wiki-brain doctor` — verify install
- `/recall` — show last 5 activities + read linked pages
+138
View File
@@ -0,0 +1,138 @@
# Docker Setup — Projetos Locais
## Tabela Resumo
| Projeto | Container(s) | Porta Host | Rede | Volume(s) |
|---|---|---|---|---|
| **AgenciaPsi** | `agenciapsi_app` | `5173` → Vite dev | `agenciapsi_net` | `agenciapsi_node_modules` |
| | `agenciapsi_mysql` | `3307` → MySQL | `agenciapsi_net` | `agenciapsi_mysql_data` |
| **Evolution API** | `evolution_api` | `8080` → API | `agenciapsi_net` (external) | — |
| | `evolution_db` | interno | `agenciapsi_net` | `evolution_db_data` |
| | `evolution_redis` | interno | `agenciapsi_net` | — |
| | `evolution_mailpit` | `1025` SMTP / `8025` Web | `agenciapsi_net` | — |
| **Supabase AgenciaPsi** | `supabase_*_agenciapsi-primesakai` | `54321` API / `54322` PG / `54323` Studio | — | volumes internos |
| **Sakai-Vue** | `sakaivue_app` | `5174` → Vite dev | `sakaivue_net` | `sakaivue_node_modules` |
| | `sakaivue_mysql` | `3308` → MySQL | `sakaivue_net` | `sakaivue_mysql_data` |
| **Supabase Sakai-Vue** | `supabase_*_sakai-vue` | `54331` API / `54332` PG / `54333` Studio | — | volumes internos |
| **Gisaf Local** | `gisaf_mysql` | `3309` → MySQL | `gisaf_net` | `gisaf_mysql_data` |
## Mapa de Portas
| Porta | Serviço |
|---|---|
| 3307 | AgenciaPsi MySQL |
| 3308 | Sakai-Vue MySQL |
| 3309 | Gisaf MySQL |
| 5173 | AgenciaPsi Vite dev |
| 5174 | Sakai-Vue Vite dev |
| 8080 | Evolution API |
| 1025 | Mailpit SMTP |
| 8025 | Mailpit Web UI |
| 54321 | Supabase AgenciaPsi — Kong (API) |
| 54322 | Supabase AgenciaPsi — PostgreSQL |
| 54323 | Supabase AgenciaPsi — Studio |
| 54327 | Supabase AgenciaPsi — Analytics |
| 54331 | Supabase Sakai-Vue — Kong (API) |
| 54332 | Supabase Sakai-Vue — PostgreSQL |
| 54333 | Supabase Sakai-Vue — Studio |
| 54337 | Supabase Sakai-Vue — Analytics |
## Ordem de Start
```bash
# 1. AgenciaPsi (cria a rede agenciapsi_net)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
docker compose up -d
# 2. Supabase AgenciaPsi (porta 54321)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
npx supabase start
# 3. Evolution API (depende da rede agenciapsi_net)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api"
docker compose up -d
# 4. Sakai-Vue
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
docker compose up -d
# 5. Supabase Sakai-Vue (porta 54331)
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
npx supabase start
# 6. Gisaf Local
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local"
docker compose up -d
```
## Parar tudo
```bash
# Na ordem inversa
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local" && docker compose down
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && npx supabase stop
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && docker compose down
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api" && docker compose down
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && npx supabase stop
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && docker compose down
```
## Caminhos dos docker-compose.yml
| Projeto | Caminho |
|---|---|
| AgenciaPsi | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\docker-compose.yml` |
| Evolution API | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\evolution-api\docker-compose.yml` |
| Sakai-Vue | `D:\leonohama\UniaoApp.com.br\Sistema\sakai-vue\docker-compose.yml` |
| Gisaf Local | `D:\leonohama\UniaoApp.com.br\Gisaf Local\docker-compose.yml` |
## DBeaver — Conexões MySQL
| Conexão | Host | Port | Database | User | Password |
|---|---|---|---|---|---|
| Gisaf | `localhost` | `3309` | `sindsp` | `sindsp` | `marlboro` |
| AgenciaPsi | `localhost` | `3307` | `agenciapsi` | `agenciapsi` | `agenciapsi123` |
| Sakai-Vue | `localhost` | `3308` | `sakaivue` | `sakaivue` | `sakaivue123` |
Para criar cada conexão: **Database → New Database Connection → MySQL → preencher dados → Test Connection → Finish**
## Supabase — Instancias Locais
Cada projeto tem sua propria instancia Supabase (schemas diferentes, nao podem compartilhar).
| Projeto | API URL | Studio | PostgreSQL | Anon Key |
|---|---|---|---|---|
| AgenciaPsi | `http://127.0.0.1:54321` | `http://127.0.0.1:54323` | `127.0.0.1:54322` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
| Sakai-Vue | `http://127.0.0.1:54331` | `http://127.0.0.1:54333` | `127.0.0.1:54332` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
**Resetar banco (aplica migrations + seed):**
```bash
# AgenciaPsi
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
npx supabase db reset
# Sakai-Vue
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
npx supabase db reset
```
### Sakai-Vue — Usuarios de teste
| Email | Senha | Role |
|---|---|---|
| `dev@sistema.com.br` | `Dev@12345` | dev |
| `master@tenant.com.br` | `Master@12345` | master |
| `admin@tenant.com.br` | `Admin@12345` | admin |
| `chefe@tenant.com.br` | `Chefe@12345` | chefe_setor |
| `servidor@tenant.com.br` | `Servidor@12345` | servidor |
| `leitura@tenant.com.br` | `Leitura@12345` | leitura |
## Importar dump SQL no Gisaf
```bash
# Via CLI (já feito)
docker exec -i gisaf_mysql mysql -usindsp -pmarlboro sindsp < "D:/leonohama/UniaoApp.com.br/Gisaf Local/Dump20260330.sql"
```
Ou via DBeaver: conectar no banco `sindsp`**Tools → Execute SQL Script** → selecionar `Dump20260330.sql`
+448
View File
@@ -0,0 +1,448 @@
# HANDOFF — 2026-05-20 (C10 ✅ + C11 ✅ + C12 ⏳ deferido · testando C13)
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
> **🎯 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.
> **🟢 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).
### 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`
---
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 10 (Status change AVULSA)
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).
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`).
---
## 📦 O que foi feito em 20/05 madrugada (C9 + rowGroup financeiro + bubble cobranca-atualizada)
### 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.
### `/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)
### 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.
Agora: `AgendaEventDialog._onCobrancaAtualizada` faz duas coisas:
1. `loadOccFinancialRecord()` — refresca estado interno do dialog
2. `emit('cobranca-atualizada')` — bubble pra MelissaLayout
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.
---
## 📦 O que foi feito em 19/05 madrugada (C8 + Usar/Revogar saldo + UI de pacote)
### 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.
### 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.
### 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
**`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)
### Fix: enum status_evento_agenda
Era `'realizada'` no insert/update, DB exige `'realizado'` (masculino). Corrigido em todas as ocorrências.
### 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
### 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".
### 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)
### 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)
---
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
### 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.
### 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.
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
### 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.
---
## 📦 O que foi feito em 18/05
### 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()`.
### 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`.
### 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.
### Preparação do C5 (Sándor + Unimed Nacional) — UX de convênio refinado (3 issues)
User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
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).
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)**.
---
## 📦 O que foi feito antes (16/05 noite/madrugada)
### 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 —").
---
## 🧭 Onde estamos no plano de 9 fases
| 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 | 📋 |
---
## 📋 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
+3
View File
@@ -0,0 +1,3 @@
{
"promptDelete": false
}
+1
View File
@@ -0,0 +1 @@
{}
+33
View File
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
+22
View File
@@ -0,0 +1,22 @@
{
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 1,
"close": true
}
File diff suppressed because it is too large Load Diff
@@ -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`
+32
View File
@@ -0,0 +1,32 @@
# Wiki Index
This is the catalog of every page in your wiki. Claude updates it automatically.
**Pattern:** `- [[Page Name]] — one-line summary`
---
## Entities
_(people, places, organizations, products — pages that describe a thing)_
## Concepts
_(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)_
## Analyses
_(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.*
+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'`
+2 -28
View File
@@ -1,29 +1,3 @@
This template should help get you started developing with Vue 3 in Vite.
Sakai is an application template for Vue based on the [create-vue](https://github.com/vuejs/create-vue), the recommended way to start a Vite-powered Vue projects.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
Visit the [documentation](https://sakai.primevue.org/documentation) to get started.
+232
View File
@@ -0,0 +1,232 @@
# Guia de Testes — AgenciaPsi
## Testes Automatizados
### Pré-requisito
Vitest já instalado (`npm install` resolve). Não precisa de banco, Supabase ou variáveis de ambiente.
### Comandos
| Comando | Descrição |
|---|---|
| `npm test` | Roda todos os testes uma vez e exibe resultado |
| `npm run test:watch` | Modo watch — re-roda ao salvar arquivos |
| `npm run test:ui` | Abre UI visual no browser (`http://localhost:51204`) |
### Arquivos de teste
| Arquivo | O que cobre |
|---|---|
| `src/features/agenda/composables/__tests__/useRecurrence.spec.js` | Geração de datas por tipo de regra, max_occurrences global, exceções, remarcação cross-range |
| `src/features/agenda/services/__tests__/agendaMappers.spec.js` | Mapeamento para FullCalendar, ícones de status, cores, buildNextSessions, minutesToDuration, buildWeeklyBreakBackgroundEvents |
### Quando rodar
- Antes de commitar qualquer mudança em `useRecurrence.js` ou `agendaMappers.js`
- Ao adicionar novo tipo de frequência (mensal, quinzenal, etc.)
- Ao mexer em exceções de recorrência
- Em CI/CD antes do deploy
---
## Testes Manuais
### Preparação
1. Limpar dados de teste no banco:
```sql
TRUNCATE TABLE recurrence_exceptions CASCADE;
TRUNCATE TABLE recurrence_rules CASCADE;
TRUNCATE TABLE agenda_eventos CASCADE;
TRUNCATE TABLE agendador_solicitacoes CASCADE;
```
2. Fazer login com seu usuário real
3. Selecionar a clínica/tenant correto
---
### 1. Evento Avulso
| Passo | Esperado |
|---|---|
| Clicar em um horário livre na agenda | Dialog de criação abre |
| Preencher paciente, horário, modalidade → Salvar | Evento aparece no calendário |
| Clicar no evento → Editar horário → Salvar | Horário atualiza |
| Clicar no evento → Marcar como "Faltou" | Cor muda para vermelho, ícone ✗ |
| Clicar no evento → Marcar como "Realizado" | Cor muda para cinza, ícone ✓ |
| Clicar no evento → Cancelar sessão | Cor muda para laranja, ícone ∅ |
| Clicar no evento → Excluir | Evento some do calendário |
---
### 2. Recorrência Semanal
| Passo | Esperado |
|---|---|
| Criar evento com frequência "Semanal" | Ocorrências aparecem em todas as semanas seguintes com ícone ↻ |
| Navegar para a semana seguinte | Ocorrências continuam aparecendo |
| Navegar para além do end_date | Não aparecem ocorrências após a data final |
| Criar série com "4 sessões" (max_occurrences) | Exatamente 4 ocorrências visíveis no calendário |
---
### 3. Recorrência Quinzenal e Dias Específicos
| Passo | Esperado |
|---|---|
| Criar série "Quinzenal" | Ocorrências aparecem a cada 2 semanas |
| Criar série "Dias específicos" (ex: seg + qua) | Ambos os dias aparecem toda semana |
| Navegar para semanas futuras | Padrão se mantém |
---
### 4. Edição de Série
| Passo | Esperado |
|---|---|
| Clicar em ocorrência → Editar → "Somente este" → mudar horário | Só aquela data muda; as outras continuam iguais |
| Clicar em ocorrência → Cancelar → "Somente este" | Só aquela data some (ou aparece cancelada) |
| Clicar em ocorrência → Cancelar → "Este e os seguintes" | A partir daquela data, sem mais ocorrências |
| Clicar em ocorrência → Cancelar → "Todos" | Série inteira some |
---
### 5. Remarcação Cross-Range ⭐
Este é o caso mais importante a testar.
| Passo | Esperado |
|---|---|
| Criar série semanal (ex: toda segunda) | Ocorrências nas segundas |
| Clicar na sessão da **semana 1** → Remarcar para **terça da semana 2** | — |
| Navegar para a **semana 1** | Segunda da semana 1 aparece vazia ou como "remarcado" |
| Navegar para a **semana 2** | Terça aparece com ícone ↺ e status "remarcado" |
---
### 6. Bloqueio de Agenda
| Passo | Esperado |
|---|---|
| Criar bloqueio de horário | Aparece no calendário com visual diferente (ícone ⊘) |
| Tentar agendar no horário bloqueado | Aviso de conflito |
---
### 7. Agendamento Online (Agendador Público)
| Passo | Esperado |
|---|---|
| Acessar URL pública do agendador | Página pública abre sem login |
| Selecionar data/horário disponível → Enviar solicitação | Confirmação exibida |
| No painel do terapeuta → "Agendamentos Recebidos" | Solicitação aparece na lista |
| Clicar em "Confirmar" | Evento criado na agenda |
| Clicar em "Recusar" | Solicitação removida, sem evento na agenda |
---
### 8. Suporte Técnico SaaS
| Passo | Esperado |
|---|---|
| Logar como `saas_admin` → Menu "Suporte Técnico" | Página de suporte abre |
| Selecionar um tenant → "Criar Sessão de Suporte" | URL com token é gerada |
| Copiar URL e abrir em outra aba | Agenda do terapeuta abre com banner de debug no rodapé |
| No banner → filtrar logs por categoria | Logs filtram corretamente |
| No banner → "Desativar suporte" | Banner some |
| No painel SaaS → "Revogar" na sessão ativa | Token invalidado |
---
### 9. Multi-Tenancy (se você tem 2 clínicas cadastradas)
| Passo | Esperado |
|---|---|
| Criar evento na clínica A | Evento aparece na agenda da clínica A |
| Trocar para clínica B | Evento da clínica A **não aparece** |
| Criar evento na clínica B | Aparece apenas na clínica B |
---
## Pedindo ao Claude para Executar os Testes
### Como usar o Claude Code para rodar e corrigir testes
O Claude Code (este agente) consegue rodar os testes, ler os erros e corrigir os problemas automaticamente. Basta iniciar a conversa com o contexto certo.
### Prompt de retomada recomendado
Cole isso no início de uma nova sessão com o Claude:
---
> Estou desenvolvendo o AgenciaPsi. Temos testes automatizados com Vitest.
>
> **Arquivos de teste:**
> - `src/features/agenda/composables/__tests__/useRecurrence.spec.js` — testa `generateDates`, `expandRules`, `mergeWithStoredSessions`
> - `src/features/agenda/services/__tests__/agendaMappers.spec.js` — testa mapeamento para FullCalendar
>
> **Rodar os testes:** `npm test`
>
> Por favor, rode os testes agora e me informe o resultado. Se houver falhas, analise a causa e corrija.
---
### O que o Claude consegue fazer automaticamente
| Pedido | O Claude faz |
|---|---|
| "Rode os testes" | Executa `npm test` e exibe o resultado |
| "Tem algum teste falhando?" | Roda e diagnóstica a causa raiz |
| "Corrija os testes que falham" | Analisa erro, ajusta o código ou o teste e re-roda |
| "Adicionei a funcionalidade X, crie testes para ela" | Lê o código e escreve novos casos no spec |
| "O teste Y está errado, o comportamento correto é Z" | Atualiza a asserção e confirma que passa |
### Boas práticas ao pedir testes ao Claude
- **Forneça o `AUDITORIA.md`** no início da sessão — dá contexto sobre a arquitetura e decisões já tomadas
- **Descreva o comportamento esperado** em português, não o código — o Claude escreve o código do teste
- **Se um teste falhar e você achar que o código está certo**, diga isso explicitamente: *"o teste está errado, não o código"* — o Claude vai ajustar a asserção
- **Se um teste falhar e você achar que o código está errado**, diga: *"o comportamento esperado é X"* — o Claude vai corrigir a implementação
### Exemplo de sessão típica
```
Você: Rodei npm test e 2 testes falharam. Analise e corrija.
Claude: [roda npm test, lê os erros, corrige o código ou as asserções, re-roda até 63/63 passarem]
```
---
## Adicionando Novos Testes
### Para `useRecurrence.spec.js`
```js
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js'
it('meu novo caso', () => {
const r = {
id: 'rule-1', type: 'weekly', weekdays: [1], interval: 1,
start_date: '2026-03-02', end_date: null, max_occurrences: null,
status: 'ativo', start_time: '09:00', end_time: '10:00',
// ... outros campos necessários
}
const dates = generateDates(r, new Date(2026, 2, 1), new Date(2026, 2, 31))
expect(dates.length).toBe(/* esperado */)
})
```
### Para `agendaMappers.spec.js`
```js
import { mapAgendaEventosToCalendarEvents } from '../agendaMappers.js'
it('meu novo caso de mapeamento', () => {
const [ev] = mapAgendaEventosToCalendarEvents([{
id: 'ev-1', titulo: 'Teste', tipo: 'sessao', status: 'agendado',
inicio_em: '2026-03-10T09:00:00', fim_em: '2026-03-10T10:00:00',
owner_id: 'owner-1',
}])
expect(ev.extendedProps./* campo */).toBe(/* esperado */)
})
```
+518
View File
@@ -0,0 +1,518 @@
# WhatsApp Setup — CRM de Conversas + Créditos + Automações
Guia end-to-end do subsistema de WhatsApp do AgenciaPSI. Cobre **WhatsApp Pessoal** (Evolution, gratuito), **WhatsApp Oficial AgenciaPSI** (Twilio com créditos), **Asaas** (gateway de pagamento), e todas as automações (auto-reply, lembretes, opt-out, tags, notas).
---
## 🎯 Arquitetura
### Dois provedores, escolha exclusiva por tenant
```
┌─────────────────────────────────────────────────┐
│ Tenant escolhe 1 canal em /configuracoes/whatsapp │
└─────────────────────────────────────────────────┘
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ WhatsApp Pessoal │ │ WhatsApp Oficial │
│ (Evolution) │ │ AgenciaPSI (Twilio) │
│ │ │ │
│ • Gratuito │ │ • Consome créditos │
│ • QR code │ │ • API oficial Meta │
│ • Celular real │ │ • Zero ban risk │
│ • Docker self-host │ │ • Cloud gerenciado │
│ • Tier free do SaaS │ │ • Tier pago do SaaS │
└────────────────────┘ └──────────────────────┘
│ │
└───────────┬────────────────┘
┌─────────────────────────┐
│ Edge Functions │
│ │
│ • send-whatsapp-message │ ← rota por provider
│ • send-session-reminders │ ← idem
│ • evolution-whatsapp-inbound (auto-reply, opt-out)
│ • twilio-whatsapp-inbound (⚠ sem auto-reply ainda)
│ • create-whatsapp-credit-charge (Asaas PIX)
│ • asaas-webhook (credita saldo)
└─────────────────────────┘
┌─────────────────────────┐
│ PostgreSQL │
│ │
│ conversation_messages │
│ conversation_notes │
│ conversation_tags │
│ conversation_optouts │
│ conversation_autoreply_* │
│ session_reminder_* │
│ whatsapp_credits_* │
│ whatsapp_credit_packages │
└─────────────────────────┘
```
### Dedução de créditos
```
Usuário envia pelo drawer / lembrete dispara / auto-reply
Edge function detecta provider do canal ativo
Evolution? Twilio?
↓ ↓
Envia direto deduct_whatsapp_credits(1) ← atômico, lock, valida saldo
↓ ↓
Registra msg ┌──── OK ────┐ ┌── insufficient ──┐
↓ ↓
send Twilio return 402
┌── ok ──┐ ┌── fail ──┐
↓ ↓
Registra add_whatsapp_credits(1, 'refund')
msg
```
---
## 🔧 Setup completo (dev local)
### 1. Supabase local + edge functions
```bash
# Subir stack Supabase local (Postgres + Auth + Storage + etc)
npx supabase start
# Em outro terminal: rodar edge functions
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
```
**Funções carregadas:**
| Função | URL | Uso |
|---|---|---|
| `evolution-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Evolution: inbound msgs + auto-reply + opt-out |
| `evolution-webhook-provision` | — | Configura webhook na Evolution |
| `twilio-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Twilio (inbound only; sem auto-reply ainda) |
| `send-whatsapp-message` | — | Envio unificado: detecta provider, deduz crédito se Twilio |
| `send-session-reminders` | — | Cron/manual: dispara lembretes 24h e 2h antes |
| `create-whatsapp-credit-charge` | — | Cria PIX Asaas pra compra de créditos |
| `asaas-webhook` | — | Recebe eventos Asaas e credita saldo |
| `deactivate-notification-channel` | — | Desativa canal (usado ao trocar provider) |
**Flag `--no-verify-jwt`:** necessária porque webhooks externos (Twilio, Evolution, Asaas) não mandam JWT.
### 2. Evolution API (WhatsApp Pessoal — tier gratuito)
```bash
# Subir Evolution + Postgres + Redis
docker compose -f evolution-api/docker-compose.yml up -d
# Verificar status
docker ps --filter name=evolution_api
```
Evolution roda em `http://localhost:8080` com API key `minha_chave_123` (ver `evolution-api/docker-compose.yml`).
### 3. Asaas (pagamentos — tier pago)
Ativa só em prod ou quando quiser testar compra de créditos end-to-end.
**Passo 1 — Criar conta sandbox:**
1. https://sandbox.asaas.com (gratuito, CPF qualquer)
2. Menu → Integrações → Integrações Avançadas → API → copia a API key (começa com `$aact_...`)
**Passo 2 — Configurar env:**
Edita `supabase/functions/.env`:
```env
ASAAS_API_KEY=$aact_sua_chave_aqui
ASAAS_API_URL=https://sandbox.asaas.com/api/v3
ASAAS_WEBHOOK_TOKEN= # opcional, pra autenticar webhook
```
**Passo 3 — Reiniciar functions serve:**
```bash
# Ctrl+C no terminal do serve
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
```
**Passo 4 — (Opcional) Expor webhook via ngrok pro Asaas alcançar:**
```bash
# Outro terminal
ngrok http 54321
# Copia a URL (ex: https://abc123.ngrok.app)
```
Configura no Asaas:
- Dashboard → Integrações → Webhooks → **Adicionar**
- URL: `https://abc123.ngrok.app/functions/v1/asaas-webhook`
- Eventos: marca **Cobranças** (PAYMENT_RECEIVED, PAYMENT_CONFIRMED, PAYMENT_OVERDUE, PAYMENT_DELETED, PAYMENT_REFUNDED)
- Token (opcional): cadastra o mesmo valor de `ASAAS_WEBHOOK_TOKEN`
**Em produção:**
```bash
supabase secrets set ASAAS_API_KEY="$aact_prod_key"
supabase secrets set ASAAS_API_URL="https://api.asaas.com/v3"
supabase secrets set ASAAS_WEBHOOK_TOKEN="token_seguro"
```
Webhook da prod aponta pro URL real do Supabase cloud (sem ngrok).
---
## 📋 Features & como testar
### A. Envio manual via drawer
**Onde:** drawer de qualquer conversa (clica no card do Kanban em `/therapist/conversas`)
**Fluxo:**
1. Compose no drawer → `store.sendMessage()` → chama `send-whatsapp-message` edge function
2. Function detecta provider ativo em `notification_channels`
3. Evolution: envia direto via `/message/sendText/{instance}`
4. Twilio: `deduct_whatsapp_credits(1)` → se OK envia via Twilio API → se falhar, refunda
5. Registra em `conversation_messages` (direction=outbound, delivery_status=sent/queued)
**Testar sem Twilio real** (valida dedução + rollback):
- Topup 100 créditos via SQL (ver seção 🧪 mais abaixo)
- Criar canal Twilio fake via SQL
- Enviar msg → deduz, tenta enviar, falha com 401, refunda → saldo volta ao original
### B. Lembretes automáticos de sessão (2.4)
**Onde:** `/configuracoes/lembretes-sessao`
**Config:**
- Toggle on/off
- Ativa lembretes 24h e/ou 2h antes da sessão
- Templates com variáveis: `{{nome_paciente}}`, `{{data_sessao}}`, `{{hora_sessao}}`, `{{modalidade}}`, `{{nome_clinica}}`
- Quiet hours (default 22h-8h SP)
- Respeitar opt-out (LGPD — recomendado ON)
**Como dispara:**
- Cron hit `send-session-reminders` a cada 15min (pg_cron comentado na migration; em prod configure via Supabase Dashboard → Database → Cron Jobs)
- Em dev: botão **"Testar agora"** na página dispara manualmente
**Query do worker:** busca `agenda_eventos` com `status='agendado'` dentro de:
- Janela 24h: `inicio_em` entre `now+23h45min` e `now+24h15min`
- Janela 2h: `inicio_em` entre `now+1h45min` e `now+2h15min`
**Anti-dup:** UNIQUE `(event_id, reminder_type)` no `session_reminder_logs`.
**Testar:**
```sql
-- Cria evento daqui a ~2h (no horário de SP)
-- Pelo UI da agenda é mais fácil; via SQL:
INSERT INTO agenda_eventos (tenant_id, owner_id, patient_id, inicio_em, fim_em, status, modalidade, tipo, titulo)
VALUES (
'<tenant>',
'<user>',
(SELECT id FROM patients WHERE tenant_id='<tenant>' LIMIT 1),
now() + interval '2 hours',
now() + interval '3 hours',
'agendado',
'presencial',
'session',
'Teste lembrete'
);
```
Depois clica **"Testar agora"** em `/configuracoes/lembretes-sessao`.
### C. Auto-reply fora do horário (2.3)
**Onde:** `/configuracoes/conversas-autoreply`
**Config:**
- Toggle on/off + mensagem + cooldown (minutos entre auto-replies pra mesma thread)
- 3 modos:
- **Seguir agenda** — usa `agenda_regras_semanais` dos membros ativos do tenant
- **Horário de funcionamento** — janela semanal editável (armazena em JSONB `business_hours`)
- **Custom** — janela específica pro auto-reply (`custom_window`)
**Como dispara:**
- Webhook Evolution `evolution-whatsapp-inbound` recebe msg
- Depois de inserir msg, chama `maybeSendAutoReply()`
- Checa: enabled, não está em horário útil, não está em cooldown
- Se OK → envia via Evolution (futuro: rotear pra Twilio se provider='twilio')
**⚠ Limitação:** atualmente **só funciona com Evolution**. Pra Twilio precisa implementar a mesma lógica em `twilio-whatsapp-inbound` (dívida técnica).
**Testar:**
- Ativa feature + define janela custom (ex: seg-sex 9h-18h)
- Fora dessa janela, paciente manda msg → chega no inbox + auto-reply é enviado de volta em ~1s
### D. Opt-out LGPD (5.2)
**Onde:** `/configuracoes/conversas-optouts`
**Como funciona:**
- Paciente envia "PARAR", "SAIR", "CANCELAR", "STOP", etc (keyword match case-insensitive sem acentos)
- Edge function `evolution-whatsapp-inbound` detecta → registra em `conversation_optouts` → envia msg de confirmação
- Paciente envia "VOLTAR" / "RETORNAR" → reativa (opted_back_in_at preenchido)
- Auto-reply e lembretes **respeitam opt-out** automaticamente (skip + log `opted_out`)
- Envio manual do terapeuta NÃO é bloqueado (relação terapêutica existe)
**Keywords padrão:** 10 palavras (configuráveis na página — pode adicionar custom do tenant).
**Testar:**
- Manda mensagem com "parar" pelo WhatsApp conectado
- Volta em `/configuracoes/conversas-optouts` → número aparece na lista
- Nova mensagem que dispararia auto-reply → não dispara mais
### E. Notas internas (3.3)
**Onde:** dentro do drawer de conversa, seção "Notas internas" collapsible
**Como funciona:**
- CRUD simples por thread
- Visível apenas pra membros ativos do tenant
- Edição/remoção só pelo criador (ou SaaS admin)
- Soft delete (`deleted_at`)
- **NÃO vai pro paciente** — apenas anotação interna da equipe
### F. Tags na conversa (3.1)
**Onde:**
- **Gestão:** `/configuracoes/conversas-tags` (CRUD de tags custom; system tags são read-only)
- **Aplicação:** dentro do drawer de conversa + pills visíveis nos cards do Kanban
**Tags system (seedadas):** Urgente (🔴), Primeira consulta (🔵), Remarcação (🟡), Confirmada (🟢), Follow-up (🟣)
**Custom:** tenant cria suas próprias com nome + slug + cor + ícone (primeicons).
### G. Mídia (áudio / imagem / vídeo / documento)
**Arquitetura:**
- Evolution manda URLs encriptadas do Meta CDN (não tocam direto)
- Edge function `evolution-whatsapp-inbound` chama `/chat/getBase64FromMediaMessage/{instance}` do Evolution → decripta
- Decoda base64 → faz upload no bucket **privado `whatsapp-media`**
- Path: `<tenant_id>/<yyyy>/<mm>/<msg_id>_<timestamp>.<ext>`
- Salva apenas o PATH em `media_url`, NÃO URL pública
- Frontend (`ConversationDrawer`) gera **signed URL on-demand** (1h TTL) ao renderizar
**LGPD:** bucket privado, RLS só permite membros ativos do tenant; path tenant-scoped; signed URLs expiram.
**Player de áudio:** `<audio min-w-[260px] controls>` + `preload="metadata"` pra mostrar duração sem baixar tudo.
**Preview de imagem:** `<Image preview>` do PrimeVue (fullscreen com zoom/rotate) + **botão download injetado via MutationObserver** (fetch blob → download attr → force download com nome do arquivo).
### H. Créditos WhatsApp (Marco B)
**Onde:** `/configuracoes/creditos-whatsapp`
**Fluxo de compra:**
1. User clica "Comprar" num pacote → `create-whatsapp-credit-charge` cria:
- Customer Asaas (ou reutiliza via `externalReference=tenant_id`)
- Pagamento PIX (`billingType=PIX`, value, dueDate)
- Busca QR Code em `/payments/{id}/pixQrCode`
2. Dialog mostra QR + copia-cola + link cartão
3. User paga
4. Asaas → webhook `asaas-webhook` recebe `PAYMENT_RECEIVED`/`CONFIRMED`
5. Webhook chama `add_whatsapp_credits(tenant, credits, 'purchase')` → saldo atualiza
**Pacotes seedados:** Iniciante (100/R$49,90), Profissional (500/R$199,90, ⭐ featured), Clínica (1500/R$499,90), Enterprise (5000/R$1499,90) — editáveis via DB.
**RPCs atômicas (SECURITY DEFINER):**
- `add_whatsapp_credits(tenant, amount, kind, purchase_id, admin_id, note)` → retorna novo saldo
- `deduct_whatsapp_credits(tenant, amount, msg_id, note)` → atômico com `SELECT FOR UPDATE`; lança `insufficient_credits` se saldo < amount
**CPF:** fallback hardcoded `24971563792` em sandbox. Em produção, frontend deve coletar CPF real do user (TODO — adicionar input no dialog + salvar em profile/tenant).
---
## 🧪 Testar sem provedor real
### Pré-requisito: creditar saldo
No Supabase Studio (`http://localhost:54323`) → SQL Editor (**desativa LIMIT 100 no dropdown**):
```sql
SELECT public.add_whatsapp_credits(
tm.tenant_id,
100,
'topup_manual',
NULL,
auth.uid(),
'Topup de teste'
) AS novo_saldo
FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL'
AND tm.status = 'active'
LIMIT 1;
```
### Simular canal Twilio fake (pra testar dedução)
```sql
-- Desativa Evolution
UPDATE public.notification_channels
SET is_active = false, deleted_at = now()
WHERE tenant_id = (
SELECT tm.tenant_id FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active' LIMIT 1
)
AND channel = 'whatsapp' AND deleted_at IS NULL;
-- Cria Twilio fake
INSERT INTO public.notification_channels (
tenant_id, owner_id, channel, provider, is_active,
twilio_subaccount_sid, twilio_phone_number, credentials
)
SELECT
tm.tenant_id, u.id, 'whatsapp', 'twilio', true,
'ACfake0000000000000000000000000000',
'+15557775555',
'{"subaccount_auth_token": "fake_token"}'::jsonb
FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active'
LIMIT 1;
```
Agora qualquer envio:
- Deduz 1 crédito (`usage -1`)
- Twilio API retorna 401 (creds fake)
- Refunda automaticamente (`refund +1`)
- Saldo final: igual ao inicial
Resultado esperado no extrato:
```
+1 Estorno — Refund envio falhou: Twilio 401: ...
-1 Uso — Envio manual WhatsApp
+100 Topup manual — Topup de teste
```
### Simular mensagem inbound via curl
```bash
curl -X POST "http://localhost:54321/functions/v1/evolution-whatsapp-inbound?tenant_id=<uuid>" \
-H "Content-Type: application/json" \
-d '{
"event": "messages.upsert",
"data": {
"key": { "remoteJid": "5516912345678@s.whatsapp.net", "fromMe": false, "id": "MSG_TEST" },
"message": { "conversation": "parar" },
"messageTimestamp": '"$(date +%s)"'
}
}'
```
Isso vai testar detecção de opt-out ("parar" é keyword) → registra na lista + envia ACK.
### Testar Twilio sandbox real (opcional)
1. Cria conta trial em https://www.twilio.com/try-twilio (US$15 grátis)
2. Dashboard → Messaging → Try it out → **Send a WhatsApp Message**
3. Segue instruções pra join sandbox (`join <frase>` do seu celular pro número Twilio)
4. Pega Account SID + Auth Token + From number do sandbox
5. Atualiza o canal Twilio no SQL com valores reais
6. Envia mensagem do drawer → chega no seu celular
---
## 🆘 Troubleshooting
| Sintoma | Causa provável | Fix |
|---|---|---|
| 404 no webhook | `supabase functions serve` não rodando OU function não carregada | Reinicia serve com `--env-file` |
| 502 edge function | Crash antes do `console.error` — erro de sintaxe, env var faltando, ou API externa timeout | Olha logs do serve; adiciona `console.log` em cada etapa |
| Asaas 400 "CPF obrigatório" | Customer existente sem CPF | Fix em `getOrCreateAsaasCustomer` faz PATCH com CPF quando falta |
| QR code PIX não aparece | Asaas sandbox sem PIX habilitado OU endpoint `/pixQrCode` falhou | Ativa PIX em Asaas → Recebimentos → Chaves PIX |
| Webhook Asaas não chega | URL não pública (localhost) | Usa ngrok OU deploy em prod |
| "insufficient_credits" no envio | Saldo zerado | Topup via `/configuracoes/creditos-whatsapp` ou SQL manual |
| Auto-reply não dispara | Tenant fora do modo horário configurado OU em cooldown OU opted-out | Checa banner de status em `/configuracoes/conversas-autoreply`; olha `conversation_autoreply_log` |
| Lembrete duplicado | Não acontece — UNIQUE `(event_id, reminder_type)` previne | — |
| Audio não toca (era o ponto inicial do Marco A-I) | URL encriptada do Meta salva direto | Fix: edge function agora decripta via `getBase64FromMediaMessage` + upload pro bucket |
| Mime `audio/ogg; codecs=opus` rejeitado pelo bucket | `allowed_mime_types` faz match exato | Fix: strip `;codecs=...` antes do upload |
| Dialog de confirmação aparece 2x | Dois `<ConfirmDialog>` montados (página + drawer global) | Fix: drawer usa `group="conversation-drawer"` pra isolar |
---
## 🔒 Segurança & Compliance
### RLS de `conversation_messages`
- **SELECT:** tenant members ativos OU saas_admin
- **INSERT direto:** bloqueado (só service_role via edge function)
- **UPDATE:** tenant members podem mudar `kanban_status`, `read_at`
- **DELETE:** bloqueado
### RLS de `whatsapp-media` bucket
- Privado
- Read: membros ativos do tenant cujo id é o primeiro segmento do path
- Write: apenas service_role
- Delete: apenas saas_admin
### RLS de `whatsapp_credits_*`
- Balance/transactions: tenant members leem
- Escrita: via RPCs `add_whatsapp_credits` / `deduct_whatsapp_credits` (SECURITY DEFINER)
- Packages: tenant members leem os ativos; saas_admin gerencia
### LGPD
- **Art. 18 §2 (direito de oposição):** opt-out implementado. Auto-reply + lembretes respeitam.
- **Dados clínicos:** canal Oficial (Twilio) recomendado em prod. Pessoal (Evolution) é uso informal, sem SLA, tenant assume risco.
- **Audit log:** toda transação de crédito fica em `whatsapp_credits_transactions` (append-only).
### Bot defense / Rate limiting
- `public_submission_attempts`, `submission_rate_limits` e `math_challenges` são tabelas do sistema de bot defense pro cadastro externo. Não relacionado ao WhatsApp, mas protege fluxo paralelo.
---
## 📚 Referência de arquivos
### Edge functions (`supabase/functions/`)
- `evolution-whatsapp-inbound/` — webhook Evolution (msgs inbound + auto-reply + opt-out + media)
- `evolution-webhook-provision/` — configura webhook na Evolution
- `twilio-whatsapp-inbound/` — webhook Twilio (inbound only, sem automações ainda)
- `twilio-whatsapp-provision/` — provisiona subconta Twilio (SaaS admin only)
- `send-whatsapp-message/` — envio unificado (Evolution ou Twilio com dedução)
- `send-session-reminders/` — worker de lembretes (chamado por cron)
- `create-whatsapp-credit-charge/` — cria PIX Asaas
- `asaas-webhook/` — recebe eventos Asaas e credita saldo
- `deactivate-notification-channel/` — soft-delete de canal (via service_role)
### Composables (`src/composables/`)
- `useConversations.js` — Kanban threads
- `useConversationNotes.js` — notas internas
- `useConversationTags.js` — tags CRUD + apply
- `useConversationOptouts.js` — opt-outs + keywords
- `useAutoReplySettings.js` — config auto-reply
- `useSessionReminders.js` — config lembretes + logs
- `useWhatsappCredits.js` — saldo + loja + extrato
### Páginas principais (`src/layout/configuracoes/`)
- `ConfiguracoesWhatsappChooserPage.vue` — landing/chooser
- `ConfiguracoesWhatsappPage.vue` — setup Evolution (Pessoal)
- `ConfiguracoesTwilioWhatsappPage.vue` — setup Twilio (Oficial, rebrandeado)
- `ConfiguracoesConversasTagsPage.vue` — CRUD tags
- `ConfiguracoesConversasOptoutsPage.vue` — lista opt-outs + keywords
- `ConfiguracoesConversasAutoreplyPage.vue` — auto-reply
- `ConfiguracoesLembretesSessaoPage.vue` — lembretes
- `ConfiguracoesCreditosWhatsappPage.vue` — saldo + loja + histórico
### Tabelas principais (database-novo/schema/)
Ver dashboard interativo: `database-novo/agenciapsi-db-dashboard.html` (regenerado via `node db.cjs dashboard`).
Domínios no dashboard:
- **CRM Conversas (WhatsApp)** — todas as tabelas de conversas, notas, tags, opt-outs, auto-reply, lembretes
- **Addons / Créditos** — créditos WhatsApp (balance, transactions, packages, purchases)
- **Comunicação / Notificações** — canais, templates, queue
---
## 📌 Dívidas técnicas conhecidas
1. **Auto-reply via Twilio** — hoje só funciona com Evolution. Precisa portar lógica pra `twilio-whatsapp-inbound`.
2. **Opt-out via Twilio** — idem (keyword detection no inbound Twilio).
3. **Admin SaaS UI de créditos** — topup manual + gestão de pacotes (hoje via SQL).
4. **Input de CPF real** no dialog de compra (hoje fallback hardcoded em sandbox).
5. **Alerta de saldo baixo** — estrutura DB pronta (`low_balance_threshold`, `low_balance_alerted_at`), falta trigger/notification.
6. **Reconnect Evolution automático** — Grupo 6.3 do roadmap (heartbeat + reconnect).
7. **pg_cron em prod** — migration tem bloco comentado; ativar via Supabase Dashboard → Database → Cron Jobs ou descomentar após setar `app.settings.service_role_key`.
---
**Última atualização:** 2026-04-21 (sessão de features CRM WhatsApp + Marco B Créditos)
+159
View File
@@ -0,0 +1,159 @@
# DialogConfirmation — Padrão de Componente
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
---
## Regras gerais
| Propriedade | Valor obrigatório |
|---|---|
| `group` | sempre `"headless"` — desacopla o template do trigger |
| `ConfirmDialog` | declarado **uma única vez**, no componente pai (página) |
| Filhos | disparam via `useConfirm()` com `group: 'headless'` — sem declarar `ConfirmDialog` próprio |
| `icon` | passado em `confirm.require({ icon })` — classe PrimeIcons sem o prefixo `pi` (ex: `'pi-trash'`) |
| `color` | passado em `confirm.require({ color })` — hex; define o fundo do círculo e a cor do botão Confirmar |
| `ConfirmationService` | obrigatório em `main.js``app.use(ConfirmationService)` |
---
## Arquitetura pai / filho
```
Pai (página)
└── <ConfirmDialog group="headless" /> ← único, renderiza aqui
├── Filho A (componente qualquer) → confirm.require({ group: 'headless', ... })
└── Filho B (Dialog interno) → confirm.require({ group: 'headless', ... })
```
> O `ConfirmDialog` **não** deve ser colocado dentro de um `<Dialog>` filho — isso causaria dois popups simultâneos. Sempre no pai.
---
## Setup obrigatório — `main.js`
```js
import ConfirmationService from 'primevue/confirmationservice'
app.use(ConfirmationService) // sem isso, useConfirm() não funciona
```
---
## Template do `ConfirmDialog` (somente no pai)
```vue
<!-- Declarado uma única vez, antes do conteúdo principal -->
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<!-- Círculo central: cor e ícone vindos de message -->
<div
class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20"
:style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }"
>
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<!-- Confirmar: cor dinâmica via message.color -->
<Button
label="Confirmar"
class="rounded-full"
:style="{
background: message.color || 'var(--p-primary-color)',
borderColor: message.color || 'var(--p-primary-color)'
}"
@click="acceptCallback"
/>
<!-- Cancelar: sempre outlined, neutro -->
<Button
label="Cancelar"
variant="outlined"
class="rounded-full"
@click="rejectCallback"
/>
</div>
</div>
</template>
</ConfirmDialog>
```
---
## Uso nos componentes (pai ou filhos)
```vue
<script setup>
import { useConfirm } from 'primevue/useconfirm'
const confirm = useConfirm()
function confirmDelete(item) {
confirm.require({
group: 'headless',
header: 'Excluir item?',
message: `"${item.name}" será removido permanentemente. Essa ação não pode ser desfeita.`,
icon: 'pi-trash',
color: '#ef4444',
accept: () => onDelete(item)
})
}
</script>
```
---
## Paleta de ícones e cores por ação
| Ação | `icon` | `color` | Observação |
|---|---|---|---|
| Excluir / Remover | `pi-trash` | `#ef4444` | Vermelho — ação destrutiva |
| Salvar / Confirmar | `pi-save` | `var(--p-primary-color)` | Cor primária do tema |
| Editar / Atualizar | `pi-pencil` | `#f97316` | Laranja — mudança de estado |
| Aviso / Atenção | `pi-exclamation-triangle` | `#eab308` | Amarelo — ação reversível |
| Info / Neutro | `pi-info-circle` | `#3b82f6` | Azul — informativo |
---
## Referência completa de `confirm.require`
```js
confirm.require({
group: 'headless', // obrigatório — aponta para o ConfirmDialog correto
header: 'Título do popup', // linha em negrito
message: 'Descrição clara.', // linha secundária
icon: 'pi-trash', // sufixo PrimeIcons sem o "pi " inicial
color: '#ef4444', // hex — fundo do círculo + cor do botão Confirmar
accept: () => { /* ação confirmada */ },
reject: () => { /* opcional — ação cancelada */ }
})
```
---
## Checklist antes de usar
- [ ] `ConfirmationService` registrado no `main.js`
- [ ] `<ConfirmDialog group="headless">` declarado **apenas no pai**, antes do conteúdo
- [ ] Filhos usam `useConfirm()` com `group: 'headless'` — sem `ConfirmDialog` próprio
- [ ] `icon` passado como sufixo PrimeIcons: `'pi-trash'`, não `'pi pi-trash'`
- [ ] `color` em hex para ações com semântica de cor (delete = `#ef4444`)
- [ ] `header` curto e direto | `message` com contexto suficiente para o usuário decidir
- [ ] `accept` contém a ação real — `reject` é opcional
---
## Variações de confirmação
| Contexto | `header` | `icon` | `color` |
|---|---|---|---|
| Excluir registro | `'Excluir <entidade>?'` | `pi-trash` | `#ef4444` |
| Remover item de lista | `'Remover campo?'` | `pi-trash` | `#ef4444` |
| Salvar com impacto | `'Confirmar alterações?'` | `pi-save` | primária |
| Atualizar com risco | `'Atualizar <entidade>?'` | `pi-pencil` | `#f97316` |
| Ação irreversível genérica | `'Tem certeza?'` | `pi-exclamation-triangle` | `#eab308` |
+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`
+247
View File
@@ -0,0 +1,247 @@
# Dialog — Padrão de Componente
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
> **Tema-aware**: header e footer respeitam dark/light automaticamente via CSS vars
---
## Regras gerais
| Propriedade | Valor obrigatório |
|---|---|
| `modal` | sempre `true` |
| `maximizable` | sempre presente — botão nativo do PrimeVue, sem estado manual |
| `:draggable` | sempre `false` |
| `:closable` | `!saving` — desabilita o X durante operações assíncronas |
| `:dismissableMask` | `!saving` — impede fechar clicando fora durante saving |
| `pt:mask:class` | `backdrop-blur-xs` |
| Largura | `w-[50rem]` (padrão); responsivo via `:breakpoints` |
| Breakpoints | `{ '1199px': '90vw', '768px': '94vw' }` |
---
## Sistema de cores (tema-aware)
O dialog **nunca** deve usar `bg-gray-100` ou cores hardcoded — isso quebra no dark mode.
Usar sempre as CSS vars do projeto:
| Var | Light | Dark | Uso |
|---|---|---|---|
| `--surface-card` | `--p-surface-0` (branco) | `--p-surface-900` (quase preto) | Fundo do **corpo** do dialog (default) |
| `--surface-ground` | `--p-surface-100` (cinza claro) | `--p-surface-950` (preto) | Fundo do **header** e **footer** — um shade mais escuro que o card |
| `--surface-border` | `--p-content-border-color` | idem | Borda separadora entre header/content/footer |
| `--text-color` | preto | branco | Título principal |
| `--text-color-secondary` | cinza médio | cinza claro | Subtítulo, hints |
> Resumo: `bg-[var(--surface-ground)]` no header/footer fica **sempre um pouco mais escuro que o corpo**, em ambos os temas. Definido em `_light.scss:19` e `_dark.scss:19`.
---
## Estrutura obrigatória
```
<Dialog>
├── #header ← dot de cor (se aplicável), título/subtítulo, btn Excluir
├── Banner ← preview visual (opcional — apenas quando há cor/identidade visual)
├── Corpo ← campos do formulário
└── #footer ← Cancelar (flat) | Salvar (primary)
```
---
## Configuração completa do `<Dialog>`
```vue
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
maximizable
class="dc-dialog w-[50rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
pt:mask:class="backdrop-blur-xs"
>
```
### Detalhes do `pt`
| Chave | O que faz |
|---|---|
| `header` | `!p-3` padding uniforme; `!rounded-t-[12px]` borda top arredondada; `border-b` separador; `bg-[var(--surface-ground)]` fundo um shade mais escuro que o card (tema-aware) |
| `content` | `!p-3` padding interno do corpo (herda `bg-[var(--surface-card)]` do PrimeVue) |
| `footer` | `!p-0` remove padding nativo (controlado pelo wrapper interno); `!rounded-b-[12px]` borda bottom arredondada; `border-t` separador; `bg-[var(--surface-ground)]` mesmo fundo do header |
| `pcCloseButton` | `!rounded-md` remove o círculo nativo; `hover:!text-red-500` feedback de danger no hover |
| `pcMaximizeButton` | `!rounded-md` remove o círculo nativo; `hover:!text-primary` feedback de cor primária no hover |
> O `!` (important) é necessário porque o PrimeVue injeta estilos inline nos botões e no root do Dialog — sem ele o Tailwind perde a disputa de especificidade.
> **Migração de dialogs antigos**: trocar `bg-gray-100` por `bg-[var(--surface-ground)]`. O `shadow-[0_1px_0_0_rgba(255,255,255,0.06)]` antigo era um hack pro dark mode; pode ser removido (a borda já dá a separação).
---
## Header — slot `#header`
```
[dot-cor] [título / subtítulo] [btn-excluir] ← Close e Maximize nativos vêm após
```
- O PrimeVue injeta **Maximize** e **Close** automaticamente à direita do slot `#header`.
- O botão **Excluir** fica **sempre no header**, nunca no footer.
- Excluir desabilitado quando o registro é nativo/padrão: `:disabled="saving || isNativeRecord"`.
```vue
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor (omitir se não houver cor associada) -->
<span
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30
shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
:style="{ backgroundColor: previewBgColor }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate text-[var(--text-color)]">
{{ form.name || (mode === 'create' ? 'Novo item' : 'Editar item') }}
</div>
<div class="text-xs text-[var(--text-color-secondary)]">
{{ mode === 'create' ? 'Criando novo registro' : 'Editando registro' }}
</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<!-- Excluir visível apenas em edit, desabilitado se nativo -->
<Button
v-if="mode === 'edit' && canDelete !== undefined"
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving || isNativeRecord"
v-tooltip.top="'Excluir'"
@click="emitDelete"
/>
<!-- Maximize e Close nativos do PrimeVue são injetados aqui automaticamente -->
</div>
</div>
</template>
```
> **Cores**: usar `text-[var(--text-color)]` no título e `text-[var(--text-color-secondary)]` no subtítulo. Não usar `opacity-50` — a cor secondary já tem contraste calibrado por tema.
---
## Footer — slot `#footer`
```vue
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<!-- Cancelar: sempre flat, hover vermelho suave -->
<Button
label="Cancelar"
severity="secondary"
text
class="rounded-full hover:!text-red-500"
:disabled="saving"
@click="close"
/>
<!-- Salvar: sempre primary -->
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
```
> **Regra**: Cancelar = `severity="secondary" text` + `hover:!text-red-500`. Salvar = primary (sem severity, usa o padrão do tema). Padding controlado pelo `div` interno (`px-3 py-3`), não pelo `pt.footer`.
---
## Maximizar
Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automaticamente — sem `ref`, sem `isMaximized`, sem `<Button>` manual.
```vue
<Dialog maximizable ...>
```
Se você precisar customizar a largura/altura quando maximizado (ex: `100vw`), use `:style` reativo a um ref `maximized` E passe `:maximizable="false"` + um botão manual no `#header`. Padrão preferido: deixar o PrimeVue gerenciar.
---
## Dialogs aninhados (Dialog dentro de Dialog)
Quando um Dialog secundário (criar tag, criar grupo, criar convênio) é aberto a partir do form de um Dialog principal:
- Cada Dialog é independente — `v-model:visible` próprio
- O Dialog secundário usa o **mesmo blueprint** (mesmo `pt`, mesmas cores)
- Pode ser menor: `w-[36rem]` é o tamanho típico de "cadastro rápido"
- Z-index: PrimeVue gerencia automaticamente (último aberto fica em cima)
- Ao salvar no Dialog secundário, o item criado pode ser auto-selecionado no Dialog principal (UX comum em formulários grandes)
---
## Checklist antes de publicar um Dialog
- [ ] `modal`, `:draggable="false"`, `:closable="!saving"`, `:dismissableMask="!saving"` presentes
- [ ] `maximizable` na prop (botão nativo, sem estado manual)
- [ ] `class="dc-dialog w-[50rem]"` + `:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"`
- [ ] `pt` completo: header, content, footer, pcCloseButton, pcMaximizeButton
- [ ] Header com `bg-[var(--surface-ground)]`, `border-b`, e `!rounded-t-[12px]`
- [ ] Footer com `bg-[var(--surface-ground)]`, `border-t`, e `!rounded-b-[12px]`
- [ ] **Nenhum `bg-gray-100` ou cor hardcoded** — só CSS vars tema-aware
- [ ] Botão **Excluir** no header (nunca no footer), desabilitado se nativo
- [ ] Cancelar = `text` + `hover:!text-red-500` | Salvar = primary
- [ ] Padding do footer via `px-3 py-3` no `div` interno
- [ ] Texto usa `text-[var(--text-color)]` e `text-[var(--text-color-secondary)]`
---
## Variações de largura
| Uso | Classe |
|---|---|
| Cadastro rápido / formulário simples | `w-[36rem]` |
| Formulário padrão | `w-[50rem]`**padrão** |
| Formulário complexo (multi-coluna) | `w-[70rem]` |
| Cadastro completo (paciente, agenda) | `w-[1100px]` |
| Tela cheia | `maximizable` — usuário controla |
---
## Anti-pattern
```vue
<!-- NÃO fazer: -->
<Dialog :pt="{
header: { class: 'bg-gray-100' }, // quebra no dark
footer: { class: 'bg-gray-100' }, // quebra no dark
}" />
<!-- NÃO fazer: -->
<div class="text-base opacity-50">subtítulo</div> <!-- usar text-color-secondary -->
```
```vue
<!-- Pattern correto: -->
<Dialog :pt="{
header: { class: 'bg-[var(--surface-ground)] border-b border-[var(--surface-border)]' },
footer: { class: 'bg-[var(--surface-ground)] border-t border-[var(--surface-border)]' },
}" />
<div class="text-xs text-[var(--text-color-secondary)]">subtítulo</div>
```
+749
View File
@@ -0,0 +1,749 @@
# Blueprint — Melissa Page
Padrão de página fullscreen dentro do MelissaLayout (Direção B do redesign).
Use isto como molde pra cada nova página: Financeiro, WhatsApp, Prontuários
etc. Validado em `MelissaAgenda.vue` (referência canônica) e
`MelissaPacientes.vue`.
---
## 1. Princípio
Cada Melissa Page é um componente fullscreen que ocupa o viewport inteiro
(menos 6px de respiro + faixa do dock 76px no bottom), montado via
`v-if="layoutReady && secaoAberta === '<key>'"` no `MelissaLayout.vue`.
A página tem **uma área central de conteúdo principal** (a coluna que importa)
e **0N colunas auxiliares** (asides). No desktop convivem lado a lado; no
mobile (<lg), as auxiliares saem do layout e viajam pra um drawer
off-canvas via `<Teleport>`.
---
## 2. Estrutura macro do template
```
<template>
<!-- 1) Drawer host: SEMPRE fora do .xx-page, sibling. v-show controla
visibilidade pra ser um Teleport target válido em todo momento. -->
<aside class="xx-mobile-drawer" :class="{ 'is-open': drawerOpen }" v-show="isMobile">
<div id="xx-mobile-drawer-target" class="xx-mobile-drawer__scroll" />
</aside>
<!-- 2) Backdrop: irmão do drawer, animado via <Transition>. -->
<Transition name="xx-drawer-fade">
<div v-if="isMobile && drawerOpen" class="xx-mobile-drawer__backdrop" @click="fecharDrawer" />
</Transition>
<!-- 3) Página propriamente dita -->
<section class="xx-page">
<header class="xx-page__head">
<button class="xx-menu-btn xx-menu-btn--mobile-only" @click="toggleDrawer">
<i class="pi pi-bars" /><span>Menu</span>
</button>
<div class="xx-page__title">…</div>
<div class="xx-page__actions">…</div>
</header>
<div class="xx-body">
<!-- Asides: cada um vai pro drawer em mobile via Teleport -->
<Teleport to="#xx-mobile-drawer-target" :disabled="!isMobile">
<aside class="xx-side">…</aside>
</Teleport>
<!-- Conteúdo central — SEMPRE fica em .xx-page, nunca teleporta -->
<div class="xx-main">…</div>
<Teleport to="#xx-mobile-drawer-target" :disabled="!isMobile">
<aside class="xx-widgets">…</aside>
</Teleport>
</div>
</section>
</template>
```
> Substitua `xx-` pelo prefixo da página (`ma-` agenda, `mp-` pacientes,
> `mf-` financeiro, etc.).
---
## 3. Breakpoints
```
≥1280px (xl) → todas as colunas + filtros inline na toolbar
10241279 (lg→xl) → todas as colunas + filtros migram pro botão "Ações"
≤1023px (<lg) → 1 coluna (central 100%) + asides off-canvas no drawer
título da página some em <lg, "Menu" button aparece
```
Convenção: se a página não tem filtros/toolbar complexa, ignore o
breakpoint xl e trabalhe só com lg.
---
## 4. Z-index hierarchy
```
.xx-mobile-drawer 80 ← drawer aberto cobre o ψ
.xx-mobile-drawer__backdrop 79 ← acima do ψ, abaixo do drawer
.psi-btn 70 ← botão Melissa (workspace)
.melissa-dock 65 ← faixa bottom (chip cronômetro etc.)
.xx-page 40 ← página em si
```
Drawer e backdrop **devem ficar acima do ψ**. O ψ continua abaixo pra ser
coberto quando o drawer está aberto (decisão de UX validada com Leonardo).
---
## 5. Setup do `<script setup>`
```js
import { ref, onMounted, onBeforeUnmount } from 'vue';
const drawerOpen = ref(false);
const isMobile = ref(false);
const isCompact = ref(false);
let _mqMobile = null;
let _mqCompact = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false; // saiu do mobile, fecha drawer
}
function _onMqCompactChange(e) {
isCompact.value = e.matches;
}
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
_mqCompact = window.matchMedia('(max-width: 1279px)');
isCompact.value = _mqCompact.matches;
_mqCompact.addEventListener('change', _onMqCompactChange);
}
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
if (_mqCompact) _mqCompact.removeEventListener('change', _onMqCompactChange);
});
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
```
---
## 6. CSS base (copy-paste, troque `xx-` pelo prefixo)
```css
/* Container glass — convenção das Melissa Pages */
.xx-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: xx-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes xx-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
/* Header da página */
.xx-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.xx-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
.xx-page__title > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xx-page__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* Body — flex row em desktop, column em mobile */
.xx-body {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
/* Botão "Menu" (mobile only) — primary filled, abre o drawer */
.xx-menu-btn { display: none; /* show via @media abaixo */ }
.xx-menu-btn {
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.xx-menu-btn:hover { background: color-mix(in srgb, var(--m-accent) 88%, white); transform: translateY(-1px); }
.xx-menu-btn:active { transform: translateY(0); }
/* Drawer mobile — fora do .xx-page, fullheight */
.xx-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh; /* iOS toolbar dynamic */
width: min(360px, 88vw);
z-index: 80; /* acima do ψ (70) */
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
}
.xx-mobile-drawer.is-open { transform: translateX(0); }
.xx-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.xx-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.xx-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Asides perdem padding/scroll/borda próprios quando teleportados pro drawer */
.xx-mobile-drawer__scroll .xx-side,
.xx-mobile-drawer__scroll .xx-widgets {
width: 100%;
flex-shrink: 0;
height: auto;
overflow: visible;
border-right: none;
border-left: none;
background: transparent;
padding: 0;
}
/* Backdrop */
.xx-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.xx-drawer-fade-enter-active,
.xx-drawer-fade-leave-active { transition: opacity 200ms ease; }
.xx-drawer-fade-enter-from,
.xx-drawer-fade-leave-to { opacity: 0; }
/* Mobile (<lg) — central 100%, asides off-canvas, título some */
@media (max-width: 1023px) {
.xx-body { flex-direction: column; }
.xx-main { width: 100%; }
.xx-page__title { display: none; }
.xx-menu-btn { display: inline-flex; }
}
```
---
## 7. Pegadinhas (DON'Ts)
### ❌ NÃO envolver Melissa Page com `<Transition>` no `MelissaLayout`
```vue
<!-- ERRADO leave delay cria orphan placeholder em Teleport
targets compartilhados. Crash: "Cannot set properties of null
(setting '__vnode')". -->
<Transition name="page-fade">
<MelissaXxx v-if="secaoAberta === 'xxx'" />
</Transition>
<!-- CERTO animação como @keyframes na própria .xx-page -->
<MelissaXxx v-if="layoutReady && secaoAberta === 'xxx'" />
```
### ❌ NÃO importar `Menu` do PrimeVue manualmente
PrimeVueResolver auto-importa. Import duplo cria instâncias fantasmas e
quebra o reconciler com `emitsOptions: null` em `shouldUpdateComponent`.
```js
// ❌ NÃO faça
import Menu from 'primevue/menu';
```
### ❌ NÃO usar `<Teleport><Transition><Element v-if>`
Quando múltiplos Teleports compartilham target (ex: `.melissa-dock`):
```vue
<!-- ERRADO placeholders órfãos no target compartilhado -->
<Teleport to=".melissa-dock">
<Transition name="...">
<Element v-if="cond" />
</Transition>
</Teleport>
<!-- CERTO Transition envolve Teleport, não o contrário -->
<Transition name="...">
<Teleport v-if="cond" to=".melissa-dock">
<Element />
</Teleport>
</Transition>
```
### ❌ NÃO escopar CSS de Teleport target
Targets globais (`.melissa-dock`, `#xx-mobile-drawer-target`) precisam
de CSS no `<style>` (sem `scoped`). Vue compiler hoista nodes static e
perde `data-v-{hash}`, então o seletor scoped não casa.
### ⚠️ Em deep-link (URL → secaoAberta), precisa do `layoutReady`
`MelissaLayout` expõe `layoutReady` que vira true 1 nextTick após mount.
Use `v-if="layoutReady && secaoAberta === 'xxx'"` no MelissaLayout, não
`v-if="secaoAberta === 'xxx'"`. Sem isso, o `<Teleport to=".melissa-dock">`
da Melissa Page tenta achar target que ainda não foi montado → crash em
`moveTeleport → insertBefore(null, ...)` quando triggers reativos do
PrimeVue setTheme caem entre mount e flush.
### ⚠️ Tooltips PrimeVue
Em código real use `v-tooltip.top="'texto'"` (auto-registrado via
PrimeVueResolver). NÃO use `title=""` em produção — só vale em preview.
---
## 8. Wire-up no `MelissaLayout.vue`
1. Importar o componente:
```js
import MelissaFinanceiro from './MelissaFinanceiro.vue';
```
2. Adicionar a section na lista de seções "promovidas" (perto de
`MelissaAgenda`/`MelissaPacientes` em `MelissaLayout.vue:~1273`):
```vue
<MelissaFinanceiro
v-if="layoutReady && secaoAberta === 'financeiro'"
@close="fecharSecao"
/>
```
3. Adicionar `'financeiro'` ao `SECOES` map se ainda não estiver.
4. Atualizar o item correspondente no `MelissaMenu.vue` pra emit
`select('financeiro')` (sem `route`) — fica seção interna do Melissa.
OU manter com `route: { name: 'therapist-financeiro' }` se for navegar
pra fora do Melissa (depende do escopo da página).
---
## 9. Loading states
Princípio: **skeleton só na primeira carga** (sem dados ainda). Refetches
subsequentes (mudança de range, refresh manual) mantêm a UI estável e
mostram só feedback discreto (overlay leve / spinner em botão).
### Classe global `.melissa-skeleton`
Definida no bloco `<style>` (não scoped) do `MelissaLayout.vue`. Herda do
shimmer global, respeita `prefers-reduced-motion`. Variantes:
| Classe | Uso |
|---|---|
| `.melissa-skeleton--text` | Linha de texto (~12px) |
| `.melissa-skeleton--title` | Heading (~18px) |
| `.melissa-skeleton--number` | Número de stat (~24×32px) |
| `.melissa-skeleton--avatar` | Círculo 32×32 |
### Pattern: skeleton só na 1ª carga
```js
// Computed no <script setup>
const pacientesCarregandoInicial = computed(
() => props.pacientesLoading && (props.pacientes?.length || 0) === 0
);
```
```vue
<!-- Template — bifurca pelo computed -->
<template v-if="pacientesCarregandoInicial">
<div v-for="i in 6" :key="`psk-${i}`" class="xx-pat xx-pat--skeleton" aria-busy="true">
<span class="xx-pat__avatar melissa-skeleton melissa-skeleton--avatar" />
<span class="melissa-skeleton melissa-skeleton--text" :style="{ width: `${55 + (i * 7) % 30}%` }" />
</div>
</template>
<div v-for="p in pacientes" v-else :key="p.id" class="xx-pat">…</div>
```
Variar a `width` do skeleton com a expressão `${55 + (i * 7) % 30}%` evita
linhas idênticas — fica mais natural visualmente.
### Pattern: classe `--skeleton` neutraliza hover/cursor
```css
.xx-pat--skeleton,
.xx-stat--skeleton,
.xx-sess--skeleton {
cursor: default;
pointer-events: none;
opacity: 0.95;
}
.xx-pat--skeleton:hover { background: inherit; transform: none; }
```
### Pattern: overlay de loading (refetch silencioso)
Quando o componente já tem dados mas tá refetcheando (ex: FullCalendar
trocando de view), use um overlay pequeno no canto:
```vue
<Transition name="xx-loading-fade">
<div v-if="loadingRef" class="xx-loading-corner" aria-busy="true">
<i class="pi pi-spin pi-spinner" />
</div>
</Transition>
```
```css
.xx-loading-corner {
position: absolute;
top: 8px; right: 8px;
z-index: 5;
pointer-events: none; /* não bloqueia clicks */
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: color-mix(in srgb, var(--m-bg-medium) 80%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--m-border);
color: var(--m-text-muted);
}
```
### Pattern: botão com spinner durante operação
```vue
<button
class="xx-act-btn"
:disabled="busy"
@click="onClick"
>
<i :class="busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'" />
<span>Agendar</span>
</button>
```
```js
const busy = ref(false);
async function onClick() {
if (busy.value) return;
busy.value = true;
try {
await operacao();
} finally {
// Pequeno timeout pra UI mostrar o spinner mesmo quando a operação
// é síncrona (perceived performance).
setTimeout(() => { busy.value = false; }, 200);
}
}
```
---
## 10. Popover de "Ações" da toolbar
Quando filtros/toggles inline ficam apertados (`<xl`), migre pra um
**Popover com `<SelectButton>`** em vez do antigo `<Menu>` com lista.
Vantagens: estado visível direto (não precisa abrir/fechar pra ver),
mudança imediata sem fechar o popover, melhor pra dedo em mobile.
```vue
<button class="xx-cal__btn xx-cal__btn--compact-only" @click="openActions">
<i class="pi pi-ellipsis-v" /><span>Ações</span>
</button>
<Popover ref="actionsPopRef" class="xx-actions-pop">
<div class="xx-actions">
<div class="xx-actions__group">
<div class="xx-actions__label">Visualização</div>
<SelectButton v-model="view" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" class="w-full" />
</div>
<div class="xx-actions__divider" />
<!-- Ações que não são toggle de estado ficam como botões -->
<div class="xx-actions__group">
<div class="xx-actions__label">Bloquear</div>
<div class="xx-actions__buttons">
<button class="xx-actions__btn" @click="onBlock('horario')">
<i class="pi pi-clock" /><span>Por horário</span>
</button>
<!-- … -->
</div>
</div>
</div>
</Popover>
```
```js
import Popover from 'primevue/popover'; // ← obrigatório (auto-import só pega Menu)
const actionsPopRef = ref(null);
function openActions(e) { actionsPopRef.value?.toggle(e); }
function closeActions() { try { actionsPopRef.value?.hide(); } catch {} }
```
CSS do popover: ver `.ma-actions*` em `MelissaAgenda.vue` como referência
(min-width 260px, gap 14px entre groups, divisor sutil, botões em grid 2×2).
> **Quando usar `<Menu>` em vez de `<Popover>`:** menus de ação simples
> com 1-2 items (kebab de paciente, etc.) — lista vertical funciona e é
> mais leve. Use `<Popover>` quando tiver SelectButton, layout custom ou
> quiser que mudanças não fechem.
---
## 11. Header — convenção de botões
| Tipo | Tamanho | Border-radius | Notas |
|---|---|---|---|
| **Botão close** (X) | 32×32 icon-only | 9px | `display: grid; place-items: center` |
| **Botão action icon-only** (config, settings) | 32×32 icon-only | 9px | Mesmo template do close |
| **Botão "Menu" mobile** (abre drawer) | 32px alto, padding 0 11px | 9px | Primary filled (`var(--m-accent)`) |
Regra: **botões icon-only no header sempre 32×32**. Não use `padding`
livre — sai com tamanho diferente do close e quebra alinhamento visual.
```css
.xx-head-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
transition: background-color 140ms ease;
}
.xx-head-btn > i { font-size: 0.85rem; }
```
---
## 12. Border-radius — convenção
Teto **12px** pra qualquer elemento dentro de uma Melissa Page. Hierarquia:
| Nível | Elemento | Radius |
|---|---|---|
| Container externo | `.xx-page` (a "tela" inteira) | **18px** |
| Card / widget | `.xx-w` (containers internos) | **12px** |
| Item dentro de card | `.xx-stat`, `.xx-sess`, `.xx-pat` | **10px** |
| Botão small | `.xx-head-btn`, `.xx-close`, ações da toolbar | **9px** |
| Pill / badge | counts, novo, status | **999px** (full round) |
| Avatar | `.xx-pat__avatar` | **50%** |
**Não passe de 12px em cards internos.** Visualmente conflita com o radius
do container externo (18px) e fica "infantil".
---
## 13. Checklist pra cada nova Melissa Page
### Estrutura
- [ ] Componente `Melissa<Nome>.vue` em `src/layout/melissa/`
- [ ] Prefixo CSS único (`mf-`, `mw-`, `mr-`...)
- [ ] Estrutura template: drawer host (sibling) + backdrop + `<section class="xx-page">`
- [ ] `<Teleport>` em cada aside, target `#xx-mobile-drawer-target`
- [ ] `isMobile`/`isCompact` via matchMedia (1023/1279)
- [ ] `drawerOpen`/`toggleDrawer`/`fecharDrawer`
- [ ] Botão "Menu" mobile-only no header
- [ ] Botão "Fechar" no header → `emit('close')` (volta pro resumo)
- [ ] `@keyframes xx-page-enter` em `.xx-page` (não use `<Transition>` no parent)
- [ ] z-index drawer 80, backdrop 79
- [ ] CSS de drawer e backdrop com mesmas dimensões da Agenda (`min(360px, 88vw)`)
- [ ] Wire-up no `MelissaLayout.vue` com `layoutReady &&`
- [ ] Adicionar entry no `MelissaMenu` (com ou sem `route`)
### Loading
- [ ] Composable expõe `loading` ref
- [ ] Prop `xxxLoading` na Melissa Page (passa do parent)
- [ ] Computed `xxxCarregandoInicial` (`loading && data.length === 0`)
- [ ] Skeleton com `melissa-skeleton` + variantes nos lugares que importam
- [ ] Botões de ação (criar, salvar) com `:disabled="busy"` + spinner
### Visual
- [ ] Botões icon-only no header: 32×32, radius 9px
- [ ] Cards internos: radius 12px (containers) / 10px (items)
- [ ] Toggles/filtros em `<Popover>` com `<SelectButton>` (não `<Menu>` lista)
---
## 14. Pattern: CRUD de catálogo (Tags / Grupos / Médicos)
Páginas estilo "catálogo simples" — entidades com nome + cor (ou só dados de
contato), CRUD básico, contagem de itens vinculados. Layout 2-col padrão:
- **Aside (~280px)**: stats (4 cards 2×2) + busca
- **Main**: lista de cards (cor/avatar + nome + meta + actions)
- **Click no card** → dialog edit
- **Botão "+ Novo"** no header do `mp-page__actions`
- **Lock visual** em items "do sistema" (tags padrão, grupos sistema, etc.) —
cards não-clicáveis, sem botões editar/excluir
- **Color picker** nativo (`<input type="color">`) + 12 preset colors clicáveis
no dialog
Em mobile: `Novo` vira icon-only 32×32 (texto some via media query).
## 15. Pattern: Lista com dialog de detalhes (Cadastros Recebidos)
Páginas onde cada item tem **muitas informações** que não cabem no card.
Padrão:
- Card mostra **só o essencial** (nome + contato + status + tempo)
- Click → **dialog de detalhes** com seções de campos (`grid-cols-2 gap-x-4 gap-y-1`)
- Footer do dialog tem **ações principais à direita** (Rejeitar / Converter)
- Dialog usa `Dialog` do PrimeVue com `:visible` controlado (não `v-model:visible`
pra ter mais controle do close)
```vue
<Dialog
:visible="dlg.open"
modal
dismissable-mask
:style="{ width: '640px', maxWidth: '94vw' }"
@update:visible="(v) => !v && closeDlg()"
>
<div class="flex flex-col gap-4">
<!-- Header com avatar + status + tempo -->
<!-- Seções: Identificação, Documentos, Endereço, ... -->
<div v-for="sec in dlgSections" :key="sec.title">
<div class="text-[0.62rem] uppercase tracking-wider font-semibold opacity-70">{{ sec.title }}</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<template v-for="r in sec.rows" :key="r.label">
<div class="text-[var(--text-color-secondary)]">{{ r.label }}</div>
<div>{{ r.value }}</div>
</template>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" text @click="closeDlg" />
<div class="flex-1" />
<!-- Ações principais à direita -->
</template>
</Dialog>
```
## 16. Pattern: Kanban grid (Conversas / threads)
Páginas com **status discretos** (urgent / awaiting / resolved) como Conversas:
- Aside esquerda: filtros + atribuição + canais + resumo por status
- Main: **grid kanban N-col** (4 cols xl, 2 cols compact, 1 col mobile)
- Cada coluna tem header colorido por status (red/amber/blue/emerald)
- Cards são botões clicáveis dentro de scroll vertical da coluna
```css
.xx-kanban {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
flex: 1;
min-height: 0;
}
.xx-col { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.xx-col__body { flex: 1; overflow-y: auto; }
@media (max-width: 1279px) { .xx-kanban { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 1023px) { .xx-kanban { grid-template-columns: 1fr; } }
```
Cores semânticas (consistentes em todas as Melissa Pages):
- `red`: 248,113,113 (urgente, faltou, rejeitado)
- `amber`: 251,191,36 (aguardando, novo, pendente)
- `blue`: 96,165,250 (info, remarcado)
- `emerald`/`green`: 74,222,128 (ok, resolvido, compareceu)
## 17. Reaproveitamento de composables/services
Sempre **reutilizar a lógica de fetch/CRUD existente** em vez de duplicar:
| Página Melissa | Reutiliza |
|---|---|
| `MelissaCompromissos` | `DeterminedCommitmentDialog`, queries supabase diretas |
| `MelissaRecorrencias` | Lógica buildSessions/ruleStats da page antiga |
| `MelissaConversas` | `useConversations`, `useConversationTags`, `ConversationDrawer` |
| `MelissaCadastrosRecebidos` | Lógica de `convertToPatient` da page antiga |
| `MelissaMedicos` | `Medicos.service.js` (createMedico/updateMedico/deleteMedico) |
| `MelissaPacientes` | `useMelissaPacientes`, `patientsRepository` |
| `MelissaAgenda` | `useMelissaAgenda` (composable orquestrador) |
Isso garante:
- 0 duplicação de regras de negócio
- Bugs corrigidos numa página antiga já valem na Melissa version
- Migração futura pra route real (Fase 5) é trivial
## 18. Referência canônica
- **3 colunas + breakpoints xl+lg + popover Ações + skeletons**: `MelissaAgenda.vue`
- **3 colunas com filtros + cards + quickview + drill-down mobile**: `MelissaPacientes.vue`
- **CRUD catálogo simples (cor+nome+contagem)**: `MelissaTags.vue`, `MelissaGrupos.vue`
- **Catálogo com mais campos**: `MelissaMedicos.vue`
- **Lista + dialog de detalhes + ações finais**: `MelissaCadastrosRecebidos.vue`
- **Cards com expansão (timeline/sessions)**: `MelissaRecorrencias.vue`
- **Kanban N-col por status**: `MelissaConversas.vue`
- **Reusa dialog externo**: `MelissaCompromissos.vue` (`DeterminedCommitmentDialog`)
- **Wrapper**: `MelissaLayout.vue` (`layoutReady`, montagem das páginas, classe global `.melissa-skeleton`)
- **Menu de navegação**: `MelissaMenu.vue` (drill-down mobile + drawer 360px)
+812
View File
@@ -0,0 +1,812 @@
# Blueprint — Melissa Table Page
Padrão de página Melissa que apresenta uma **coleção tabular** (intake
requests, médicos, recorrências, compromissos, etc.) com 2 modos de
visualização (lista/grade), filtros laterais coloridos, busca, e
DataTable com paginação + coluna de ação fixa.
Validado em `src/layout/melissa/MelissaCadastrosRecebidos.vue`
(referência canônica). Estende o
[`melissa-page-blueprint.md`](./melissa-page-blueprint.md) — leia aquele
primeiro pra entender a estrutura macro (`.xx-page` / `.xx-body` /
`.xx-side` / `.xx-main`, drawer mobile, header).
---
## 1. Princípio
Página de coleção = **sidebar de filtros + coluna principal com
toolbar + visualização tabular**. O user controla:
- **Busca** (texto livre — nome / email / telefone / etc.)
- **Filtro de status** (mutualmente exclusivo, com botão "Limpar")
- **Modo de visualização** (lista densa via DataTable ou grade de cards)
- **Paginação** (10/20/50/100 por página)
A linha tem 1 ação primária visível (botão pencil) que abre um Dialog
com detalhes + ações secundárias (rejeitar, converter, etc.).
---
## 2. Estrutura do template
Segue a macro do `melissa-page-blueprint.md` (drawer + backdrop + page
+ header + body com aside Teleportada). Sobre essa base, esta blueprint
adiciona um **subheader explicativo** (logo abaixo do header, antes do
body) e a estrutura tabular dentro da `.xx-main`:
```vue
<section class="xx-page">
<header class="xx-page__head"></header>
<!-- Subheader explicativo 1 frase de contexto sobre o que essa
página faz, com palavras-chave em <strong>. Diferencia páginas
que têm layout idêntico (ex: Cadastros Recebidos vs.
Agendamentos Recebidos). -->
<div class="xx-subheader">
<i class="pi pi-info-circle xx-subheader__icon" />
<span class="xx-subheader__text">
Texto descritivo da página em 1-2 frases. Use
<strong>palavras-chave</strong> em negrito pra destacar as
ações disponíveis (autorize, recuse, converta, etc.).
</span>
</div>
<div class="xx-body">sidebar + main</div>
</section>
```
A diferença dentro da `.xx-main`:
```vue
<div class="xx-main">
<!-- A) Toolbar busca + view toggle -->
<div class="xx-toolbar">
<div class="xx-search">
<i class="pi pi-search xx-search__icon" />
<input v-model="busca" class="xx-search__input" placeholder="…" />
<button v-if="busca" class="xx-search__clear" @click="busca = ''">
<i class="pi pi-times" />
</button>
</div>
<div class="xx-view-toggle" role="group" aria-label="Visualização">
<button :class="{ 'is-active': viewMode === 'list' }" @click="setViewMode('list')">
<i class="pi pi-list" />
</button>
<button :class="{ 'is-active': viewMode === 'grid' }" @click="setViewMode('grid')">
<i class="pi pi-th-large" />
</button>
</div>
</div>
<!-- B) View Lista (DataTable) -->
<DataTable v-if="viewMode === 'list'" />
<!-- C) View Grade (cards em CSS grid + Paginator standalone) -->
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
<div class="xx-grid">
<div v-for="r in pagedItems" class="xx-grid__card" role="button" tabindex="0" @click="openDetails(r)"></div>
</div>
<Paginator class="xx-paginator" :rows="rowsXX" :first="firstXX" />
</div>
</div>
```
E na sidebar (`.xx-side`), ao invés de Hoje/Pacientes/Mini-cal, tem:
```vue
<aside class="xx-side">
<!-- Stats (4 contadores em grid 2x2) -->
<div class="xx-w xx-w--side">
<div class="xx-w__head">
<span class="xx-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="xx-stats">
<div v-for="s in stats" class="xx-stat" :class="`is-${s.cls}`">
<div class="xx-stat__val">{{ s.value }}</div>
<div class="xx-stat__lbl">{{ s.label }}</div>
</div>
</div>
</div>
<!-- Filtros (botões coloridos por status + Limpar filtro) -->
<div class="xx-w xx-w--side">
<div class="xx-w__head">
<span class="xx-w__title"><i class="pi pi-filter" /> Status</span>
<span v-if="statusFilter" class="xx-w__count">1</span>
</div>
<div class="xx-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
class="xx-side__item"
:class="[`is-${o.key}`, { 'is-active': statusFilter === o.key }]"
@click="toggleStatusFilter(o.key)"
>
<i :class="o.icon" /><span>{{ o.label }}</span>
</button>
<Transition name="xx-clear">
<button v-if="statusFilter" class="xx-side__item is-clear" @click="statusFilter = ''">
<i class="pi pi-filter-slash" /><span>Limpar filtro</span>
</button>
</Transition>
</div>
</div>
</aside>
```
---
## 3. Estado JS (script setup)
```js
// ── Filtros + busca ──
const busca = ref('');
const statusFilter = ref('');
function toggleStatusFilter(s) {
statusFilter.value = statusFilter.value === s ? '' : s;
}
// ── Computeds derivados ──
const stats = computed(() => {/* contadores por status */});
const filtered = computed(() => {/* aplica busca + statusFilter sobre rows */});
// ── Paginação compartilhada (DataTable + grid) ──
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const rowsXX = ref(10);
const firstXX = ref(0);
function onPage(event) {
firstXX.value = event.first;
rowsXX.value = event.rows;
}
watch([busca, statusFilter], () => { firstXX.value = 0; }); // reset à pg 1
// ── View mode persistido ──
const VIEW_MODE_KEY = 'xx.viewMode.v1';
const viewMode = ref('list');
try {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === 'list' || saved === 'grid') viewMode.value = saved;
} catch (_) {}
function setViewMode(m) {
if (m !== 'list' && m !== 'grid') return;
viewMode.value = m;
try { localStorage.setItem(VIEW_MODE_KEY, m); } catch (_) {}
}
// ── Slice da grid (DataTable pagina internamente) ──
const pagedItems = computed(() =>
filtered.value.slice(firstXX.value, firstXX.value + rowsXX.value)
);
// ── Row click + ação ──
function onRowClick(event) { if (event?.data) openDetails(event.data); }
function rowStatusClass(data) { return statusClass(data?.status); }
```
---
## 4. DataTable (view Lista) — props canônicas
```vue
<DataTable
v-if="viewMode === 'list'"
:value="filtered"
:loading="loading"
dataKey="id"
paginator
:rows="rowsXX"
:first="firstXX"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
:rowClass="rowStatusClass"
selectionMode="single"
scrollable
scrollHeight="flex"
tableStyle="min-width: 640px"
class="xx-table"
@row-click="onRowClick"
@page="onPage"
>
<Column header="Paciente" style="min-width: 220px">
<template #body="{ data }">avatar + nome + badge</template>
</Column>
<Column header="Contato" style="min-width: 220px">
<template #body="{ data }">email + tel</template>
</Column>
<Column header="Recebido" style="width: 130px">
<template #body="{ data }">tempo relativo</template>
</Column>
<!-- Coluna de ação fixa (frozen à direita) -->
<Column
header=""
:style="{ width: '60px', maxWidth: '60px', minWidth: '60px' }"
frozen
alignFrozen="right"
>
<template #body="{ data }">
<button class="xx-row__action" @click.stop="openDetails(data)">
<i class="pi pi-pencil" />
</button>
</template>
</Column>
<template #empty>empty state contextual</template>
<template #loading>spinner inline</template>
</DataTable>
```
### Props críticas explicadas
| Prop | Por quê |
|---|---|
| `:loading="loading"` | Overlay nativo do PrimeVue + slot `#loading` custom — substitui skeleton manual. |
| `paginator + :rows + :first + @page` | Paginator embutido controlado; `firstXX` permite resetar à página 1 quando filtros mudam. |
| `paginatorTemplate="RowsPerPageDropdown First… Last…"` | Ordem do exemplo PrimeVue 4: dropdown ANTES dos navegadores; CurrentPageReport no meio. |
| `currentPageReportTemplate="{first}{last} de {totalRecords}"` | i18n PT-BR. |
| `:rowClass="rowStatusClass"` | Aplica `is-new` / `is-done` / `is-rejected` no `<tr>` → border-left colorido via CSS deep. |
| `selectionMode="single"` | Marca visualmente a row selecionada; `@row-click` abre o dialog. |
| `scrollable + scrollHeight="flex"` | Tabela preenche o flex restante da `.xx-main` e scrolla internamente (vertical). |
| `tableStyle="min-width: 640px"` | Força scroll horizontal em mobile pra ativar a coluna frozen. |
| `dataKey="id"` | Identificação estável de rows pra seleção + reactive updates. |
### Coluna frozen — regras
- **Última `<Column>`** do template
- `frozen alignFrozen="right"` — fixa à direita
- `width: 60px, maxWidth: 60px, minWidth: 60px` — todas três pra evitar reflow durante scroll
- **`header=""`** vazio (icon do botão é auto-explicativo; tooltip cobre o resto)
- Botão interno usa **`@click.stop`** — sem isso, o row-click do DataTable também dispararia
---
## 5. View Grade (cards em CSS grid)
Quando `viewMode === 'grid'`, renderiza cards num grid responsivo com
Paginator standalone abaixo (compartilha state com a list view):
```vue
<div v-else-if="viewMode === 'grid'" class="xx-grid-wrap">
<div v-if="loading && filtered.length === 0" class="xx-grid__loading"></div>
<div v-else-if="filtered.length === 0" class="xx-empty"></div>
<div v-else class="xx-grid">
<div
v-for="r in pagedItems"
class="xx-grid__card"
:class="statusClass(r.status)"
role="button"
tabindex="0"
@click="openDetails(r)"
@keydown.enter.prevent="openDetails(r)"
@keydown.space.prevent="openDetails(r)"
>
<div class="xx-grid__top">
<span class="xx-card__avatar"></span>
<div class="xx-grid__top-right">
<span class="xx-card__badge" :class="statusClass(r.status)"></span>
<button class="xx-row__action" @click.stop="openDetails(r)">
<i class="pi pi-pencil" />
</button>
</div>
</div>
<div class="xx-grid__name"></div>
<div class="xx-grid__meta"></div>
<div class="xx-grid__time"></div>
</div>
</div>
<Paginator
v-if="filtered.length > 0"
class="xx-paginator"
:rows="rowsXX"
:totalRecords="filtered.length"
:first="firstXX"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
template="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
@page="onPage"
/>
</div>
```
### Por que `<div role="button">` em vez de `<button>`?
HTML não permite aninhar `<button>` em `<button>`. A grid card tem o
botão pencil interno, então o card precisa ser um `<div>` com
`role="button"`, `tabindex="0"` e handlers de teclado (`@keydown.enter`
+ `@keydown.space`) pra manter acessibilidade.
---
## 6. Tokens de surface (light/dark)
A consistência visual entre **header da tabela**, **coluna frozen**, e
**cards da sidebar** depende de usar o token certo:
| Elemento | Token | Light | Dark |
|---|---|---|---|
| `.xx-page` (background da página) | `var(--m-bg-medium)` | branco opaco | 88% opaco (glass) |
| `.xx-side` (sidebar) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
| `.xx-w` (cards) | `var(--m-bg-medium)` | branco opaco | 88% opaco |
| `.xx-card` / `.xx-grid__card` (cards de linha) | `var(--m-bg-soft)` | surface-100 | 50% opaco |
| **Header da tabela** (`.p-datatable-thead > tr > th`) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
| **Coluna frozen** (header + body) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
| **Botão pencil** (bg) | **`var(--p-content-background)`** | **branco opaco** | **surface dark configurado** |
**Por que `--p-content-background` e não `--m-bg-medium`** pro frozen?
No dark mode `--m-bg-medium` tem 12% de transparência (efeito glass),
o que faz a coluna frozen vazar conteúdo de outras colunas durante
scroll horizontal. `--p-content-background` é 100% opaco em ambos os
modos e segue a config de surface do tema PrimeVue (token canônico de
"superfície de card").
---
## 7. Cores de status (semântica + paleta)
Tailwind 600 — fortes o bastante pra ler em ambos os modos:
| Status | Cor | RGB | Uso |
|---|---|---|---|
| Novo / Pendente | 🔵 azul | `rgb(37, 99, 235)` | item recém-chegado, ação requerida |
| Convertido / Concluído | 🟢 verde | `rgb(22, 163, 74)` | sucesso, finalizado |
| Rejeitado / Cancelado | 🔴 vermelho | `rgb(220, 38, 38)` | descartado, falha |
**Aplicação consistente** em 4 lugares por status:
1. **Stat value** (`.xx-stat.is-info / is-ok / is-danger`) — número colorido
2. **Filtro lateral** (`.xx-side__item.is-X`) — bg/border/ícone tinted (3 níveis: default 5% / hover 10% / active 16% + ring)
3. **Border-left da row** (`.xx-table tr.is-X`) — 3px sólido na cor
4. **Badge** (`.xx-card__badge.is-X`) — pill colorido no card/row
Variável `cls` no objeto stats:
```js
{ key: 'new', label: 'Novos', value: n, cls: n > 0 ? 'info' : 'neutral' },
{ key: 'converted', label: 'Convertidos', value: c, cls: 'ok' },
{ key: 'rejected', label: 'Rejeitados', value: r, cls: r > 0 ? 'danger' : 'neutral' },
```
**Não usar `is-warn`** (amarelo) pra "Novo" — semanticamente novo é
informativo, não alerta.
---
## 8. Filtro de status — botões + "Limpar filtro"
3 botões coloridos (Novo / Convertido / Rejeitado) + 4º botão
**"Limpar filtro"** que aparece com `<Transition name="xx-clear">`
quando algum filtro está ativo:
```css
.xx-side__item.is-clear {
margin-top: 4px;
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-muted);
font-style: italic;
}
/* Fade + slide vertical + collapse de altura */
.xx-clear-enter-active,
.xx-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease,
max-height 220ms ease, margin-top 220ms ease;
overflow: hidden;
}
.xx-clear-enter-from,
.xx-clear-leave-to {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
margin-top: 0;
}
.xx-clear-enter-to,
.xx-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 40px;
}
```
**Estilo neutro/itálico** (não colorido) pra distinguir dos 3 botões
de filtro coloridos. Ícone `pi pi-filter-slash`.
---
## 9. Subheader explicativo
Faixa estreita abaixo do `xx-page__head`, antes do `xx-body`. Tem 2
papéis:
1. **Diferenciar páginas** que têm o mesmo layout (Cadastros Recebidos
vs. Agendamentos Recebidos parecem visualmente idênticos sem isso)
2. **Resumir as ações** disponíveis pra reduzir cliques exploratórios
do user
```css
.xx-subheader {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--m-border);
background: var(--m-bg-soft);
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.xx-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.xx-subheader__text {
flex: 1;
min-width: 0;
}
.xx-subheader__text strong {
color: var(--m-text);
font-weight: 600;
}
```
### Convenção do texto
- 1-2 frases curtas (~20-30 palavras max)
- Inicia descrevendo a fonte/origem dos dados ("Solicitações vindas
de...", "Cadastros enviados por...")
- Termina enumerando as ações principais com `<strong>`
(`autorize`, `recuse`, `converta`)
- Tom direto, sem formalidade excessiva ("a gente cria o paciente
automaticamente" ✓ vs. "o sistema procederá com a criação" ✗)
- Ícone fixo: `pi pi-info-circle` em primary
### Exemplos validados
**Cadastros Recebidos:**
> Cadastros completos enviados por pacientes via formulário externo
> (link público). Revise os dados, **converta em paciente ativo** com
> 1 clique ou **rejeite** com motivo opcional.
**Agendamentos Recebidos:**
> Solicitações de horário vindas do agendador online à espera de ação.
> **Autorize** pra reservar o slot, **recuse** com motivo, ou
> **converta direto em sessão** — a gente cria o paciente
> automaticamente se ainda não existir.
---
## 10. Toolbar — busca + view toggle (no main column)
```css
.xx-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.xx-search {
position: relative;
flex: 1;
min-width: 0;
}
.xx-search__input {
width: 100%;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
padding: 9px 36px 9px 34px; /* espaço pro ícone esq + clear dir */
border-radius: 10px;
}
.xx-search__icon { position: absolute; left: 12px; }
.xx-search__clear { position: absolute; right: 8px; }
/* Segmented control list/grid */
.xx-view-toggle {
flex-shrink: 0;
display: inline-flex;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 2px;
gap: 2px;
}
.xx-view-toggle__btn {
width: 32px; height: 32px;
background: transparent;
border: none;
border-radius: 8px;
}
.xx-view-toggle__btn.is-active {
background: var(--m-accent-soft);
color: var(--m-accent);
}
```
A **busca está no main column** (não na sidebar). Esta é a regra do
blueprint — sidebar só tem stats + filtros; busca + toggle ficam acima
da tabela.
---
## 11. DataTable — estilos de header, rows, paginator
```css
/* Wrapper que faz a DataTable ocupar o flex restante */
.xx-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.xx-table :deep(.p-datatable) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: transparent;
border: 1px solid var(--m-border);
border-radius: 10px;
overflow: hidden;
}
.xx-table :deep(.p-datatable-table-container) {
flex: 1;
min-height: 0;
background: transparent;
}
/* Header — totalmente transparente nos níveis externos, surface no <th> */
.xx-table :deep(.p-datatable-thead),
.xx-table :deep(.p-datatable-thead > tr) {
background: transparent !important;
}
.xx-table :deep(.p-datatable-thead > tr > th) {
background: var(--p-content-background) !important; /* canônico */
color: var(--m-text);
font-size: 0.78rem;
font-weight: 700; /* negrito */
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
/* Rows */
.xx-table :deep(.p-datatable-tbody > tr) {
background: transparent;
cursor: pointer;
border-left: 3px solid var(--m-border); /* default neutro */
transition: background-color 140ms ease;
}
.xx-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
vertical-align: middle;
}
.xx-table :deep(.p-datatable-tbody > tr:hover) { background: var(--m-bg-soft-hover); }
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected) {
background: var(--m-accent-soft);
}
/* Border-left colorido por status — espelha .ma-sess do MelissaAgenda */
.xx-table :deep(tr.is-new) { border-left-color: rgb(37, 99, 235); }
.xx-table :deep(tr.is-done) { border-left-color: rgb(22, 163, 74); }
.xx-table :deep(tr.is-rejected) { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
/* Coluna frozen — mesma surface do header */
.xx-table :deep(td.p-datatable-frozen-column),
.xx-table :deep(th.p-datatable-frozen-column) {
background: var(--p-content-background) !important;
box-shadow: -3px 0 6px -3px rgba(0, 0, 0, 0.18);
z-index: 1;
}
.xx-table :deep(.p-datatable-tbody > tr:hover td.p-datatable-frozen-column) {
background: var(--m-bg-soft-hover);
}
.xx-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected td.p-datatable-frozen-column) {
background: var(--m-accent-soft);
}
/* Paginator integrado — centralizado, sem refresh à esquerda */
.xx-table :deep(.p-paginator) {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
}
.xx-table :deep(.p-paginator-current) {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
}
.xx-table :deep(.p-paginator-page.p-paginator-page-selected) {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
/* Select de "rows per page" — bg transparente + label centralizado */
.xx-table :deep(.p-select) {
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
display: inline-flex;
align-items: center;
}
.xx-table :deep(.p-select-label) {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
display: flex;
align-items: center;
line-height: 1;
height: 100%;
background: transparent;
}
```
---
## 12. Botão de ação (pencil) — coluna fixa
```css
.xx-row__action {
width: 30px; height: 30px;
display: grid;
place-items: center;
background: var(--p-content-background); /* opaco — não vaza no scroll */
border: 1px solid color-mix(in srgb, var(--p-primary-color) 30%, var(--m-border));
color: var(--p-primary-color); /* primary do tema */
border-radius: 8px;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.xx-row__action:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--p-content-background));
border-color: var(--p-primary-color);
}
```
Reutilizável **na list view (dentro da coluna frozen)** e **na grid
view (dentro do `.xx-grid__top-right`)** — mesma classe, mesmo visual.
---
## 13. Empty / loading
Ambos via slot do DataTable + replicados na grid view:
```vue
<template #empty>
<div class="xx-empty">
<i class="pi pi-inbox xx-empty__icon" />
<div class="xx-empty__title">Nenhum cadastro encontrado</div>
<div class="xx-empty__hint">
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
<template v-else> mensagem default contextual </template>
</div>
</div>
</template>
<template #loading>
<div class="xx-table__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando</span>
</div>
</template>
```
```css
.xx-empty {
margin: 24px 0;
padding: 56px 28px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--m-text-muted);
border: 2px dashed var(--m-border-strong);
border-radius: 12px;
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
gap: 8px;
}
.xx-empty__icon { font-size: 2rem; opacity: 0.6; }
.xx-empty__title { font-size: 0.92rem; font-weight: 600; }
.xx-empty__hint { font-size: 0.78rem; }
```
---
## 14. Mobile (<1024px)
A sidebar é Teleportada pro drawer (já documentado em
`melissa-page-blueprint.md`). Específico desta página:
```css
@media (max-width: 1023px) {
.xx-body { flex-direction: column; padding: 0; }
.xx-main { width: 100%; padding: 8px; }
.xx-page__title > span:first-of-type { display: none; }
.xx-menu-btn--mobile-only { display: inline-flex; }
/* IMPORTANTE: NÃO esconder colunas em mobile.
O scroll horizontal (via tableStyle min-width:640px) cuida
do overflow, e a coluna frozen "Ação" continua visível na
borda direita enquanto o user scrolla as outras. */
/* (sem display: none em qualquer th/td) */
/* Reset do bg/border-right da sidebar quando teleportada */
.xx-mobile-drawer__scroll .xx-side {
background: transparent;
border-right: none;
}
}
```
---
## 15. Acessibilidade
- `role="button" tabindex="0"` no card grid + `@keydown.enter.prevent` + `@keydown.space.prevent`
- `:focus-visible { outline: 2px solid var(--p-primary-color); outline-offset: 2px; }` nos cards
- `aria-label` em todos os icon-only buttons (pencil, view toggle, search clear)
- `v-tooltip` complementa visualmente (não substitui aria-label)
---
## 16. Checklist de adoção
Ao criar uma nova página tabular Melissa (ex: MelissaCompromissos):
- [ ] Renomeia `xx` → prefixo da página (`mco`, `mmd`, `mcv` etc.)
- [ ] Define `STATUS_FILTER_OPTIONS` com 3 keys/labels/icons
- [ ] Define `stats` computed retornando 4 itens (total + 3 status) com `cls` correto
- [ ] Implementa `filtered` computed (busca + statusFilter)
- [ ] Adiciona `rowsXX/firstXX/onPage` + watch reset
- [ ] Adiciona `viewMode` com persistência (`xx.viewMode.v1`)
- [ ] Adiciona `pagedItems` computed (slice pra grid)
- [ ] Adiciona `onRowClick + rowStatusClass`
- [ ] Adiciona `openDetails(r)` que abre o Dialog
- [ ] **Subheader explicativo** abaixo do `xx-page__head` (1-2 frases, fonte/origem + ações com `<strong>`, ícone `pi pi-info-circle`)
- [ ] Template: drawer + backdrop + page + header + **subheader** + body + sidebar (stats + filtros + clear) + main (toolbar + DataTable + grid)
- [ ] DataTable: `:loading + paginator + scrollable + scrollHeight="flex" + tableStyle="min-width: 640px"`
- [ ] Coluna frozen Ação: `width 60px + frozen alignFrozen="right"` + button pencil com `@click.stop`
- [ ] Grid card: `<div role="button" tabindex="0">` + handlers de teclado
- [ ] CSS: tokens `--p-content-background` em header, frozen, e botão pencil
- [ ] Mobile: NÃO esconder colunas; scroll horizontal via `tableStyle min-width`
---
## 17. Anti-patterns (NÃO fazer)
-**Busca na sidebar** — sempre no topo do main, ao lado do view toggle
-**`display: none` em colunas no mobile** — usar scroll horizontal + frozen
-**`<button>` envolvendo card no grid** — quebra HTML quando tem pencil interno; usar `<div role="button">`
-**`var(--m-bg-medium)` na coluna frozen no dark** — tem 12% transparência, vaza scroll. Usar `var(--p-content-background)`
-**`text-amber-300` Tailwind hardcoded** no ícone do header da página — usar `color: var(--p-primary-color)` via classe
-**`cls: 'warn'` pra "Novo"** — semanticamente errado (warn = aviso amarelo, novo = info azul)
-**Paginator com `#paginatorstart` slot duplicando refresh** — refresh já vive no header da página; centralizar o paginator (sem paginatorstart)
-**Skeleton manual + `carregandoInicial` na lista** — DataTable tem `:loading` nativo
-**`pageMCR + filteredPaginated` manual** — DataTable pagina internamente; só usa `firstXX/rowsXX` compartilhado
-**Border-left só em `is-new`** — todos os 3 status devem ter border-left colorido (consistência visual)
-**Misturar opacidade pesada (0.55, 0.75) com border colorido** — escolher uma estratégia; preferir border + opacidade leve (0.85 max)
---
## 18. Referência canônica
`src/layout/melissa/MelissaCadastrosRecebidos.vue` — implementação 1:1
deste blueprint. Quando dúvida, abrir esse arquivo lado-a-lado e
copiar o padrão exato (variáveis, ordem dos templates, tokens CSS).
Próximas adoções planejadas: `MelissaCompromissos`, `MelissaMedicos`,
`MelissaConversas`, `MelissaRecorrencias`, `MelissaTags`,
`MelissaGrupos` — todas seguem este blueprint.
@@ -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`
+176
View File
@@ -0,0 +1,176 @@
# Sessões 6 (continuação) → 10 — hardening em 6 áreas + scan completo do SaaS
Continuação do commit `7c20b51` (Sessões 1-6 iniciais). Esta etapa fechou
**toda revisão sênior do SaaS** + refator parcial de pacientes.
**Estado final do projeto:**
- A# auditoria abertos: **1** (A#31 Deploy real)
- V# verificações abertos: 14 (todos médios/baixos adiados com plano completo no DB)
- 🔴 Críticos: **0**
- 🟠 Altos: **0**
- Vitest: **208/208** (era 192)
- SQL integration: **33/33**
- E2E (Playwright): **5/5**
- Áreas auditadas: **15** (todas as principais do SaaS)
---
## Sessão 6 (continuação) — Documentos pendentes + Pacientes V#3
### Documentos: 100% fechado (V#50, V#51, V#52)
- **V#50** — Policy `documents: portal patient read` adicional. Paciente lê documento via portal quando `compartilhado_portal=true` AND patient pertence a auth.uid AND não expirou.
- **V#51**`documents.content_sha256` (nullable, índice parcial). `Documents.service.uploadDocument` calcula SHA-256 hex client-side via `crypto.subtle.digest`. Helper novo `verifyDocumentIntegrity(docId)` baixa arquivo e re-hash.
- **V#52** — Migration `...13` cron retention via pg_cron: 4 jobs (document_access_logs 1 ano, math_challenges 1h, public_submission_attempts 90 dias, submission_rate_limits 30 dias).
### Pacientes V#3 (parcial — fundação)
- `src/features/patients/services/patientsRepository.js` — list/get/create/update/softDelete + groups + tags + getSessionCounts.
- `src/features/patients/composables/usePatients.js` — wrapper reativo (rows/loading/error).
- PatientsListPage.hydrateAssociationsSupabase migrada — substitui 4 queries diretas por chamadas ao repo (paralelismo preservado).
- V#9 (PatientsCadastroPage 1991 linhas) → adiado pra Sessão 10.
---
## Sessão 7 — Tenants + Calendário
### Tenants (8 V#)
- 🔴 **V#1 P0**`tenant_invites` com RLS off + 0 policies (mesmo padrão A#30 Sessão 5). Tabela tinha 0 rows. Migration: ENABLE RLS + 4 policies (SELECT tenant_admin/saas; INSERT WITH CHECK invited_by=auth.uid; UPDATE só revogação; DELETE tenant_admin/saas). Aceitar invite continua via RPC `tenant_accept_invite` SECURITY DEFINER.
- 🟠 **V#2** profiles INSERT WITH CHECK (id = auth.uid)
- 🟠 **V#3** support_sessions INSERT WITH CHECK (admin_id = auth.uid + saas_admin guard)
- 🟡 **V#4 (signup público)** verificado: RPC `ensure_personal_tenant` SECURITY DEFINER já existia (Signup.vue:232) → **ok**
- 🟡 **V#5 (accept_invite)** verificado: RPCs `tenant_accept_invite` + `tenant_invite_member_by_email` já existiam → **ok**
- 🟡 **V#6** user_settings INSERT WITH CHECK
- 🟢 V#7/V#8 baixos — adiados
### Calendário (2 V#) — 100% fechado
- 🔴 **V#1** feriados_insert + feriados_saas_insert ganharam WITH CHECK. Spam de feriado global bloqueado.
- 🟢 **V#2** feriados_delete agora permite tenant_admin (não só owner).
---
## Sessão 8 — Addons + Central SaaS
### Addons (4 V#)
- 🔴 **V#1 P0 (dinheiro)**`addon_transactions_admin_insert` ganhou WITH CHECK (EXISTS saas_admins). Edge functions com service_role bypassam RLS, pipeline preservado. **Authenticated comum não cria mais transação fake.**
- 🟠 **V#2** — 3 CHECK constraints em `addon_credits`: balance >= 0, total_consumed >= 0, total_purchased >= 0. Saldo negativo silencioso eliminado.
- 🟡 V#3 (UI extrato) — adiado.
- 🟡 V#4 — verificado: `addon_products` não tem `tenant_id` (catálogo global por design) → **ok**.
### Central SaaS (3 V#)
- 🟠 **V#1**`faq_admin_write` substituído por `faq_saas_admin_write` em `saas_faq` E `saas_faq_itens` — só saas_admin escreve. Tenant_admin lê via `faq_auth_read` (permanece).
- 🟢 V#2/V#3 médios/baixos — adiados.
---
## Sessão 9 — Serviços/Prontuários (100% fechado)
5/5 V# corrigidos:
- 🔴 **V#1** services + insurance_plans → 4 policies separadas (SELECT tenant_member; INSERT/UPDATE/DELETE owner+saas).
- 🔴 **V#2** medicos → 4 policies separadas (catálogo de médicos referenciadores compartilhado entre profissionais do tenant).
- 🟠 **V#3** commitment_services — cascade reescrito via JOIN com services (USING permite tenant_member; WITH CHECK só owner).
- 🟠 **V#4** insurance_plan_services — cascade reescrito via JOIN com insurance_plans.
- 🟡 **V#5** commitment_time_logs/determined_commitments/determined_commitment_fields ganharam WITH CHECK em INSERT.
---
## Sessão 10 — Pacientes V#9 (script extraído)
PatientsCadastroPage.vue: 1991 → 1951 linhas (qualitativo > quantitativo).
### 2 composables novos
- **`useCep.js`** — busca ViaCEP reutilizável. 6 testes (sem rede, mock fetch).
- **`usePatientSupportContacts.js`** — CRUD de contatos de suporte encapsulado (load/save/add/remove/iniciaisFor). 10 testes com builder thenable.
### patientsRepository estendido
- `getPatientRelations(patientId)` — retorna {groupIds, tagIds}
- `replacePatientGroup(patientId, groupId, {tenantId})`
- `replacePatientTags(patientId, tagIds, {tenantId, ownerId})`
### PatientsCadastroPage refatorado
- 8 funções de query → delegação 1-linha ao patientsRepository
- onCepBlur → usa composable useCep
- Contatos de suporte → composable
- Template **não** foi tocado (zero risco de regressão visual)
- Quebra de template em sub-componentes Vue → adiado pra quando houver E2E cobrindo a página
---
## 📦 Migrations consolidadas neste commit (8)
```
20260419000011_documents_portal_patient_policy.sql (V#50)
20260419000012_documents_content_hash.sql (V#51)
20260419000013_cron_retention_jobs.sql (V#52 + math_challenges + submissions + rate_limits)
20260419000014_financial_security_hardening.sql (5 V# financeiro — fechados na Sessão 6)
20260419000015_communication_security_hardening.sql (5 V# comunicação — fechados na Sessão 6)
20260419000016_tenants_calendario_hardening.sql (Tenants V#1-V#3,V#6 + Calendário V#1-V#2)
20260419000017_addons_central_saas_hardening.sql (Addons V#1-V#2 + Central SaaS V#1)
20260419000018_servicos_prontuarios_hardening.sql (Serviços V#1-V#5)
```
**Total acumulado de migrations no histórico: 18** (Sessões 1-10).
Várias dessas exigiram conexão direta como `supabase_admin` (ver memory `project_supabase_admin_gotcha.md` e `commit.md` anterior) por causa de tabelas owned por esse role.
---
## 🆕 Novos arquivos (código)
```
src/features/patients/composables/useCep.js
src/features/patients/composables/usePatientSupportContacts.js
src/features/patients/composables/usePatients.js
src/features/patients/composables/__tests__/useCep.spec.js (+6 testes)
src/features/patients/composables/__tests__/usePatientSupportContacts.spec.js (+10 testes)
src/features/patients/services/patientsRepository.js
```
---
## 🛠️ Modificações
- `src/features/patients/PatientsListPage.vue` — hydrateAssociationsSupabase usa repo
- `src/features/patients/cadastro/PatientsCadastroPage.vue` — script extraído (queries → repo, CEP → composable, contatos → composable). Template intocado.
- `src/services/Documents.service.js` — uploadDocument calcula content_sha256 + helper verifyDocumentIntegrity
---
## 📊 Áreas auditadas (estado final)
| Área | V# total | Estado |
|---|---|---|
| auth | 10 | 100% fechado/ok |
| router | 9 | 100% |
| stores | 1 | 100% |
| agenda | 11 | 100% |
| pacientes | 10 | **100% fechado** ✅ |
| seguranca | 1 | 100% |
| saas | 10 | 100% |
| documentos | 10 | **100% fechado** ✅ |
| financeiro | 11 | 5 fechados, 6 médios/baixos adiados |
| comunicacao | 10 | 5 fechados, 5 médios/baixos adiados |
| tenants | 8 | 6 fechados, 2 baixos adiados |
| calendario | 2 | **100% fechado** ✅ |
| addons | 4 | 3 resolvidos, 1 médio adiado |
| central_saas | 3 | 1 alto fechado, 2 médios adiados |
| servicos | 5 | **100% fechado** ✅ |
**Zero crítico/alto restante no sistema inteiro.**
---
## ⚠️ Pendências documentadas no DB (não esquecidas)
### A# (1 aberto)
- **A#31 Deploy real** — alto. Reformulação pendente: como ainda não há cloud Supabase nem secrets reais, próxima sessão é "Preparação completa pra deploy" (DEPLOY.md, validar migrations num container limpo, audit de edge functions, listar env vars, script `db.cjs deploy-check`).
### V# adiados (14)
Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`:
- financeiro (6): parcelamento CHECK, payouts flow, recurrence DELETE policy, composables, máscara PIX, dashboard inadimplência
- comunicacao (5): notifications/schedules silos, email_templates_global filtros, retention notification_logs, dashboard health, audit dismissals/preferences
- tenants (2): owner_users policies, company_profiles + dev_user_credentials
- central_saas (2): rate limit voto, valores tipo_acesso
- addons (1): UI de extrato
### Outros
- PatientsCadastroPage template breakdown — quando houver E2E
- Pacientes V#9 segue 100% no banco (script foi extraído; template é refator visual separado)
@@ -0,0 +1,96 @@
# README — generate-dashboard.js
Script Node.js que lê o `schema.sql` do backup mais recente e gera um `dashboard.html` interativo com a visão completa do banco de dados do projeto.
---
## Como usar
Coloque o `generate-dashboard.js` na **raiz do projeto** (mesma pasta do `db.cjs`) e rode:
```bash
# Usa o backup mais recente automaticamente
node generate-dashboard.js
# Ou especifica uma data
node generate-dashboard.js 2026-03-27
```
O arquivo `dashboard.html` será gerado na raiz do projeto. Basta abrir no browser.
---
## Fluxo recomendado
Sempre que fizer alterações no banco, rode os dois comandos em sequência:
```bash
node db.cjs backup # gera o backup em database-novo/backups/YYYY-MM-DD/
node generate-dashboard.js # lê o backup mais recente e gera o dashboard.html
```
---
## O que o dashboard mostra
- **Visão geral** — cards com os 9 domínios do projeto, quantidade de tabelas e FKs por domínio
- **Tabelas** — todas as 86 tabelas com colunas, tipos, badges PK/FK
- **Foreign Keys** — cada FK aparece como link clicável que pula direto para a tabela destino
- **Views** — lista das 24 views do schema público
- **Busca** — busca em tempo real por nome de tabela ou nome de coluna
- **Sidebar** — navegação por domínio
---
## Estrutura de pastas esperada
O script espera essa estrutura para funcionar:
```
raiz-do-projeto/
├── db.cjs
├── db.config.json
├── generate-dashboard.js ← script
├── dashboard.html ← gerado aqui
└── database-novo/
└── backups/
└── 2026-03-27/
├── schema.sql ← lido pelo script
├── data.sql
└── full_dump.sql
```
---
## Tabelas novas não aparecem no domínio certo?
Quando você criar uma migration nova com uma tabela nova, ela aparecerá no dashboard na seção **"Outros"** e o script vai avisar no terminal:
```
⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):
- minha_tabela_nova
→ Edite DOMAIN_TABLES no script para mapeá-las.
```
Para corrigir, abra o `generate-dashboard.js` e adicione a tabela no domínio correto dentro do objeto `DOMAIN_TABLES` no topo do arquivo:
```js
const DOMAIN_TABLES = {
'Agenda': [
'agenda_eventos',
'agenda_configuracoes',
// ...
'minha_tabela_nova', // ← adiciona aqui
],
// ...
};
```
Depois rode `node generate-dashboard.js` novamente.
---
## Requisitos
- Node.js instalado (qualquer versão >= 14)
- Sem dependências externas — usa apenas módulos nativos (`fs`, `path`)
+119
View File
@@ -0,0 +1,119 @@
# database-novo
Banco de dados do AgenciaPsi — organizado, documentado e com CLI para gerenciamento.
## Quick Start
```bash
cd database-novo
# Instalação do zero (schema + fixes + seeds + backup)
node db.cjs setup
# Ver estado do banco
node db.cjs status
# Backup
node db.cjs backup
# Restaurar (perdi o banco!)
node db.cjs restore
```
Para o guia completo, veja **`docs/setup_guide.md`**.
## Comandos do CLI
| Comando | O que faz |
|---------|-----------|
| `node db.cjs setup` | Instala do zero (schema + fixes + seeds) |
| `node db.cjs backup` | Exporta backup com data para `backups/` |
| `node db.cjs restore [data]` | Restaura de um backup |
| `node db.cjs migrate` | Aplica migrations pendentes |
| `node db.cjs seed [grupo]` | Roda seeds (all, users, system, test_data) |
| `node db.cjs status` | Estado do banco, backups, migrations |
| `node db.cjs diff` | Compara schema atual vs último backup |
| `node db.cjs reset` | Reseta e reinstala tudo |
| `node db.cjs verify` | Verifica integridade dos dados |
## Estrutura
```
database-novo/
├── db.cjs # CLI de gerenciamento do banco
├── db.config.json # Configuração (container, seeds, fixes)
├── schema/ # Schema SQL separado por seção
│ ├── 00_full/schema.sql # Schema completo (referência)
│ ├── 01_extensions/ # Schemas + extensões PostgreSQL
│ ├── 02_types/ # Enums (auth, public, infra)
│ ├── 03_functions/ # 11 arquivos por domínio
│ ├── 04_tables/ # 10 arquivos por domínio
│ ├── 05_views/ # 24 views
│ ├── 06_indexes/ # Índices
│ ├── 07_foreign_keys/ # PKs, FKs, constraints
│ ├── 08_triggers/ # Triggers
│ ├── 09_policies/ # 217 RLS policies
│ └── 10_grants/ # Grants
├── seeds/ # Seeds de dados
│ ├── seed_001_fixed.sql # 6 usuários base + tenants
│ ├── seed_002.sql # Supervisor + Editor
│ ├── seed_003.sql # Therapist2, Therapist3, Secretary
│ ├── seed_010_plans.sql # 7 planos + 4 preços
│ ├── seed_011_features.sql # 26 features
│ ├── seed_012_plan_features.sql # 85 vínculos plano↔feature
│ ├── seed_013_subscriptions.sql # 9 subscriptions + compromissos
│ ├── seed_014_global_data.sql # 11 email + 16 notif templates + 3 slides
│ ├── seed_020_test_data.sql # Dados de teste (50 pacientes, eventos, etc.)
│ ├── seed_020_test_data_cleanup.sql # Limpeza dos dados de teste
│ └── run_all_seeds.sh # Script bash alternativo
├── migrations/ # Migrations incrementais
├── fixes/ # 7 correções aplicadas
├── backups/ # Backups com data (auto-gerenciados)
│ └── 2026-03-23/ # schema.sql + data.sql + full_dump.sql
└── docs/ # Documentação
├── setup_guide.md # Guia completo de instalação e uso
├── schema_map.md # Mapa das 84 tabelas
├── business_rules.md # Regras de negócio
└── users_test.md # 11 usuários de teste (UUIDs + vínculos)
```
## Planos
| Key | Target | Preço | Limites |
|-----|--------|-------|---------|
| `patient_free` | patient | R$0 | — |
| `therapist_free` | therapist | R$0 | 40 agendamentos/mês, 50 lembretes/mês |
| `therapist_pro` | therapist | R$49/mês · R$490/ano | Ilimitado |
| `clinic_free` | clinic | R$0 | 30 pacientes, 5 terapeutas, 40 agend/mês |
| `clinic_pro` | clinic | R$149/mês · R$1490/ano | Ilimitado |
| `supervisor_free` | supervisor | R$0 | Até 3 supervisionados |
| `supervisor_pro` | supervisor | R$0 | Até 20 supervisionados |
## Usuários de Teste
Senha de todos: `Teste@123`
| Email | Plano | Tipo |
|-------|-------|------|
| paciente@agenciapsi.com.br | patient_free | Paciente |
| terapeuta@agenciapsi.com.br | therapist_free | Terapeuta solo + Clínica 3 |
| clinica1@agenciapsi.com.br | clinic_free | Clínica coworking |
| clinica2@agenciapsi.com.br | clinic_free | Clínica recepção |
| clinica3@agenciapsi.com.br | clinic_free | Clínica full |
| saas@agenciapsi.com.br | — | Admin plataforma |
| supervisor@agenciapsi.com.br | supervisor_free | Supervisor |
| editor@agenciapsi.com.br | therapist_free | Editor |
| therapist2@agenciapsi.com.br | therapist_free | Terapeuta |
| therapist3@agenciapsi.com.br | therapist_free | Terapeuta |
| secretary@agenciapsi.com.br | — | Secretária (Clínica 2) |
## Idempotência
Todos os seeds são idempotentes (ON CONFLICT DO UPDATE ou DELETE + INSERT). Podem ser re-executados quantas vezes necessário.
File diff suppressed because one or more lines are too long
+1064
View File
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
{
"container": "supabase_db_agenciapsi-primesakai",
"database": "postgres",
"user": "postgres",
"backupRetentionDays": 30,
"schema": "schema/00_full/schema.sql",
"migrationsDir": "migrations",
"seedsDir": "seeds",
"fixesDir": "fixes",
"seeds": {
"users": [
"seed_001_fixed.sql",
"seed_002.sql",
"seed_003.sql"
],
"system": [
"seed_010_plans.sql",
"seed_011_features.sql",
"seed_012_plan_features.sql",
"seed_013_subscriptions.sql",
"seed_014_global_data.sql",
"seed_015_document_templates.sql",
"seed_030_dev_phases_items.sql",
"seed_031_dev_auditoria.sql",
"seed_032_dev_competitors.sql"
],
"test_data": [
"seed_020_test_data.sql"
]
},
"fixes": [
"fix_addon_credits_fk.sql",
"fix_addon_rls_saas_admin.sql",
"fix_missing_subscriptions.sql",
"fix_notification_templates_rls_admin.sql",
"fix_seed_patient_groups.sql",
"fix_subscriptions_validate_scope.sql",
"fix_template_keys_match_populate.sql",
"fix_encoding_accents.sql"
],
"verify": {
"tables": [
{ "name": "auth.users", "min": 1 },
{ "name": "profiles", "min": 1 },
{ "name": "tenants", "min": 1 },
{ "name": "plans", "min": 7 },
{ "name": "features", "min": 20 },
{ "name": "plan_features", "min": 50 },
{ "name": "subscriptions", "min": 1 },
{ "name": "email_templates_global", "min": 10 },
{ "name": "notification_templates", "min": 5 },
{ "name": "document_templates", "min": 1 }
],
"views": [
"v_tenant_entitlements",
"v_tenant_active_subscription"
]
},
"status": {
"tables": [
"auth.users",
"profiles",
"tenants",
"tenant_members",
"plans",
"features",
"plan_features",
"subscriptions",
"patients",
"agenda_eventos",
"services",
"financial_records",
"document_templates",
"documents",
"email_templates_global",
"notification_templates"
]
},
"domains": {
"SaaS / Planos": [
"plans", "plan_features", "plan_prices", "plan_public", "plan_public_bullets",
"features", "modules", "module_features",
"subscriptions", "subscription_events",
"subscription_intents_legacy", "subscription_intents_personal", "subscription_intents_tenant",
"tenant_modules", "tenant_features", "tenant_feature_exceptions_log",
"billing_contracts", "entitlements_invalidation"
],
"Addons / Créditos": [
"addon_products", "addon_credits", "addon_transactions",
"whatsapp_credits_balance", "whatsapp_credits_transactions",
"whatsapp_credit_packages", "whatsapp_credit_purchases"
],
"Tenants / Multi-tenant": [
"tenants", "profiles", "user_settings",
"tenant_invites", "tenant_members",
"company_profiles", "support_sessions",
"saas_admins", "owner_users", "dev_user_credentials"
],
"Pacientes": [
"patients", "patient_contacts", "patient_support_contacts",
"patient_groups", "patient_group_patient",
"patient_tags", "patient_patient_tag",
"patient_discounts", "patient_intake_requests", "patient_invites",
"patient_status_history", "patient_timeline",
"contact_types", "contact_phones",
"contact_email_types", "contact_emails"
],
"Agenda / Agendamento": [
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes",
"agenda_online_slots", "agenda_regras_semanais",
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
"agendador_configuracoes", "agendador_solicitacoes"
],
"Financeiro": [
"financial_categories", "financial_exceptions", "financial_records",
"payment_settings", "professional_pricing",
"therapist_payouts", "therapist_payout_records",
"recurrence_rules", "recurrence_exceptions", "recurrence_rule_services"
],
"Serviços / Prontuários": [
"services", "commitment_services", "commitment_time_logs",
"determined_commitments", "determined_commitment_fields",
"insurance_plans", "insurance_plan_services",
"medicos"
],
"Documentos": [
"documents", "document_templates", "document_generated",
"document_access_logs", "document_share_links", "document_signatures"
],
"Comunicação / Notificações": [
"email_templates_global", "email_templates_tenant", "email_layout_config",
"notification_templates", "notification_channels", "notification_preferences",
"notification_logs", "notification_schedules", "notification_queue",
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
"twilio_subaccount_usage", "saas_twilio_config"
],
"CRM Conversas (WhatsApp)": [
"conversation_messages", "conversation_threads",
"conversation_notes",
"conversation_tags", "conversation_thread_tags",
"conversation_optouts", "conversation_optout_keywords",
"conversation_autoreply_settings", "conversation_autoreply_log",
"session_reminder_settings", "session_reminder_logs",
"conversation_assignments",
"conversation_bots", "conversation_bot_sessions",
"conversation_sla_rules", "conversation_sla_breaches",
"whatsapp_connection_incidents"
],
"Segurança / Auditoria": [
"submission_rate_limits",
"audit_logs",
"saas_security_config",
"math_challenges",
"patient_invite_attempts",
"public_submission_attempts"
],
"Central SaaS (docs/FAQ)": [
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
],
"Dev / Tracking": [
"dev_auditoria_items", "dev_verificacoes_items", "dev_test_items",
"dev_roadmap_phases", "dev_roadmap_items",
"dev_competitors", "dev_competitor_features",
"dev_comparison_matrix", "dev_comparison_competitor_status",
"dev_generation_log"
],
"Estrutura / Calendário": [
"feriados"
]
},
"domainColors": {
"SaaS / Planos": "#4f8cff",
"Addons / Créditos": "#a78bfa",
"Tenants / Multi-tenant": "#6ee7b7",
"Pacientes": "#f472b6",
"Agenda / Agendamento": "#38bdf8",
"Financeiro": "#f87171",
"Serviços / Prontuários": "#34d399",
"Documentos": "#0ea5e9",
"Comunicação / Notificações": "#fbbf24",
"CRM Conversas (WhatsApp)": "#25d366",
"Segurança / Auditoria": "#ef4444",
"Central SaaS (docs/FAQ)": "#c084fc",
"Dev / Tracking": "#94a3b8",
"Estrutura / Calendário": "#fb923c"
},
"infrastructure": {
"Banco & Backend": {
"color": "#4f8cff",
"items": [
{
"name": "Supabase",
"role": "Postgres + Auth + Storage + Realtime + Edge Functions",
"env": "Local (Docker) + Cloud",
"status": "ativo",
"notes": "Stack principal. Migrations em database-novo/migrations/. Functions em supabase/functions/. CLI via npx supabase."
},
{
"name": "PostgreSQL 15",
"role": "Banco de dados relacional (via container supabase_db_agenciapsi-primesakai)",
"env": "Local (Docker)",
"status": "ativo",
"notes": "RLS habilitada em todas as tabelas públicas. Multi-tenant via tenant_id. SECURITY DEFINER em RPCs sensíveis."
},
{
"name": "Docker + Docker Compose",
"role": "Orquestração dos containers do stack Supabase local + Evolution API",
"env": "Local",
"status": "ativo",
"notes": "docker-compose.yml na raiz. Iniciado via npx supabase start."
}
]
},
"Email": {
"color": "#fbbf24",
"items": [
{
"name": "Mailpit (Supabase inbucket)",
"role": "Inbox SMTP local para capturar emails de teste",
"env": "Local (Docker)",
"status": "ativo",
"notes": "Container supabase_inbucket. Usado em dev para validar templates sem enviar email real."
},
{
"name": "SMTP produção",
"role": "Envio real de emails transacionais (faturas, convites, notificações)",
"env": "Cloud (pendente)",
"status": "pendente",
"notes": "Requer SMTP_HOST/PORT/USER/PASS/FROM nos secrets das edge functions."
}
]
},
"WhatsApp / SMS": {
"color": "#34d399",
"items": [
{
"name": "Evolution API",
"role": "WhatsApp self-hosted via Baileys (tier gratuito do SaaS — 'WhatsApp Pessoal')",
"env": "Local (Docker)",
"status": "ativo",
"notes": "Container via evolution-api/docker-compose.yml. Uso do usuário conecta via QR code no celular real. Sem SLA, Meta pode banir número. Envio sem custo. Edge functions: evolution-whatsapp-inbound, evolution-webhook-provision, send-whatsapp-message."
},
{
"name": "Twilio WhatsApp Business API",
"role": "WhatsApp oficial (tier pago rebrandeado como 'WhatsApp Oficial AgenciaPSI')",
"env": "Cloud",
"status": "ativo",
"notes": "API oficial Meta, zero risco de ban. Credenciais em notification_channels (twilio_subaccount_sid + credentials.subaccount_auth_token). Envio consome 1 crédito via RPC deduct_whatsapp_credits (atômico + rollback em falha). Provisionamento: supabase/functions/twilio-whatsapp-provision/."
}
]
},
"Pagamentos / Billing": {
"color": "#fb923c",
"items": [
{
"name": "Asaas (gateway PIX/cartão/boleto)",
"role": "Processamento de pagamentos pra compra de créditos WhatsApp (Marco B)",
"env": "Cloud — sandbox.asaas.com em dev, api.asaas.com em prod",
"status": "ativo",
"notes": "API key em ASAAS_API_KEY (env secret). URL em ASAAS_API_URL. Webhook token opcional em ASAAS_WEBHOOK_TOKEN. Edge functions: create-whatsapp-credit-charge (cria customer + PIX), asaas-webhook (recebe PAYMENT_RECEIVED/CONFIRMED e credita saldo via add_whatsapp_credits)."
},
{
"name": "ngrok (dev only — tunnel pro webhook)",
"role": "Expõe edge functions locais pra Asaas alcançar via internet",
"env": "Local (dev)",
"status": "opcional",
"notes": "Uso: ngrok http 54321 → copia URL e cadastra em Asaas → Integrações → Webhooks → /functions/v1/asaas-webhook. Necessário só pra testar fluxo completo local incluindo confirmação de pagamento."
}
]
},
"Geração de documentos": {
"color": "#38bdf8",
"items": [
{
"name": "pdfmake 0.3.7",
"role": "Geração de PDF client-side (atestados, laudos, recibos)",
"env": "Browser",
"status": "ativo",
"notes": "UMD/webpack. Requer optimizeDeps.include explícito no vite.config.mjs."
},
{
"name": "html-to-pdfmake / html2pdf.js / jsPDF",
"role": "Conversão HTML→PDF para documentos ricos",
"env": "Browser",
"status": "ativo",
"notes": "Usado em document_templates e documents gerados para pacientes."
},
{
"name": "Jodit + Quill",
"role": "Editores de texto rico para templates de documentos",
"env": "Browser",
"status": "ativo",
"notes": "Jodit em DocumentTemplateEditor; Quill em páginas legadas. Migração em andamento."
},
{
"name": "html2canvas-pro",
"role": "Captura de screenshots de DOM (preview/export)",
"env": "Browser",
"status": "ativo",
"notes": "Usado para thumbnails de templates e previews."
}
]
},
"Frontend": {
"color": "#a78bfa",
"items": [
{
"name": "Vue 3 + Composition API",
"role": "Framework principal (script setup)",
"env": "Browser",
"status": "ativo",
"notes": "~487 componentes Vue. Pinia para state management."
},
{
"name": "Vite 5",
"role": "Build tool e dev server",
"env": "Node.js",
"status": "ativo",
"notes": "vite-plugin-compression (Brotli/Gzip), unplugin-auto-import para PrimeVue e Vue. rollup-plugin-visualizer para análise de bundle."
},
{
"name": "PrimeVue 4 (tema Sakai)",
"role": "Biblioteca de componentes UI",
"env": "Browser",
"status": "ativo",
"notes": "@primeuix/themes. auto-import-resolver. DataTable, Dialog, DatePicker, Popover, Toast, ConfirmDialog headless."
},
{
"name": "Tailwind CSS v4",
"role": "Utility-first CSS",
"env": "Browser",
"status": "ativo",
"notes": "@tailwindcss/vite + tailwindcss-primeui. Surface tokens do PrimeVue (var(--surface-card), var(--text-color-secondary))."
},
{
"name": "Vue Router",
"role": "Roteamento SPA com guards por role/tenant",
"env": "Browser",
"status": "ativo",
"notes": "Grupos de rota: therapist, admin, supervisor, saas, billing, account, configuracoes, features."
},
{
"name": "FullCalendar 6",
"role": "Calendário para agenda de terapeutas",
"env": "Browser",
"status": "ativo",
"notes": "Plugins: daygrid, timegrid, interaction, list, resource, resource-timegrid."
},
{
"name": "Chart.js 3",
"role": "Gráficos para dashboards (financeiro, KPIs)",
"env": "Browser",
"status": "ativo",
"notes": "Usado em dashboards do therapist e clinic."
}
]
},
"Dev / Tooling": {
"color": "#94a3b8",
"items": [
{
"name": "Supabase CLI",
"role": "Gerencia ambiente local, migrations, edge functions",
"env": "Node.js",
"status": "ativo",
"notes": "Via npx supabase. Start/stop/status/db-push/functions-deploy."
},
{
"name": "db.cjs (este projeto)",
"role": "CLI auxiliar pra setup/backup/restore/migrate/verify via docker exec",
"env": "Node.js",
"status": "ativo",
"notes": "Complementa o supabase CLI com fluxo schema + fixes + seeds + migrations. Encoding UTF-8 preservado."
},
{
"name": "generate-dashboard.cjs",
"role": "Gera dashboard HTML estático do schema (tabelas, FKs, infra)",
"env": "Node.js",
"status": "ativo",
"notes": "Standalone, sem dependências externas. Lê config de db.config.json e schema do backup mais recente."
},
{
"name": "Vitest 4",
"role": "Runner de testes unitários",
"env": "Node.js",
"status": "ativo",
"notes": "npm test / test:watch / test:ui. Bateria inicial em src/**/__tests__."
},
{
"name": "ESLint + Prettier",
"role": "Lint + formatação automática",
"env": "Node.js",
"status": "ativo",
"notes": "@vue/eslint-config-prettier. Rodado via npm run lint."
}
]
}
}
}
+176
View File
@@ -0,0 +1,176 @@
# Regras de Negócio — Banco de Dados AgenciaPsi
## 1. Planos e Targets
| Target | Planos | Escopo da Subscription |
|--------|--------|----------------------|
| `patient` | patient_free | `user_id` (sem tenant_id) |
| `therapist` | therapist_free, therapist_pro | `user_id` (sem tenant_id) |
| `clinic` | clinic_free, clinic_pro | `tenant_id` (sem user_id) |
| `supervisor` | supervisor_free, supervisor_pro | `user_id` (sem tenant_id) |
**Constraint `subscriptions_owner_xor`**: Uma subscription DEVE ter `tenant_id` XOR `user_id`, nunca ambos.
**Trigger `subscriptions_validate_scope`**: Valida que o target do plano casa com o escopo:
- `clinic` → exige `tenant_id`, rejeita `user_id`
- `therapist`, `supervisor`, `patient` → exige `user_id`, rejeita `tenant_id`
## 2. Planos Core (protegidos)
Os planos `clinic_free`, `clinic_pro`, `therapist_free`, `therapist_pro` são **core**:
- **Não podem ter `key` alterada** (trigger `trg_no_change_core_plan_key`)
- **Não podem ter `target` alterado** (trigger `trg_no_change_plan_target`)
- **Não podem ser deletados** (trigger `trg_no_delete_core_plans`)
Para bypass (migração): `SET LOCAL app.plan_migration_bypass = '1'`
## 3. Entitlements (Features)
### Resolução de features para TENANTS (clínicas)
```
tenant_has_feature(tenant_id, feature_key) =
EXISTS em v_tenant_entitlements (via plano)
OR
EXISTS em tenant_features (override direto)
```
### Resolução de features para USERS (terapeutas, supervisores)
```
user_has_feature(user_id, feature_key) =
EXISTS em v_user_entitlements (via plano pessoal)
```
### Cadeia de resolução
```
subscription → plan → plan_features → features
plan_features.limits (jsonb) → limites quantitativos
```
### Views de entitlements
- `v_tenant_active_subscription` → subscription ativa do tenant
- `v_user_active_subscription` → subscription ativa do user
- `v_tenant_entitlements` → feature_key + allowed
- `v_tenant_entitlements_full` → + limits + plan_id + plan_key
- `v_user_entitlements` → feature_key + allowed (para planos pessoais)
## 4. Tipos de Tenant
| kind | Descrição | Criação |
|------|-----------|---------|
| `therapist` | Terapeuta solo | Automático ao criar conta de terapeuta |
| `clinic_coworking` | Clínica coworking | Manual |
| `clinic_reception` | Clínica com recepção | Manual |
| `clinic_full` | Clínica completa | Manual |
| `supervisor` | Supervisor | Automático |
| `saas` | Sistema (legado) | — |
| `clinic` | Legado | — |
**O `kind` é imutável após criação** (trigger `trg_tenant_kind_immutable`).
## 5. Roles e Permissões
### Profile roles
| Role | Descrição |
|------|-----------|
| `saas_admin` | Administrador da plataforma |
| `tenant_member` | Membro de um ou mais tenants |
| `portal_user` | Paciente (acesso ao portal) |
| `patient` | Paciente (legado) |
### Tenant member roles
| Role | Descrição |
|------|-----------|
| `tenant_admin` | Admin do tenant (dono) |
| `therapist` | Terapeuta membro |
| `clinic_admin` | Admin da clínica (secretária com poderes) |
| `secretary` | Secretária |
| `supervisor` | Supervisor |
| `patient` | Paciente do tenant |
### Platform roles (array em profiles)
| Role | Descrição |
|------|-----------|
| `editor` | Editor de conteúdo da plataforma |
## 6. Compromissos Determinados
A função `seed_determined_commitments(tenant_id)` cria 5 tipos nativos:
| native_key | Nome | locked | active |
|------------|------|--------|--------|
| `session` | Sessão | true | true |
| `reading` | Leitura | false | true |
| `supervision` | Supervisão | false | true |
| `class` | Aula | false | **false** |
| `analysis` | Análise Pessoal | false | true |
- `session` é **locked** (não pode ser editado/deletado)
- O `native_key = 'session'` é usado pelo agendador online para identificar o compromisso padrão
## 7. Grupos de Pacientes Padrão
A função `seed_default_patient_groups(tenant_id)` cria 3 grupos sistema:
| Nome | Cor | is_system |
|------|-----|-----------|
| Crianças | #60a5fa | true |
| Adolescentes | #a78bfa | true |
| Idosos | #34d399 | true |
Grupos sistema não podem ser editados/deletados (trigger `prevent_system_group_changes`).
## 8. Subscriptions — Status
| Status | Descrição |
|--------|-----------|
| `pending` | Aguardando ativação |
| `active` | Ativa |
| `past_due` | Pagamento atrasado |
| `suspended` | Suspensa |
| `cancelled` | Cancelada |
| `expired` | Expirada |
## 9. Templates de Email
**Globais** (`email_templates_global`): templates padrão da plataforma, gerenciados pelo saas_admin.
**Tenant** (`email_templates_tenant`): overrides por tenant. Se existir, usa o do tenant; se não, usa o global.
### Keys de template
| Domínio | Templates |
|---------|-----------|
| session | reminder, confirmation, cancellation, rescheduled |
| intake | received, approved, rejected |
| scheduler | request_accepted, request_rejected |
| system | welcome, password_reset |
Canais: `email`, `whatsapp`, `sms`
## 10. Notificações — Sistema
| Canal | Tipos |
|-------|-------|
| WhatsApp | lembrete_sessao, confirmacao_sessao, cancelamento_sessao |
| SMS | lembrete_sessao |
### Schedule keys
| Key | Descrição |
|-----|-----------|
| `lembrete_24h` | 24 horas antes |
| `lembrete_2h` | 2 horas antes |
| `lembrete_30min` | 30 minutos antes |
| `confirmacao_imediata` | Imediato após confirmar |
| `cancelamento_imediato` | Imediato após cancelar |
## 11. RLS (Row Level Security)
Todas as tabelas do schema `public` têm RLS habilitado. As policies usam:
- `auth.uid()` — ID do usuário autenticado
- `is_saas_admin()` — verifica se é admin da plataforma
- `is_tenant_member(tenant_id)` — verifica se pertence ao tenant
- `is_tenant_admin(tenant_id)` — verifica se é admin do tenant
- `current_member_role(tenant_id)` — role do membro no tenant
- `tenant_has_feature(tenant_id, feature_key)` — verifica feature
**Se as features/plan_features não existirem no banco, as policies de RLS bloqueiam o acesso.**
+190
View File
@@ -0,0 +1,190 @@
# Schema Map — AgenciaPsi
Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-03-23).
**84 tabelas** no schema `public` + tabelas de infraestrutura (auth, storage, realtime).
## Domínios
### Core (11 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `profiles` | Perfil do usuário (role, account_type, full_name, platform_roles) |
| `tenants` | Organizações (clínicas, terapeutas solo, supervisores) |
| `tenant_members` | Vínculo usuário↔tenant com role (tenant_admin, therapist, secretary, etc.) |
| `tenant_invites` | Convites pendentes para ingressar em um tenant |
| `tenant_features` | Overrides de features por tenant (exceções comerciais) |
| `tenant_feature_exceptions_log` | Log de alterações em tenant_features |
| `saas_admins` | Administradores da plataforma |
| `owner_users` | Mapeamento owner_id→user_id para RLS |
| `user_settings` | Configurações pessoais do usuário |
| `company_profiles` | Perfil da empresa/clínica (logo, endereço, etc.) |
| `dev_user_credentials` | Credenciais de teste (apenas dev) |
### Plans & Billing (20 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `plans` | Planos disponíveis (key, target, price_cents, max_supervisees) |
| `plan_prices` | Preços por intervalo (month/year) com versionamento |
| `plan_features` | Vínculo plano↔feature com limites (limits jsonb) |
| `plan_public` | Info pública dos planos (para página de preços) |
| `plan_public_bullets` | Bullets de marketing dos planos |
| `features` | Features do sistema (key, name, descricao) |
| `entitlements_invalidation` | Cache invalidation de entitlements |
| `subscriptions` | Assinaturas ativas (user_id XOR tenant_id) |
| `subscription_events` | Histórico de eventos de assinatura |
| `subscription_intents_personal` | Intenções de assinatura pessoal |
| `subscription_intents_tenant` | Intenções de assinatura de tenant |
| `subscription_intents_legacy` | Intenções legadas |
| `billing_contracts` | Contratos de cobrança |
| `addon_credits` | Créditos de add-ons por tenant |
| `addon_products` | Produtos add-on disponíveis |
| `addon_transactions` | Transações de add-ons |
| `modules` | Módulos do sistema |
| `module_features` | Features por módulo |
| `tenant_modules` | Módulos ativos por tenant |
### 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_online_slots` | Slots de agendamento online |
| `agenda_regras_semanais` | Regras semanais de disponibilidade |
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
| `agenda_slots_regras` | Regras de slots |
| `recurrence_rules` | Regras de recorrência de sessões |
| `recurrence_exceptions` | Exceções a recorrências |
| `recurrence_rule_services` | Serviços vinculados a recorrências |
### Agendador Online (2 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `agendador_configuracoes` | Configurações do agendador online público |
| `agendador_solicitacoes` | Solicitações de agendamento recebidas |
### Pacientes (8 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `patients` | Pacientes vinculados a um tenant |
| `patient_groups` | Grupos de pacientes (sistema + customizados) |
| `patient_group_patient` | Vínculo paciente↔grupo |
| `patient_tags` | Tags personalizadas |
| `patient_patient_tag` | Vínculo paciente↔tag |
| `patient_intake_requests` | Solicitações de cadastro (triagem) |
| `patient_invites` | Convites para portal do paciente |
| `patient_discounts` | Descontos por paciente |
### Compromissos Determinados (4 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `determined_commitments` | Tipos de compromisso (sessão, leitura, supervisão, etc.) |
| `determined_commitment_fields` | Campos customizados por tipo de compromisso |
| `commitment_services` | Serviços vinculados a compromissos |
| `commitment_time_logs` | Logs de tempo por compromisso |
### Financeiro (9 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `financial_records` | Lançamentos financeiros (receita/despesa) |
| `financial_categories` | Categorias de lançamento |
| `financial_exceptions` | Exceções financeiras |
| `payment_settings` | Configurações de pagamento por tenant |
| `professional_pricing` | Precificação por profissional |
| `therapist_payouts` | Repasses a terapeutas |
| `therapist_payout_records` | Registros de repasse |
| `services` | Serviços oferecidos |
| `insurance_plans` + `insurance_plan_services` | Convênios e serviços por convênio |
### Notificações (10 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `notification_channels` | Canais de notificação por tenant |
| `notification_logs` | Logs de envio |
| `notification_preferences` | Preferências do paciente (opt-in/out) |
| `notification_queue` | Fila de envio |
| `notification_schedules` | Agendamentos de notificação |
| `notification_templates` | Templates WhatsApp/SMS (default + tenant) |
| `notifications` | Notificações in-app |
| `email_templates_global` | Templates de email globais (plataforma) |
| `email_templates_tenant` | Overrides de templates por tenant |
| `email_layout_config` | Configuração de layout de email |
### SaaS Admin / UI (8 tabelas)
| Tabela | Descrição |
|--------|-----------|
| `saas_docs` | Documentação da plataforma |
| `saas_doc_votos` | Votos em docs |
| `saas_faq` | Categorias de FAQ |
| `saas_faq_itens` | Itens de FAQ |
| `feriados` | Feriados nacionais/regionais |
| `global_notices` | Avisos globais da plataforma |
| `login_carousel_slides` | Slides do carrossel de login |
| `notice_dismissals` | Dismissals de avisos por usuário |
### Suporte (1 tabela)
| Tabela | Descrição |
|--------|-----------|
| `support_sessions` | Sessões de suporte técnico |
---
## Views Principais
| View | Descrição |
|------|-----------|
| `v_tenant_active_subscription` | Subscription ativa por tenant |
| `v_user_active_subscription` | Subscription ativa por user |
| `v_tenant_entitlements` | Features habilitadas por tenant (via plano) |
| `v_tenant_entitlements_full` | Entitlements + limits + plan info |
| `v_tenant_entitlements_json` | Entitlements agregados como JSON |
| `v_user_entitlements` | Features habilitadas por user (via plano) |
| `v_tenant_members_with_profiles` | Membros do tenant com dados do perfil |
| `v_tenant_staff` | Staff do tenant (membros + convites) |
| `v_tenant_people` | Todas as pessoas do tenant |
| `v_plan_active_prices` | Preços ativos dos planos |
| `v_public_pricing` | Preços públicos para página de marketing |
| `v_subscription_health` | Saúde das subscriptions |
| `v_cashflow_projection` | Projeção de fluxo de caixa |
| `v_commitment_totals` | Totais de compromissos |
| `v_patient_groups_with_counts` | Grupos com contagem de pacientes |
| `v_tag_patient_counts` | Tags com contagem de pacientes |
| `subscription_intents` | View unificada de intenções (com INSTEAD OF trigger) |
| `owner_feature_entitlements` | Entitlements por owner |
| `current_tenant_id` | Tenant ativo do usuário corrente |
---
## Funções Críticas
| Função | Tipo | Descrição |
|--------|------|-----------|
| `tenant_has_feature(uuid, text)` | Query | Verifica se tenant tem feature (plano + override) |
| `user_has_feature(uuid, text)` | Query | Verifica se user tem feature via plano pessoal |
| `has_feature(uuid, text)` | Query | Alias genérico |
| `seed_determined_commitments(uuid)` | Seed | Cria 5 tipos de compromisso nativos por tenant |
| `seed_default_patient_groups(uuid)` | Seed | Cria 3 grupos de pacientes padrão |
| `seed_default_financial_categories(uuid)` | Seed | Cria categorias financeiras padrão |
| `subscriptions_validate_scope()` | Trigger | Valida XOR (user_id vs tenant_id) por target |
| `activate_subscription_from_intent(uuid)` | RPC | Ativa subscription a partir de intent |
| `handle_new_user()` | Trigger | Cria profile + tenant pessoal ao cadastrar |
| `ensure_personal_tenant()` | RPC | Garante que o user tem um tenant pessoal |
| `populate_notification_queue()` | Cron | Popula fila de notificações |
| `agendador_slots_disponiveis(text, date)` | RPC | Retorna slots disponíveis para agendamento |
---
## Enums (public schema)
| Tipo | Valores |
|------|---------|
| `commitment_log_source` | manual, auto |
| `determined_field_type` | text, textarea, number, date, select, boolean |
| `financial_record_type` | receita, despesa |
| `recurrence_exception_type` | cancel_session, reschedule_session, patient_missed, therapist_canceled, holiday_block |
| `recurrence_type` | weekly, biweekly, monthly, yearly, custom_weekdays |
| `status_agenda_serie` | ativo, pausado, cancelado |
| `status_evento_agenda` | agendado, realizado, faltou, cancelado, remarcar |
| `status_excecao_agenda` | pendente, ativo, arquivado |
| `tipo_evento_agenda` | sessao, bloqueio |
| `tipo_excecao_agenda` | bloqueio, horario_extra |
+297
View File
@@ -0,0 +1,297 @@
# Guia de Instalação e Uso — AgenciaPsi Database
## Pré-requisitos
1. **Docker Desktop** instalado e rodando
2. **Node.js** 18+ instalado
3. **Supabase CLI** instalado (`npm install -g supabase`)
## Instalação do Zero (banco vazio)
### 1. Iniciar o Supabase
```bash
# Na raiz do projeto (agenciapsi-primesakai/)
npx supabase start
```
Aguarde até o container `supabase_db_agenciapsi-primesakai` estar rodando.
### 2. Verificar se o container está ok
```bash
docker ps | grep supabase_db
```
Deve mostrar o container com status `Up`.
### 3. Instalar o banco completo
```bash
cd database-novo
node db.cjs setup
```
Isso faz tudo automaticamente:
- Aplica o schema completo (84 tabelas, funções, triggers, policies)
- Aplica os 7 fixes conhecidos
- Cria os 11 usuários de teste
- Cria os 7 planos + 4 preços
- Cria as 26 features + 85 vínculos plano↔feature
- Cria as 9 subscriptions + compromissos determinados
- Cria os templates de email, notificação e carousel
- Cria backup automático pós-instalação
- Verifica integridade no final
### 4. Verificar
```bash
node db.cjs status
```
Deve mostrar todos os counts verdes.
## Backup
### Criar backup manual
```bash
node db.cjs backup
```
Salva em `backups/YYYY-MM-DD/` com 3 arquivos:
- `schema.sql` — estrutura do banco
- `data.sql` — dados (sem schemas de infra)
- `full_dump.sql` — tudo junto
### Backup automático
O backup é feito automaticamente:
- Após o `setup`
- Antes de cada `migrate`
- Antes de cada `restore`
- Antes de cada `reset`
### Retenção
Backups com mais de 30 dias são removidos automaticamente. Para alterar, edite `backupRetentionDays` no `db.config.json`.
## Restaurar o Banco
### Restaurar do último backup
```bash
node db.cjs restore
```
### Restaurar de uma data específica
```bash
node db.cjs restore 2026-03-23
```
O restore:
1. Cria backup de segurança do estado atual
2. Limpa o schema public
3. Aplica o full_dump.sql do backup
4. Verifica integridade
## Migrations (alterações no banco)
### Criar uma migration
Crie um arquivo SQL na pasta `migrations/` com nome sequencial:
```
migrations/
├── 001_add_column_x.sql
├── 002_create_table_y.sql
└── 003_fix_something.sql
```
O nome deve começar com número para garantir a ordem.
### Aplicar migrations pendentes
```bash
node db.cjs migrate
```
O CLI:
1. Cria backup automático
2. Compara com a tabela `_db_migrations` no banco
3. Aplica apenas as que ainda não foram executadas
4. Registra cada migration aplicada
5. Se uma falhar, para imediatamente (use `restore` para voltar)
### Ver migrations aplicadas
```bash
node db.cjs status
```
## Seeds (dados de teste)
### Rodar todos os seeds
```bash
node db.cjs seed all # ou simplesmente: node db.cjs seed
```
### Rodar grupo específico
```bash
node db.cjs seed users # Apenas usuários (seed_001 a 003)
node db.cjs seed system # Apenas sistema (seed_010 a 014)
node db.cjs seed test_data # Dados de teste (seed_020)
```
### Ordem dos seeds
| # | Arquivo | O que faz |
|---|---------|-----------|
| 1 | `seed_001_fixed.sql` | 6 usuários base + tenants |
| 2 | `seed_002.sql` | Supervisor + Editor |
| 3 | `seed_003.sql` | Therapist2, Therapist3, Secretary |
| 4 | `seed_010_plans.sql` | 7 planos + 4 preços |
| 5 | `seed_011_features.sql` | 26 features |
| 6 | `seed_012_plan_features.sql` | 85 vínculos plano↔feature |
| 7 | `seed_013_subscriptions.sql` | 9 subscriptions + compromissos |
| 8 | `seed_014_global_data.sql` | Templates + carousel |
## Outros Comandos
### Ver status
```bash
node db.cjs status
```
Mostra: container, backups, migrations aplicadas/pendentes, counts de todas as tabelas.
### Comparar mudanças
```bash
node db.cjs diff
```
Compara o schema atual no banco com o último backup. Mostra tabelas adicionadas, removidas ou alteradas.
### Verificar integridade
```bash
node db.cjs verify
```
Checa se os dados essenciais existem (plans, features, subscriptions, etc).
### Reset completo
```bash
node db.cjs reset
```
**⚠ CUIDADO**: Apaga tudo e reinstala do zero. Cria backup antes.
## Estrutura de Pastas
```
database-novo/
├── db.js ← CLI principal
├── db.config.json ← Configuração (container, seeds, fixes)
├── schema/ ← Schema SQL separado por seção
│ ├── 00_full/ ← Schema completo (referência)
│ ├── 01_extensions/ ← Extensões PostgreSQL
│ ├── 02_types/ ← Enums e tipos
│ ├── 03_functions/ ← Funções (11 arquivos por domínio)
│ ├── 04_tables/ ← Tabelas (10 arquivos por domínio)
│ ├── 05_views/ ← 24 views
│ ├── 06_indexes/ ← Índices
│ ├── 07_foreign_keys/ ← PKs, FKs, constraints
│ ├── 08_triggers/ ← Triggers
│ ├── 09_policies/ ← 217 RLS policies
│ └── 10_grants/ ← Grants
├── seeds/ ← Seeds de dados
│ ├── seed_001_fixed.sql
│ ├── ...
│ └── run_all_seeds.sh
├── migrations/ ← Migrations (alterações incrementais)
├── fixes/ ← Correções aplicadas
├── backups/ ← Backups com data
│ ├── 2026-03-23/
│ └── ...
└── docs/ ← Documentação
├── setup_guide.md ← Este arquivo
├── schema_map.md ← Mapa de 84 tabelas
├── business_rules.md ← Regras de negócio
└── users_test.md ← Usuários de teste
```
## Credenciais de Teste
| Email | Senha | Tipo |
|-------|-------|------|
| paciente@agenciapsi.com.br | Teste@123 | Paciente |
| terapeuta@agenciapsi.com.br | Teste@123 | Terapeuta solo |
| clinica1@agenciapsi.com.br | Teste@123 | Clínica coworking |
| clinica2@agenciapsi.com.br | Teste@123 | Clínica recepção |
| clinica3@agenciapsi.com.br | Teste@123 | Clínica full |
| saas@agenciapsi.com.br | Teste@123 | Admin plataforma |
| supervisor@agenciapsi.com.br | Teste@123 | Supervisor |
| editor@agenciapsi.com.br | Teste@123 | Editor |
| therapist2@agenciapsi.com.br | Teste@123 | Terapeuta |
| therapist3@agenciapsi.com.br | Teste@123 | Terapeuta |
| secretary@agenciapsi.com.br | Teste@123 | Secretária |
## Troubleshooting
### "Container não está rodando"
```bash
# Verificar
docker ps | grep supabase
# Reiniciar
npx supabase stop
npx supabase start
```
### "Tabela não existe" após setup
O schema pode não ter sido aplicado corretamente. Rode:
```bash
node db.cjs reset
```
### "Permission denied" / RLS bloqueando
Se features/plan_features estiverem vazios, o RLS bloqueia tudo. Rode:
```bash
node db.cjs seed system
```
### Migration falhou no meio
```bash
# Voltar ao estado anterior
node db.cjs restore
# Corrigir o SQL da migration, depois tentar de novo
node db.cjs migrate
```
### Quero começar do zero
```bash
node db.cjs reset
```
Isso apaga tudo, reaplica schema, fixes, seeds, e verifica.
+90
View File
@@ -0,0 +1,90 @@
# Usuários de Teste — AgenciaPsi
Senha de todos: `Teste@123`
## Mapa de UUIDs
### Users (auth.users.id = profiles.id)
| Email | UUID | Nome |
|-------|------|------|
| paciente@agenciapsi.com.br | `aaaaaaaa-0001-0001-0001-000000000001` | Ana Paciente |
| terapeuta@agenciapsi.com.br | `aaaaaaaa-0002-0002-0002-000000000002` | Bruno Terapeuta |
| clinica1@agenciapsi.com.br | `aaaaaaaa-0003-0003-0003-000000000003` | Clínica Espaço Psi |
| clinica2@agenciapsi.com.br | `aaaaaaaa-0004-0004-0004-000000000004` | Clínica Mente sã |
| clinica3@agenciapsi.com.br | `aaaaaaaa-0005-0005-0005-000000000005` | Clínica Bem Estar |
| saas@agenciapsi.com.br | `aaaaaaaa-0006-0006-0006-000000000006` | Admin Plataforma |
| supervisor@agenciapsi.com.br | `aaaaaaaa-0007-0007-0007-000000000007` | Carlos Supervisor |
| editor@agenciapsi.com.br | `aaaaaaaa-0008-0008-0008-000000000008` | Diana Editora |
| therapist2@agenciapsi.com.br | `aaaaaaaa-0009-0009-0009-000000000009` | Eva Terapeuta |
| therapist3@agenciapsi.com.br | `aaaaaaaa-0010-0010-0010-000000000010` | Felipe Terapeuta |
| secretary@agenciapsi.com.br | `aaaaaaaa-0011-0011-0011-000000000011` | Gabriela Secretária |
### Tenants
| Nome | UUID | Kind |
|------|------|------|
| Bruno Terapeuta | `bbbbbbbb-0002-0002-0002-000000000002` | therapist |
| Clínica Espaço Psi | `bbbbbbbb-0003-0003-0003-000000000003` | clinic_coworking |
| Clínica Mente sã | `bbbbbbbb-0004-0004-0004-000000000004` | clinic_reception |
| Clínica Bem Estar | `bbbbbbbb-0005-0005-0005-000000000005` | clinic_full |
| Eva Terapeuta | `bbbbbbbb-0009-0009-0009-000000000009` | therapist |
| Felipe Terapeuta | `bbbbbbbb-0010-0010-0010-000000000010` | therapist |
## Mapa de Vínculos
```
paciente@ → portal_user / patient_free (user_id)
Sem tenant próprio
terapeuta@ → tenant_member / therapist
Tenant: bbbbbbbb-0002 (therapist) → tenant_admin
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
clinica1@ → tenant_member / clinic
Tenant: bbbbbbbb-0003 (clinic_coworking) → tenant_admin
Subscription: clinic_free (tenant_id)
clinica2@ → tenant_member / clinic
Tenant: bbbbbbbb-0004 (clinic_reception) → tenant_admin
Subscription: clinic_free (tenant_id)
clinica3@ → tenant_member / clinic
Tenant: bbbbbbbb-0005 (clinic_full) → tenant_admin
Subscription: clinic_free (tenant_id)
saas@ → saas_admin
Sem tenant, sem subscription
supervisor@ → tenant_member / therapist
Clínica 3: bbbbbbbb-0005 → supervisor
Subscription: supervisor_free (user_id)
editor@ → tenant_member / therapist + platform_roles: {editor}
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
therapist2@ → tenant_member / therapist
Tenant: bbbbbbbb-0009 (therapist) → tenant_admin
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
therapist3@ → tenant_member / therapist
Tenant: bbbbbbbb-0010 (therapist) → tenant_admin
Clínica 3: bbbbbbbb-0005 → therapist
Subscription: therapist_free (user_id)
secretary@ → tenant_member / therapist (profile)
Clínica 2: bbbbbbbb-0004 → clinic_admin
Sem subscription própria (usa plano da Clínica 2)
```
## Clínica 3 — Bem Estar (Full) — Membros
| Membro | Role |
|--------|------|
| clinica3@ | tenant_admin |
| terapeuta@ | therapist |
| supervisor@ | supervisor |
| editor@ | therapist |
| therapist2@ | therapist |
| therapist3@ | therapist |
@@ -0,0 +1,11 @@
-- ============================================================
-- Fix: addon_credits e addon_transactions tenant_id FK
-- Corrige FK que apontava para auth.users → agora aponta para public.tenants
-- Agência PSI — 2026-03-22
-- ============================================================
ALTER TABLE public.addon_credits DROP CONSTRAINT IF EXISTS addon_credits_tenant_id_fkey;
ALTER TABLE public.addon_credits ADD CONSTRAINT addon_credits_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
ALTER TABLE public.addon_transactions DROP CONSTRAINT IF EXISTS addon_transactions_tenant_id_fkey;
ALTER TABLE public.addon_transactions ADD CONSTRAINT addon_transactions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
@@ -0,0 +1,83 @@
-- ============================================================
-- Fix: RLS addon_credits e addon_transactions
-- 1. SaaS Admin: acesso total
-- 2. Tenant members: SELECT nos seus créditos/transações
-- Agência PSI — 2026-03-22
-- ============================================================
-- ── addon_products: admin pode tudo (CRUD) ────────────────────
DROP POLICY IF EXISTS "addon_products_admin_all" ON public.addon_products;
CREATE POLICY "addon_products_admin_all"
ON public.addon_products FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_credits: admin pode ver todos ───────────────────────
DROP POLICY IF EXISTS "addon_credits_admin_select" ON public.addon_credits;
CREATE POLICY "addon_credits_admin_select"
ON public.addon_credits FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_credits: admin pode inserir/atualizar ───────────────
DROP POLICY IF EXISTS "addon_credits_admin_write" ON public.addon_credits;
CREATE POLICY "addon_credits_admin_write"
ON public.addon_credits FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_transactions: admin pode ver todas ──────────────────
DROP POLICY IF EXISTS "addon_transactions_admin_select" ON public.addon_transactions;
CREATE POLICY "addon_transactions_admin_select"
ON public.addon_transactions FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_transactions: admin pode inserir ────────────────────
DROP POLICY IF EXISTS "addon_transactions_admin_insert" ON public.addon_transactions;
CREATE POLICY "addon_transactions_admin_insert"
ON public.addon_transactions FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ══════════════════════════════════════════════════════════════
-- Corrige policies de tenant members (SELECT)
-- A policy original usava tenant_id = auth.uid(), mas o auth.uid()
-- é o user_id, não o tenant_id. Usa is_tenant_member() em vez disso.
-- ══════════════════════════════════════════════════════════════
-- addon_credits: membro do tenant vê os créditos do seu tenant
DROP POLICY IF EXISTS "addon_credits_select_own" ON public.addon_credits;
CREATE POLICY "addon_credits_select_own"
ON public.addon_credits FOR SELECT
TO authenticated
USING (
public.is_tenant_member(tenant_id)
OR owner_id = auth.uid()
);
-- addon_transactions: membro do tenant vê as transações do seu tenant
DROP POLICY IF EXISTS "addon_transactions_select_own" ON public.addon_transactions;
CREATE POLICY "addon_transactions_select_own"
ON public.addon_transactions FOR SELECT
TO authenticated
USING (
public.is_tenant_member(tenant_id)
OR owner_id = auth.uid()
);
@@ -0,0 +1,179 @@
-- =============================================================================
-- FIX: Corrige acentuação perdida (caracteres ?? no banco)
-- =============================================================================
-- Causa: Seeds aplicados originalmente sem encoding UTF-8 correto.
-- Os ?? são bytes literais 0x3F (ASCII ?) onde deveria haver UTF-8.
-- Este fix faz UPDATE direto nos valores conhecidos.
-- =============================================================================
BEGIN;
SET client_encoding TO 'UTF8';
-- ============================================================
-- 1. PROFILES — full_name
-- ============================================================
UPDATE profiles SET full_name = 'Clínica Espaço Psi' WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003' AND full_name != 'Clínica Espaço Psi';
UPDATE profiles SET full_name = 'Clínica Mente Sã' WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004' AND full_name != 'Clínica Mente Sã';
UPDATE profiles SET full_name = 'Clínica Bem Estar' WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005' AND full_name != 'Clínica Bem Estar';
UPDATE profiles SET full_name = 'Gabriela Secretária' WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011' AND full_name != 'Gabriela Secretária';
-- ============================================================
-- 2. TENANTS — name
-- ============================================================
UPDATE tenants SET name = 'Clínica Espaço Psi' WHERE id = 'bbbbbbbb-0003-0003-0003-000000000003';
UPDATE tenants SET name = 'Clínica Mente Sã' WHERE id = 'bbbbbbbb-0004-0004-0004-000000000004';
UPDATE tenants SET name = 'Clínica Bem Estar' WHERE id = 'bbbbbbbb-0005-0005-0005-000000000005';
-- ============================================================
-- 3. DETERMINED_COMMITMENTS — name
-- ============================================================
UPDATE determined_commitments SET name = 'Sessão' WHERE native_key = 'session';
UPDATE determined_commitments SET name = 'Supervisão' WHERE native_key = 'supervision';
UPDATE determined_commitments SET name = 'Análise Pessoal' WHERE native_key = 'analysis';
-- ============================================================
-- 4. PLANS — name, description
-- ============================================================
UPDATE plans SET name = 'THERAPIST PRO', description = 'Plano profissional para terapeutas' WHERE key = 'therapist_pro' AND description LIKE '%??%';
UPDATE plans SET name = 'CLINIC PRO', description = 'Plano profissional para clínicas' WHERE key = 'clinic_pro' AND description LIKE '%??%';
UPDATE plans SET name = 'THERAPIST FREE', description = 'Plano gratuito para terapeutas' WHERE key = 'therapist_free' AND description LIKE '%??%';
UPDATE plans SET name = 'CLINIC FREE', description = 'Plano gratuito para clínicas' WHERE key = 'clinic_free' AND description LIKE '%??%';
-- ============================================================
-- 5. FEATURES — name, description
-- ============================================================
UPDATE features SET name = 'Agenda - Visualizar', description = 'Visualização da agenda' WHERE key = 'agenda.view';
UPDATE features SET name = 'Agenda - Gerenciar', description = 'Gerenciamento completo da agenda' WHERE key = 'agenda.manage';
UPDATE features SET name = 'Pacientes', description = 'Módulo de pacientes' WHERE key = 'patients';
UPDATE features SET name = 'Pacientes - Visualizar', description = 'Visualização de pacientes' WHERE key = 'patients.view';
UPDATE features SET name = 'Pacientes - Gerenciar', description = 'Gerenciamento completo de pacientes' WHERE key = 'patients.manage';
UPDATE features SET name = 'Agendamento Online', description = 'Sistema de agendamento online' WHERE key = 'online_scheduling';
UPDATE features SET name = 'Agendamento Online - Gerenciar', description = 'Gerenciamento do agendamento online' WHERE key = 'online_scheduling.manage';
UPDATE features SET name = 'Agendamento Online - Público', description = 'Página pública do agendador' WHERE key = 'online_scheduling.public';
UPDATE features SET name = 'Lembretes', description = 'Sistema de lembretes automáticos' WHERE key = 'reminders';
UPDATE features SET name = 'Relatórios Básicos', description = 'Relatórios básicos' WHERE key = 'reports_basic';
UPDATE features SET name = 'Relatórios Avançados', description = 'Relatórios avançados com exportação' WHERE key = 'reports_advanced';
UPDATE features SET name = 'Secretária', description = 'Funcionalidade de secretária' WHERE key = 'secretary';
UPDATE features SET name = 'Recepção Compartilhada', description = 'Recepção compartilhada entre terapeutas' WHERE key = 'shared_reception';
UPDATE features SET name = 'Salas', description = 'Gerenciamento de salas' WHERE key = 'rooms';
UPDATE features SET name = 'Intake Público', description = 'Formulário de intake público' WHERE key = 'intake_public';
UPDATE features SET name = 'Intakes PRO', description = 'Funcionalidades avançadas de intake' WHERE key = 'intakes_pro';
UPDATE features SET name = 'Branding Personalizado', description = 'Personalização de marca' WHERE key = 'custom_branding';
UPDATE features SET name = 'Acesso API', description = 'Acesso via API' WHERE key = 'api_access';
UPDATE features SET name = 'Log de Auditoria', description = 'Log de auditoria completo' WHERE key = 'audit_log';
UPDATE features SET name = 'Lembrete SMS', description = 'Lembretes via SMS' WHERE key = 'sms_reminder';
UPDATE features SET name = 'Calendário da Clínica', description = 'Visão consolidada do calendário' WHERE key = 'clinic_calendar';
UPDATE features SET name = 'Relatórios Avançados (Clínica)', description = 'Relatórios avançados da clínica' WHERE key = 'advanced_reports';
UPDATE features SET name = 'Supervisor - Acesso', description = 'Acesso ao módulo de supervisão' WHERE key = 'supervisor.access';
UPDATE features SET name = 'Supervisor - Convidar', description = 'Convidar supervisionados' WHERE key = 'supervisor.invite';
UPDATE features SET name = 'Supervisor - Sessões', description = 'Gerenciar sessões de supervisão' WHERE key = 'supervisor.sessions';
UPDATE features SET name = 'Supervisor - Relatórios', description = 'Relatórios de supervisão' WHERE key = 'supervisor.reports';
-- ============================================================
-- 6. EMAIL_TEMPLATES_GLOBAL — subject, body_html, body_text
-- ============================================================
UPDATE email_templates_global SET
subject = 'Lembrete: sua sessão amanhã às {{session_time}}',
body_text = 'Olá {{patient_name}}, lembrete da sua sessão amanhã às {{session_time}} com {{therapist_name}}.'
WHERE key = 'session.reminder';
UPDATE email_templates_global SET
subject = 'Sessão confirmada — {{session_date}} às {{session_time}}',
body_text = 'Sua sessão com {{therapist_name}} em {{session_date}} às {{session_time}} foi confirmada.'
WHERE key = 'session.confirmation';
UPDATE email_templates_global SET
subject = 'Sessão cancelada — {{session_date}}',
body_text = 'A sessão de {{session_date}} às {{session_time}} com {{therapist_name}} foi cancelada.'
WHERE key = 'session.cancellation';
UPDATE email_templates_global SET
subject = 'Sessão reagendada — novo horário: {{session_date}} às {{session_time}}',
body_text = 'Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.'
WHERE key = 'session.rescheduled';
UPDATE email_templates_global SET
subject = 'Recebemos seu cadastro — {{patient_name}}',
body_text = 'Olá {{patient_name}}, recebemos seu formulário de cadastro. Entraremos em contato em breve.'
WHERE key = 'intake.received';
UPDATE email_templates_global SET
subject = 'Cadastro aprovado — Bem-vindo(a)!',
body_text = 'Olá {{patient_name}}, seu cadastro foi aprovado. Você já pode acessar a plataforma.'
WHERE key = 'intake.approved';
UPDATE email_templates_global SET
subject = 'Cadastro não aprovado',
body_text = 'Olá {{patient_name}}, infelizmente seu cadastro não foi aprovado no momento.'
WHERE key = 'intake.rejected';
UPDATE email_templates_global SET
subject = 'Solicitação aceita — {{session_date}} às {{session_time}}',
body_text = 'Sua solicitação de agendamento para {{session_date}} às {{session_time}} foi aceita.'
WHERE key = 'scheduler.request_accepted';
UPDATE email_templates_global SET
subject = 'Solicitação não disponível',
body_text = 'Infelizmente o horário solicitado não está disponível. Por favor, escolha outro horário.'
WHERE key = 'scheduler.request_rejected';
UPDATE email_templates_global SET
subject = 'Bem-vindo(a) à AgenciaPsi!',
body_text = 'Olá {{user_name}}, sua conta foi criada com sucesso. Acesse a plataforma para começar.'
WHERE key = 'system.welcome';
UPDATE email_templates_global SET
subject = 'Redefinição de senha — AgenciaPsi',
body_text = 'Clique no link abaixo para redefinir sua senha: {{reset_link}}'
WHERE key = 'system.password_reset';
-- ============================================================
-- 7. LOGIN_CAROUSEL_SLIDES — title, description
-- ============================================================
UPDATE login_carousel_slides SET
title = '<strong>Gestão clínica simplificada</strong>',
body = 'Gerencie agenda, pacientes e financeiro em um só lugar. Simples, rápido e seguro.'
WHERE ordem = 1;
UPDATE login_carousel_slides SET
title = '<strong>Múltiplos profissionais, uma só plataforma</strong>',
body = 'Ideal para clínicas com vários terapeutas. Cada profissional com sua agenda e seus pacientes.'
WHERE ordem = 2;
UPDATE login_carousel_slides SET
title = '<strong>Seguro, privado e sempre disponível</strong>',
body = 'Seus dados protegidos com criptografia. Acesse de qualquer lugar, a qualquer hora.'
WHERE ordem = 3;
-- ============================================================
-- 8. PATIENT_GROUPS (default groups) — name
-- ============================================================
UPDATE patient_groups SET nome = 'Crianças' WHERE nome LIKE 'Crian%' AND is_system = true;
UPDATE patient_groups SET nome = 'Adolescentes' WHERE nome LIKE 'Adolescen%' AND is_system = true;
UPDATE patient_groups SET nome = 'Idosos' WHERE nome LIKE 'Idoso%' AND is_system = true;
-- ============================================================
-- 9. AUTH.USERS — raw_user_meta_data (name field)
-- ============================================================
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Espaço Psi"') WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Mente Sã"') WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Bem Estar"') WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Gabriela Secretária"') WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011';
COMMIT;
-- ============================================================
DO $$
DECLARE
broken_count int;
BEGIN
SELECT count(*) INTO broken_count
FROM profiles WHERE full_name LIKE '%??%';
IF broken_count = 0 THEN
RAISE NOTICE 'fix_encoding_accents: Todos os acentos corrigidos com sucesso.';
ELSE
RAISE WARNING 'fix_encoding_accents: Ainda restam % registros com ?? em profiles.full_name', broken_count;
END IF;
END $$;
@@ -0,0 +1,220 @@
-- =============================================================================
-- FIX: Atribuir plano free a usuários/tenants sem assinatura ativa
-- =============================================================================
-- Execute no SQL Editor do Supabase (service_role)
-- Idempotente: só insere onde não existe assinatura ativa.
--
-- Regras:
-- • tenant kind = 'therapist' → therapist_free (por user_id do admin)
-- • tenant kind IN (clinic_*) → clinic_free (por tenant_id)
-- • profiles.account_type = 'patient' / portal_user → patient_free (por user_id)
-- =============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────────
-- DIAGNÓSTICO — mostra o estado atual antes de corrigir
-- ──────────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
r RECORD;
BEGIN
RAISE NOTICE '=== DIAGNÓSTICO DE ASSINATURAS ===';
RAISE NOTICE '';
-- Terapeutas sem plano
RAISE NOTICE '--- Terapeutas SEM assinatura ativa ---';
FOR r IN
SELECT
tm.user_id,
p.full_name,
t.id AS tenant_id,
t.name AS tenant_name
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
JOIN public.profiles p ON p.id = tm.user_id
WHERE t.kind = 'therapist'
AND tm.role = 'tenant_admin'
AND tm.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tm.user_id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%) — tenant %', r.full_name, r.user_id, r.tenant_id;
END LOOP;
-- Clínicas sem plano
RAISE NOTICE '';
RAISE NOTICE '--- Clínicas SEM assinatura ativa ---';
FOR r IN
SELECT t.id, t.name, t.kind
FROM public.tenants t
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.tenant_id = t.id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%) — kind %', r.name, r.id, r.kind;
END LOOP;
-- Pacientes sem plano
RAISE NOTICE '';
RAISE NOTICE '--- Pacientes SEM assinatura ativa ---';
FOR r IN
SELECT p.id, p.full_name
FROM public.profiles p
WHERE p.account_type = 'patient'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = p.id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%)', r.full_name, r.id;
END LOOP;
RAISE NOTICE '';
RAISE NOTICE '=== FIM DO DIAGNÓSTICO — aplicando correções... ===';
END;
$$;
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 1: Terapeutas sem assinatura → therapist_free
-- Escopo: user_id do tenant_admin do tenant kind='therapist'
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
tm.user_id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
JOIN public.plans p ON p.key = 'therapist_free'
WHERE t.kind = 'therapist'
AND tm.role = 'tenant_admin'
AND tm.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tm.user_id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 2: Clínicas sem assinatura → clinic_free
-- Escopo: tenant_id
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
t.id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.tenants t
JOIN public.plans p ON p.key = 'clinic_free'
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.tenant_id = t.id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 3: Pacientes sem assinatura → patient_free
-- Escopo: user_id
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
pr.id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.profiles pr
JOIN public.plans p ON p.key = 'patient_free'
WHERE pr.account_type = 'patient'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = pr.id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CONFIRMAÇÃO — mostra o que foi inserido (source = 'fix_seed')
-- ──────────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
r RECORD;
total INT := 0;
BEGIN
RAISE NOTICE '';
RAISE NOTICE '=== ASSINATURAS CRIADAS NESTA EXECUÇÃO ===';
FOR r IN
SELECT
s.plan_key,
COALESCE(pr.full_name, t.name) AS nome,
COALESCE(s.user_id::text, s.tenant_id::text) AS owner_id
FROM public.subscriptions s
LEFT JOIN public.profiles pr ON pr.id = s.user_id
LEFT JOIN public.tenants t ON t.id = s.tenant_id
WHERE s.source = 'fix_seed'
AND s.started_at >= now() - interval '5 seconds'
ORDER BY s.plan_key, nome
LOOP
RAISE NOTICE ' ✅ % → % (%)', r.plan_key, r.nome, r.owner_id;
total := total + 1;
END LOOP;
IF total = 0 THEN
RAISE NOTICE ' (nenhuma nova assinatura criada — todos já tinham plano ativo)';
ELSE
RAISE NOTICE '';
RAISE NOTICE ' Total: % assinatura(s) criada(s).', total;
END IF;
END;
$$;
COMMIT;
@@ -0,0 +1,45 @@
-- ============================================================
-- Fix: RLS notification_templates — acesso SaaS Admin
-- Admin precisa criar/editar/excluir templates globais (tenant_id IS NULL)
-- Agência PSI — 2026-03-22
-- ============================================================
-- SaaS Admin: acesso total (SELECT + INSERT + UPDATE + DELETE)
DROP POLICY IF EXISTS "notif_templates_admin_all" ON public.notification_templates;
CREATE POLICY "notif_templates_admin_all"
ON public.notification_templates FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- Tenant member: pode ler os globais + os do seu tenant
DROP POLICY IF EXISTS "notif_templates_read_global" ON public.notification_templates;
CREATE POLICY "notif_templates_read_global"
ON public.notification_templates FOR SELECT
TO authenticated
USING (
deleted_at IS NULL
AND (
(tenant_id IS NULL AND is_default = true)
OR owner_id = auth.uid()
OR public.is_tenant_member(tenant_id)
)
);
-- Tenant member: pode inserir/atualizar templates do seu tenant
DROP POLICY IF EXISTS "notif_templates_write_owner" ON public.notification_templates;
CREATE POLICY "notif_templates_write_owner"
ON public.notification_templates FOR ALL
TO authenticated
USING (
owner_id = auth.uid()
OR public.is_tenant_member(tenant_id)
)
WITH CHECK (
owner_id = auth.uid()
OR public.is_tenant_member(tenant_id)
);
@@ -0,0 +1,37 @@
-- ============================================================
-- Fix: cria função seed_default_patient_groups
-- Colunas reais: nome, cor, descricao, tenant_id (NOT NULL)
-- ============================================================
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
BEGIN
-- busca o owner (tenant_admin) do tenant
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;
INSERT INTO public.patient_groups (owner_id, nome, cor, is_system, tenant_id)
VALUES
(v_owner_id, 'Crianças', '#60a5fa', true, p_tenant_id),
(v_owner_id, 'Adolescentes', '#a78bfa', true, p_tenant_id),
(v_owner_id, 'Idosos', '#34d399', true, p_tenant_id)
ON CONFLICT (owner_id, nome) DO NOTHING;
END;
$$;
GRANT EXECUTE ON FUNCTION public.seed_default_patient_groups(uuid)
TO postgres, anon, authenticated, service_role;
@@ -0,0 +1,50 @@
-- Fix: subscriptions_validate_scope — adiciona suporte a target='patient'
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_target text;
BEGIN
SELECT lower(p.target) INTO v_target
FROM public.plans p
WHERE p.id = NEW.plan_id;
IF v_target IS NULL THEN
RAISE EXCEPTION 'Plano inválido (target nulo).';
END IF;
IF v_target = 'clinic' THEN
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
END IF;
IF NEW.user_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
END IF;
ELSIF v_target = 'therapist' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura therapist não deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura therapist exige user_id.';
END IF;
ELSIF v_target = 'patient' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura patient exige user_id.';
END IF;
ELSE
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.subscriptions_validate_scope() OWNER TO supabase_admin;
@@ -0,0 +1,78 @@
-- ============================================================
-- Fix: Template keys devem casar com o que populate_notification_queue gera
-- Agência PSI — 2026-03-22
-- ============================================================
-- O populate gera: 'session.' || REPLACE(event_type, '_sessao', '') || '.' || channel
-- Ex: event_type='lembrete_sessao' → 'session.lembrete.whatsapp'
--
-- Os seeds usavam nomes em inglês (session.reminder.whatsapp).
-- Este fix renomeia para casar com o populate.
-- ============================================================
-- ── 1. Renomeia templates existentes ──────────────────────────
UPDATE public.notification_templates
SET key = 'session.lembrete.whatsapp'
WHERE key = 'session.reminder.whatsapp';
UPDATE public.notification_templates
SET key = 'session.lembrete_2h.whatsapp'
WHERE key = 'session.reminder_2h.whatsapp';
UPDATE public.notification_templates
SET key = 'session.confirmacao.whatsapp'
WHERE key = 'session.confirmation.whatsapp';
UPDATE public.notification_templates
SET key = 'session.cancelamento.whatsapp'
WHERE key = 'session.cancellation.whatsapp';
UPDATE public.notification_templates
SET key = 'session.reagendamento.whatsapp'
WHERE key = 'session.reschedule.whatsapp';
UPDATE public.notification_templates
SET key = 'cobranca.pendente.whatsapp'
WHERE key = 'billing.pending.whatsapp';
UPDATE public.notification_templates
SET key = 'sistema.boas_vindas.whatsapp'
WHERE key = 'system.welcome.whatsapp';
-- ── SMS templates (mesmo padrão) ──────────────────────────────
UPDATE public.notification_templates
SET key = 'session.lembrete.sms'
WHERE key = 'session.reminder.sms';
UPDATE public.notification_templates
SET key = 'session.lembrete_2h.sms'
WHERE key = 'session.reminder_2h.sms';
UPDATE public.notification_templates
SET key = 'session.confirmacao.sms'
WHERE key = 'session.confirmation.sms';
UPDATE public.notification_templates
SET key = 'session.cancelamento.sms'
WHERE key = 'session.cancellation.sms';
UPDATE public.notification_templates
SET key = 'session.reagendamento.sms'
WHERE key = 'session.reschedule.sms';
UPDATE public.notification_templates
SET key = 'cobranca.pendente.sms'
WHERE key = 'billing.pending.sms';
UPDATE public.notification_templates
SET key = 'sistema.boas_vindas.sms'
WHERE key = 'system.welcome.sms';
-- ── 2. Verifica resultado ─────────────────────────────────────
SELECT key, channel, domain, event_type, is_default
FROM notification_templates
WHERE deleted_at IS NULL
ORDER BY channel, key;
@@ -0,0 +1,163 @@
-- ============================================================
-- Fix: Remove templates com keys em inglês (WhatsApp/SMS)
-- Agência PSI — 2026-04-22
-- ============================================================
-- Ambiente de desenvolvimento sem dados reais: DELETE físico.
-- Mantém apenas as keys canônicas em português definidas
-- pelo seed_014_global_data.sql. Se alguma key PT estiver
-- faltando após rodar esta migration, rode o Step 3 de reseed.
--
-- Idempotente: rodar de novo não causa erro (DELETE simples
-- encontra 0 linhas).
-- ============================================================
BEGIN;
-- ── Step 1: Snapshot ANTES ────────────────────────────────────────────
-- Útil pra conferir o que vai ser apagado
SELECT
'BEFORE' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active,
deleted_at
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 2: DELETE físico de todas as keys em inglês ─────────────────
DELETE FROM public.notification_templates
WHERE key IN (
-- WhatsApp — variantes em inglês
'session.reminder.whatsapp',
'session.reminder_2h.whatsapp',
'session.confirmation.whatsapp',
'session.cancellation.whatsapp',
'session.reschedule.whatsapp',
'session.rescheduled.whatsapp',
'billing.pending.whatsapp',
'system.welcome.whatsapp',
-- SMS — variantes em inglês
'session.reminder.sms',
'session.reminder_2h.sms',
'session.confirmation.sms',
'session.cancellation.sms',
'session.reschedule.sms',
'session.rescheduled.sms',
'billing.pending.sms',
'system.welcome.sms'
);
-- ── Step 3: Re-seed (inserção idempotente) das keys PT canônicas ─────
-- Garante que todas as keys esperadas existem como globais ativas.
-- Usa INSERT … ON CONFLICT DO UPDATE para ser idempotente.
-- Os body_text são placeholders padrão; se quiser textos diferentes,
-- edite depois via /configuracoes/whatsapp-templates ou /saas/notification-templates.
INSERT INTO public.notification_templates
(tenant_id, owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES
-- ── WhatsApp ─────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Lembrete: sua sessão com {{therapist_name}} é amanhã às {{session_time}}. Até lá!',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} começa em 2 horas ({{session_time}}). Até já!',
'["patient_name","therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.whatsapp', 'session', 'whatsapp', 'confirmacao_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} foi confirmada para {{session_date}} às {{session_time}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.whatsapp', 'session', 'whatsapp', 'cancelamento_sessao',
'Olá {{patient_name}}. Sua sessão de {{session_date}} às {{session_time}} foi cancelada. Entre em contato para remarcar.',
'["patient_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.whatsapp', 'session', 'whatsapp', 'reagendamento',
'Olá {{patient_name}}! Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.whatsapp', 'billing', 'whatsapp', 'cobranca_pendente',
'Olá {{patient_name}}! Identificamos um pagamento pendente de {{valor}} com vencimento em {{vencimento}}. Qualquer dúvida, estou à disposição.',
'["patient_name","valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.whatsapp', 'system', 'whatsapp', 'boas_vindas_paciente',
'Olá {{patient_name}}! Bem-vindo(a) à {{clinic_name}}. Seu terapeuta {{therapist_name}} está à disposição.',
'["patient_name","clinic_name","therapist_name"]'::jsonb, true, true),
-- ── SMS ──────────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.sms', 'session', 'sms', 'lembrete_sessao',
'Lembrete: sua sessao com {{therapist_name}} e amanha as {{session_time}}.',
'["therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.sms', 'session', 'sms', 'lembrete_sessao',
'Sua sessao com {{therapist_name}} comeca em 2h ({{session_time}}).',
'["therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.sms', 'session', 'sms', 'confirmacao_sessao',
'Sua sessao foi confirmada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.sms', 'session', 'sms', 'cancelamento_sessao',
'Sua sessao de {{session_date}} as {{session_time}} foi cancelada.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.sms', 'session', 'sms', 'reagendamento',
'Sua sessao foi reagendada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.sms', 'billing', 'sms', 'cobranca_pendente',
'Pagamento pendente: {{valor}}, venc. {{vencimento}}.',
'["valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.sms', 'system', 'sms', 'boas_vindas_paciente',
'Bem-vindo a {{clinic_name}}! Seu terapeuta e {{therapist_name}}.',
'["clinic_name","therapist_name"]'::jsonb, true, true)
ON CONFLICT (tenant_id, owner_id, key, deleted_at)
DO UPDATE SET
body_text = EXCLUDED.body_text,
variables = EXCLUDED.variables,
is_default = EXCLUDED.is_default,
is_active = EXCLUDED.is_active,
domain = EXCLUDED.domain,
event_type = EXCLUDED.event_type,
updated_at = now();
-- ── Step 4: Snapshot DEPOIS ──────────────────────────────────────────
SELECT
'AFTER' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
AND deleted_at IS NULL
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 5: Verificação — esperado 0 linhas ativas em EN ─────────────
SELECT
count(*) AS remaining_english_keys
FROM public.notification_templates
WHERE deleted_at IS NULL
AND key IN (
'session.reminder.whatsapp', 'session.reminder_2h.whatsapp', 'session.confirmation.whatsapp',
'session.cancellation.whatsapp', 'session.reschedule.whatsapp', 'session.rescheduled.whatsapp',
'billing.pending.whatsapp', 'system.welcome.whatsapp',
'session.reminder.sms', 'session.reminder_2h.sms', 'session.confirmation.sms',
'session.cancellation.sms', 'session.reschedule.sms', 'session.rescheduled.sms',
'billing.pending.sms', 'system.welcome.sms'
);
COMMIT;
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Dashboard Generator
// =============================================================================
// Uso:
// node generate-dashboard.cjs → usa backup mais recente
// node generate-dashboard.cjs 2026-04-17 → usa backup de data específica
//
// Lê de: ./backups/YYYY-MM-DD/schema.sql
// Lê de: ./db.config.json (domínios, cores e infraestrutura)
// Gera: ./agenciapsi-db-dashboard.html (na mesma pasta do script)
// =============================================================================
const fs = require('fs');
const path = require('path');
const ROOT = __dirname;
const BACKUPS_DIR = path.join(ROOT, 'backups');
const OUTPUT_FILE = path.join(ROOT, 'agenciapsi-db-dashboard.html');
const CONFIG_FILE = path.join(ROOT, 'db.config.json');
// ---------------------------------------------------------------------------
// Carrega config (domínios, cores e infraestrutura)
// ---------------------------------------------------------------------------
if (!fs.existsSync(CONFIG_FILE)) {
console.error(`✖ Config não encontrada: ${CONFIG_FILE}`);
process.exit(1);
}
const CONFIG = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
const DOMAIN_TABLES = CONFIG.domains || {};
const DOMAIN_COLORS = CONFIG.domainColors || {};
const INFRASTRUCTURE = CONFIG.infrastructure || {};
// ---------------------------------------------------------------------------
// 1. Resolve qual schema.sql usar
// ---------------------------------------------------------------------------
function resolveSchema() {
const arg = process.argv[2];
if (!fs.existsSync(BACKUPS_DIR)) {
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
console.error(` Rode primeiro: node db.cjs backup`);
process.exit(1);
}
const available = fs
.readdirSync(BACKUPS_DIR)
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
.sort()
.reverse();
if (available.length === 0) {
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
console.error(' Rode primeiro: node db.cjs backup');
process.exit(1);
}
const date = arg && /^\d{4}-\d{2}-\d{2}$/.test(arg) ? arg : available[0];
if (!available.includes(date)) {
console.error(`✖ Backup não encontrado para: ${date}`);
console.error(` Disponíveis: ${available.join(', ')}`);
process.exit(1);
}
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
if (!fs.existsSync(schemaPath)) {
console.error(`✖ schema.sql não encontrado em backups/${date}/`);
process.exit(1);
}
return { schemaPath, date, available };
}
// ---------------------------------------------------------------------------
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
// ---------------------------------------------------------------------------
function parseSchema(content) {
const tables = {};
// Tabelas public.*
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
let m;
while ((m = tableRe.exec(content)) !== null) {
const name = m[1].replace('public.', '');
const body = m[2];
const columns = [];
for (let line of body.split('\n')) {
line = line.trim().replace(/,$/, '');
if (!line || line.startsWith('--')) continue;
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
const col = line.match(
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
);
if (col) {
columns.push({
name: col[1],
type: col[2].trim().split('(')[0].trim(),
pk: col[1] === 'id'
});
}
}
tables[name] = { columns, fks: [] };
}
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
while ((m = fkRe.exec(content)) !== null) {
const [, fromTable, fromCol, toTable, toCol] = m;
if (tables[fromTable]) {
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
}
}
// Views
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
const views = [];
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
return { tables, views };
}
// ---------------------------------------------------------------------------
// 3. Monta os domínios
// Tabelas novas que ainda não estão mapeadas vão para "Outros"
// ---------------------------------------------------------------------------
function buildDomains(tables) {
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
const others = Object.keys(tables).filter((t) => !mapped.has(t) && t !== '_db_migrations');
const domains = {};
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
const present = list.filter((t) => tables[t]);
if (present.length > 0) domains[domain] = present;
}
if (others.length > 0) {
domains['Outros'] = others;
DOMAIN_COLORS['Outros'] = '#6b7280';
}
return domains;
}
// ---------------------------------------------------------------------------
// 4. Gera o HTML final (standalone, sem dependências externas de JS)
// ---------------------------------------------------------------------------
function generateHTML(tables, views, domains, date, available) {
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
const infraGroups = Object.keys(INFRASTRUCTURE).length;
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
const generated = new Date().toLocaleString('pt-BR');
// Slug por domínio — usado como id para scroll (ex: "SaaS / Planos" → "saas-planos")
const slugify = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const domainSlugs = {};
for (const d of Object.keys(domains)) domainSlugs[d] = slugify(d);
// Serializa dados para embutir no HTML
const jsonData = JSON.stringify({ tables, views, domains, slugs: domainSlugs });
const jsonColors = JSON.stringify(DOMAIN_COLORS);
const jsonInfra = JSON.stringify(INFRASTRUCTURE);
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgenciaPsi DB · ${date}</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}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;min-height:100vh;overflow-x:hidden}
.topbar{position:sticky;top:0;z-index:100;background:rgba(11,13,18,.94);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 28px;height:56px;display:flex;align-items:center;gap:20px}
.brand{font-weight:700;font-size:15px;letter-spacing:-.3px}.brand span{color:var(--accent)}
.gen{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.pills{display:flex;gap:10px;margin-left:auto}
.pill{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 12px}
.pill strong{color:var(--text);font-size:13px}
.search{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;color:var(--text);font-family:'Space Grotesk',sans-serif;font-size:13px;outline:none;width:200px;transition:border-color .2s,width .2s}
.search:focus{border-color:var(--accent);width:280px}
.search::placeholder{color:var(--text3)}
.layout{display:flex;height:calc(100vh - 56px)}
.sidebar{width:260px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
.sb-i:hover{color:var(--text);background:var(--bg3)}
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(99,102,241,.08)}
.sb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.sb-c{margin-left:auto;font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.main{flex:1;overflow-y:auto}
.main::-webkit-scrollbar{width:5px}.main::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.overview{padding:32px 36px;border-bottom:1px solid var(--border)}
.ov-t{font-size:22px;font-weight:700;margin-bottom:6px}
.ov-s{font-size:14px;color:var(--text2);margin-bottom:28px}
.dgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.dc{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:16px 18px;cursor:pointer;transition:all .2s;position:relative;overflow:hidden}
.dc::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--c)}
.dc:hover{border-color:var(--border2);transform:translateY(-1px)}
.dc-n{font-size:14px;font-weight:600;margin-bottom:6px}
.dc-m{font-size:12px;color:var(--text2);font-family:'IBM Plex Mono',monospace}
.dc-m span{font-weight:600}
.section{padding:28px 36px}
.sec-h{display:flex;align-items:center;gap:14px;margin-bottom:20px}
.sec-t{font-size:18px;font-weight:700}
.sec-b{font-size:11px;font-family:'IBM Plex Mono',monospace;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:3px 10px;color:var(--text2)}
.tgrid{display:flex;flex-direction:column;gap:10px}
.tc{background:var(--bg3);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .15s}
.tc:hover{border-color:var(--border2)}.tc.hl{border-color:var(--accent)}
.tc-h{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;user-select:none}
.tc-n{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600}
.tc-m{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.tc-f{font-size:11px;color:var(--fk);font-family:'IBM Plex Mono',monospace;margin-left:4px}
.tc-tg{margin-left:auto;color:var(--text3);font-size:11px;transition:transform .2s}
.tc-tg.open{transform:rotate(180deg)}
.tc-b{display:none;border-top:1px solid var(--border)}.tc-b.open{display:block}
.cols{padding:6px 0}
.cr{display:flex;align-items:center;gap:10px;padding:5px 16px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2)}
.cr:hover{background:rgba(255,255,255,.02)}
.bdg{font-size:9px;font-weight:700;letter-spacing:.5px;padding:1px 5px;border-radius:3px;width:26px;text-align:center;flex-shrink:0}
.bdg.pk{background:rgba(251,191,36,.15);color:var(--pk)}.bdg.fk{background:rgba(244,114,182,.15);color:var(--fk)}.bdg.x{background:transparent}
.cn{color:var(--text)}.ct{color:var(--text3);margin-left:auto;font-size:11px}
.fksec{border-top:1px solid var(--border);padding:10px 16px}
.fkt{font-size:10px;font-weight:600;letter-spacing:1px;color:var(--text3);text-transform:uppercase;margin-bottom:8px}
.fkr{display:flex;align-items:center;gap:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2);padding:3px 0}
.fka{color:var(--fk)}.fkl{color:var(--accent);cursor:pointer}.fkl:hover{text-decoration:underline}
.vsec{padding:0 36px 32px}
.vgrid{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.vc{background:rgba(110,231,183,.08);border:1px solid rgba(110,231,183,.2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--accent2)}
.empty{padding:40px;text-align:center;color:var(--text3);font-size:14px}
mark{background:rgba(99,102,241,.3);color:#fff;border-radius:2px}
/* Infraestrutura */
.igroup{margin-bottom:28px}
.igroup-h{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.igroup-t{font-size:15px;font-weight:600;letter-spacing:-.2px}
.igroup-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.igrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
.ic{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:16px 18px;transition:border-color .15s;position:relative;overflow:hidden}
.ic::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;background:var(--c)}
.ic:hover{border-color:var(--border2)}
.ic-h{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.ic-n{font-size:14px;font-weight:600;flex:1;min-width:0}
.ic-st{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;padding:2px 7px;border-radius:10px;flex-shrink:0;white-space:nowrap}
.ic-st.ativo{background:rgba(52,211,153,.15);color:var(--ok)}
.ic-st.pendente{background:rgba(248,113,113,.15);color:var(--pend)}
.ic-st.planejado{background:rgba(251,191,36,.15);color:var(--warn)}
.ic-st.legado{background:rgba(148,163,184,.2);color:var(--leg)}
.ic-r{font-size:12px;color:var(--text2);margin-bottom:8px;line-height:1.5}
.ic-e{font-size:10px;color:var(--text3);font-family:'IBM Plex Mono',monospace;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}
.ic-nt{font-size:11px;color:var(--text3);line-height:1.55;border-top:1px solid var(--border);padding-top:8px;margin-top:8px}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">Agência<span>Psi</span> DB</div>
<span class="gen">${date} · ${generated}</span>
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
<div class="pills">
<div class="pill"><strong>${Object.keys(tables).length}</strong> tabelas</div>
<div class="pill"><strong>${totalFKs}</strong> FKs</div>
<div class="pill"><strong>${views.length}</strong> views</div>
<div class="pill"><strong>${totalCols}</strong> colunas</div>
<div class="pill"><strong>${infraItems}</strong> infra</div>
</div>
</div>
<div class="layout">
<nav class="sidebar" id="sb"></nav>
<main class="main" id="mn"></main>
</div>
<script>
const D=${jsonData};
const C=${jsonColors};
const INFRA=${jsonInfra};
const INFRA_GROUPS=${infraGroups};
const INFRA_ITEMS=${infraItems};
const T2D={};
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
let dom=null,view='overview',q='';
function gc(d){return C[d]||'#6b7280';}
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
function buildSB(){
let h=\`<div class="sb-h">Visão Geral</div>
<div class="sb-i \${view==='overview'&&!dom?'active':''}" onclick="selOverview()">
<div class="sb-dot" style="background:#6366f1"></div>Todos (tabelas)
<span class="sb-c">\${Object.keys(D.tables).length}</span>
</div>
<div class="sb-i \${view==='infra'?'active':''}" onclick="selInfra()">
<div class="sb-dot" style="background:#fbbf24"></div>Infraestrutura
<span class="sb-c">\${INFRA_ITEMS}</span>
</div>
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
for(const[d,ts]of Object.entries(D.domains)){
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain('\${D.slugs[d]}')">
<div class="sb-dot" style="background:\${gc(d)}"></div>\${escapeHtml(d)}
<span class="sb-c">\${ts.length}</span>
</div>\`;
}
h+=\`<div class="sb-i" onclick="scrollToViews()">
<div class="sb-dot" style="background:#6ee7b7"></div>Views
<span class="sb-c">\${D.views.length}</span>
</div>\`;
document.getElementById('sb').innerHTML=h;
}
function buildMN(){
const mn=document.getElementById('mn');
let h='';
if(q){
const matches=Object.entries(D.tables).filter(([n,t])=>n.includes(q)||t.columns.some(c=>c.name.includes(q)));
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${escapeHtml(q)}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
h+=matches.length?matches.map(([n,t])=>card(n,t,q)).join(''):'<div class="empty">Nenhum resultado.</div>';
h+='</div></div>';
} else if(view==='infra'){
h+=\`<div class="overview"><div class="ov-t">Infraestrutura</div>
<div class="ov-s">Serviços, bibliotecas e ferramentas que o sistema usa · \${INFRA_GROUPS} grupos · \${INFRA_ITEMS} itens</div></div>
<div class="section">\`;
for(const[grupo,info]of Object.entries(INFRA)){
const color=info.color||'#6b7280';
h+=\`<div class="igroup">
<div class="igroup-h">
<div class="igroup-c" style="background:\${color}"></div>
<div class="igroup-t" style="color:\${color}">\${escapeHtml(grupo)}</div>
<div class="sec-b">\${info.items.length} itens</div>
</div>
<div class="igrid">\${info.items.map(item=>infraCard(item,color)).join('')}</div>
</div>\`;
}
h+='</div>';
} else {
const ds=dom?{[dom]:D.domains[dom]}:D.domains;
if(!dom){
h+=\`<div class="overview"><div class="ov-t">AgenciaPsi — Banco de Dados</div>
<div class="ov-s">Schema público · \${Object.keys(D.tables).length} tabelas · \${Object.values(D.tables).reduce((a,t)=>a+t.fks.length,0)} FKs · \${D.views.length} views</div>
<div class="dgrid">\`;
for(const[d,ts]of Object.entries(D.domains)){
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain('\${D.slugs[d]}')">
<div class="dc-n">\${escapeHtml(d)}</div>
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
</div>\`;
}
h+='</div></div>';
}
for(const[d,ts]of Object.entries(ds)){
h+=\`<div class="section" id="dom-\${D.slugs[d]||''}"><div class="sec-h">
<div class="sec-t" style="color:\${gc(d)}">\${escapeHtml(d)}</div>
<div class="sec-b">\${ts.length} tabelas</div>
</div><div class="tgrid">\`;
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
h+='</div></div>';
}
if(!dom){
h+=\`<div class="vsec" id="dom-views"><div class="sec-h">
<div class="sec-t" style="color:#6ee7b7">Views</div>
<div class="sec-b">\${D.views.length}</div>
</div><div class="vgrid">\${D.views.map(v=>\`<div class="vc">\${v}</div>\`).join('')}</div></div>\`;
}
}
mn.innerHTML=h;
}
function infraCard(item,color){
const status=(item.status||'ativo').toLowerCase();
return \`<div class="ic" style="--c:\${color}">
<div class="ic-h">
<div class="ic-n">\${escapeHtml(item.name)}</div>
<div class="ic-st \${status}">\${escapeHtml(item.status||'ativo')}</div>
</div>
<div class="ic-r">\${escapeHtml(item.role||'')}</div>
\${item.env?\`<div class="ic-e">\${escapeHtml(item.env)}</div>\`:''}
\${item.notes?\`<div class="ic-nt">\${escapeHtml(item.notes)}</div>\`:''}
</div>\`;
}
function card(name,t,hl){
const fkCols=new Set(t.fks.map(f=>f.from_col));
const c=gc(T2D[name]);
const cols=t.columns.map(col=>{
let n=col.name;
if(hl&&n.includes(hl))n=n.replace(new RegExp(\`(\${hl})\`,'gi'),'<mark>$1</mark>');
const b=col.pk?'pk':fkCols.has(col.name)?'fk':'x';
const l=col.pk?'PK':fkCols.has(col.name)?'FK':'';
return \`<div class="cr"><span class="bdg \${b}">\${l}</span><span class="cn">\${n}</span><span class="ct">\${col.type}</span></div>\`;
}).join('');
const fks=t.fks.length?\`<div class="fksec"><div class="fkt">Foreign Keys</div>\${
t.fks.map(f=>\`<div class="fkr"><span>\${f.from_col}</span><span class="fka">→</span><span class="fkl" onclick="jump('\${f.to_table}')">\${f.to_table}.\${f.to_col}</span></div>\`).join('')
}</div>\`:'';
return \`<div class="tc \${hl&&name.includes(hl)?'hl':''}" id="tc-\${name}">
<div class="tc-h" onclick="tog('\${name}')">
<div style="width:8px;height:8px;border-radius:50%;background:\${c};flex-shrink:0"></div>
<div class="tc-n">\${name}</div>
<span class="tc-m">\${t.columns.length} cols</span>
\${t.fks.length?\`<span class="tc-f">\${t.fks.length} FK</span>\`:''}
<span class="tc-tg" id="tg-\${name}"></span>
</div>
<div class="tc-b" id="bd-\${name}"><div class="cols">\${cols}</div>\${fks}</div>
</div>\`;
}
function tog(n){
document.getElementById('bd-'+n)?.classList.toggle('open');
document.getElementById('tg-'+n)?.classList.toggle('open');
}
function sel(d){
dom=d;view='overview';q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function scrollToDomain(slug){
// Sempre ir pra overview (com todos os domínios visíveis) antes de scrollar
const needRebuild=view!=='overview'||dom!==null||q;
if(needRebuild){
dom=null;view='overview';q='';
document.getElementById('si').value='';
buildSB();buildMN();
}
setTimeout(()=>{
const el=document.getElementById('dom-'+slug);
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
}, needRebuild?80:0);
}
function scrollToViews(){
const needRebuild=view!=='overview'||dom!==null||q;
if(needRebuild){
dom=null;view='overview';q='';
document.getElementById('si').value='';
buildSB();buildMN();
}
setTimeout(()=>{
const el=document.getElementById('dom-views');
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
}, needRebuild?80:0);
}
function selOverview(){
dom=null;view='overview';q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function selInfra(){
dom=null;view='infra';q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function jump(name){
dom=T2D[name]||null;view='overview';q='';document.getElementById('si').value='';
buildSB();buildMN();
setTimeout(()=>{
const el=document.getElementById('tc-'+name);
if(!el)return;
el.scrollIntoView({behavior:'smooth',block:'center'});
const bd=document.getElementById('bd-'+name);
const tg=document.getElementById('tg-'+name);
if(bd&&!bd.classList.contains('open')){bd.classList.add('open');tg?.classList.add('open');}
el.style.borderColor='#6366f1';
setTimeout(()=>el.style.borderColor='',2000);
},80);
}
let st;
function search(v){
clearTimeout(st);q=v.trim();
st=setTimeout(()=>{dom=null;view='overview';buildSB();buildMN();},200);
}
buildSB();buildMN();
</script>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// 5. Execução
// ---------------------------------------------------------------------------
console.log('\n═══ AgenciaPsi — Dashboard Generator ═══\n');
const { schemaPath, date, available } = resolveSchema();
console.log(` → Schema: ${schemaPath}`);
if (available.length > 1) console.log(` → Outros backups: ${available.slice(1).join(', ')}`);
const content = fs.readFileSync(schemaPath, 'utf8');
console.log(` → Lendo schema... (${(content.length / 1024).toFixed(0)} KB)`);
const { tables, views } = parseSchema(content);
const domains = buildDomains(tables);
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
console.log(`${Object.keys(tables).length} tabelas · ${totalFKs} FKs · ${views.length} views`);
// Avisa sobre tabelas novas não mapeadas
if (domains['Outros']) {
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
domains['Outros'].forEach((t) => console.log(` - ${t}`));
console.log(` → Edite "domains" em db.config.json para mapeá-las.\n`);
}
// Infra stats
const infraGroups = Object.keys(INFRASTRUCTURE).length;
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
console.log(` → Infraestrutura: ${infraGroups} grupos, ${infraItems} itens`);
const html = generateHTML(tables, views, domains, date, available);
fs.writeFileSync(OUTPUT_FILE, html, 'utf8');
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
console.log(` Abra no browser: file://${OUTPUT_FILE.replace(/\\/g, '/')}\n`);
@@ -0,0 +1,132 @@
-- =============================================================================
-- AgenciaPsi — Migration 001: Twilio WhatsApp Subaccounts
-- =============================================================================
-- Adiciona suporte a subcontas Twilio com número WhatsApp dedicado por tenant.
-- Cada clínica/terapeuta recebe sua própria subconta Twilio com número exclusivo.
-- =============================================================================
-- ── 1. Campos de subconta Twilio em notification_channels ──────────────────
ALTER TABLE public.notification_channels
ADD COLUMN IF NOT EXISTS twilio_subaccount_sid text,
ADD COLUMN IF NOT EXISTS twilio_phone_number text,
ADD COLUMN IF NOT EXISTS twilio_phone_sid text,
ADD COLUMN IF NOT EXISTS webhook_url text,
ADD COLUMN IF NOT EXISTS cost_per_message_usd numeric(8,6) DEFAULT 0,
ADD COLUMN IF NOT EXISTS price_per_message_brl numeric(8,4) DEFAULT 0,
ADD COLUMN IF NOT EXISTS provisioned_at timestamp with time zone;
COMMENT ON COLUMN public.notification_channels.twilio_subaccount_sid IS 'SID da subconta Twilio criada para este tenant';
COMMENT ON COLUMN public.notification_channels.twilio_phone_number IS 'Número WhatsApp provisionado (E.164, ex: +5511999990000)';
COMMENT ON COLUMN public.notification_channels.twilio_phone_sid IS 'SID do número de telefone na subconta Twilio';
COMMENT ON COLUMN public.notification_channels.webhook_url IS 'URL do webhook configurada na Twilio para receber callbacks de status';
COMMENT ON COLUMN public.notification_channels.cost_per_message_usd IS 'Custo real Twilio por mensagem WhatsApp (USD)';
COMMENT ON COLUMN public.notification_channels.price_per_message_brl IS 'Valor cobrado do tenant por mensagem (BRL, inclui margem SaaS)';
COMMENT ON COLUMN public.notification_channels.provisioned_at IS 'Timestamp do provisionamento da subconta';
-- Índice para busca rápida por subconta
CREATE INDEX IF NOT EXISTS idx_notification_channels_twilio_subaccount_sid
ON public.notification_channels (twilio_subaccount_sid)
WHERE twilio_subaccount_sid IS NOT NULL;
-- ── 2. Tabela de consumo por subconta ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.twilio_subaccount_usage (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
channel_id uuid NOT NULL,
twilio_subaccount_sid text NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
messages_sent integer DEFAULT 0 NOT NULL,
messages_delivered integer DEFAULT 0 NOT NULL,
messages_failed integer DEFAULT 0 NOT NULL,
cost_usd numeric(12,6) DEFAULT 0 NOT NULL,
cost_brl numeric(12,4) DEFAULT 0 NOT NULL,
revenue_brl numeric(12,4) DEFAULT 0 NOT NULL,
margin_brl numeric(12,4) GENERATED ALWAYS AS (revenue_brl - cost_brl) STORED,
usd_brl_rate numeric(8,4) DEFAULT 0,
synced_at timestamp with time zone DEFAULT now(),
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT twilio_subaccount_usage_pkey PRIMARY KEY (id),
CONSTRAINT twilio_subaccount_usage_channel_fk
FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE,
CONSTRAINT twilio_subaccount_usage_period_check
CHECK (period_end >= period_start)
);
COMMENT ON TABLE public.twilio_subaccount_usage IS
'Consumo mensal de mensagens WhatsApp por subconta Twilio. Sincronizado via Edge Function.';
CREATE INDEX IF NOT EXISTS idx_twilio_usage_tenant_period
ON public.twilio_subaccount_usage (tenant_id, period_start DESC);
CREATE INDEX IF NOT EXISTS idx_twilio_usage_channel
ON public.twilio_subaccount_usage (channel_id, period_start DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_twilio_usage_unique_period
ON public.twilio_subaccount_usage (channel_id, period_start, period_end);
ALTER TABLE public.twilio_subaccount_usage OWNER TO supabase_admin;
-- ── 3. RLS: twilio_subaccount_usage ───────────────────────────────────────
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
-- Tenant vê apenas seu próprio consumo
CREATE POLICY "tenant_select_own_usage"
ON public.twilio_subaccount_usage
FOR SELECT
USING (
tenant_id IN (
SELECT tenant_id FROM public.tenant_members
WHERE user_id = auth.uid()
)
);
-- Apenas service_role pode inserir/atualizar (via Edge Function)
CREATE POLICY "service_role_manage_usage"
ON public.twilio_subaccount_usage
FOR ALL
USING (auth.role() = 'service_role');
-- ── 4. RLS: notification_channels — acesso ao twilio_subaccount_sid ───────
-- As políticas existentes já cobrem SELECT/UPDATE. Nenhuma alteração necessária.
-- ── 5. View: resumo de subcontas para o painel SaaS admin ─────────────────
CREATE OR REPLACE VIEW public.v_twilio_whatsapp_overview AS
SELECT
nc.id AS channel_id,
nc.tenant_id,
nc.owner_id,
nc.is_active,
nc.connection_status,
nc.display_name,
nc.twilio_subaccount_sid,
nc.twilio_phone_number,
nc.twilio_phone_sid,
nc.cost_per_message_usd,
nc.price_per_message_brl,
nc.provisioned_at,
nc.created_at,
nc.updated_at,
-- Uso do mês atual
COALESCE(u.messages_sent, 0) AS current_month_sent,
COALESCE(u.messages_delivered, 0) AS current_month_delivered,
COALESCE(u.messages_failed, 0) AS current_month_failed,
COALESCE(u.cost_usd, 0) AS current_month_cost_usd,
COALESCE(u.cost_brl, 0) AS current_month_cost_brl,
COALESCE(u.revenue_brl, 0) AS current_month_revenue_brl,
COALESCE(u.margin_brl, 0) AS current_month_margin_brl
FROM public.notification_channels nc
LEFT JOIN public.twilio_subaccount_usage u
ON u.channel_id = nc.id
AND u.period_start = date_trunc('month', CURRENT_DATE)::date
WHERE nc.channel = 'whatsapp'
AND nc.provider = 'twilio'
AND nc.deleted_at IS NULL;
COMMENT ON VIEW public.v_twilio_whatsapp_overview IS
'Visão consolidada de subcontas Twilio WhatsApp com uso do mês corrente.';
@@ -0,0 +1,57 @@
-- ============================================================
-- Migration 002 — SetupWizard1: campos Negócio e Atendimento
-- ============================================================
-- Tabela: tenants (Step 2 — Negócio)
-- Tabela: agenda_configuracoes (Step 3 — Atendimento)
-- ============================================================
-- ----------------------------------------------------------
-- tenants: dados do negócio
-- ----------------------------------------------------------
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS business_type text,
ADD COLUMN IF NOT EXISTS logo_url text,
ADD COLUMN IF NOT EXISTS address text,
ADD COLUMN IF NOT EXISTS phone text,
ADD COLUMN IF NOT EXISTS contact_email text,
ADD COLUMN IF NOT EXISTS site_url text,
ADD COLUMN IF NOT EXISTS social_instagram text;
-- Valores aceitos: consultorio | clinica | instituto | grupo
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_business_type_check
CHECK (business_type IS NULL OR business_type = ANY (ARRAY[
'consultorio'::text,
'clinica'::text,
'instituto'::text,
'grupo'::text
]));
-- ----------------------------------------------------------
-- agenda_configuracoes: modo de atendimento
-- ----------------------------------------------------------
ALTER TABLE public.agenda_configuracoes
ADD COLUMN IF NOT EXISTS atendimento_mode text DEFAULT 'particular'::text;
ALTER TABLE public.agenda_configuracoes
ADD CONSTRAINT agenda_configuracoes_atendimento_mode_check
CHECK (atendimento_mode IS NULL OR atendimento_mode = ANY (ARRAY[
'particular'::text,
'convenio'::text,
'ambos'::text
]));
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.business_type IS 'Tipo de negócio: consultorio, clinica, instituto, grupo';
COMMENT ON COLUMN public.tenants.logo_url IS 'URL da logo do negócio (Storage bucket)';
COMMENT ON COLUMN public.tenants.address IS 'Endereço do negócio (texto livre)';
COMMENT ON COLUMN public.tenants.phone IS 'Telefone/WhatsApp do negócio';
COMMENT ON COLUMN public.tenants.contact_email IS 'E-mail público de contato do negócio';
COMMENT ON COLUMN public.tenants.site_url IS 'Site do negócio';
COMMENT ON COLUMN public.tenants.social_instagram IS 'Instagram do negócio (sem @)';
COMMENT ON COLUMN public.agenda_configuracoes.atendimento_mode IS 'Modo de atendimento: particular | convenio | ambos';
@@ -0,0 +1,33 @@
-- ============================================================
-- Migration 003 — Tenants: campos de endereço detalhado
-- ============================================================
-- Substitui o campo address (texto livre) por campos estruturados
-- preenchidos via consulta de CEP (ViaCEP)
-- ============================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cep text,
ADD COLUMN IF NOT EXISTS logradouro text,
ADD COLUMN IF NOT EXISTS numero text,
ADD COLUMN IF NOT EXISTS complemento text,
ADD COLUMN IF NOT EXISTS bairro text,
ADD COLUMN IF NOT EXISTS cidade text,
ADD COLUMN IF NOT EXISTS estado text;
-- Migra dados existentes do campo address para logradouro
UPDATE public.tenants
SET logradouro = address
WHERE address IS NOT NULL
AND logradouro IS NULL;
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.cep IS 'CEP do endereço do negócio';
COMMENT ON COLUMN public.tenants.logradouro IS 'Logradouro (rua, avenida, etc.)';
COMMENT ON COLUMN public.tenants.numero IS 'Número do endereço';
COMMENT ON COLUMN public.tenants.complemento IS 'Complemento (sala, andar, etc.)';
COMMENT ON COLUMN public.tenants.bairro IS 'Bairro';
COMMENT ON COLUMN public.tenants.cidade IS 'Cidade';
COMMENT ON COLUMN public.tenants.estado IS 'UF (2 letras)';
@@ -0,0 +1,147 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `medicos`
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026 · São Carlos/SP — Brasil
--
-- Propósito:
-- Armazena médicos e profissionais de referência (psiquiatras, neurologistas,
-- clínicos gerais, etc.) que encaminham pacientes ou fazem parte da rede de
-- suporte clínico do terapeuta.
--
-- Usado em:
-- - PatientsCadastroPage: campo "Encaminhado por" (FK medico_id)
-- - CadastroRapidoMedico.vue: cadastro rápido dentro do formulário
-- - MedicosCadastroPage.vue: página completa de gestão de médicos
--
-- Relacionamentos:
-- medicos.owner_id → auth.users(id)
-- medicos.tenant_id → tenants(id)
-- patients.medico_encaminhador_id → medicos(id) (opcional, ver abaixo)
--
-- RLS: owner_id = auth.uid() — cada profissional vê apenas seus médicos.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.medicos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Identidade profissional
nome text NOT NULL,
crm text, -- Ex: "123456/SP"
especialidade text, -- Ex: "Psiquiatria"
-- Contatos — telefone_pessoal é sensível (exibido com ícone de olho)
telefone_profissional text, -- Consultório / clínica
telefone_pessoal text, -- WhatsApp / pessoal
email text,
-- Local de atuação
clinica text, -- Nome da clínica/hospital
cidade text,
estado text DEFAULT 'SP',
-- Notas internas do terapeuta
observacoes text,
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT medicos_pkey PRIMARY KEY (id),
-- CRM único por owner (mesmo terapeuta não cadastra o mesmo CRM duas vezes)
CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm)
);
-- --------------------------------------------------------------------------
-- 2. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS medicos_owner_idx
ON public.medicos USING btree (owner_id);
CREATE INDEX IF NOT EXISTS medicos_tenant_idx
ON public.medicos USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS medicos_nome_idx
ON public.medicos USING btree (nome);
CREATE INDEX IF NOT EXISTS medicos_especialidade_idx
ON public.medicos USING btree (especialidade);
-- Busca textual por nome e especialidade
CREATE INDEX IF NOT EXISTS medicos_nome_trgm_idx
ON public.medicos USING gin (nome gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger de updated_at
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.set_medicos_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_medicos_updated_at
BEFORE UPDATE ON public.medicos
FOR EACH ROW
EXECUTE FUNCTION public.set_medicos_updated_at();
-- --------------------------------------------------------------------------
-- 4. Row Level Security
-- --------------------------------------------------------------------------
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
-- Owner tem acesso total aos seus próprios médicos
CREATE POLICY "medicos: owner full access"
ON public.medicos
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 5. Comentários de documentação
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.medicos IS 'Médicos e profissionais de referência cadastrados pelo terapeuta.';
COMMENT ON COLUMN public.medicos.owner_id IS 'Terapeuta dono do cadastro (auth.uid()).';
COMMENT ON COLUMN public.medicos.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.medicos.nome IS 'Nome completo do médico/profissional.';
COMMENT ON COLUMN public.medicos.crm IS 'CRM com UF. Ex: 123456/SP. Único por owner_id.';
COMMENT ON COLUMN public.medicos.especialidade IS 'Especialidade médica. Ex: Psiquiatria, Neurologia.';
COMMENT ON COLUMN public.medicos.telefone_profissional IS 'Telefone do consultório ou clínica.';
COMMENT ON COLUMN public.medicos.telefone_pessoal IS 'Telefone pessoal / WhatsApp. Campo sensível.';
COMMENT ON COLUMN public.medicos.email IS 'E-mail profissional.';
COMMENT ON COLUMN public.medicos.clinica IS 'Nome da clínica ou hospital onde atua.';
COMMENT ON COLUMN public.medicos.cidade IS 'Cidade de atuação.';
COMMENT ON COLUMN public.medicos.estado IS 'UF de atuação. Default SP.';
COMMENT ON COLUMN public.medicos.observacoes IS 'Notas internas do terapeuta sobre o médico.';
COMMENT ON COLUMN public.medicos.ativo IS 'Soft delete: false oculta da listagem.';
-- --------------------------------------------------------------------------
-- 6. Coluna FK opcional em patients
-- (Conecta "Encaminhado por" ao cadastro de médico)
-- Execute apenas se quiser a FK estruturada; caso contrário,
-- o campo encaminhado_por (text) no PatientsCadastroPage já funciona.
-- --------------------------------------------------------------------------
-- ALTER TABLE public.patients
-- ADD COLUMN IF NOT EXISTS medico_encaminhador_id uuid
-- REFERENCES public.medicos(id) ON DELETE SET NULL;
-- CREATE INDEX IF NOT EXISTS patients_medico_encaminhador_idx
-- ON public.patients USING btree (medico_encaminhador_id);
-- COMMENT ON COLUMN public.patients.medico_encaminhador_id
-- IS 'FK para medicos.id — quem encaminhou o paciente.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================
@@ -0,0 +1,119 @@
-- ==========================================================================
-- Agência PSI — Migração: novos campos em `patients`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000002_patients_new_columns.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Adiciona as colunas identificadas na engenharia reversa da tela de detalhe
-- (PatientsDetailPage) que ainda não existiam na tabela `patients`.
--
-- Também ajusta os CHECK constraints de `status` e `patient_scope` para
-- aceitar os valores usados no novo formulário de cadastro.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Colunas novas
-- --------------------------------------------------------------------------
-- Identidade
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS pronomes text,
ADD COLUMN IF NOT EXISTS nome_social text,
ADD COLUMN IF NOT EXISTS etnia text;
-- Contato
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS canal_preferido text,
ADD COLUMN IF NOT EXISTS horario_contato text;
-- Clínico / convênio
-- convenio: nome de exibição (badge azul no header)
-- convenio_id: FK para insurance_plans (opcional — permite vincular ao cadastro)
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS convenio text,
ADD COLUMN IF NOT EXISTS convenio_id uuid REFERENCES public.insurance_plans(id) ON DELETE SET NULL;
-- Origem
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
ADD COLUMN IF NOT EXISTS motivo_saida text;
-- --------------------------------------------------------------------------
-- 2. Ajuste do CHECK constraint de `status`
-- Valores originais: Ativo | Inativo | Alta | Encaminhado | Arquivado
-- Valores novos: + Em espera
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- --------------------------------------------------------------------------
-- 3. Ajuste do CHECK constraint de `patient_scope`
-- Valores originais: clinic | therapist (valores técnicos internos)
-- Valores novos: + Clínica | Particular | Online | Híbrido
-- Estratégia: remover o constraint restritivo e deixar livre (text),
-- pois o controle já é feito no frontend via Select com opções fixas.
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
-- Também remove a constraint de consistência que dependia do scope antigo
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- --------------------------------------------------------------------------
-- 4. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS patients_convenio_id_idx
ON public.patients USING btree (convenio_id);
CREATE INDEX IF NOT EXISTS patients_pronomes_idx
ON public.patients USING btree (pronomes);
CREATE INDEX IF NOT EXISTS patients_etnia_idx
ON public.patients USING btree (etnia);
-- --------------------------------------------------------------------------
-- 5. Comentários
-- --------------------------------------------------------------------------
COMMENT ON COLUMN public.patients.pronomes
IS 'Pronomes de tratamento. Ex: ela/dela, ele/dele. Exibido no header do perfil.';
COMMENT ON COLUMN public.patients.nome_social
IS 'Nome social / como prefere ser chamado(a) no atendimento.';
COMMENT ON COLUMN public.patients.etnia
IS 'Etnia / raça autodeclarada. Exibida no card "Dados pessoais".';
COMMENT ON COLUMN public.patients.canal_preferido
IS 'Canal preferido de contato. Ex: WhatsApp, Telefone, E-mail.';
COMMENT ON COLUMN public.patients.horario_contato
IS 'Horário preferido para contato. Ex: 08h18h.';
COMMENT ON COLUMN public.patients.convenio
IS 'Nome do convênio para exibição (badge azul no header). Derivado de convenio_id.';
COMMENT ON COLUMN public.patients.convenio_id
IS 'FK para insurance_plans.id. Vincula o paciente ao convênio cadastrado.';
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido
IS 'Método de pagamento preferido. Ex: PIX, Cartão crédito. Exibido no card Origem.';
COMMENT ON COLUMN public.patients.motivo_saida
IS 'Motivo de encerramento do acompanhamento. Exibido no card Origem quando preenchido.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================
@@ -0,0 +1,70 @@
-- ==========================================================================
-- Agência PSI — Migração: remove check constraints dos novos campos
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000003_patients_drop_check_constraints.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- O banco tinha CHECK constraints nos novos campos que foram adicionados
-- pela migration anterior (ou que já existiam no schema ao vivo).
-- O frontend já controla os valores via Select com opções fixas,
-- então os constraints são desnecessários e serão removidos.
-- ==========================================================================
-- canal_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check;
-- horario_contato
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_horario_contato_check;
-- pronomes
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_pronomes_check;
-- nome_social
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_nome_social_check;
-- etnia
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_etnia_check;
-- convenio
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_convenio_check;
-- metodo_pagamento_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_preferido_check;
-- motivo_saida
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_motivo_saida_check;
-- status (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- patient_scope (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================
@@ -0,0 +1,56 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `patient_support_contacts`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000004_create_patient_support_contacts.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Contatos da rede de suporte do paciente.
-- Alimenta o card "Contatos & rede de suporte" na tela de detalhe.
-- is_primario = true → badge vermelho "emergência" no perfil.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.patient_support_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
nome text,
relacao text, -- Ex: mãe, psiquiatra, cônjuge
tipo text, -- emergencia | familiar | profissional_saude | amigo | outro
telefone text,
email text,
is_primario boolean DEFAULT false NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT patient_support_contacts_pkey PRIMARY KEY (id)
);
-- Índices
CREATE INDEX IF NOT EXISTS psc_patient_idx ON public.patient_support_contacts USING btree (patient_id);
CREATE INDEX IF NOT EXISTS psc_owner_idx ON public.patient_support_contacts USING btree (owner_id);
-- Trigger updated_at
CREATE TRIGGER trg_psc_updated_at
BEFORE UPDATE ON public.patient_support_contacts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- RLS
ALTER TABLE public.patient_support_contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "psc: owner full access"
ON public.patient_support_contacts
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- Comentários
COMMENT ON TABLE public.patient_support_contacts IS 'Rede de suporte do paciente. Exibida no card "Contatos & rede de suporte" do perfil.';
COMMENT ON COLUMN public.patient_support_contacts.is_primario IS 'true = badge vermelho "emergência" no perfil do paciente.';
COMMENT ON COLUMN public.patient_support_contacts.tipo IS 'emergencia | familiar | profissional_saude | amigo | outro';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================
@@ -0,0 +1,454 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Documentos & Arquivos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Modulo completo de documentos do paciente.
-- Tabelas: documents, document_access_logs, document_signatures,
-- document_share_links.
--
-- Relacionamentos:
-- documents.patient_id → patients(id)
-- documents.owner_id → auth.users(id)
-- documents.tenant_id → tenants(id)
-- documents.agenda_evento_id → agenda_eventos(id) (opcional)
-- document_access_logs.documento_id → documents(id)
-- document_signatures.documento_id → documents(id)
-- document_share_links.documento_id → documents(id)
--
-- RLS: owner_id = auth.uid() para documents, signatures e share_links.
-- access_logs: somente INSERT (imutavel) + SELECT por tenant.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal: documents
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.documents (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Vinculo com paciente
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
-- Arquivo no Storage
bucket_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'documents',
nome_original text NOT NULL,
mime_type text,
tamanho_bytes bigint,
-- Classificacao
tipo_documento text NOT NULL DEFAULT 'outro',
-- laudo | receita | exame | termo_assinado | relatorio_externo
-- identidade | convenio | declaracao | atestado | recibo | outro
categoria text,
descricao text,
tags text[] DEFAULT '{}',
-- Vinculo opcional com sessao/nota
agenda_evento_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
session_note_id uuid,
-- Visibilidade & controle de acesso
visibilidade text NOT NULL DEFAULT 'privado',
-- privado | compartilhado_supervisor | compartilhado_portal
compartilhado_portal boolean DEFAULT false NOT NULL,
compartilhado_supervisor boolean DEFAULT false NOT NULL,
compartilhado_em timestamptz,
expira_compartilhamento timestamptz,
-- Upload pelo paciente (portal)
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
status_revisao text DEFAULT 'aprovado',
-- pendente | aprovado | rejeitado
revisado_por uuid,
revisado_em timestamptz,
-- Quem fez upload
uploaded_by uuid NOT NULL,
uploaded_at timestamptz DEFAULT now() NOT NULL,
-- Soft delete com retencao (LGPD / CFP)
deleted_at timestamptz,
deleted_by uuid,
retencao_ate timestamptz,
-- Controle
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT documents_pkey PRIMARY KEY (id),
-- Validacoes
CONSTRAINT documents_tipo_check CHECK (
tipo_documento = ANY (ARRAY[
'laudo', 'receita', 'exame', 'termo_assinado', 'relatorio_externo',
'identidade', 'convenio', 'declaracao', 'atestado', 'recibo', 'outro'
])
),
CONSTRAINT documents_visibilidade_check CHECK (
visibilidade = ANY (ARRAY['privado', 'compartilhado_supervisor', 'compartilhado_portal'])
),
CONSTRAINT documents_status_revisao_check CHECK (
status_revisao = ANY (ARRAY['pendente', 'aprovado', 'rejeitado'])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — documents
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS docs_patient_idx
ON public.documents USING btree (patient_id);
CREATE INDEX IF NOT EXISTS docs_owner_idx
ON public.documents USING btree (owner_id);
CREATE INDEX IF NOT EXISTS docs_tenant_idx
ON public.documents USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS docs_tipo_idx
ON public.documents USING btree (patient_id, tipo_documento);
CREATE INDEX IF NOT EXISTS docs_tags_idx
ON public.documents USING gin (tags);
CREATE INDEX IF NOT EXISTS docs_uploaded_at_idx
ON public.documents USING btree (patient_id, uploaded_at DESC);
-- Excluir soft-deleted da listagem padrao
CREATE INDEX IF NOT EXISTS docs_active_idx
ON public.documents USING btree (patient_id, uploaded_at DESC)
WHERE deleted_at IS NULL;
-- Busca textual no nome do arquivo
CREATE INDEX IF NOT EXISTS docs_nome_trgm_idx
ON public.documents USING gin (nome_original gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at — documents
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_documents_updated_at
BEFORE UPDATE ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. Trigger: registrar na patient_timeline ao adicionar documento
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
NEW.patient_id,
NEW.tenant_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 TRIGGER trg_documents_timeline_insert
AFTER INSERT ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.fn_documents_timeline_insert();
-- --------------------------------------------------------------------------
-- 5. RLS — documents
-- --------------------------------------------------------------------------
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "documents: owner full access"
ON public.documents
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 6. Comentarios — documents
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.documents IS 'Documentos e arquivos vinculados a pacientes. Armazenados no Supabase Storage.';
COMMENT ON COLUMN public.documents.owner_id IS 'Terapeuta dono do documento (auth.uid()).';
COMMENT ON COLUMN public.documents.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.documents.patient_id IS 'Paciente ao qual o documento pertence.';
COMMENT ON COLUMN public.documents.bucket_path IS 'Caminho do arquivo no Supabase Storage bucket.';
COMMENT ON COLUMN public.documents.storage_bucket IS 'Nome do bucket no Storage. Default: documents.';
COMMENT ON COLUMN public.documents.nome_original IS 'Nome original do arquivo enviado.';
COMMENT ON COLUMN public.documents.mime_type IS 'MIME type do arquivo. Ex: application/pdf, image/jpeg.';
COMMENT ON COLUMN public.documents.tamanho_bytes IS 'Tamanho do arquivo em bytes.';
COMMENT ON COLUMN public.documents.tipo_documento IS 'Tipo: laudo|receita|exame|termo_assinado|relatorio_externo|identidade|convenio|declaracao|atestado|recibo|outro.';
COMMENT ON COLUMN public.documents.categoria IS 'Categoria livre para organizacao adicional.';
COMMENT ON COLUMN public.documents.tags IS 'Tags livres para busca e filtro. Array de text.';
COMMENT ON COLUMN public.documents.visibilidade IS 'privado|compartilhado_supervisor|compartilhado_portal.';
COMMENT ON COLUMN public.documents.compartilhado_portal IS 'true = visivel para o paciente no portal.';
COMMENT ON COLUMN public.documents.compartilhado_supervisor IS 'true = visivel para o supervisor.';
COMMENT ON COLUMN public.documents.enviado_pelo_paciente IS 'true = upload feito pelo paciente via portal.';
COMMENT ON COLUMN public.documents.status_revisao IS 'pendente|aprovado|rejeitado — para uploads do paciente.';
COMMENT ON COLUMN public.documents.deleted_at IS 'Soft delete: data da exclusao. NULL = ativo.';
COMMENT ON COLUMN public.documents.retencao_ate IS 'LGPD/CFP: arquivo retido ate esta data mesmo apos soft delete.';
-- ==========================================================================
-- 7. Tabela: document_access_logs (imutavel — auditoria)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_access_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Acao realizada
acao text NOT NULL,
-- visualizou | baixou | imprimiu | compartilhou | assinou
user_id uuid,
ip inet,
user_agent text,
acessado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_access_logs_pkey PRIMARY KEY (id),
CONSTRAINT dal_acao_check CHECK (
acao = ANY (ARRAY['visualizou', 'baixou', 'imprimiu', 'compartilhou', 'assinou'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS dal_documento_idx
ON public.document_access_logs USING btree (documento_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_tenant_idx
ON public.document_access_logs USING btree (tenant_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_user_idx
ON public.document_access_logs USING btree (user_id, acessado_em DESC);
-- RLS — somente INSERT (imutavel) + SELECT
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dal: tenant members can insert"
ON public.document_access_logs
FOR INSERT
WITH CHECK (true);
CREATE POLICY "dal: tenant members can select"
ON public.document_access_logs
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_access_logs IS 'Log imutavel de acessos a documentos. Conformidade CFP e LGPD. Sem UPDATE/DELETE.';
COMMENT ON COLUMN public.document_access_logs.acao IS 'visualizou|baixou|imprimiu|compartilhou|assinou.';
-- ==========================================================================
-- 8. Tabela: document_signatures (assinatura eletronica)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_signatures (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Signatario
signatario_tipo text NOT NULL,
-- paciente | responsavel_legal | terapeuta
signatario_id uuid,
signatario_nome text,
signatario_email text,
-- Ordem e status
ordem smallint DEFAULT 1 NOT NULL,
status text NOT NULL DEFAULT 'pendente',
-- pendente | enviado | assinado | recusado | expirado
-- Dados da assinatura (preenchidos ao assinar)
ip inet,
user_agent text,
assinado_em timestamptz,
hash_documento text,
-- Controle
criado_em timestamptz DEFAULT now(),
atualizado_em timestamptz DEFAULT now(),
CONSTRAINT document_signatures_pkey PRIMARY KEY (id),
CONSTRAINT ds_signatario_tipo_check CHECK (
signatario_tipo = ANY (ARRAY['paciente', 'responsavel_legal', 'terapeuta'])
),
CONSTRAINT ds_status_check CHECK (
status = ANY (ARRAY['pendente', 'enviado', 'assinado', 'recusado', 'expirado'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS ds_documento_idx
ON public.document_signatures USING btree (documento_id, ordem);
CREATE INDEX IF NOT EXISTS ds_tenant_idx
ON public.document_signatures USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS ds_status_idx
ON public.document_signatures USING btree (documento_id, status);
-- Trigger updated_at
CREATE TRIGGER trg_ds_updated_at
BEFORE UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- Trigger: ao assinar, registrar na patient_timeline
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_patient_id uuid;
v_tenant_id uuid;
v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
SELECT d.patient_id, d.tenant_id, d.nome_original
INTO v_patient_id, v_tenant_id, v_doc_nome
FROM public.documents d
WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
v_patient_id,
v_tenant_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 TRIGGER trg_ds_timeline
AFTER UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.fn_document_signature_timeline();
-- RLS
ALTER TABLE public.document_signatures ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ds: tenant members access"
ON public.document_signatures
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
))
WITH CHECK (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_signatures IS 'Assinaturas eletronicas de documentos. Cada signatario tem seu registro.';
COMMENT ON COLUMN public.document_signatures.signatario_tipo IS 'paciente|responsavel_legal|terapeuta.';
COMMENT ON COLUMN public.document_signatures.status IS 'pendente|enviado|assinado|recusado|expirado.';
COMMENT ON COLUMN public.document_signatures.hash_documento IS 'Hash SHA-256 do documento no momento da assinatura. Garante integridade.';
COMMENT ON COLUMN public.document_signatures.ip IS 'IP do signatario no momento da assinatura.';
-- ==========================================================================
-- 9. Tabela: document_share_links (links temporarios)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_share_links (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Token unico para o link
token text NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
-- Limites
expira_em timestamptz NOT NULL,
usos_max smallint DEFAULT 5 NOT NULL,
usos smallint DEFAULT 0 NOT NULL,
-- Quem criou
criado_por uuid NOT NULL,
criado_em timestamptz DEFAULT now(),
-- Controle
ativo boolean DEFAULT true NOT NULL,
CONSTRAINT document_share_links_pkey PRIMARY KEY (id),
CONSTRAINT dsl_token_unique UNIQUE (token)
);
-- Indices
CREATE INDEX IF NOT EXISTS dsl_documento_idx
ON public.document_share_links USING btree (documento_id);
CREATE INDEX IF NOT EXISTS dsl_token_idx
ON public.document_share_links USING btree (token)
WHERE ativo = true;
CREATE INDEX IF NOT EXISTS dsl_expira_idx
ON public.document_share_links USING btree (expira_em)
WHERE ativo = true;
-- RLS
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dsl: creator full access"
ON public.document_share_links
USING (criado_por = auth.uid())
WITH CHECK (criado_por = auth.uid());
-- Politica publica de leitura por token (para acesso externo sem login)
CREATE POLICY "dsl: public read by token"
ON public.document_share_links
FOR SELECT
USING (ativo = true AND expira_em > now() AND usos < usos_max);
-- Comentarios
COMMENT ON TABLE public.document_share_links IS 'Links temporarios assinados para compartilhar documento com profissional externo.';
COMMENT ON COLUMN public.document_share_links.token IS 'Token unico gerado automaticamente (32 bytes hex).';
COMMENT ON COLUMN public.document_share_links.expira_em IS 'Data/hora de expiracao do link.';
COMMENT ON COLUMN public.document_share_links.usos_max IS 'Numero maximo de acessos permitidos.';
COMMENT ON COLUMN public.document_share_links.usos IS 'Numero de vezes que o link ja foi acessado.';
-- ==========================================================================
-- FIM DA MIGRACAO 005
-- ==========================================================================
@@ -0,0 +1,260 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Templates de Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Templates de documentos (declaracao, atestado, recibo, relatorio etc.)
-- e registro de cada documento gerado (instancia PDF).
--
-- Tabelas: document_templates, document_generated.
--
-- Relacionamentos:
-- document_templates.tenant_id → tenants(id)
-- document_templates.owner_id → auth.users(id)
-- document_generated.template_id → document_templates(id)
-- document_generated.patient_id → patients(id)
-- document_generated.tenant_id → tenants(id)
--
-- Templates globais: is_global = true, tenant_id = NULL.
-- Templates do tenant: is_global = false, tenant_id preenchido.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela: document_templates
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.document_templates (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto
tenant_id uuid,
owner_id uuid,
-- Identificacao
nome_template text NOT NULL,
tipo text NOT NULL DEFAULT 'outro',
-- declaracao_comparecimento | atestado_psicologico
-- relatorio_acompanhamento | recibo_pagamento
-- termo_consentimento | encaminhamento | outro
descricao text,
-- Corpo do template
corpo_html text NOT NULL DEFAULT '',
cabecalho_html text,
rodape_html text,
-- Variaveis que o template utiliza
variaveis text[] DEFAULT '{}',
-- Ex: {paciente_nome, paciente_cpf, data_sessao, terapeuta_nome, ...}
-- Personalizacao visual
logo_url text,
-- Escopo
is_global boolean DEFAULT false NOT NULL,
-- true = template padrao do sistema (visivel para todos)
-- false = template criado pelo tenant/terapeuta
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT document_templates_pkey PRIMARY KEY (id),
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', 'outro'
])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — document_templates
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dt_tenant_idx
ON public.document_templates USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS dt_owner_idx
ON public.document_templates USING btree (owner_id);
CREATE INDEX IF NOT EXISTS dt_global_idx
ON public.document_templates USING btree (is_global)
WHERE is_global = true;
CREATE INDEX IF NOT EXISTS dt_tipo_idx
ON public.document_templates USING btree (tipo);
CREATE INDEX IF NOT EXISTS dt_nome_trgm_idx
ON public.document_templates USING gin (nome_template gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_dt_updated_at
BEFORE UPDATE ON public.document_templates
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. RLS — document_templates
-- --------------------------------------------------------------------------
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
-- Templates globais: todos podem ler
CREATE POLICY "dt: global templates readable by all"
ON public.document_templates
FOR SELECT
USING (is_global = true);
-- Templates do tenant: membros do tenant podem ler
CREATE POLICY "dt: tenant members can select"
ON public.document_templates
FOR SELECT
USING (
is_global = false
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Owner pode inserir/atualizar/deletar seus templates
CREATE POLICY "dt: owner can insert"
ON public.document_templates
FOR INSERT
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can update"
ON public.document_templates
FOR UPDATE
USING (owner_id = auth.uid() AND is_global = false)
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can delete"
ON public.document_templates
FOR DELETE
USING (owner_id = auth.uid() AND is_global = false);
-- SaaS admin pode gerenciar templates globais (usa funcao public.is_saas_admin())
CREATE POLICY "dt: saas admin can insert global"
ON public.document_templates
FOR INSERT
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can update global"
ON public.document_templates
FOR UPDATE
USING (is_global = true AND public.is_saas_admin())
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can delete global"
ON public.document_templates
FOR DELETE
USING (is_global = true AND public.is_saas_admin());
-- --------------------------------------------------------------------------
-- 5. Comentarios — document_templates
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_templates IS 'Templates de documentos para geracao automatica (declaracao, atestado, recibo etc.).';
COMMENT ON COLUMN public.document_templates.nome_template IS 'Nome do template. Ex: Declaracao de Comparecimento.';
COMMENT ON COLUMN public.document_templates.tipo IS 'declaracao_comparecimento|atestado_psicologico|relatorio_acompanhamento|recibo_pagamento|termo_consentimento|encaminhamento|outro.';
COMMENT ON COLUMN public.document_templates.corpo_html IS 'Corpo do template em HTML com variaveis {{nome_variavel}}.';
COMMENT ON COLUMN public.document_templates.cabecalho_html IS 'HTML do cabecalho (logo, nome da clinica etc.).';
COMMENT ON COLUMN public.document_templates.rodape_html IS 'HTML do rodape (CRP, endereco, contato etc.).';
COMMENT ON COLUMN public.document_templates.variaveis IS 'Array com nomes das variaveis usadas no template. Ex: {paciente_nome, data_sessao}.';
COMMENT ON COLUMN public.document_templates.is_global IS 'true = template padrao do sistema visivel para todos. false = template do tenant.';
COMMENT ON COLUMN public.document_templates.logo_url IS 'URL do logo personalizado para o cabecalho do documento.';
-- ==========================================================================
-- 6. Tabela: document_generated (cada PDF gerado)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_generated (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Origem
template_id uuid NOT NULL REFERENCES public.document_templates(id) ON DELETE RESTRICT,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Dados usados no preenchimento (snapshot — permite auditoria futura)
dados_preenchidos jsonb NOT NULL DEFAULT '{}',
-- PDF gerado
pdf_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'generated-docs',
-- Vinculo opcional com documento pai (se o PDF gerado tambem for registrado em documents)
documento_id uuid REFERENCES public.documents(id) ON DELETE SET NULL,
-- Quem gerou
gerado_por uuid NOT NULL,
gerado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_generated_pkey PRIMARY KEY (id)
);
-- --------------------------------------------------------------------------
-- 7. Indices — document_generated
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dg_template_idx
ON public.document_generated USING btree (template_id);
CREATE INDEX IF NOT EXISTS dg_patient_idx
ON public.document_generated USING btree (patient_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_tenant_idx
ON public.document_generated USING btree (tenant_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_gerado_por_idx
ON public.document_generated USING btree (gerado_por, gerado_em DESC);
-- --------------------------------------------------------------------------
-- 8. RLS — document_generated
-- --------------------------------------------------------------------------
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dg: generator full access"
ON public.document_generated
USING (gerado_por = auth.uid())
WITH CHECK (gerado_por = auth.uid());
-- Membros do tenant podem visualizar
CREATE POLICY "dg: tenant members can select"
ON public.document_generated
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- --------------------------------------------------------------------------
-- 9. Comentarios — document_generated
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_generated IS 'Registro de cada documento PDF gerado a partir de um template.';
COMMENT ON COLUMN public.document_generated.template_id IS 'Template usado para gerar o documento.';
COMMENT ON COLUMN public.document_generated.dados_preenchidos IS 'Snapshot JSON dos dados usados no preenchimento. Permite auditoria futura.';
COMMENT ON COLUMN public.document_generated.pdf_path IS 'Caminho do PDF gerado no Supabase Storage bucket.';
COMMENT ON COLUMN public.document_generated.documento_id IS 'FK opcional para documents — se o PDF gerado tambem foi registrado como documento do paciente.';
COMMENT ON COLUMN public.document_generated.gerado_por IS 'Usuario que gerou o documento (auth.uid()).';
-- ==========================================================================
-- FIM DA MIGRACAO 006
-- ==========================================================================
@@ -0,0 +1,93 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Buckets para Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Cria os buckets no Supabase Storage para documentos de pacientes
-- e PDFs gerados pelo sistema.
-- ==========================================================================
-- Bucket: documents (uploads de terapeuta/paciente)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'documents',
'documents',
false,
52428800, -- 50 MB
ARRAY[
'application/pdf',
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
)
ON CONFLICT (id) DO NOTHING;
-- Bucket: generated-docs (PDFs gerados pelo sistema)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'generated-docs',
'generated-docs',
false,
20971520, -- 20 MB
ARRAY['application/pdf']
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: documents
-- --------------------------------------------------------------------------
-- Upload: usuario autenticado pode fazer upload no path do seu tenant
CREATE POLICY "documents: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'documents');
-- Download: usuario autenticado pode ler arquivos do seu tenant
CREATE POLICY "documents: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'documents');
-- Delete: usuario autenticado pode deletar seus arquivos
CREATE POLICY "documents: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'documents');
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: generated-docs
-- --------------------------------------------------------------------------
CREATE POLICY "generated-docs: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'generated-docs');
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,275 @@
-- =============================================================================
-- Migration: 20260417000001_dev_tables
-- Área de Desenvolvimento (dev_*) — roadmap, auditoria, concorrentes, logs
-- -----------------------------------------------------------------------------
-- Tabelas usadas pela página /saas/desenvolvimento. Todas restritas a
-- saas_admins via RLS (helper public.is_saas_admin()).
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Helper trigger: updated_at
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.dev_set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- =============================================================================
-- 1. dev_roadmap_phases — Fases (1, 2, 3...)
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_roadmap_phases (
id BIGSERIAL PRIMARY KEY,
numero INTEGER NOT NULL UNIQUE,
nome VARCHAR(160) NOT NULL,
objetivo TEXT,
timeline_sugerida VARCHAR(160),
criterio_saida TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'planejada'
CHECK (status IN ('planejada','em_andamento','concluida','arquivada')),
data_inicio DATE,
data_fim DATE,
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_status ON public.dev_roadmap_phases(status);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases(ordem);
DROP TRIGGER IF EXISTS trg_dev_roadmap_phases_updated_at ON public.dev_roadmap_phases;
CREATE TRIGGER trg_dev_roadmap_phases_updated_at
BEFORE UPDATE ON public.dev_roadmap_phases
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 2. dev_roadmap_items — Itens das fases
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_roadmap_items (
id BIGSERIAL PRIMARY KEY,
phase_id BIGINT NOT NULL REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE,
numero INTEGER,
bloco VARCHAR(160),
feature TEXT NOT NULL,
descricao TEXT,
esforco VARCHAR(4)
CHECK (esforco IS NULL OR esforco IN ('S','M','L','XL')),
prioridade VARCHAR(20)
CHECK (prioridade IS NULL OR prioridade IN ('bloqueador','alta','media','diferencial')),
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
CHECK (status IN ('pendente','em_andamento','concluido','cancelado','bloqueado')),
notas TEXT,
assignee VARCHAR(120),
data_inicio DATE,
data_conclusao DATE,
ordem INTEGER NOT NULL DEFAULT 0,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_phase ON public.dev_roadmap_items(phase_id);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_status ON public.dev_roadmap_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_prior ON public.dev_roadmap_items(prioridade);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_ordem ON public.dev_roadmap_items(phase_id, ordem);
DROP TRIGGER IF EXISTS trg_dev_roadmap_items_updated_at ON public.dev_roadmap_items;
CREATE TRIGGER trg_dev_roadmap_items_updated_at
BEFORE UPDATE ON public.dev_roadmap_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 3. dev_auditoria_items — Bugs / débitos técnicos / decisões
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_auditoria_items (
id BIGSERIAL PRIMARY KEY,
categoria VARCHAR(120),
titulo TEXT NOT NULL,
descricao_problema TEXT,
solucao TEXT,
severidade VARCHAR(20)
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
status VARCHAR(20) NOT NULL DEFAULT 'aberto'
CHECK (status IN ('aberto','em_analise','resolvido','wontfix','duplicado')),
resolvido_em DATE,
sessao_resolucao VARCHAR(160),
arquivo_afetado TEXT,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_status ON public.dev_auditoria_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_severidade ON public.dev_auditoria_items(severidade);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_categoria ON public.dev_auditoria_items(categoria);
DROP TRIGGER IF EXISTS trg_dev_auditoria_items_updated_at ON public.dev_auditoria_items;
CREATE TRIGGER trg_dev_auditoria_items_updated_at
BEFORE UPDATE ON public.dev_auditoria_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 4. dev_competitors — Concorrentes
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_competitors (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(80) NOT NULL UNIQUE,
nome VARCHAR(160) NOT NULL,
pais VARCHAR(40),
foco VARCHAR(160),
pricing TEXT,
posicionamento TEXT,
url TEXT,
ultima_pesquisa DATE,
notas TEXT,
ativo BOOLEAN NOT NULL DEFAULT true,
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_competitors_ativo ON public.dev_competitors(ativo);
CREATE INDEX IF NOT EXISTS idx_dev_competitors_pais ON public.dev_competitors(pais);
DROP TRIGGER IF EXISTS trg_dev_competitors_updated_at ON public.dev_competitors;
CREATE TRIGGER trg_dev_competitors_updated_at
BEFORE UPDATE ON public.dev_competitors
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 5. dev_competitor_features — features de cada concorrente
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_competitor_features (
id BIGSERIAL PRIMARY KEY,
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
categoria VARCHAR(120),
nome TEXT NOT NULL,
descricao TEXT,
fonte VARCHAR(20) NOT NULL DEFAULT 'publico'
CHECK (fonte IN ('fetched','observacao','publico','hipotese')),
fonte_url TEXT,
data_fonte DATE,
destaque BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_comp ON public.dev_competitor_features(competitor_id);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_cat ON public.dev_competitor_features(categoria);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_destaque ON public.dev_competitor_features(destaque);
DROP TRIGGER IF EXISTS trg_dev_competitor_features_updated_at ON public.dev_competitor_features;
CREATE TRIGGER trg_dev_competitor_features_updated_at
BEFORE UPDATE ON public.dev_competitor_features
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 6. dev_comparison_matrix — AgenciaPsi × features-de-concorrente
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_comparison_matrix (
id BIGSERIAL PRIMARY KEY,
dominio VARCHAR(120),
feature TEXT NOT NULL,
nosso_status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
CHECK (nosso_status IN ('tem','parcial','gap','na','a_definir')),
nossa_nota TEXT,
importancia VARCHAR(20)
CHECK (importancia IS NULL OR importancia IN ('alta','media','baixa')),
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix(dominio);
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_status ON public.dev_comparison_matrix(nosso_status);
DROP TRIGGER IF EXISTS trg_dev_comparison_matrix_updated_at ON public.dev_comparison_matrix;
CREATE TRIGGER trg_dev_comparison_matrix_updated_at
BEFORE UPDATE ON public.dev_comparison_matrix
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- dev_comparison_competitor_status — opcional: status por concorrente por feature
-- (se quisermos marcar que competitor X tem feature Y). Tabela ponte N-N.
CREATE TABLE IF NOT EXISTS public.dev_comparison_competitor_status (
id BIGSERIAL PRIMARY KEY,
comparison_id BIGINT NOT NULL REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE,
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
CHECK (status IN ('tem','parcial','gap','na','a_definir')),
nota TEXT,
fonte VARCHAR(20)
CHECK (fonte IS NULL OR fonte IN ('fetched','observacao','publico','hipotese')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (comparison_id, competitor_id)
);
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comp ON public.dev_comparison_competitor_status(competitor_id);
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comparison ON public.dev_comparison_competitor_status(comparison_id);
DROP TRIGGER IF EXISTS trg_dev_ccs_updated_at ON public.dev_comparison_competitor_status;
CREATE TRIGGER trg_dev_ccs_updated_at
BEFORE UPDATE ON public.dev_comparison_competitor_status
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 7. dev_generation_log — histórico de execuções (backup, dashboard, export...)
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_generation_log (
id BIGSERIAL PRIMARY KEY,
tipo VARCHAR(40) NOT NULL,
comando TEXT,
sucesso BOOLEAN NOT NULL DEFAULT false,
stdout TEXT,
stderr TEXT,
duration_ms INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
trigger_user_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_tipo ON public.dev_generation_log(tipo);
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_created ON public.dev_generation_log(created_at DESC);
-- =============================================================================
-- RLS — tudo restrito a saas_admins (helper existente: public.is_saas_admin())
-- =============================================================================
DO $$
DECLARE
t TEXT;
dev_tables TEXT[] := ARRAY[
'dev_roadmap_phases',
'dev_roadmap_items',
'dev_auditoria_items',
'dev_competitors',
'dev_competitor_features',
'dev_comparison_matrix',
'dev_comparison_competitor_status',
'dev_generation_log'
];
BEGIN
FOREACH t IN ARRAY dev_tables
LOOP
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY;', t);
-- Drop policy se existir (idempotente)
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I;', t || '_saas_admin_all', t);
-- Cria policy que permite tudo pra saas_admin
EXECUTE format(
'CREATE POLICY %I ON public.%I FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());',
t || '_saas_admin_all',
t
);
END LOOP;
END $$;
-- =============================================================================
-- Comentários
-- =============================================================================
COMMENT ON TABLE public.dev_roadmap_phases IS 'Fases do roadmap (MVP, Paridade, Diferenciação). Visível só pra saas_admins.';
COMMENT ON TABLE public.dev_roadmap_items IS 'Itens de cada fase do roadmap.';
COMMENT ON TABLE public.dev_auditoria_items IS 'Bugs, dívidas técnicas e decisões arquiteturais.';
COMMENT ON TABLE public.dev_competitors IS 'Concorrentes analisados no benchmark.';
COMMENT ON TABLE public.dev_competitor_features IS 'Features catalogadas de cada concorrente.';
COMMENT ON TABLE public.dev_comparison_matrix IS 'Matriz de comparação AgenciaPsi × features esperadas do mercado.';
COMMENT ON TABLE public.dev_comparison_competitor_status IS 'Qual concorrente tem qual feature (ponte N-N com matrix).';
COMMENT ON TABLE public.dev_generation_log IS 'Histórico de execuções (backup, dashboard, export, seed, etc).';
@@ -0,0 +1,48 @@
-- =============================================================================
-- Migration: 20260417000002_dev_tables_ordem
-- Adiciona coluna `ordem` em dev_auditoria_items e dev_competitor_features
-- (pra suportar reordenação por drag-and-drop na UI).
-- =============================================================================
-- dev_auditoria_items
ALTER TABLE public.dev_auditoria_items
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_ordem ON public.dev_auditoria_items(ordem);
-- Popular ordem existente (status + id pra evitar colisão)
UPDATE public.dev_auditoria_items SET ordem = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (
ORDER BY
CASE status
WHEN 'aberto' THEN 1
WHEN 'em_analise' THEN 2
WHEN 'resolvido' THEN 3
WHEN 'wontfix' THEN 4
WHEN 'duplicado' THEN 5
ELSE 6
END,
id
) AS rn
FROM public.dev_auditoria_items
) sub
WHERE public.dev_auditoria_items.id = sub.id;
-- dev_competitor_features
ALTER TABLE public.dev_competitor_features
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_ordem
ON public.dev_competitor_features(competitor_id, ordem);
-- Popular ordem existente (por competitor + categoria + id)
UPDATE public.dev_competitor_features SET ordem = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY competitor_id
ORDER BY COALESCE(categoria, 'zzz'), id
) AS rn
FROM public.dev_competitor_features
) sub
WHERE public.dev_competitor_features.id = sub.id;
@@ -0,0 +1,51 @@
-- =============================================================================
-- Migration: 20260418000001_dev_verificacoes
-- Nova aba "Verificações" em /saas/desenvolvimento
-- -----------------------------------------------------------------------------
-- Diferente de dev_auditoria_items (bugs conhecidos), esta tabela registra o
-- PROCESSO de revisão sênior sessão-a-sessão: o que já foi olhado, o que falta
-- olhar, o que foi encontrado em cada área do sistema.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_verificacoes_items (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(80) NOT NULL,
categoria VARCHAR(120),
titulo TEXT NOT NULL,
descricao TEXT,
resultado TEXT,
acao_sugerida TEXT,
severidade VARCHAR(20)
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
CHECK (status IN ('pendente','verificando','ok','problema','corrigido','wontfix')),
verificado_em DATE,
sessao_verificacao VARCHAR(160),
arquivo_afetado TEXT,
auditoria_item_id BIGINT REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL,
tags TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_area ON public.dev_verificacoes_items(area);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_status ON public.dev_verificacoes_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_severidade ON public.dev_verificacoes_items(severidade);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_ordem ON public.dev_verificacoes_items(area, ordem);
DROP TRIGGER IF EXISTS trg_dev_verificacoes_updated_at ON public.dev_verificacoes_items;
CREATE TRIGGER trg_dev_verificacoes_updated_at
BEFORE UPDATE ON public.dev_verificacoes_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items;
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.dev_verificacoes_items IS 'Revisão sênior por área/sessão — o que foi verificado e o que foi encontrado.';
COMMENT ON COLUMN public.dev_verificacoes_items.area IS 'Domínio revisado: auth, router, agenda, financeiro, pacientes, comunicacao, etc.';
COMMENT ON COLUMN public.dev_verificacoes_items.auditoria_item_id IS 'Link opcional: se a verificação virou um bug em dev_auditoria_items.';
@@ -0,0 +1,403 @@
-- =============================================================================
-- Migration: 20260418000002_patient_intake_security_hardening
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
-- -----------------------------------------------------------------------------
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
-- avatars + storage policies.
--
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
-- whitelist de valores, validação de token completa (active/expires/max_uses).
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. create_patient_intake_request_v2 — versão hardened
-- -----------------------------------------------------------------------------
-- Mudanças vs versão anterior:
-- • A#16: valida active, expires_at, max_uses; incrementa uses no final
-- • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
-- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
-- • A#27: length checks em TODOS os campos texto
-- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
-- lower em emails, whitelist para genero/estado_civil
-- • Consent obrigatório (raise se false)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
p_token text,
p_payload jsonb
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_owner_id uuid;
v_tenant_id uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_intake_id uuid;
v_birth_raw text;
v_birth date;
v_email text;
v_email_alt text;
v_nome text;
v_consent boolean;
v_genero text;
v_estado_civil text;
-- Whitelists para campos tipados
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Carrega invite e valida TUDO (A#16)
-- ───────────────────────────────────────────────────────────────────────
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
FROM public.patient_invites
WHERE token = p_token
LIMIT 1;
IF v_owner_id IS NULL THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Resolver tenant_id (A#19)
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
-- ───────────────────────────────────────────────────────────────────────
IF v_tenant_id IS NULL THEN
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_owner_id
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização + validações de campos (A#27)
-- ───────────────────────────────────────────────────────────────────────
-- Nome obrigatório (max 200)
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
RAISE EXCEPTION 'Nome é obrigatório';
END IF;
IF length(v_nome) > 200 THEN
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
END IF;
-- Email principal obrigatório + lower + max 120
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
RAISE EXCEPTION 'E-mail é obrigatório';
END IF;
IF length(v_email) > 120 THEN
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail inválido';
END IF;
-- Email alternativo opcional mas validado se presente
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
IF v_email_alt IS NOT NULL THEN
IF length(v_email_alt) > 120 THEN
RAISE EXCEPTION 'E-mail alternativo muito longo';
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail alternativo inválido';
END IF;
END IF;
-- Consent obrigatório
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
RAISE EXCEPTION 'Consentimento é obrigatório';
END IF;
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
v_birth := CASE
WHEN v_birth_raw IS NULL THEN NULL
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
ELSE NULL
END;
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
v_genero := nullif(trim(p_payload->>'genero'), '');
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
v_genero := NULL;
END IF;
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
v_estado_civil := NULL;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- INSERT com sanitização inline
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
-- do terapeuta, não deve vir do paciente.
-- ───────────────────────────────────────────────────────────────────────
INSERT INTO public.patient_intake_requests (
owner_id,
tenant_id,
token,
status,
consent,
nome_completo,
email_principal,
email_alternativo,
telefone,
telefone_alternativo,
avatar_url,
data_nascimento,
cpf,
rg,
genero,
estado_civil,
profissao,
escolaridade,
nacionalidade,
naturalidade,
cep,
pais,
cidade,
estado,
endereco,
numero,
complemento,
bairro,
observacoes,
encaminhado_por,
onde_nos_conheceu
)
VALUES (
v_owner_id,
v_tenant_id,
p_token,
'new',
v_consent,
v_nome,
v_email,
v_email_alt,
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'rg'), ''), 20),
v_genero,
v_estado_civil,
left(nullif(trim(p_payload->>'profissao'), ''), 120),
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'pais'), ''), 60),
left(nullif(trim(p_payload->>'cidade'), ''), 120),
left(nullif(trim(p_payload->>'estado'), ''), 2),
left(nullif(trim(p_payload->>'endereco'), ''), 200),
left(nullif(trim(p_payload->>'numero'), ''), 20),
left(nullif(trim(p_payload->>'complemento'), ''), 120),
left(nullif(trim(p_payload->>'bairro'), ''), 120),
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
)
RETURNING id INTO v_intake_id;
-- Incrementa contador de uso (A#16)
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
RETURN v_intake_id;
END;
$function$;
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) IS
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas (campo interno); exige consent=true.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
-- -----------------------------------------------------------------------------
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
-- Novo: gera gen_random_uuid() server-side e retorna.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_new_token text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
v_new_token := replace(gen_random_uuid()::text, '-', '');
-- Resolve tenant_id do usuário (active)
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
-- Desativa tokens ativos anteriores
UPDATE public.patient_invites
SET active = false
WHERE owner_id = v_uid
AND active = true;
-- Insere novo
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_new_token, true);
RETURN v_new_token;
END;
$function$;
COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';
GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
-- -----------------------------------------------------------------------------
-- Substitui o client-side newToken() + direct insert em patient_invites.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_token text;
v_existing text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
SELECT token
INTO v_existing
FROM public.patient_invites
WHERE owner_id = v_uid
AND active = true
ORDER BY created_at DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
v_token := replace(gen_random_uuid()::text, '-', '');
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_token, true);
RETURN v_token;
END;
$function$;
COMMENT ON FUNCTION public.issue_patient_invite() IS
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';
GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
-- -----------------------------------------------------------------------------
UPDATE storage.buckets
SET file_size_limit = 5242880, -- 5 MB
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
WHERE id = 'avatars';
-- ─────────────────────────────────────────────────────────────────────────
-- 5. Storage policies — remover upload anon irrestrito (A#15)
-- -----------------------------------------------------------------------------
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
-- arquivo. Removemos essas policies. Upload público passa a exigir token
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;
-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
-- o terapeuta vê no painel de cadastros recebidos).
CREATE POLICY "intake_read_owner_only"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = 'intakes'
);
COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';
@@ -0,0 +1,280 @@
-- =============================================================================
-- Migration: 20260418000003_patient_invite_attempts_log
-- Resolve A#24: log de tentativas de submit no cadastro público externo.
-- -----------------------------------------------------------------------------
-- Observação sobre IP: em RPC Postgres chamada via PostgREST o IP real do
-- cliente não chega aqui (só o do connection pooler). Por isso o registro
-- guarda o user_agent enviado pelo cliente (quando disponível) + metadados
-- resolvidos (owner, tenant). Rate-limit real por IP deve ser feito em edge
-- function no futuro (A#20).
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.patient_invite_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token text NOT NULL,
ok boolean NOT NULL,
error_code text,
error_msg text,
client_info text, -- user_agent enviado pelo cliente (cap 500 no INSERT)
owner_id uuid, -- resolvido do token quando possível
tenant_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_created ON public.patient_invite_attempts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_token ON public.patient_invite_attempts(token);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_owner ON public.patient_invite_attempts(owner_id);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_ok ON public.patient_invite_attempts(ok) WHERE ok = false;
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
-- Owner vê suas próprias tentativas (qualquer flood/erro que envolveu seus links)
DROP POLICY IF EXISTS patient_invite_attempts_owner_read ON public.patient_invite_attempts;
CREATE POLICY patient_invite_attempts_owner_read
ON public.patient_invite_attempts FOR SELECT
TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
COMMENT ON TABLE public.patient_invite_attempts IS
'Log de tentativas (ok e falhas) de submit do form público de cadastro externo. Base para monitoramento de flood/tentativas maliciosas. Sem IP direto — proteção LGPD.';
COMMENT ON COLUMN public.patient_invite_attempts.client_info IS
'User-agent enviado pelo cliente (opcional). Limitado a 500 chars no insert. Não contém PII.';
-- =============================================================================
-- create_patient_intake_request_v2 — versão instrumentada
-- -----------------------------------------------------------------------------
-- Mesma função do hardening anterior, agora com log em patient_invite_attempts.
-- O log é feito num bloco EXCEPTION que NUNCA propaga falha de log pro fluxo
-- principal (log falhar jamais deve impedir o cadastro de ser aceito).
-- =============================================================================
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
p_token text,
p_payload jsonb,
p_client_info text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_owner_id uuid;
v_tenant_id uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_intake_id uuid;
v_birth_raw text;
v_birth date;
v_email text;
v_email_alt text;
v_nome text;
v_consent boolean;
v_genero text;
v_estado_civil text;
v_err_msg text;
v_err_code text;
v_clean_info text;
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
-- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
-- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
BEGIN
-- Sanitiza client_info recebido (cap + trim)
v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');
-- ───────────────────────────────────────────────────────────────────────
-- Resolve invite + valida TUDO (A#16)
-- ───────────────────────────────────────────────────────────────────────
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
FROM public.patient_invites
WHERE token = p_token
LIMIT 1;
IF v_owner_id IS NULL THEN
v_err_code := 'TOKEN_INVALID';
v_err_msg := 'Token inválido';
-- Log + raise (owner_id NULL porque token não bateu)
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
v_err_code := 'TOKEN_DISABLED';
v_err_msg := 'Link desativado';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
v_err_code := 'TOKEN_EXPIRED';
v_err_msg := 'Link expirado';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
v_err_code := 'TOKEN_MAX_USES';
v_err_msg := 'Limite de uso atingido';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
-- Resolve tenant_id se invite não tiver (A#19)
IF v_tenant_id IS NULL THEN
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_owner_id
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização + validações de campos (A#27)
-- ───────────────────────────────────────────────────────────────────────
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF length(v_nome) > 200 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF length(v_email) > 120 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
IF v_email_alt IS NOT NULL THEN
IF length(v_email_alt) > 120 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
END IF;
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
v_birth := CASE
WHEN v_birth_raw IS NULL THEN NULL
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
ELSE NULL
END;
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
v_genero := nullif(trim(p_payload->>'genero'), '');
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
v_genero := NULL;
END IF;
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
v_estado_civil := NULL;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- INSERT
-- ───────────────────────────────────────────────────────────────────────
INSERT INTO public.patient_intake_requests (
owner_id, tenant_id, token, status, consent,
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
avatar_url,
data_nascimento, cpf, rg, genero, estado_civil,
profissao, escolaridade, nacionalidade, naturalidade,
cep, pais, cidade, estado, endereco, numero, complemento, bairro,
observacoes, encaminhado_por, onde_nos_conheceu
)
VALUES (
v_owner_id, v_tenant_id, p_token, 'new', v_consent,
v_nome, v_email, v_email_alt,
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'rg'), ''), 20),
v_genero, v_estado_civil,
left(nullif(trim(p_payload->>'profissao'), ''), 120),
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'pais'), ''), 60),
left(nullif(trim(p_payload->>'cidade'), ''), 120),
left(nullif(trim(p_payload->>'estado'), ''), 2),
left(nullif(trim(p_payload->>'endereco'), ''), 200),
left(nullif(trim(p_payload->>'numero'), ''), 20),
left(nullif(trim(p_payload->>'complemento'), ''), 120),
left(nullif(trim(p_payload->>'bairro'), ''), 120),
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
)
RETURNING id INTO v_intake_id;
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
-- Log de sucesso (best-effort, não propaga erro)
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RETURN v_intake_id;
END;
$function$;
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) IS
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas; exige consent=true; registra cada tentativa em patient_invite_attempts (A#24).';
@@ -0,0 +1,149 @@
-- =============================================================================
-- Migration: 20260418000004_dev_tests
-- Nova aba "Testes" em /saas/desenvolvimento — catálogo de suítes de teste.
-- -----------------------------------------------------------------------------
-- Espelha a estrutura de dev_verificacoes_items. Uma linha = uma suíte de
-- teste (arquivo .spec.js ou grupo de testes). Serve para responder "quais
-- áreas estão cobertas por teste?" sem rodar npm test.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_test_items (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(80) NOT NULL,
categoria VARCHAR(120), -- unit, integration, e2e, manual
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), -- cobertura estimada daquela área
status VARCHAR(20) NOT NULL DEFAULT 'ok'
CHECK (status IN ('ok','falhando','pendente','obsoleto','a_escrever')),
last_run_at TIMESTAMPTZ,
sessao_criacao VARCHAR(160),
notas TEXT,
tags TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_area ON public.dev_test_items(area);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_status ON public.dev_test_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_ordem ON public.dev_test_items(area, ordem);
DROP TRIGGER IF EXISTS trg_dev_test_items_updated_at ON public.dev_test_items;
CREATE TRIGGER trg_dev_test_items_updated_at
BEFORE UPDATE ON public.dev_test_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dev_test_items_saas_admin_all ON public.dev_test_items;
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.dev_test_items IS
'Catálogo de suítes de teste por área. Responde "o que está testado?" sem precisar rodar npm test.';
-- =============================================================================
-- Seed inicial — testes existentes em 2026-04-18
-- =============================================================================
INSERT INTO public.dev_test_items
(area, categoria, titulo, arquivo, descricao, total_tests, passing, failing, skipped, cobertura_pct, status, last_run_at, sessao_criacao, notas, tags, ordem)
VALUES
('agenda', 'unit',
'useRecurrence — geração de ocorrências',
'src/features/agenda/composables/__tests__/useRecurrence.spec.js',
$$Cobre: generateDates (weekly, biweekly, custom_weekdays, monthly, yearly), expandRules com exceções (cancel_session, patient_missed, reschedule_session, holiday_block), mergeWithStoredSessions, max_occurrences, range boundaries, remarcação inbound.$$,
23, 23, 0, 0, NULL,
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
'Suite sólida. Cobre os branches críticos da expansão de recorrência. Testes sobreviveram à adição do cap de range (V#20) e ao filtro de tenant_id nas CRUDs (V#12).',
ARRAY['unit','agenda','recurrence','critical'], 1),
('agenda', 'unit',
'agendaMappers — transformação pra FullCalendar',
'src/features/agenda/services/__tests__/agendaMappers.spec.js',
$$Cobre: mapAgendaEventosToCalendarEvents (shape, campos extras), status cor + ícone (agendado, realizado, faltou, cancelado, remarcado), aliases de FK (patients, determined_commitments), tipo fallback, ocorrência virtual (is_occurrence), resource events (clinic mosaic).$$,
40, 40, 0, 0, NULL,
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
'Quatro testes estavam falhando antes do V#21 (status "remarcado" vs "remarcar" + cores faltou/cancelado invertidas). Agora 100%.',
ARRAY['unit','agenda','mappers'], 2),
('auth', 'a_escrever',
'guards.js — branches do router beforeEach',
'src/router/__tests__/guards.spec.js (não existe)',
$$Deveria cobrir: rotas públicas liberadas, redirect pra /auth/login sem session, área /account sem tenant, saas_admin em /saas, tenant lockdown, trocaTenantScope, matchesRoles com aliases, cache de globalRole, cache de saasAdmin.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'guards.js tem ~650 linhas e só roda via navegação real. Sem teste unitário → mudanças no guard são de alto risco. Prioridade média para criar (mock do router + pinia).',
ARRAY['unit','auth','router','guard','missing'], 3),
('auth', 'a_escrever',
'session.js — hydrate e race conditions',
'src/app/__tests__/session.spec.js (não existe)',
$$Deveria cobrir: initSession com/sem session, refreshSession que não dispara se refreshing, SIGNED_IN redundante ignorado, SIGNED_OUT zera state, TOKEN_REFRESHED não derruba cache, hydrate preserva user em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Módulo tem histórico de race conditions (comentado no próprio arquivo). Teste unitário daria garantia contra regressão.',
ARRAY['unit','auth','session','race','missing'], 4),
('stores', 'a_escrever',
'tenantStore — singleflight + persist',
'src/stores/__tests__/tenantStore.spec.js (não existe)',
$$Deveria cobrir: loadSessionAndTenant com Promise compartilhada (V#3), ensureLoaded sem setInterval, tenant salvo se pertence ao user, normalizeTenantRole, reset, persistência em localStorage.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'V#3 trocou polling por Promise singleflight — a correção não tem teste que proteja contra regressão.',
ARRAY['unit','store','tenant','missing'], 5),
('utils', 'a_escrever',
'roleNormalizer — saídas esperadas',
'src/utils/__tests__/roleNormalizer.spec.js (não existe)',
$$Fácil de testar função pura, sem IO. Cobre: tenant_admin+therapisttherapist, tenant_admin+clinicclinic_admin, tenant_admin+supervisorsupervisor, tenant_admin sem kindclinic_admin, clinic_adminclinic_admin, pass-through.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Criado em V#4. É função pura — fácil de cobrir em 10min. Baixa prioridade técnica mas alto valor simbólico (garantir que os 2 consumidores — guards.js e tenantStore.js — concordam).',
ARRAY['unit','utils','trivial'], 6),
('pacientes', 'a_escrever',
'Cadastros externos — fluxo do paciente',
'src/features/patients/__tests__/external-intake.spec.js (não existe)',
$$Deveria cobrir: validação client-side (token regex, email, consent), truncation em todos os campos, payload final, não envio de notas_internas, comportamento com token inválido.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Página pública é ponto crítico de segurança. Teste de regressão importante após A#17/A#18/A#21 — garantir que nenhum dos valores "perigosos" voltem a ser enviados.',
ARRAY['unit','pacientes','external','security-regression'], 7),
('database', 'manual',
'RPCs de intake — validação de inputs maliciosos',
'database-novo/tests/test_patient_intake_security.sql (sugerido)',
$$Deveria cobrir: token inválido raise, token desativado raise (A#16), token expirado raise, max_uses raise, uses incrementa após sucesso, consent=false raise, payload com notas_internas é ignorado (A#17), tenant_id é preenchido (A#19), nome > 200 chars raise, email inválido raise, genero fora whitelist vira NULL, data_nascimento futura vira NULL.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Testes SQL diretos via psql. Importantes porque as validações estão dentro do RPC SECURITY DEFINER. Executar antes de cada deploy.',
ARRAY['manual','sql','security','rpc'], 8),
('agenda', 'a_escrever',
'useAgendaEvents — wrapper do repository',
'src/features/agenda/composables/__tests__/useAgendaEvents.spec.js (não existe)',
$$Deveria cobrir: loadMyRange chama listMyAgendaEvents, estado loading/error transições, sem ownerId retorna cedo, rollback em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 2 — agenda',
'Após refactor V#14 o composable virou fino. Teste garante que continue fino.',
ARRAY['unit','agenda','composable','missing'], 9),
('e2e', 'a_escrever',
'Fluxo completo: terapeuta cria link → paciente preenche → terapeuta vê',
'(não existe)',
$$Deveria cobrir o happy path integrado: login terapeuta, gera link via issue_patient_invite, abre /cadastro/paciente em aba anônima, preenche, submit, terapeuta em /therapist/patients/recebidos.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Não há E2E hoje. Playwright ou Cypress valem? Decidir provider. Alta prioridade pra confiança em deploy.',
ARRAY['e2e','critical','missing','decisão-pendente'], 10);
SELECT id, area, categoria, status, total_tests, passing FROM public.dev_test_items ORDER BY ordem;
@@ -0,0 +1,167 @@
-- =============================================================================
-- Migration: 20260418000005_saas_rls_emergency_fix
-- Corrige A#30 (P0) — 7 tabelas SaaS estavam com RLS desabilitado + grants
-- totais pra anon/authenticated/service_role. Qualquer usuário anônimo
-- podia alterar/deletar dados críticos (tenant_features, plan_prices,
-- subscription_intents_personal/tenant, plan_public, ...).
--
-- Estratégia:
-- 1. Habilitar RLS em todas as 7 tabelas
-- 2. REVOKE ALL de anon (nunca deveria ter tido)
-- 3. REVOKE ALL de authenticated (controle passa a ser via policy)
-- 4. Policies explícitas por caso de uso
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. REVOKE grants inseguros
-- -----------------------------------------------------------------------------
REVOKE ALL ON public.tenant_features FROM anon, authenticated;
REVOKE ALL ON public.plan_prices FROM anon, authenticated;
REVOKE ALL ON public.plan_public FROM anon, authenticated;
REVOKE ALL ON public.plan_public_bullets FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_personal FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_tenant FROM anon, authenticated;
REVOKE ALL ON public.tenant_feature_exceptions_log FROM anon, authenticated;
-- Concede o mínimo necessário (controlado por RLS abaixo)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_features TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_prices TO authenticated;
GRANT SELECT ON public.plan_public TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public TO authenticated;
GRANT SELECT ON public.plan_public_bullets TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public_bullets TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_personal TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_tenant TO authenticated;
GRANT SELECT ON public.tenant_feature_exceptions_log TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. HABILITAR RLS em todas
-- -----------------------------------------------------------------------------
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. POLICIES — tenant_features
-- -----------------------------------------------------------------------------
-- SELECT: membros do tenant leem as features do próprio tenant. Saas admin lê tudo.
DROP POLICY IF EXISTS tenant_features_select ON public.tenant_features;
CREATE POLICY tenant_features_select ON public.tenant_features
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
-- WRITE: apenas tenant_admin do próprio tenant OU saas_admin.
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
CREATE POLICY tenant_features_write ON public.tenant_features
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 4. POLICIES — plan_prices (SaaS admin only pra escrita; authenticated lê)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_prices_read ON public.plan_prices;
CREATE POLICY plan_prices_read ON public.plan_prices
FOR SELECT TO authenticated
USING (true); -- preços são públicos pra usuários logados
DROP POLICY IF EXISTS plan_prices_write ON public.plan_prices;
CREATE POLICY plan_prices_write ON public.plan_prices
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 5. POLICIES — plan_public + plan_public_bullets (anon pode ler — landing page)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_public_read_anon ON public.plan_public;
CREATE POLICY plan_public_read_anon ON public.plan_public
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_write ON public.plan_public;
CREATE POLICY plan_public_write ON public.plan_public
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
DROP POLICY IF EXISTS plan_public_bullets_read_anon ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_bullets_write ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 6. POLICIES — subscription_intents_personal + _tenant
-- -----------------------------------------------------------------------------
-- Dono vê o próprio intent; saas admin vê tudo; owner cria/atualiza seus próprios.
DROP POLICY IF EXISTS subscription_intents_personal_owner ON public.subscription_intents_personal;
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal
FOR ALL TO authenticated
USING (user_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (user_id = auth.uid() OR public.is_saas_admin());
DROP POLICY IF EXISTS subscription_intents_tenant_member ON public.subscription_intents_tenant;
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 7. POLICY — tenant_feature_exceptions_log (somente leitura)
-- -----------------------------------------------------------------------------
-- Log de auditoria. Inserts vêm de triggers/funções server-side (SECURITY DEFINER).
DROP POLICY IF EXISTS tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log;
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
COMMENT ON TABLE public.tenant_features IS
'Controle de features por tenant. RLS: member do tenant lê; tenant_admin ou saas_admin escreve. Antes da migration 20260418000005 estava com RLS off + GRANT ALL pra anon (A#30).';
@@ -0,0 +1,214 @@
-- =============================================================================
-- Migration: 20260419000001_tenant_features_b2_governance
-- Resolve V#34 (isEnabled opt-out por padrão) + V#41 (dupla fonte entitlements
-- vs tenant_features) — Opção B2 (plano + override com exceção comercial).
--
-- Mudanças:
-- 1. Trigger tenant_features_guard_with_plan ganha bypass via session flag
-- (current_setting('app.allow_feature_exception')) — só RPC pode setar.
-- 2. Nova RPC set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)
-- SECURITY DEFINER, com regras assimétricas:
-- - p_enabled=false → tenant_admin OU saas_admin (preferência)
-- - p_enabled=true AND plano permite → tenant_admin OU saas_admin
-- - p_enabled=true AND plano NÃO permite → SOMENTE saas_admin + reason obrigatório
-- Toda mudança grava em tenant_feature_exceptions_log.
-- 3. Policy tenant_features_write restringida a saas_admin (writes diretos).
-- Tenant_admin agora muda só via RPC.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. Trigger: bypass controlado por session flag
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.tenant_features_guard_with_plan()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_allowed boolean;
v_bypass text;
BEGIN
-- Só valida quando está habilitando
IF new.enabled IS DISTINCT FROM true THEN
RETURN new;
END IF;
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
-- após validar que o caller é saas_admin com reason.
v_bypass := current_setting('app.allow_feature_exception', true);
IF v_bypass = 'true' THEN
RETURN new;
END IF;
-- Permitido pelo plano do tenant?
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements_full v
WHERE v.tenant_id = new.tenant_id
AND v.feature_key = new.feature_key
AND v.allowed = true
) INTO v_allowed;
IF NOT v_allowed THEN
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
new.feature_key, new.tenant_id
USING ERRCODE = 'P0001';
END IF;
RETURN new;
END;
$$;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. RPC set_tenant_feature_exception
-- (substitui versão anterior que retornava void; retorna jsonb agora)
-- -----------------------------------------------------------------------------
DROP FUNCTION IF EXISTS public.set_tenant_feature_exception(uuid, text, boolean, text);
CREATE OR REPLACE FUNCTION public.set_tenant_feature_exception(
p_tenant_id uuid,
p_feature_key text,
p_enabled boolean,
p_reason text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_is_saas boolean := public.is_saas_admin();
v_is_tenant_adm boolean;
v_plan_allows boolean;
v_feature_key text;
v_reason text;
v_is_exception boolean;
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização (padrão V#31)
-- ───────────────────────────────────────────────────────────────────────
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
END IF;
IF p_enabled IS NULL THEN
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
END IF;
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
IF v_feature_key IS NULL THEN
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
END IF;
IF length(v_feature_key) > 80 THEN
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
END IF;
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
END IF;
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
v_reason := substring(v_reason FROM 1 FOR 500);
END IF;
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Plano permite essa feature?
-- ───────────────────────────────────────────────────────────────────────
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements vte
WHERE vte.tenant_id = p_tenant_id
AND vte.feature_key = v_feature_key
) INTO v_plan_allows;
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
-- ───────────────────────────────────────────────────────────────────────
-- Caller é tenant_admin desse tenant?
-- ───────────────────────────────────────────────────────────────────────
v_is_tenant_adm := EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = p_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
);
-- ───────────────────────────────────────────────────────────────────────
-- Autorização (assimétrica — V#34 Opção B2)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
-- Override positivo fora do plano = exceção comercial
IF NOT v_is_saas THEN
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
END IF;
IF v_reason IS NULL THEN
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
END IF;
ELSE
-- Demais casos: tenant_admin OR saas_admin
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
END IF;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Persistência: bypass controlado do trigger guard quando é exceção
-- (escopo de transação via SET LOCAL — só esta RPC vê)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'true', true);
END IF;
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
ON CONFLICT (tenant_id, feature_key)
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'false', true);
END IF;
INSERT INTO public.tenant_feature_exceptions_log
(tenant_id, feature_key, enabled, reason, created_by)
VALUES
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
RETURN jsonb_build_object(
'tenant_id', p_tenant_id,
'feature_key', v_feature_key,
'enabled', p_enabled,
'plan_allows', v_plan_allows,
'is_exception', v_is_exception,
'reason', v_reason
);
END;
$function$;
REVOKE ALL ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. Policy: writes diretos só via saas_admin
-- (tenant_admin agora muda só via RPC set_tenant_feature_exception)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
DROP POLICY IF EXISTS tenant_features_write_saas_only ON public.tenant_features;
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
@@ -0,0 +1,21 @@
-- =============================================================================
-- Migration: 20260419000002_features_is_active
-- V#40 — features hard-deleted: adiciona is_active para soft-delete.
--
-- Estratégia conservadora:
-- - features.is_active boolean DEFAULT true NOT NULL
-- - SaasFeaturesPage substitui DELETE por UPDATE is_active=false
-- - Views que expõem features para o app (v_tenant_entitlements etc) NÃO são
-- alteradas: features depreciadas ainda servem tenants legados via plan_features
-- enquanto não houver migração explícita
-- - Permite reativar feature acidentalmente deprecada
-- =============================================================================
ALTER TABLE public.features
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
CREATE INDEX IF NOT EXISTS idx_features_is_active
ON public.features (is_active) WHERE is_active = false;
COMMENT ON COLUMN public.features.is_active IS
'V#40: false = feature depreciada, escondida no catálogo SaaS mas continua válida em planos/tenants existentes.';
@@ -0,0 +1,69 @@
-- =============================================================================
-- Migration: 20260419000003_delete_plan_safe
-- V#36 — DELETE de plans sem checagem de assinaturas ativas pode quebrar tenants.
--
-- Cria RPC delete_plan_safe(plan_id) que:
-- - Valida saas_admin
-- - Conta subscriptions ativas (status='active') no plano
-- - Se houver, RAISE EXCEPTION descritivo com a contagem
-- - Se OK, desativa prices ativos e deleta o plano (atomic)
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_plan_safe(
p_plan_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_active_count int;
v_plan_key text;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
END IF;
IF p_plan_id IS NULL THEN
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
END IF;
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
IF v_plan_key IS NULL THEN
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
END IF;
SELECT COUNT(*) INTO v_active_count
FROM public.subscriptions
WHERE plan_id = p_plan_id
AND status = 'active';
IF v_active_count > 0 THEN
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
v_plan_key, v_active_count
USING ERRCODE = 'P0001';
END IF;
-- desativa preços ativos antes de deletar
UPDATE public.plan_prices
SET is_active = false,
active_to = now()
WHERE plan_id = p_plan_id
AND is_active = true;
DELETE FROM public.plans WHERE id = p_plan_id;
RETURN jsonb_build_object(
'deleted', true,
'plan_key', v_plan_key
);
END;
$function$;
REVOKE ALL ON FUNCTION public.delete_plan_safe(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.delete_plan_safe(uuid) TO authenticated;
@@ -0,0 +1,46 @@
-- =============================================================================
-- Migration: 20260419000004_consolidate_policies
-- V#35 — Consolida policies duplicadas em plans, features, plan_features e
-- subscriptions. Remove legado redundante e documenta as que ficam.
--
-- Análise (auditada via pg_policies):
-- • plans/features/plan_features: cada uma tem "read * (auth)" duplicado
-- com "*_read_authenticated" (mesmo USING true). Removidos os legados.
-- • subscriptions:
-- - "subscriptions read own" (USING user_id = auth.uid()) é SUBSET de
-- "subscriptions_read_own" (USING user_id = auth.uid() OR is_saas_admin())
-- - "subscriptions_select_own_personal" (user_id = auth.uid() AND tenant_id IS NULL)
-- é SUBSET de "subscriptions_read_own"
-- - "subscriptions_no_direct_update" (USING false) é no-op em OR com
-- "subscriptions_update_only_saas_admin"
-- Removidas as 3 redundâncias.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Drops dos legados redundantes
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "read plans (auth)" ON public.plans;
DROP POLICY IF EXISTS "read features (auth)" ON public.features;
DROP POLICY IF EXISTS "read plan_features (auth)" ON public.plan_features;
DROP POLICY IF EXISTS "subscriptions read own" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_select_own_personal" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_no_direct_update" ON public.subscriptions;
-- ─────────────────────────────────────────────────────────────────────────
-- COMMENT ON POLICY — documenta escopo das que ficaram
-- -----------------------------------------------------------------------------
COMMENT ON POLICY plans_read_authenticated ON public.plans IS 'Qualquer usuário autenticado lê o catálogo de planos (vitrine, upgrade UI).';
COMMENT ON POLICY plans_write_saas_admin ON public.plans IS 'Somente saas_admin escreve. DELETE deve ser via RPC delete_plan_safe (V#36).';
COMMENT ON POLICY features_read_authenticated ON public.features IS 'Qualquer logado lê o catálogo de features.';
COMMENT ON POLICY features_write_saas_admin ON public.features IS 'Somente saas_admin escreve. DELETE = soft delete via is_active=false (V#40).';
COMMENT ON POLICY plan_features_read_authenticated ON public.plan_features IS 'Qualquer logado lê o vínculo plano↔feature (necessário para entitlements).';
COMMENT ON POLICY plan_features_write_saas_admin ON public.plan_features IS 'Somente saas_admin escreve.';
COMMENT ON POLICY subscriptions_read_own ON public.subscriptions IS 'Dono da assinatura (user_id) ou saas_admin. Cobre o caso pessoal.';
COMMENT ON POLICY subscriptions_select_for_tenant_members ON public.subscriptions IS 'Membros ativos do tenant leem assinaturas do tenant.';
COMMENT ON POLICY "subscriptions: read if linked owner_users" ON public.subscriptions IS 'Caso especial: usuários ligados ao owner via owner_users (terapeutas de uma clínica que precisam ver a assinatura do owner).';
COMMENT ON POLICY subscriptions_insert_own_personal ON public.subscriptions IS 'Usuário cria a própria assinatura pessoal (intent → conversion).';
COMMENT ON POLICY subscriptions_update_only_saas_admin ON public.subscriptions IS 'UPDATE direto somente via saas_admin. Mudanças de tenant devem passar por RPC dedicada.';
@@ -0,0 +1,29 @@
-- =============================================================================
-- Migration: 20260419000005_restrict_intake_rpc
-- A#20 — Restringe create_patient_intake_request_v2 a service_role.
--
-- Antes: anon (e PUBLIC) podia chamar direto. Bot bypassava qualquer
-- proteção do front (Turnstile etc).
-- Agora: edge function `submit-patient-intake` valida CAPTCHA e chama
-- a RPC com service_role. Anon não chama mais a RPC direto.
-- =============================================================================
-- Revoga PUBLIC (DEFAULT) e anon
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) FROM PUBLIC, anon;
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) FROM PUBLIC, anon;
-- Mantém grants explícitos pra authenticated (uso interno futuro) e service_role (edge function)
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) TO authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) TO authenticated, service_role;
-- Mesma proteção para RPC v1 legada (caso ainda exista)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'create_patient_intake_request'
) THEN
EXECUTE 'REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) FROM PUBLIC, anon';
EXECUTE 'GRANT EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) TO authenticated, service_role';
END IF;
END$$;
@@ -0,0 +1,136 @@
-- =============================================================================
-- Migration: 20260419000006_layered_bot_defense
-- A#20 (rev2) — Defesa em camadas self-hosted (substitui Turnstile).
--
-- Camadas:
-- 1. Honeypot field (no front) → invisível, sempre ativo
-- 2. Rate limit por IP no edge → submission_rate_limits
-- 3. Math captcha CONDICIONAL → só se IP teve N falhas recentes
-- 4. Logging em public_submission_attempts (genérico, não só intake)
-- 5. Modo paranoid global → saas_security_config.captcha_required
--
-- Substitui chamadas Turnstile na edge function submit-patient-intake.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. saas_security_config (singleton)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.saas_security_config (
id boolean PRIMARY KEY DEFAULT true,
honeypot_enabled boolean NOT NULL DEFAULT true,
rate_limit_enabled boolean NOT NULL DEFAULT true,
rate_limit_window_min integer NOT NULL DEFAULT 10,
rate_limit_max_attempts integer NOT NULL DEFAULT 5,
captcha_after_failures integer NOT NULL DEFAULT 3,
captcha_required_globally boolean NOT NULL DEFAULT false,
block_duration_min integer NOT NULL DEFAULT 30,
captcha_required_window_min integer NOT NULL DEFAULT 60,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK (id = true)
);
INSERT INTO public.saas_security_config (id) VALUES (true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.saas_security_config FROM anon, authenticated;
GRANT SELECT, UPDATE ON public.saas_security_config TO authenticated;
DROP POLICY IF EXISTS saas_security_config_read ON public.saas_security_config;
CREATE POLICY saas_security_config_read ON public.saas_security_config
FOR SELECT TO authenticated
USING (true); -- qualquer logado pode ler config global (não tem segredo)
DROP POLICY IF EXISTS saas_security_config_write ON public.saas_security_config;
CREATE POLICY saas_security_config_write ON public.saas_security_config
FOR UPDATE TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.saas_security_config IS 'Singleton: configuração global de defesa contra bots em endpoints públicos.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. public_submission_attempts (log genérico)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.public_submission_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text, -- 'honeypot' | 'rate_limit' | 'captcha' | 'rpc' | null
user_agent text,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_psa_endpoint_created ON public.public_submission_attempts (endpoint, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_psa_ip_hash_created ON public.public_submission_attempts (ip_hash, created_at DESC) WHERE ip_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_psa_failed ON public.public_submission_attempts (created_at DESC) WHERE success = false;
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.public_submission_attempts FROM anon, authenticated;
GRANT SELECT ON public.public_submission_attempts TO authenticated;
DROP POLICY IF EXISTS psa_read_saas_admin ON public.public_submission_attempts;
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.public_submission_attempts IS 'Log de tentativas em endpoints públicos (intake, signup, agendador). Escrita apenas via RPC SECURITY DEFINER.';
-- ─────────────────────────────────────────────────────────────────────────
-- 3. submission_rate_limits (estado vigente por IP+endpoint)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
fail_count integer NOT NULL DEFAULT 0,
window_start timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
requires_captcha_until timestamptz,
last_attempt_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (ip_hash, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_srl_blocked_until ON public.submission_rate_limits (blocked_until) WHERE blocked_until IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_srl_endpoint ON public.submission_rate_limits (endpoint, last_attempt_at DESC);
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.submission_rate_limits FROM anon, authenticated;
GRANT SELECT ON public.submission_rate_limits TO authenticated;
DROP POLICY IF EXISTS srl_read_saas_admin ON public.submission_rate_limits;
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.submission_rate_limits IS 'Estado de rate limit por IP+endpoint. Escrita apenas via RPC. SaaS admin lê pra dashboard.';
-- ─────────────────────────────────────────────────────────────────────────
-- 4. math_challenges (TTL 5min, limpa via cron)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.math_challenges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
question text NOT NULL,
answer integer NOT NULL,
used boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + interval '5 minutes')
);
CREATE INDEX IF NOT EXISTS idx_mc_expires ON public.math_challenges (expires_at);
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.math_challenges FROM anon, authenticated;
-- nenhum grant: tabela acessada apenas via RPC SECURITY DEFINER
COMMENT ON TABLE public.math_challenges IS 'Challenges de math captcha. TTL 5min. Escrita/leitura apenas via RPC.';
@@ -0,0 +1,299 @@
-- =============================================================================
-- Migration: 20260419000007_bot_defense_rpcs
-- A#20 (rev2) — RPCs da defesa em camadas:
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
-- • record_submission_attempt — log + atualiza contadores e bloqueios
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
-- • verify_math_challenge — valida {id, answer}, marca used
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- check_rate_limit
-- Lê config + estado atual, decide o que retornar.
-- Se fora da janela atual, "rolha" os contadores (reset).
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.check_rate_limit(
p_ip_hash text,
p_endpoint text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
rl submission_rate_limits%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
v_in_window boolean;
v_requires_captcha boolean := false;
v_blocked_until timestamptz;
v_retry_after_seconds integer := 0;
BEGIN
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND THEN
-- Sem config: fail-open (libera). Logado.
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
END IF;
-- Modo paranoid global: sempre captcha
IF cfg.captcha_required_globally THEN
v_requires_captcha := true;
END IF;
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
IF NOT cfg.rate_limit_enabled THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
);
END IF;
-- Sem ip_hash: libera (não dá pra rastrear)
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', 'no_ip'
);
END IF;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
-- Bloqueio temporário ativo?
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'blocked'
);
END IF;
-- Captcha condicional ativo?
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
v_requires_captcha := true;
END IF;
-- Janela atual ainda válida?
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
v_in_window := FOUND AND rl.window_start >= v_window_start;
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
-- Excedeu — bloqueia
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
UPDATE submission_rate_limits
SET blocked_until = v_blocked_until,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'rate_limit_exceeded'
);
END IF;
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
);
END;
$function$;
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- record_submission_attempt
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
-- requires_captcha_until = now + captcha_required_window_min.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
p_endpoint text,
p_ip_hash text,
p_success boolean,
p_blocked_by text DEFAULT NULL,
p_error_code text DEFAULT NULL,
p_error_msg text DEFAULT NULL,
p_user_agent text DEFAULT NULL,
p_metadata jsonb DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
rl submission_rate_limits%ROWTYPE;
BEGIN
-- Log sempre (mesmo sem ip)
INSERT INTO public_submission_attempts
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
VALUES
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
left(coalesce(p_error_code, ''), 80),
left(coalesce(p_error_msg, ''), 500),
left(coalesce(p_user_agent, ''), 500),
p_metadata);
-- Sem ip ou rate limit desligado: não atualiza contador
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF NOT FOUND THEN
INSERT INTO submission_rate_limits
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
VALUES
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
ELSE
IF rl.window_start < v_window_start THEN
-- Reset janela
UPDATE submission_rate_limits
SET attempt_count = 1,
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
window_start = v_now,
last_attempt_at = v_now,
blocked_until = NULL
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
ELSE
UPDATE submission_rate_limits
SET attempt_count = attempt_count + 1,
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
-- Se atingiu threshold de captcha condicional, marca
IF NOT p_success THEN
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF rl.fail_count >= cfg.captcha_after_failures
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
UPDATE submission_rate_limits
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
END IF;
END IF;
END;
$function$;
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- generate_math_challenge
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
-- Operações: + - * (resultado sempre positivo)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_a integer;
v_b integer;
v_op text;
v_ans integer;
v_q text;
v_id uuid;
BEGIN
v_a := 1 + floor(random() * 9)::int;
v_b := 1 + floor(random() * 9)::int;
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
-- garantir resultado positivo na subtração
IF v_op = '-' AND v_b > v_a THEN
v_a := v_a + v_b;
END IF;
v_ans := CASE v_op
WHEN '+' THEN v_a + v_b
WHEN '-' THEN v_a - v_b
WHEN '*' THEN v_a * v_b
END;
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
INSERT INTO math_challenges (question, answer)
VALUES (v_q, v_ans)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'question', v_q);
END;
$function$;
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- verify_math_challenge
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
p_id uuid,
p_answer integer
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
mc math_challenges%ROWTYPE;
BEGIN
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
RETURN false;
END IF;
UPDATE math_challenges SET used = true WHERE id = p_id;
RETURN mc.answer = p_answer;
END;
$function$;
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- cleanup_expired_math_challenges (chamável via cron)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
RETURNS integer
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
WITH d AS (
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
)
SELECT COUNT(*)::int FROM d;
$function$;
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
@@ -0,0 +1,155 @@
-- =============================================================================
-- Migration: 20260419000008_saas_twilio_config
-- Permite saas_admin editar config Twilio operacional pelo painel, sem redeploy.
--
-- DECISÃO DE SEGURANÇA:
-- • TWILIO_AUTH_TOKEN (secret) NÃO entra na tabela. Continua em env var
-- da Edge Function. Painel apenas exibe se está configurado (best-effort).
-- • TWILIO_ACCOUNT_SID (público no Twilio dashboard, identificador) → DB
-- • TWILIO_WHATSAPP_WEBHOOK (URL) → DB
-- • USD_BRL_RATE / MARGIN_MULTIPLIER (operacional) → DB
--
-- Edge function: lê primeiro do banco; cai pra env vars como fallback se row
-- ainda não foi configurada (back-compat com deploys antigos).
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.saas_twilio_config (
id boolean PRIMARY KEY DEFAULT true,
account_sid text,
whatsapp_webhook_url text,
usd_brl_rate numeric(10,4) NOT NULL DEFAULT 5.5,
margin_multiplier numeric(10,4) NOT NULL DEFAULT 1.4,
notes text,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_twilio_config_singleton CHECK (id = true),
CONSTRAINT saas_twilio_config_rate_chk CHECK (usd_brl_rate > 0 AND usd_brl_rate < 100),
CONSTRAINT saas_twilio_config_mult_chk CHECK (margin_multiplier >= 1 AND margin_multiplier <= 10),
CONSTRAINT saas_twilio_config_sid_chk CHECK (account_sid IS NULL OR account_sid ~ '^AC[a-zA-Z0-9]{32}$'),
CONSTRAINT saas_twilio_config_url_chk CHECK (whatsapp_webhook_url IS NULL OR whatsapp_webhook_url ~ '^https?://')
);
INSERT INTO public.saas_twilio_config (id) VALUES (true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.saas_twilio_config FROM anon, authenticated;
GRANT SELECT ON public.saas_twilio_config TO authenticated;
DROP POLICY IF EXISTS saas_twilio_config_read ON public.saas_twilio_config;
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config
FOR SELECT TO authenticated
USING (public.is_saas_admin()); -- só admin vê config (mesmo sem secret, é dado operacional)
COMMENT ON TABLE public.saas_twilio_config IS
'Config operacional Twilio editável via painel. AUTH_TOKEN continua em env var por segurança.';
-- ─────────────────────────────────────────────────────────────────────────
-- RPC get_twilio_config — retorna config atual (saas_admin OU service_role)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_twilio_config()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_twilio_config%ROWTYPE;
BEGIN
-- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
-- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
END IF;
SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'account_sid', NULL,
'whatsapp_webhook_url', NULL,
'usd_brl_rate', 5.5,
'margin_multiplier', 1.4
);
END IF;
RETURN jsonb_build_object(
'account_sid', cfg.account_sid,
'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
'usd_brl_rate', cfg.usd_brl_rate,
'margin_multiplier', cfg.margin_multiplier,
'notes', cfg.notes,
'updated_at', cfg.updated_at,
'updated_by', cfg.updated_by
);
END;
$function$;
REVOKE ALL ON FUNCTION public.get_twilio_config() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.get_twilio_config() TO authenticated, service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- RPC update_twilio_config — só saas_admin
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.update_twilio_config(
p_account_sid text DEFAULT NULL,
p_whatsapp_webhook_url text DEFAULT NULL,
p_usd_brl_rate numeric DEFAULT NULL,
p_margin_multiplier numeric DEFAULT NULL,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_account_sid text;
v_webhook_url text;
v_notes text;
BEGIN
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
END IF;
-- Sanitização
v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
v_notes := nullif(btrim(coalesce(p_notes, '')), '');
IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
END IF;
IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
END IF;
IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
END IF;
IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
END IF;
IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
v_notes := substring(v_notes FROM 1 FOR 1000);
END IF;
UPDATE saas_twilio_config
SET account_sid = COALESCE(v_account_sid, account_sid),
whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate),
margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier),
notes = COALESCE(v_notes, notes),
updated_at = now(),
updated_by = v_caller
WHERE id = true;
RETURN public.get_twilio_config();
END;
$function$;
REVOKE ALL ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) TO authenticated;
@@ -0,0 +1,34 @@
-- =============================================================================
-- Migration: 20260419000009_patient_session_counts_rpc
-- V#8 — Substitui o .limit(1000) arbitrário em PatientsListPage por RPC
-- agregada que retorna contagens por paciente (sempre atualizada, sem teto).
--
-- Tenant scoping é feito via WHERE tenant_id IN (memberships do caller),
-- consistente com a policy SELECT de agenda_eventos.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_session_counts(
p_patient_ids uuid[]
)
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
ae.patient_id,
COUNT(*)::int AS session_count,
MAX(ae.inicio_em) AS last_session_at
FROM public.agenda_eventos ae
WHERE ae.patient_id = ANY(p_patient_ids)
AND ae.tenant_id IN (
SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
)
GROUP BY ae.patient_id;
$function$;
REVOKE ALL ON FUNCTION public.get_patient_session_counts(uuid[]) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_session_counts(uuid[]) TO authenticated;
@@ -0,0 +1,304 @@
-- =============================================================================
-- Migration: 20260419000010_documents_security_hardening
-- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos
-- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes
-- pra próxima sessão (precisam de design/decisão).
--
-- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}"
-- (storage.foldername(name))[1] = tenant_id
-- =============================================================================
-- Tabelas de documents são owned por supabase_admin
SET LOCAL ROLE supabase_admin;
-- ─────────────────────────────────────────────────────────────────────────
-- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs"
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects;
CREATE POLICY "documents: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "documents: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'documents'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "documents: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects;
CREATE POLICY "generated-docs: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'generated-docs'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#45: documents — policies separadas por cmd
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: owner full access" ON public.documents;
DROP POLICY IF EXISTS "documents: select" ON public.documents;
DROP POLICY IF EXISTS "documents: insert" ON public.documents;
DROP POLICY IF EXISTS "documents: update" ON public.documents;
DROP POLICY IF EXISTS "documents: delete" ON public.documents;
-- SELECT: owner OR tenant_member ativo OR saas_admin
CREATE POLICY "documents: select" ON public.documents
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller
CREATE POLICY "documents: insert" ON public.documents
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- UPDATE: só owner
CREATE POLICY "documents: update" ON public.documents
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());
-- DELETE: só owner ou saas_admin
CREATE POLICY "documents: delete" ON public.documents
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#46: document_share_links — RPC validate_share_token + remover SELECT direto
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
sl document_share_links%ROWTYPE;
v_doc documents%ROWTYPE;
v_token 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 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;
-- Incrementa uso atomicamente
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
-- Loga acesso (best-effort)
BEGIN
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
FROM documents d WHERE d.id = sl.document_id;
EXCEPTION WHEN OTHERS THEN
-- não derruba a request se log falhar (schema pode variar)
NULL;
END;
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
RETURN jsonb_build_object(
'document_id', sl.document_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;
$function$;
REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role;
-- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário)
DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links;
DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links;
CREATE POLICY "dsl: creator full access" ON public.document_share_links
FOR ALL TO authenticated
USING (criado_por = auth.uid() OR public.is_saas_admin())
WITH CHECK (criado_por = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: select" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: update" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures;
CREATE POLICY "ds: select" ON public.document_signatures
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller
-- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row
-- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo)
CREATE POLICY "ds: insert" ON public.document_signatures
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
AND (signatario_id IS NULL OR signatario_id = auth.uid())
);
-- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado')
CREATE POLICY "ds: update" ON public.document_signatures
FOR UPDATE TO authenticated
USING (signatario_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin());
-- DELETE: signatário, saas_admin ou tenant_admin/owner
CREATE POLICY "ds: delete" ON public.document_signatures
FOR DELETE TO authenticated
USING (
signatario_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#48: document_access_logs — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs;
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#49: document_templates — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates;
DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates;
CREATE POLICY "dt: owner can insert" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (
is_global = false
AND owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (is_global = true AND public.is_saas_admin());
@@ -0,0 +1,24 @@
-- =============================================================================
-- Migration: 20260419000011_documents_portal_patient_policy
-- V#50 — paciente vê documento via portal quando compartilhado_portal=true.
--
-- Adiciona policy SELECT ADICIONAL em documents (combina via OR com a policy
-- existente "documents: select"). Paciente conseguem ler documentos próprios
-- quando o terapeuta compartilhou via portal.
-- =============================================================================
DROP POLICY IF EXISTS "documents: portal patient read" ON public.documents;
CREATE POLICY "documents: portal patient read" ON public.documents
FOR SELECT TO authenticated
USING (
compartilhado_portal = true
AND patient_id IN (
SELECT p.id FROM public.patients p
WHERE p.user_id = auth.uid()
)
AND (expira_compartilhamento IS NULL OR expira_compartilhamento > now())
);
COMMENT ON POLICY "documents: portal patient read" ON public.documents IS
'V#50: paciente lê documento quando compartilhado_portal=true E patient_id pertence ao auth.uid + não expirou.';
@@ -0,0 +1,18 @@
-- =============================================================================
-- Migration: 20260419000012_documents_content_hash
-- V#51 — hash SHA-256 do conteúdo pra detecção de tampering.
--
-- Coluna nullable (documentos antigos não têm). Calculado client-side via
-- crypto.subtle.digest('SHA-256') antes do upload pro storage.
-- Integridade pode ser verificada baixando o arquivo e recalculando o hash.
-- =============================================================================
ALTER TABLE public.documents
ADD COLUMN IF NOT EXISTS content_sha256 text;
CREATE INDEX IF NOT EXISTS idx_documents_content_sha256
ON public.documents (content_sha256)
WHERE content_sha256 IS NOT NULL;
COMMENT ON COLUMN public.documents.content_sha256 IS
'V#51: SHA-256 hex (64 chars) do conteúdo no momento do upload. Permite verificar integridade. NULL pra documentos legados pré-V#51.';
@@ -0,0 +1,65 @@
-- =============================================================================
-- Migration: 20260419000013_cron_retention_jobs
-- V#52 — retention automática de logs/challenges via pg_cron.
--
-- Jobs:
-- • document_access_logs_cleanup — diário, retém 1 ano (CFP típico)
-- • math_challenges_cleanup — horário, remove expirados há >1h
-- • public_submission_attempts_cleanup — diário, retém 90 dias
-- =============================================================================
-- Garante extensão (idempotente em ambientes que não têm)
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- ─────────────────────────────────────────────────────────────────────────
-- document_access_logs: retém 1 ano (suficiente pra auditoria CFP)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('document_access_logs_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'document_access_logs_cleanup');
SELECT cron.schedule(
'document_access_logs_cleanup',
'0 3 * * *', -- todo dia às 03:00
$$DELETE FROM public.document_access_logs WHERE created_at < now() - interval '1 year'$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- math_challenges: remove expirados (> 1h após expiração)
-- (RPC cleanup_expired_math_challenges já existe desde 20260419000007)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('math_challenges_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'math_challenges_cleanup');
SELECT cron.schedule(
'math_challenges_cleanup',
'0 * * * *', -- toda hora
$$SELECT public.cleanup_expired_math_challenges()$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- public_submission_attempts: retém 90 dias (analytics + alertas)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('public_submission_attempts_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'public_submission_attempts_cleanup');
SELECT cron.schedule(
'public_submission_attempts_cleanup',
'15 3 * * *', -- todo dia 03:15 (após o de docs)
$$DELETE FROM public.public_submission_attempts WHERE created_at < now() - interval '90 days'$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- submission_rate_limits: limpa entradas antigas (>30 dias sem atividade)
-- (estados expirados não fazem mal, mas tabela cresce sem limite)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('submission_rate_limits_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'submission_rate_limits_cleanup');
SELECT cron.schedule(
'submission_rate_limits_cleanup',
'30 3 * * *', -- todo dia 03:30
$$DELETE FROM public.submission_rate_limits
WHERE last_attempt_at < now() - interval '30 days'
AND (blocked_until IS NULL OR blocked_until < now())
AND (requires_captcha_until IS NULL OR requires_captcha_until < now())$$
);
@@ -0,0 +1,117 @@
-- =============================================================================
-- Migration: 20260419000014_financial_security_hardening
-- Sessão 6 — revisão Financeiro. Resolve V#1-V#5 (2 críticos + 3 altos).
-- V#6-V#11 adiados (médios/baixos com plano).
--
-- Auditoria prévia confirmou:
-- • 0 financial_records com tenant_id NULL
-- • 0 records com clinic_fee_amount > amount
-- → seguro aplicar NOT NULL e CHECK constraints.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1: billing_contracts policy granular
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "billing_contracts: owner full access" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: select" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: insert" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: update" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: delete" ON public.billing_contracts;
CREATE POLICY "billing_contracts: select" ON public.billing_contracts
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "billing_contracts: update" ON public.billing_contracts
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 "billing_contracts: delete" ON public.billing_contracts
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#2: financial_records.tenant_id NOT NULL + trigger backfill
-- (auditoria: 0 órfãos, seguro aplicar)
-- -----------------------------------------------------------------------------
ALTER TABLE public.financial_records ALTER COLUMN tenant_id SET NOT NULL;
-- Trigger defensivo: se tentar inserir sem tenant_id, busca via owner_id->tenant_members
CREATE OR REPLACE FUNCTION public.financial_records_inject_tenant()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
SELECT tm.tenant_id INTO NEW.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
ORDER BY tm.created_at DESC
LIMIT 1;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_financial_records_inject_tenant ON public.financial_records;
CREATE TRIGGER trg_financial_records_inject_tenant
BEFORE INSERT ON public.financial_records
FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
-- ─────────────────────────────────────────────────────────────────────────
-- V#5: financial_records CHECK contra net_amount negativo
-- -----------------------------------------------------------------------------
ALTER TABLE public.financial_records
DROP CONSTRAINT IF EXISTS financial_records_fee_lte_amount_chk;
ALTER TABLE public.financial_records
ADD CONSTRAINT financial_records_fee_lte_amount_chk
CHECK (clinic_fee_amount IS NULL OR (clinic_fee_amount >= 0 AND clinic_fee_amount <= amount));
-- ─────────────────────────────────────────────────────────────────────────
-- V#3: payment_settings — adicionar SELECT pra tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "payment_settings: tenant_admin read" ON public.payment_settings;
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings
FOR SELECT TO authenticated
USING (
tenant_id IS NOT NULL
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- (a policy ALL "owner full access" continua — owner mexe nos próprios)
-- ─────────────────────────────────────────────────────────────────────────
-- V#4: professional_pricing — adicionar SELECT pra tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "professional_pricing: tenant_admin read" ON public.professional_pricing;
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing
FOR SELECT TO authenticated
USING (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
@@ -0,0 +1,127 @@
-- =============================================================================
-- Migration: 20260419000015_communication_security_hardening
-- Sessão 6 — revisão Comunicação. Resolve V#1-V#5 (2 críticos + 3 altos).
-- V#6-V#10 adiados (médios/baixos com plano completo no DB).
--
-- 🔴 V#1+V#2 são bugs P0: policies usavam (tenant_id = auth.uid()) — comparação
-- de UUID de tenant com UUID de user. Tabelas inacessíveis na prática.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1: email_layout_config — fix BUG do tenant_id = auth.uid()
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "tenant owns email layout config" ON public.email_layout_config;
DROP POLICY IF EXISTS "email_layout_config: tenant_admin all" ON public.email_layout_config;
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#2: email_templates_tenant — MESMO bug
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "tenant manages own overrides" ON public.email_templates_tenant;
DROP POLICY IF EXISTS "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant;
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#3: notification_logs — SELECT pra tenant_member
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notif_logs_tenant_member" ON public.notification_logs;
CREATE POLICY "notif_logs_tenant_member" ON public.notification_logs
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#4: notification_queue — SELECT pra tenant_member
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notif_queue_tenant_member" ON public.notification_queue;
CREATE POLICY "notif_queue_tenant_member" ON public.notification_queue
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#5: notification_channels — SELECT pra tenant_member; INSERT tenant_admin; UPDATE/DELETE owner
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notification_channels_owner" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_select" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_modify" ON public.notification_channels;
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 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "notif_channels_insert" ON public.notification_channels
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
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_delete" ON public.notification_channels
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
@@ -0,0 +1,157 @@
-- =============================================================================
-- Migration: 20260419000016_tenants_calendario_hardening
-- Sessão 7 — Tenants + Calendário scan (corrige críticos + altos + WITH CHECKs).
--
-- Resolve:
-- • Tenants V#1 (P0) — tenant_invites RLS off + 0 policies
-- • Tenants V#2 — profiles_insert_own sem WITH CHECK
-- • Tenants V#3 — support_sessions_saas_insert sem WITH CHECK
-- • Tenants V#6 — user_settings_insert_own sem WITH CHECK
-- • Calendário V#1 — feriados_insert + feriados_saas_insert sem WITH CHECK
--
-- Auditoria prévia: tenant_invites tem 0 rows (seguro habilitar RLS sem
-- migração de dados).
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#1 (P0): tenant_invites
-- -----------------------------------------------------------------------------
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.tenant_invites FROM anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_invites TO authenticated;
-- SELECT: tenant_admin/admin/owner do tenant + saas_admin
DROP POLICY IF EXISTS tenant_invites_select ON public.tenant_invites;
CREATE POLICY tenant_invites_select ON public.tenant_invites
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- INSERT: só tenant_admin do tenant_id, e invited_by deve ser o caller
DROP POLICY IF EXISTS tenant_invites_insert ON public.tenant_invites;
CREATE POLICY tenant_invites_insert ON public.tenant_invites
FOR INSERT TO authenticated
WITH CHECK (
invited_by = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- UPDATE: só revogação por tenant_admin do tenant. Aceitar é via RPC tenant_accept_invite (SECURITY DEFINER).
DROP POLICY IF EXISTS tenant_invites_update ON public.tenant_invites;
CREATE POLICY tenant_invites_update ON public.tenant_invites
FOR UPDATE TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- DELETE: tenant_admin OR saas_admin
DROP POLICY IF EXISTS tenant_invites_delete ON public.tenant_invites;
CREATE POLICY tenant_invites_delete ON public.tenant_invites
FOR DELETE TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
COMMENT ON TABLE public.tenant_invites IS
'Convites pra entrar em tenant. Aceitar deve ser via RPC tenant_accept_invite (SECURITY DEFINER). Criar/revogar via UI por tenant_admin.';
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#2: profiles INSERT WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS profiles_insert_own ON public.profiles;
CREATE POLICY profiles_insert_own ON public.profiles
FOR INSERT TO authenticated
WITH CHECK (id = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#3: support_sessions INSERT WITH CHECK
-- (admin_id deve ser o caller E o caller deve ser saas_admin)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS support_sessions_saas_insert ON public.support_sessions;
CREATE POLICY support_sessions_saas_insert ON public.support_sessions
FOR INSERT TO authenticated
WITH CHECK (
admin_id = auth.uid()
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#6: user_settings INSERT WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS user_settings_insert_own ON public.user_settings;
CREATE POLICY user_settings_insert_own ON public.user_settings
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- Calendário V#1: feriados INSERT WITH CHECK (tenant + global)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS feriados_insert ON public.feriados;
CREATE POLICY feriados_insert ON public.feriados
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS feriados_saas_insert ON public.feriados;
CREATE POLICY feriados_saas_insert ON public.feriados
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IS NULL
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Calendário V#2: feriados DELETE — adicionar tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS feriados_delete ON public.feriados;
CREATE POLICY feriados_delete ON public.feriados
FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR (tenant_id IS NOT NULL AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
))
);
@@ -0,0 +1,65 @@
-- =============================================================================
-- Migration: 20260419000017_addons_central_saas_hardening
-- Sessão 8 — Addons + Central SaaS scan.
--
-- Resolve:
-- • Addons V#1 (CRÍTICO — dinheiro real): addon_transactions sem WITH CHECK
-- • Addons V#2: addon_credits sem CHECK contra saldo negativo
-- • Central SaaS V#1: saas_faq write permite tenant_admin/clinic_admin
--
-- Auditoria prévia: 0 addon_credits com balance < 0 (seguro CHECK).
-- Edge functions consomem créditos via service_role (bypass RLS) — nova
-- restrição não quebra pipeline.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Addons V#1: addon_transactions INSERT WITH CHECK (saas_admin only)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS addon_transactions_admin_insert ON public.addon_transactions;
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Addons V#2: addon_credits CHECK contra saldo negativo
-- -----------------------------------------------------------------------------
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_balance_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_balance_nonneg_chk
CHECK (balance >= 0);
-- Aproveita: total_consumed também não deve ser negativo
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_consumed_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_consumed_nonneg_chk
CHECK (total_consumed >= 0);
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_purchased_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_purchased_nonneg_chk
CHECK (total_purchased >= 0);
-- ─────────────────────────────────────────────────────────────────────────
-- Central SaaS V#1: saas_faq + saas_faq_itens write SÓ saas_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS faq_admin_write ON public.saas_faq;
CREATE POLICY faq_saas_admin_write ON public.saas_faq
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
DROP POLICY IF EXISTS faq_itens_admin_write ON public.saas_faq_itens;
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- (Policies de leitura — faq_auth_read, faq_public_read, faq_itens_auth_read — permanecem)
@@ -0,0 +1,223 @@
-- =============================================================================
-- Migration: 20260419000018_servicos_prontuarios_hardening
-- Sessão 9 — Serviços/Prontuários scan.
--
-- Resolve:
-- • Serviços V#1+V#2 (CRÍTICOS): silos por owner em services/medicos/insurance_plans
-- • Serviços V#3+V#4 (ALTOS): cascade silos em commitment_services/insurance_plan_services
-- • Serviços V#5: WITH CHECK ausente em commitment_time_logs/determined_*
--
-- Padrão validado em 5 áreas anteriores (Documentos/Financeiro/Comunicação/etc):
-- SELECT tenant_member, INSERT/UPDATE/DELETE owner+saas, com WITH CHECK explícito.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1 services — split em 4 policies
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "services: owner full access" ON public.services;
DROP POLICY IF EXISTS "services: select" ON public.services;
DROP POLICY IF EXISTS "services: insert" ON public.services;
DROP POLICY IF EXISTS "services: update" ON public.services;
DROP POLICY IF EXISTS "services: delete" ON public.services;
CREATE POLICY "services: select" ON public.services
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "services: insert" ON public.services
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
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 "services: delete" ON public.services
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#2 medicos — mesmo padrão
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "medicos: owner full access" ON public.medicos;
DROP POLICY IF EXISTS "medicos: select" ON public.medicos;
DROP POLICY IF EXISTS "medicos: insert" ON public.medicos;
DROP POLICY IF EXISTS "medicos: update" ON public.medicos;
DROP POLICY IF EXISTS "medicos: delete" ON public.medicos;
CREATE POLICY "medicos: select" ON public.medicos
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "medicos: insert" ON public.medicos
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "medicos: update" ON public.medicos
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 "medicos: delete" ON public.medicos
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#1 (parte 2) insurance_plans — mesmo padrão
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "insurance_plans: owner full access" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: select" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: insert" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: update" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: delete" ON public.insurance_plans;
CREATE POLICY "insurance_plans: select" ON public.insurance_plans
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "insurance_plans: update" ON public.insurance_plans
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 "insurance_plans: delete" ON public.insurance_plans
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#3 commitment_services — cascade via JOIN com services.tenant_id
-- (tabela N:N sem tenant_id próprio; herda do services pai)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "commitment_services: owner full access" ON public.commitment_services;
DROP POLICY IF EXISTS "commitment_services: tenant_member" ON public.commitment_services;
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.services s
WHERE s.id = commitment_services.service_id
AND (
s.owner_id = auth.uid()
OR public.is_saas_admin()
OR s.tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.services s
WHERE s.id = commitment_services.service_id
AND (s.owner_id = auth.uid() OR public.is_saas_admin())
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#4 insurance_plan_services — cascade via JOIN com insurance_plans
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "insurance_plan_services_owner" ON public.insurance_plan_services;
DROP POLICY IF EXISTS "insurance_plan_services: tenant_member" ON public.insurance_plan_services;
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.insurance_plans ip
WHERE ip.id = insurance_plan_services.insurance_plan_id
AND (
ip.owner_id = auth.uid()
OR public.is_saas_admin()
OR ip.tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.insurance_plans ip
WHERE ip.id = insurance_plan_services.insurance_plan_id
AND (ip.owner_id = auth.uid() OR public.is_saas_admin())
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#5 — adicionar WITH CHECK em INSERT das 3 tabelas que não tinham
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS ctl_insert_for_active_member ON public.commitment_time_logs;
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS dcf_insert_for_active_member ON public.determined_commitment_fields;
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS dc_insert_for_active_member ON public.determined_commitments;
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
@@ -0,0 +1,107 @@
-- =============================================================================
-- Migration: 20260420000001_patient_intake_invite_info_rpc
-- A#31 — RPC read-only de lookup público do terapeuta/clínica a partir do
-- token do patient_invite. Consumida pela edge function get-intake-invite-info
-- para alimentar o "hero header" da página /cadastro/paciente.
--
-- Segurança:
-- • SECURITY DEFINER (ignora RLS de profiles/company_profiles)
-- • Valida token: existe, ativo, não-expirado, dentro do max_uses
-- • Retorna APENAS campos explicitamente seguros (não-sensíveis)
-- • Execute revogado de PUBLIC/anon; grantado só para service_role (edge)
-- e authenticated (usos internos futuros)
--
-- Payload devolvido:
-- { ok: true, info: { therapist: {...}, clinic: {...}|null } }
-- { error: 'invalid-token' } — token inválido/expirado/esgotado
-- { error: 'missing-token' } — input vazio
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_intake_invite_info(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_token_clean text;
v_invite RECORD;
v_result jsonb;
BEGIN
v_token_clean := nullif(trim(coalesce(p_token, '')), '');
IF v_token_clean IS NULL THEN
RETURN jsonb_build_object('error', 'missing-token');
END IF;
SELECT pi.owner_id, pi.tenant_id, pi.active, pi.expires_at, pi.max_uses, pi.uses
INTO v_invite
FROM public.patient_invites pi
WHERE pi.token = v_token_clean
LIMIT 1;
IF v_invite.owner_id IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.active IS DISTINCT FROM true THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.max_uses IS NOT NULL AND v_invite.uses >= v_invite.max_uses THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
SELECT jsonb_build_object(
'therapist', jsonb_build_object(
'display_name', coalesce(
nullif(trim(p.full_name), ''),
nullif(trim(p.nickname), ''),
'Profissional'
),
'avatar_url', nullif(trim(coalesce(p.avatar_url, '')), ''),
'work_description', nullif(trim(coalesce(p.work_description, '')), ''),
'bio', nullif(trim(coalesce(p.bio, '')), ''),
'phone', nullif(trim(coalesce(p.phone, '')), ''),
'site_url', nullif(trim(coalesce(p.site_url, '')), ''),
'instagram', nullif(trim(coalesce(p.social_instagram, '')), '')
),
'clinic', CASE
WHEN cp.tenant_id IS NOT NULL THEN jsonb_build_object(
'name', nullif(trim(coalesce(cp.nome_fantasia, '')), ''),
'logo_url', nullif(trim(coalesce(cp.logo_url, '')), ''),
'email', nullif(trim(coalesce(cp.email, '')), ''),
'phone', nullif(trim(coalesce(cp.telefone, '')), ''),
'site', nullif(trim(coalesce(cp.site, '')), ''),
'city', nullif(trim(coalesce(cp.cidade, '')), ''),
'state', nullif(trim(coalesce(cp.estado, '')), ''),
'neighborhood', nullif(trim(coalesce(cp.bairro, '')), ''),
'street', nullif(trim(coalesce(cp.logradouro, '')), ''),
'number', nullif(trim(coalesce(cp.numero, '')), ''),
'social', coalesce(cp.redes_sociais, '[]'::jsonb)
)
ELSE NULL
END
)
INTO v_result
FROM public.profiles p
LEFT JOIN public.company_profiles cp ON cp.tenant_id = v_invite.tenant_id
WHERE p.id = v_invite.owner_id
LIMIT 1;
IF v_result IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
RETURN jsonb_build_object('ok', true, 'info', v_result);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) TO authenticated, service_role;
COMMENT ON FUNCTION public.get_patient_intake_invite_info(text) IS
'A#31 — Lookup público read-only (via edge function) dos dados de apresentação do terapeuta/clínica dono do link de cadastro externo. Só retorna campos não-sensíveis.';
@@ -0,0 +1,199 @@
-- =============================================================================
-- Migration: 20260420000002_audit_logs_lgpd
-- Sessao 11 - Fase 2a (Opcao C).
--
-- Resolve: LGPD Art. 37 - registro das operacoes de tratamento.
-- Projeto ja tinha logs pontuais (document_access_logs, patient_status_history,
-- notification_logs, addon_transactions) mas nao registrava:
-- - Edicao de dados do paciente (nome/CPF/endereco)
-- - CRUD de sessoes na agenda
-- - CRUD de registros financeiros
-- - CRUD de documentos (metadata)
-- - Mudancas de permissao / members do tenant
--
-- Cria tabela audit_logs imutavel + funcao trigger generica + triggers nas
-- tabelas criticas. RLS: tenant member le; ninguem INSERT/UPDATE/DELETE direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela audit_logs
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
action TEXT NOT NULL CHECK (action IN ('insert', 'update', 'delete')),
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created
ON public.audit_logs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity
ON public.audit_logs (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
ON public.audit_logs (user_id, created_at DESC) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_fields
ON public.audit_logs USING GIN (changed_fields);
COMMENT ON TABLE public.audit_logs IS
'Registro imutavel de operacoes de tratamento (LGPD Art. 37). INSERT apenas via trigger SECURITY DEFINER.';
-- ---------------------------------------------------------------------------
-- Funcao trigger generica
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_tenant_id UUID;
v_entity_id TEXT;
v_old JSONB;
v_new JSONB;
v_changed TEXT[];
v_heavy_fields TEXT[] := ARRAY[
'content', 'content_html', 'content_json', 'raw_data',
'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text'
];
v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at'];
BEGIN
IF TG_OP = 'DELETE' THEN
v_tenant_id := OLD.tenant_id;
v_entity_id := OLD.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := NULL;
v_new := to_jsonb(NEW) - v_heavy_fields;
ELSE -- UPDATE
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := to_jsonb(NEW) - v_heavy_fields;
-- calcular campos realmente alterados
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;
-- se nada mudou, ignora
IF v_changed IS NULL THEN
RETURN NEW;
END IF;
-- se mudou apenas campos de ruido (ex: updated_at), ignora
IF v_changed <@ v_noise_fields 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;
$$;
COMMENT ON FUNCTION public.log_audit_change() IS
'Trigger generica de audit. Filtra campos pesados (content, signature_data) e ruido (updated_at).';
-- ---------------------------------------------------------------------------
-- Triggers nas tabelas criticas
-- ---------------------------------------------------------------------------
-- patients
DROP TRIGGER IF EXISTS trg_audit_patients ON public.patients;
CREATE TRIGGER trg_audit_patients
AFTER INSERT OR UPDATE OR DELETE ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- agenda_eventos
DROP TRIGGER IF EXISTS trg_audit_agenda_eventos ON public.agenda_eventos;
CREATE TRIGGER trg_audit_agenda_eventos
AFTER INSERT OR UPDATE OR DELETE ON public.agenda_eventos
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- financial_records
DROP TRIGGER IF EXISTS trg_audit_financial_records ON public.financial_records;
CREATE TRIGGER trg_audit_financial_records
AFTER INSERT OR UPDATE OR DELETE ON public.financial_records
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- documents
DROP TRIGGER IF EXISTS trg_audit_documents ON public.documents;
CREATE TRIGGER trg_audit_documents
AFTER INSERT OR UPDATE OR DELETE ON public.documents
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- tenant_members (mudanca de permissao)
DROP TRIGGER IF EXISTS trg_audit_tenant_members ON public.tenant_members;
CREATE TRIGGER trg_audit_tenant_members
AFTER INSERT OR UPDATE OR DELETE ON public.tenant_members
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; saas_admin le tudo; ninguem escreve direto
-- ---------------------------------------------------------------------------
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.audit_logs FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "audit_logs: select tenant" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: saas_admin all" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct insert" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct update" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct delete" ON public.audit_logs;
CREATE POLICY "audit_logs: select tenant" ON public.audit_logs
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Explicitamente NEGA insert/update/delete via API
-- (SECURITY DEFINER na funcao trigger bypassa RLS; app nao consegue escrever direto)
CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "audit_logs: no direct update" ON public.audit_logs
FOR UPDATE TO authenticated
USING (false) WITH CHECK (false);
CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs
FOR DELETE TO authenticated
USING (false);
-- ---------------------------------------------------------------------------
-- Marca hardening na auditoria
-- ---------------------------------------------------------------------------
COMMENT ON COLUMN public.audit_logs.old_values IS 'Estado anterior (jsonb); NULL em INSERT; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.new_values IS 'Estado posterior (jsonb); NULL em DELETE; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.changed_fields IS 'Lista de campos alterados em UPDATE (NULL em INSERT/DELETE)';
@@ -0,0 +1,148 @@
-- =============================================================================
-- Migration: 20260420000003_audit_logs_unified_view
-- Sessao 11 - Fase 2a (Opcao C).
--
-- View audit_log_unified que junta:
-- - audit_logs (nova, trigger generico em patients/agenda/etc)
-- - document_access_logs (visualizacao/download/impressao de documento)
-- - patient_status_history (mudancas de status de paciente)
-- - notification_logs (envio de SMS/email/WhatsApp)
-- - addon_transactions (compras/consumos de recursos extras)
--
-- RLS: aplica-se das tabelas base. View usa security_invoker para herdar.
-- =============================================================================
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
CREATE VIEW public.audit_log_unified
WITH (security_invoker = true)
AS
-- 1) audit_logs (trigger generico)
SELECT
'audit:' || al.id::text AS uid,
al.tenant_id AS tenant_id,
al.user_id AS user_id,
al.entity_type AS entity_type,
al.entity_id AS entity_id,
al.action AS 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
UNION ALL
-- 2) document_access_logs
SELECT
'doc_access:' || dal.id::text,
dal.tenant_id,
dal.user_id,
'document' AS entity_type,
dal.documento_id::text AS entity_id,
dal.acao AS action,
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 AS description,
dal.acessado_em AS occurred_at,
'document_access_logs' AS source,
jsonb_build_object(
'ip', dal.ip::text,
'user_agent', dal.user_agent
) AS details
FROM public.document_access_logs dal
UNION ALL
-- 3) patient_status_history
SELECT
'psh:' || psh.id::text,
psh.tenant_id,
psh.alterado_por,
'patient_status' AS entity_type,
psh.patient_id::text AS entity_id,
'status_change' AS action,
'Status do paciente: '
|| COALESCE(psh.status_anterior, '') || '' || psh.status_novo
|| COALESCE(' (' || psh.motivo || ')', '') AS description,
psh.alterado_em AS occurred_at,
'patient_status_history' AS source,
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
) AS details
FROM public.patient_status_history psh
UNION ALL
-- 4) notification_logs
SELECT
'notif:' || nl.id::text,
nl.tenant_id,
nl.owner_id AS user_id,
'notification' AS entity_type,
nl.patient_id::text AS entity_id,
nl.status AS action,
'Notificação ' || nl.channel || ' '
|| nl.status
|| COALESCE(' para ' || nl.recipient_address, '') AS description,
nl.created_at AS occurred_at,
'notification_logs' AS source,
jsonb_build_object(
'channel', nl.channel,
'template_key', nl.template_key,
'status', nl.status,
'provider', nl.provider,
'failure_reason', nl.failure_reason
) AS details
FROM public.notification_logs nl
UNION ALL
-- 5) addon_transactions
SELECT
'addon:' || at.id::text,
at.tenant_id,
at.admin_user_id AS user_id,
'addon_transaction' AS entity_type,
at.id::text AS entity_id,
at.type AS action,
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 AS description,
at.created_at AS occurred_at,
'addon_transactions' AS source,
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
) AS details
FROM public.addon_transactions at;
COMMENT ON VIEW public.audit_log_unified IS
'Timeline unificada de eventos auditaveis (LGPD). Herda RLS das tabelas base via security_invoker.';
GRANT SELECT ON public.audit_log_unified TO authenticated;
@@ -0,0 +1,225 @@
-- =============================================================================
-- Migration: 20260420000004_lgpd_export_patient_rpc
-- Sessao 11 - Fase 2b (Opcao C).
--
-- Implementa LGPD Art. 18 - direito de portabilidade do titular.
-- RPC export_patient_data(p_patient_id uuid) retorna jsonb com todos os dados
-- relacionados ao paciente. Registra o evento em audit_logs para rastreabilidade.
--
-- Seguranca:
-- - SECURITY DEFINER permite agregar tabelas diversas bypassando RLS
-- - Verificacao explicita: caller deve ser tenant_member ativo da clinica do paciente
-- - Proibido acesso cross-tenant
-- =============================================================================
CREATE OR REPLACE FUNCTION public.export_patient_data(p_patient_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_patient RECORD;
v_tenant_id UUID;
v_caller UUID;
v_is_member BOOLEAN;
v_result JSONB;
BEGIN
v_caller := auth.uid();
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE = '28000';
END IF;
-- carrega paciente
SELECT * INTO v_patient FROM public.patients WHERE id = p_patient_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE = 'P0002';
END IF;
v_tenant_id := v_patient.tenant_id;
-- verifica se caller e membro ativo do tenant do paciente
SELECT EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = v_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
) OR public.is_saas_admin() INTO v_is_member;
IF NOT v_is_member THEN
RAISE EXCEPTION 'Sem permissao para exportar dados deste paciente' USING ERRCODE = '42501';
END IF;
-- monta o payload
v_result := jsonb_build_object(
'export_metadata', jsonb_build_object(
'generated_at', now(),
'generated_by', v_caller,
'tenant_id', v_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 public.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 public.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 public.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 public.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 public.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,
'inicio_em', ae.inicio_em,
'fim_em', ae.fim_em,
'status', ae.status,
'observacoes', ae.observacoes,
'created_at', ae.created_at
) ORDER BY ae.inicio_em
)
FROM public.agenda_eventos ae
WHERE ae.patient_id = p_patient_id
), '[]'::jsonb),
'registros_financeiros', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', fr.id,
'amount', fr.amount,
'discount_amount', fr.discount_amount,
'final_amount', fr.final_amount,
'status', fr.status,
'due_date', fr.due_date,
'paid_at', fr.paid_at,
'payment_method', fr.payment_method,
'notes', fr.notes,
'created_at', fr.created_at
) ORDER BY fr.created_at
)
FROM public.financial_records fr
WHERE fr.patient_id = p_patient_id
), '[]'::jsonb),
'documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', d.id,
'nome_original', d.nome_original,
'tipo_documento', d.tipo_documento,
'categoria', d.categoria,
'descricao', d.descricao,
'mime_type', d.mime_type,
'tamanho_bytes', d.tamanho_bytes,
'status_revisao', d.status_revisao,
'visibilidade', d.visibilidade,
'uploaded_at', d.uploaded_at,
'created_at', d.created_at
) ORDER BY d.created_at
)
FROM public.documents d
WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL
), '[]'::jsonb),
'notificacoes_enviadas', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', nl.id,
'channel', nl.channel,
'template_key', nl.template_key,
'recipient_address', nl.recipient_address,
'status', nl.status,
'sent_at', nl.sent_at,
'delivered_at', nl.delivered_at,
'read_at', nl.read_at,
'failure_reason', nl.failure_reason,
'created_at', nl.created_at
) ORDER BY nl.created_at
)
FROM public.notification_logs nl
WHERE nl.patient_id = p_patient_id
), '[]'::jsonb),
'audit_trail', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', al.id,
'action', al.action,
'entity_type', al.entity_type,
'changed_fields', al.changed_fields,
'user_id', al.user_id,
'created_at', al.created_at
) ORDER BY al.created_at
)
FROM public.audit_logs al
WHERE al.tenant_id = v_tenant_id
AND al.entity_type = 'patients'
AND al.entity_id = p_patient_id::text
), '[]'::jsonb),
'acessos_a_documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', dal.id,
'documento_id', dal.documento_id,
'acao', dal.acao,
'user_id', dal.user_id,
'acessado_em', dal.acessado_em
) ORDER BY dal.acessado_em
)
FROM public.document_access_logs dal
WHERE dal.documento_id IN (
SELECT id FROM public.documents WHERE patient_id = p_patient_id
)
), '[]'::jsonb),
'grupos', COALESCE((
SELECT jsonb_agg(jsonb_build_object('patient_group_id', pgp.patient_group_id))
FROM public.patient_group_patient pgp
WHERE pgp.patient_id = p_patient_id
), '[]'::jsonb),
'tags', COALESCE((
SELECT jsonb_agg(jsonb_build_object('tag_id', ppt.tag_id))
FROM public.patient_patient_tag ppt
WHERE ppt.patient_id = p_patient_id
), '[]'::jsonb)
);
-- registra o export como evento auditavel
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields, metadata
) VALUES (
v_tenant_id, v_caller, 'patients', p_patient_id::text, 'update',
NULL, NULL, ARRAY['__lgpd_export__'],
jsonb_build_object(
'action_kind', 'lgpd_export',
'lgpd_basis', 'Art. 18, II',
'patient_name', v_patient.nome_completo
)
);
RETURN v_result;
END;
$$;
COMMENT ON FUNCTION public.export_patient_data(UUID) IS
'LGPD Art. 18, II - exporta todos os dados do paciente em jsonb portavel. Registra evento em audit_logs.';
REVOKE ALL ON FUNCTION public.export_patient_data(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.export_patient_data(UUID) TO authenticated;
@@ -0,0 +1,263 @@
-- =============================================================================
-- Migration: 20260420000005_conversation_messages
-- Sessao 11 - Fase 5a (CRM de WhatsApp / inbox).
--
-- Cria infraestrutura para receber mensagens inbound de WhatsApp (Twilio e
-- Evolution API) e exibir num Kanban de conversas.
--
-- - conversation_messages — todas as mensagens (in/out) com link opcional
-- ao paciente via telefone matching
-- - function match_patient_by_phone(tenant_id, phone) — encontra paciente
-- - view conversation_threads — agregado por paciente/numero pra UI Kanban
--
-- RLS: tenant members leem; service_role (edge function) escreve via SECURITY
-- DEFINER match_and_insert. App nao escreve direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela de mensagens
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_messages (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
channel TEXT NOT NULL CHECK (channel IN ('whatsapp', 'sms', 'email')),
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
from_number TEXT,
to_number TEXT,
body TEXT,
media_url TEXT,
media_mime TEXT,
provider TEXT NOT NULL CHECK (provider IN ('twilio', 'evolution', 'manual')),
provider_message_id TEXT,
provider_raw JSONB,
-- estado Kanban
kanban_status TEXT NOT NULL DEFAULT 'awaiting_us'
CHECK (kanban_status IN ('urgent', 'awaiting_us', 'awaiting_patient', 'resolved')),
priority INT NOT NULL DEFAULT 0,
read_at TIMESTAMPTZ,
responded_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_msg_tenant_created
ON public.conversation_messages (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_patient
ON public.conversation_messages (patient_id, created_at DESC) WHERE patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_msg_from_number
ON public.conversation_messages (tenant_id, from_number);
CREATE INDEX IF NOT EXISTS idx_conv_msg_kanban
ON public.conversation_messages (tenant_id, kanban_status, priority DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_provider_msg_id
ON public.conversation_messages (provider_message_id) WHERE provider_message_id IS NOT NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_messages_updated_at ON public.conversation_messages;
CREATE TRIGGER trg_conv_messages_updated_at
BEFORE UPDATE ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_messages IS
'Mensagens in/out de WhatsApp/SMS/email. Timeline de conversas do tenant com pacientes.';
-- ---------------------------------------------------------------------------
-- Funcao: normaliza telefone BR (remove tudo que nao seja digito, tira DDI 55)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.normalize_phone_br(p_phone TEXT)
RETURNS TEXT
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_digits TEXT;
BEGIN
IF p_phone IS NULL THEN RETURN NULL; END IF;
-- remove tudo que nao seja digito
v_digits := regexp_replace(p_phone, '\D', '', 'g');
-- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero)
IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN
v_digits := substr(v_digits, 3);
END IF;
-- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero)
IF length(v_digits) > 11 THEN
v_digits := right(v_digits, 11);
END IF;
RETURN v_digits;
END;
$$;
COMMENT ON FUNCTION public.normalize_phone_br(TEXT) IS
'Normaliza telefone BR para os ultimos 11 digitos (DDD+numero), removendo DDI +55 e formatacao.';
-- ---------------------------------------------------------------------------
-- Funcao: match paciente por telefone dentro de um tenant
-- ---------------------------------------------------------------------------
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 = 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;
-- prioridade: telefone principal, depois alternativo, depois responsavel
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND 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 public.patients
WHERE tenant_id = p_tenant_id
AND 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 public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_responsavel) = v_normalized
LIMIT 1;
RETURN v_patient_id;
END;
$$;
COMMENT ON FUNCTION public.match_patient_by_phone(UUID, TEXT) IS
'Encontra patient_id do tenant cujo telefone (principal/alternativo/responsavel) bate com o numero.';
-- ---------------------------------------------------------------------------
-- View: threads agrupadas por paciente ou numero anonimo
-- ---------------------------------------------------------------------------
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
CREATE VIEW public.conversation_threads
WITH (security_invoker = true)
AS
WITH base AS (
SELECT
cm.id,
cm.tenant_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 public.conversation_messages cm
),
latest AS (
SELECT DISTINCT ON (tenant_id, thread_key)
tenant_id, thread_key, patient_id, channel, contact_number,
body AS last_message_body,
direction AS last_message_direction,
kanban_status,
created_at AS last_message_at
FROM base
ORDER BY tenant_id, thread_key, created_at DESC
),
counts AS (
SELECT
tenant_id, thread_key,
COUNT(*) AS message_count,
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
FROM base
GROUP BY tenant_id, thread_key
)
SELECT
l.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
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;
COMMENT ON VIEW public.conversation_threads IS
'Agregado de conversas por paciente ou por numero anonimo. Base do Kanban.';
GRANT SELECT ON public.conversation_threads TO authenticated;
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; ninguem escreve direto (so via edge function service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_messages FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_msg: select tenant" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: update kanban" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct insert" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct delete" ON public.conversation_messages;
CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- tenant member pode atualizar apenas kanban_status/read_at/responded_at/resolved_at
-- (nao pode mexer em body, provider, etc)
CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages
FOR UPDATE TO authenticated
USING (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages
FOR DELETE TO authenticated
USING (false);
@@ -0,0 +1,275 @@
-- =============================================================================
-- Migration: 20260420000005_search_global_rpc
-- Busca global do topbar — RPC única que retorna resultados agrupados por
-- entidade (pacientes, agendamentos, documentos, serviços).
--
-- Segurança:
-- • SECURITY INVOKER → respeita RLS do chamador (terapeuta vê só os dele,
-- clínica vê do tenant, saas_admin vê global). Sem reinvenção de permissão.
-- • GRANT apenas para `authenticated` (paciente anônimo não tem busca global).
--
-- Índices trigram:
-- • patients(nome_completo, email_principal, cpf)
-- • services(name)
-- • agenda_eventos(titulo, titulo_custom)
-- • documents(nome_original) — já existe em 06_indexes/indexes.sql (skip)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Índices trigram (GIN) pra ILIKE/similarity performarem
-- pg_trgm instalado em schema `extensions`; ops class vive em `public`.
-- -----------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_patients_nome_trgm
ON public.patients USING gin (nome_completo public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_patients_email_trgm
ON public.patients USING gin (email_principal public.gin_trgm_ops)
WHERE email_principal IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patients_cpf_trgm
ON public.patients USING gin (cpf public.gin_trgm_ops)
WHERE cpf IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_services_name_trgm
ON public.services USING gin (name public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_trgm
ON public.agenda_eventos USING gin (titulo public.gin_trgm_ops)
WHERE titulo IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_custom_trgm
ON public.agenda_eventos USING gin (titulo_custom public.gin_trgm_ops)
WHERE titulo_custom IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patient_intake_requests_nome_trgm
ON public.patient_intake_requests USING gin (nome_completo public.gin_trgm_ops)
WHERE status = 'new';
-- -----------------------------------------------------------------------------
-- RPC principal
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.search_global(
p_q text,
p_scope text[] DEFAULT NULL,
p_limit int DEFAULT 8
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
STABLE
SET search_path = 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
-- Sanitize + length guards
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));
-- ─────────────────────────────────────────────────────────────────────
-- Pacientes
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
WITH ranked AS (
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 public.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
)
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 ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Agendamentos (com nome do paciente via join)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
WITH ranked AS (
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 public.agenda_eventos e
LEFT JOIN public.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
)
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 ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Documentos
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
WITH ranked AS (
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 public.documents d
LEFT JOIN public.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
)
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 ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Serviços (ativos)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
WITH ranked AS (
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 public.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
)
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 ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Intakes pendentes (patient_intake_requests com status='new')
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
WITH ranked AS (
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.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
)
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 ranked;
END IF;
RETURN jsonb_build_object(
'patients', v_patients,
'appointments', v_appointments,
'documents', v_documents,
'services', v_services,
'intakes', v_intakes
);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.search_global(text, text[], int) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.search_global(text, text[], int) TO authenticated;
COMMENT ON FUNCTION public.search_global(text, text[], int) IS
'Busca global do topbar — retorna jsonb agrupado por entidade. SECURITY INVOKER (RLS do chamador aplica).';
@@ -0,0 +1,117 @@
-- =============================================================================
-- Migration: 20260420000006_conv_messages_notifications
-- Sessao 11 - Fase 5a (extensao).
--
-- Integra conversation_messages ao sistema de notifications existente:
-- - Adiciona 'inbound_message' ao CHECK do notifications.type
-- - Trigger em conversation_messages: quando chega inbound, fan-out para
-- members do tenant apropriados (responsible_member do paciente, ou
-- todos tenant_admin/clinic_admin/therapist ativos se sem vinculo)
-- =============================================================================
-- Ajusta CHECK do type
ALTER TABLE public.notifications
DROP CONSTRAINT IF EXISTS notifications_type_check;
ALTER TABLE public.notifications
ADD CONSTRAINT notifications_type_check
CHECK (type = ANY (ARRAY[
'new_scheduling',
'new_patient',
'recurrence_alert',
'session_status',
'inbound_message'
]));
-- ---------------------------------------------------------------------------
-- Trigger function: fan-out mensagem inbound para notifications dos members
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 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;
BEGIN
-- so processa inbound
IF NEW.direction <> 'inbound' THEN
RETURN NEW;
END IF;
-- busca nome do paciente (se vinculado)
IF NEW.patient_id IS NOT NULL THEN
SELECT nome_completo INTO v_patient_name FROM public.patients WHERE id = NEW.patient_id;
END IF;
-- titulo e detalhes
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
-- iniciais
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;
-- deeplink para a pagina de conversas (clinic padrao; therapist tambem funciona via mesma rota na area dele)
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
);
-- ─── decide destinatarios ─────────────────────────────────────────────
-- Caso 1: paciente vinculado e tem responsible_member_id
IF NEW.patient_id IS NOT NULL THEN
SELECT tm.user_id INTO v_target_user
FROM public.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 public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
VALUES (v_target_user, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload);
RETURN NEW;
END IF;
END IF;
-- Caso 2: fallback — fan-out pra todos tenant_admin/clinic_admin/therapist ativos
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT tm.user_id, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload
FROM public.tenant_members tm
WHERE tm.tenant_id = NEW.tenant_id
AND tm.status = 'active'
AND tm.role IN ('clinic_admin', 'tenant_admin', 'therapist');
RETURN NEW;
END;
$$;
-- Trigger
DROP TRIGGER IF EXISTS trg_fanout_inbound_to_notifications ON public.conversation_messages;
CREATE TRIGGER trg_fanout_inbound_to_notifications
AFTER INSERT ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications();
COMMENT ON FUNCTION public.fanout_inbound_message_to_notifications() IS
'Cria registros em notifications pra members apropriados quando chega mensagem inbound. Respeita responsible_member do paciente.';
@@ -0,0 +1,26 @@
-- =============================================================================
-- Migration: 20260420000007_notif_channels_saas_admin_insert
--
-- Fix: SaaS admin nao conseguia INSERT em notification_channels via /saas/whatsapp
-- porque a policy de insert exigia owner_id = auth.uid() e o saas_admin esta
-- inserindo em nome do tenant_admin (outro user). As policies de update/delete
-- ja tinham OR is_saas_admin() — o insert foi esquecido.
-- =============================================================================
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
CREATE POLICY "notif_channels_insert" ON public.notification_channels
FOR INSERT TO authenticated
WITH CHECK (
public.is_saas_admin()
OR (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
COMMENT ON POLICY "notif_channels_insert" ON public.notification_channels IS
'SaaS admin pode inserir em nome de qualquer tenant; tenant_member insere pra si mesmo.';
@@ -0,0 +1,12 @@
-- =============================================================================
-- Migration: 20260420000008_conv_messages_realtime
--
-- Adiciona conversation_messages na publicacao supabase_realtime para que
-- INSERT/UPDATE sejam entregues ao subscribe do frontend.
-- =============================================================================
ALTER PUBLICATION supabase_realtime ADD TABLE public.conversation_messages;
-- REPLICA IDENTITY FULL permite que o payload do Realtime traga a row completa
-- (necessario pra usar filters e receber old/new em UPDATEs)
ALTER TABLE public.conversation_messages REPLICA IDENTITY FULL;
@@ -0,0 +1,17 @@
-- =============================================================================
-- Migration: 20260420000009_conv_messages_delivery_status
--
-- Adiciona colunas para rastrear status de entrega/leitura das mensagens
-- outbound (envio pelo sistema). Evolution dispara evento messages.update
-- com status = SENT | DELIVERED | READ que vamos capturar.
-- =============================================================================
ALTER TABLE public.conversation_messages
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS read_by_recipient_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS delivery_status TEXT
CHECK (delivery_status IS NULL OR delivery_status IN ('pending','sent','delivered','read','failed'));
CREATE INDEX IF NOT EXISTS idx_conv_msg_delivery_status
ON public.conversation_messages (tenant_id, delivery_status)
WHERE direction = 'outbound';
@@ -0,0 +1,91 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Bucket para midia de WhatsApp
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Cria bucket privado `whatsapp-media` para armazenar audio/imagem/video/
-- documentos recebidos via Evolution API. URLs do WhatsApp sao encriptadas
-- com mediaKey da Meta — precisamos decriptar via Evolution getBase64 e
-- subir pro nosso storage para playback no browser.
--
-- Privacidade LGPD:
-- - Bucket privado (public=false)
-- - Upload apenas via service_role (edge function)
-- - Leitura via signed URLs gerados on-demand pelo frontend (expiracao curta)
-- - Paths tenant-scoped: <tenant_id>/<yyyy>/<mm>/<uuid>.<ext>
-- ==========================================================================
-- Bucket whatsapp-media
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'whatsapp-media',
'whatsapp-media',
false,
26214400, -- 25 MB (WhatsApp aceita ate 16MB audio/video, margem extra)
ARRAY[
-- Audio
'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/wav', 'audio/webm',
-- Imagem
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
-- Video
'video/mp4', 'video/3gpp', 'video/quicktime', 'video/webm',
-- Documento
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'application/zip',
'application/octet-stream'
]
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — whatsapp-media
-- --------------------------------------------------------------------------
-- Politica: APENAS service_role faz upload (edge function).
-- Usuarios autenticados leem se forem membros ativos do tenant no path[0].
-- --------------------------------------------------------------------------
-- Read: membro ativo do tenant cujo id e o primeiro segmento do path
CREATE POLICY "whatsapp-media: read tenant members"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND (
-- SaaS admins leem qualquer tenant
public.is_saas_admin()
OR
-- Membros ativos do tenant (tenant_id e o primeiro segmento do path)
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND (storage.foldername(name))[1] = tm.tenant_id::text
)
)
);
-- Insert: bloqueado para authenticated (apenas service_role sobe)
-- Sem policy de INSERT para authenticated = bloqueado por default no RLS.
-- Delete: SaaS admin pode deletar (retention policy futura)
CREATE POLICY "whatsapp-media: delete saas admin"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND public.is_saas_admin()
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,116 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Notas internas de conversa (CRM Grupo 3.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Notas internas da equipe em cada thread de conversa (WhatsApp/SMS/etc).
-- NAO sao enviadas ao paciente — apenas visiveis aos membros do tenant.
--
-- thread_key segue o padrao de conversation_threads:
-- - '<uuid>' → thread de paciente conhecido
-- - 'anon:<phone>' → thread de numero nao identificado
--
-- RLS:
-- - READ/CREATE: qualquer membro ativo do tenant
-- - UPDATE/DELETE: apenas o criador da nota OU saas_admin
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
contact_number TEXT,
body TEXT NOT NULL CHECK (length(body) > 0 AND length(body) <= 4000),
created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_conv_notes_tenant_thread
ON public.conversation_notes (tenant_id, thread_key, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_patient
ON public.conversation_notes (patient_id, created_at DESC)
WHERE deleted_at IS NULL AND patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_created_by
ON public.conversation_notes (created_by, created_at DESC)
WHERE deleted_at IS NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_notes_updated_at ON public.conversation_notes;
CREATE TRIGGER trg_conv_notes_updated_at
BEFORE UPDATE ON public.conversation_notes
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_notes IS
'Notas internas por thread de conversa. Visiveis apenas aos membros do tenant; nao enviadas ao paciente.';
-- --------------------------------------------------------------------------
-- RLS
-- --------------------------------------------------------------------------
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
-- SELECT: membro ativo do tenant OU saas_admin
DROP POLICY IF EXISTS "conv_notes: select tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: select tenant members"
ON public.conversation_notes
FOR SELECT
TO authenticated
USING (
deleted_at IS NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- INSERT: membro ativo do tenant, created_by deve ser o proprio usuario
DROP POLICY IF EXISTS "conv_notes: insert tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: insert tenant members"
ON public.conversation_notes
FOR INSERT
TO authenticated
WITH CHECK (
created_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: apenas criador OU saas_admin
DROP POLICY IF EXISTS "conv_notes: update creator or saas" ON public.conversation_notes;
CREATE POLICY "conv_notes: update creator or saas"
ON public.conversation_notes
FOR UPDATE
TO authenticated
USING (
deleted_at IS NULL
AND (created_by = auth.uid() OR public.is_saas_admin())
)
WITH CHECK (
created_by = (SELECT created_by FROM public.conversation_notes WHERE id = conversation_notes.id)
);
-- DELETE: soft delete via UPDATE deleted_at (nao permite hard delete)
-- Mantemos politica de DELETE bloqueada por default (sem policy = nao permitido)
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Tags de conversa (CRM Grupo 3.1)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Tags aplicaveis a uma thread de conversa (urgente, primeira consulta,
-- remarcacao, etc). Cada tenant pode criar tags custom alem das padrao.
--
-- Tabelas:
-- - conversation_tags — definicoes (system com tenant_id NULL + custom)
-- - conversation_thread_tags — join (tenant_id, thread_key, tag_id)
--
-- thread_key: mesma logica de conversation_threads
-- - '<uuid>' → thread de paciente
-- - 'anon:<phone>' → thread anonima
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: conversation_tags (definicoes)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system tag
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
color TEXT NOT NULL DEFAULT '#6366f1' CHECK (color ~ '^#[0-9a-fA-F]{6}$'),
icon TEXT, -- classe de icone primeicons (ex: 'pi pi-exclamation-triangle')
position INT NOT NULL DEFAULT 100,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: (tenant_id, slug). Para tenant_id NULL (system), um indice parcial separado.
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_tenant_slug
ON public.conversation_tags (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_system_slug
ON public.conversation_tags (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_tags_tenant
ON public.conversation_tags (tenant_id, position);
DROP TRIGGER IF EXISTS trg_conv_tags_updated_at ON public.conversation_tags;
CREATE TRIGGER trg_conv_tags_updated_at
BEFORE UPDATE ON public.conversation_tags
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_tags IS
'Definicoes de tags aplicaveis a threads. tenant_id NULL = tag do sistema (todos veem).';
-- ---------------------------------------------------------------------------
-- Tabela: conversation_thread_tags (join many-to-many)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_thread_tags (
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
tag_id UUID NOT NULL REFERENCES public.conversation_tags(id) ON DELETE CASCADE,
tagged_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
tagged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, thread_key, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tenant_thread
ON public.conversation_thread_tags (tenant_id, thread_key);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tag
ON public.conversation_thread_tags (tag_id);
COMMENT ON TABLE public.conversation_thread_tags IS
'Join de tags aplicadas a cada thread de conversa.';
-- ---------------------------------------------------------------------------
-- Seed de tags padrao (system)
-- ---------------------------------------------------------------------------
INSERT INTO public.conversation_tags (tenant_id, name, slug, color, icon, position, is_system)
VALUES
(NULL, 'Urgente', 'urgente', '#ef4444', 'pi pi-exclamation-triangle', 10, true),
(NULL, 'Primeira consulta','primeira-consulta','#0ea5e9', 'pi pi-user-plus', 20, true),
(NULL, 'Remarcação', 'remarcacao', '#f59e0b', 'pi pi-calendar-times', 30, true),
(NULL, 'Confirmada', 'confirmada', '#22c55e', 'pi pi-check-circle', 40, true),
(NULL, 'Follow-up', 'follow-up', '#a855f7', 'pi pi-reply', 50, true)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- RLS: conversation_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
-- SELECT: system tags (tenant_id NULL) = todos; custom = membros ativos do tenant
DROP POLICY IF EXISTS "conv_tags: select" ON public.conversation_tags;
CREATE POLICY "conv_tags: select"
ON public.conversation_tags
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
);
-- INSERT: membros ativos do tenant criam custom. Nao podem criar system (tenant_id NULL)
DROP POLICY IF EXISTS "conv_tags: insert custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: insert custom"
ON public.conversation_tags
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: tenant members para tags proprias (custom). Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: update custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: update custom"
ON public.conversation_tags
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
)
WITH CHECK (is_system = false);
-- DELETE: tenant members removem tags custom. Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: delete custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: delete custom"
ON public.conversation_tags
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- ---------------------------------------------------------------------------
-- RLS: conversation_thread_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_thread_tags: select" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: select"
ON public.conversation_thread_tags
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_thread_tags.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "conv_thread_tags: insert" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: insert"
ON public.conversation_thread_tags
FOR INSERT
TO authenticated
WITH CHECK (
tagged_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "conv_thread_tags: delete" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: delete"
ON public.conversation_thread_tags
FOR DELETE
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_thread_tags.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,143 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Auto-reply fora do horario (CRM Grupo 2.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente manda mensagem fora do horario de atendimento, dispara
-- resposta automatica configuravel. Anti-spam via cooldown por thread.
--
-- Modos de horario:
-- - 'agenda' → usa agenda_regras_semanais dos membros do tenant
-- - 'business_hours' → janela semanal do tenant (clinica inteira)
-- - 'custom' → janela semanal especifica deste auto-reply
--
-- business_hours e custom_window usam mesma estrutura JSONB:
-- [{ "dow": 0-6, "start": "HH:MM", "end": "HH:MM" }, ...]
-- Multiplas entradas por dia permitidas (ex: 08:00-12:00 + 13:00-18:00)
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
message TEXT NOT NULL DEFAULT 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!'
CHECK (length(message) > 0 AND length(message) <= 2000),
cooldown_minutes INT NOT NULL DEFAULT 180
CHECK (cooldown_minutes >= 0 AND cooldown_minutes <= 43200), -- 0 min a 30 dias
schedule_mode TEXT NOT NULL DEFAULT 'agenda'
CHECK (schedule_mode IN ('agenda', 'business_hours', 'custom')),
-- Janela de funcionamento da clinica (reutilizavel por outras features)
-- Ex: [{ "dow": 1, "start": "08:00", "end": "18:00" }, ...]
business_hours JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Janela especifica deste auto-reply (quando schedule_mode = 'custom')
custom_window JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_conv_autoreply_settings_updated_at ON public.conversation_autoreply_settings;
CREATE TRIGGER trg_conv_autoreply_settings_updated_at
BEFORE UPDATE ON public.conversation_autoreply_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_autoreply_settings IS
'Configuracao por tenant do auto-reply fora do horario.';
-- ---------------------------------------------------------------------------
-- Log (anti-spam: uma resposta auto por thread por cooldown)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
message_id UUID -- referencia opcional pra message na tabela conversation_messages
);
CREATE INDEX IF NOT EXISTS idx_autoreply_log_cooldown
ON public.conversation_autoreply_log (tenant_id, thread_key, sent_at DESC);
COMMENT ON TABLE public.conversation_autoreply_log IS
'Log de auto-replies enviados. Usado pra respeitar cooldown por thread.';
-- ---------------------------------------------------------------------------
-- RLS: settings
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_settings: select" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: select"
ON public.conversation_autoreply_settings
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_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: insert" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: insert"
ON public.conversation_autoreply_settings
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: update" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: update"
ON public.conversation_autoreply_settings
FOR UPDATE
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_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: log (read-only pra tenant members; escrita via service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_log: select" ON public.conversation_autoreply_log;
CREATE POLICY "autoreply_log: select"
ON public.conversation_autoreply_log
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_autoreply_log.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Opt-out de conversas (CRM Grupo 5.2, LGPD)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente envia keyword de opt-out (PARAR, SAIR, CANCELAR, STOP...),
-- bloqueia envios automaticos (auto-reply + futuros lembretes).
--
-- LGPD: direito de oposicao (Art. 18, §2). Pedido de interrupcao deve ser
-- respeitado. Mensagens manuais do terapeuta nao sao bloqueadas — relacao
-- terapeutica existe.
--
-- Phone e normalizado (apenas digitos) pra matching consistente.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_optouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
phone TEXT NOT NULL CHECK (phone ~ '^\d{6,15}$'),
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
-- 'keyword' = detectado automaticamente por palavra-chave
-- 'manual' = adicionado manualmente pelo terapeuta/admin
source TEXT NOT NULL DEFAULT 'keyword'
CHECK (source IN ('keyword', 'manual')),
keyword_matched TEXT, -- palavra/frase que disparou (quando source='keyword')
original_message TEXT, -- texto completo da msg original (truncado)
notes TEXT, -- observacao do terapeuta (quando manual)
blocked_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- quem adicionou manual
opted_out_at TIMESTAMPTZ NOT NULL DEFAULT now(),
opted_back_in_at TIMESTAMPTZ, -- quando usuario restaurou (opt-in)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: um registro ativo por tenant+phone. Permite historico se fizer opt-in e depois opt-out de novo.
-- Active = opted_back_in_at IS NULL
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_optouts_active
ON public.conversation_optouts (tenant_id, phone)
WHERE opted_back_in_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_optouts_tenant_phone
ON public.conversation_optouts (tenant_id, phone);
CREATE INDEX IF NOT EXISTS idx_conv_optouts_patient
ON public.conversation_optouts (patient_id)
WHERE patient_id IS NOT NULL;
DROP TRIGGER IF EXISTS trg_conv_optouts_updated_at ON public.conversation_optouts;
CREATE TRIGGER trg_conv_optouts_updated_at
BEFORE UPDATE ON public.conversation_optouts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_optouts IS
'Numeros que pediram pra nao receber mensagens automaticas. LGPD Art. 18 Sec.2.';
-- ---------------------------------------------------------------------------
-- Keywords de opt-out — lista do tenant (reutilizavel)
-- Cada tenant pode customizar suas palavras-chave. Default aplicado via seed.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_optout_keywords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system (todos)
keyword TEXT NOT NULL CHECK (length(keyword) > 0 AND length(keyword) <= 100),
enabled BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_optout_kw_tenant
ON public.conversation_optout_keywords (tenant_id)
WHERE enabled = true;
-- Seed keywords padrao (system, tenant_id NULL, todos veem)
INSERT INTO public.conversation_optout_keywords (tenant_id, keyword, is_system, enabled) VALUES
(NULL, 'parar', true, true),
(NULL, 'sair', true, true),
(NULL, 'cancelar', true, true),
(NULL, 'stop', true, true),
(NULL, 'descadastrar', true, true),
(NULL, 'remover', true, true),
(NULL, 'nao quero mais', true, true),
(NULL, 'não quero mais', true, true),
(NULL, 'desinscrever', true, true),
(NULL, 'unsubscribe', true, true)
ON CONFLICT DO NOTHING;
COMMENT ON TABLE public.conversation_optout_keywords IS
'Palavras-chave que disparam opt-out quando paciente envia. Sistema (tenant_id NULL) + custom do tenant.';
-- ---------------------------------------------------------------------------
-- RLS: optouts
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optouts: select" ON public.conversation_optouts;
CREATE POLICY "optouts: select"
ON public.conversation_optouts
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_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: insert" ON public.conversation_optouts;
CREATE POLICY "optouts: insert"
ON public.conversation_optouts
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: update" ON public.conversation_optouts;
CREATE POLICY "optouts: update"
ON public.conversation_optouts
FOR UPDATE
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_optouts.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: keywords
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optout_kw: select" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: select"
ON public.conversation_optout_keywords
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optout_kw: insert custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: insert custom"
ON public.conversation_optout_keywords
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: update/delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: update/delete custom"
ON public.conversation_optout_keywords
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: delete custom"
ON public.conversation_optout_keywords
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,152 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Lembretes automáticos de sessão (CRM Grupo 2.4)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Envia WhatsApp automatico antes das sessoes agendadas (24h e 2h antes).
-- Respeita opt-out (LGPD), quiet hours, canal ativo do tenant.
--
-- Arquitetura:
-- - pg_cron agenda edge function `send-session-reminders` a cada 15 min
-- - edge function busca eventos na janela de lembretes, envia + registra log
-- - UNIQUE (event_id, reminder_type) no log impede envio duplicado
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
-- Lead times (quais lembretes enviar)
send_24h BOOLEAN NOT NULL DEFAULT true,
send_2h BOOLEAN NOT NULL DEFAULT true,
-- Templates com variaveis: {{nome_paciente}}, {{data_sessao}}, {{hora_sessao}}, {{nome_clinica}}, {{modalidade}}
template_24h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!'
CHECK (length(template_24h) > 0 AND length(template_24h) <= 2000),
template_2h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊'
CHECK (length(template_2h) > 0 AND length(template_2h) <= 2000),
-- Quiet hours (não envia lembretes nessa janela, mesmo se a sessão estiver na janela)
-- Format: 'HH:MM'. Se start > end, janela atravessa a meia-noite (ex: 22:00 → 08:00).
quiet_hours_enabled BOOLEAN NOT NULL DEFAULT true,
quiet_hours_start TIME NOT NULL DEFAULT '22:00',
quiet_hours_end TIME NOT NULL DEFAULT '08:00',
-- Respeita opt-out (LGPD)? default true, mas expomos pra caso haja tenant com regra especifica.
respect_opt_out BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_session_reminder_settings_updated_at ON public.session_reminder_settings;
CREATE TRIGGER trg_session_reminder_settings_updated_at
BEFORE UPDATE ON public.session_reminder_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.session_reminder_settings IS
'Configuracao por tenant dos lembretes automaticos de sessao via WhatsApp.';
-- ---------------------------------------------------------------------------
-- Log (anti-duplicata + auditoria)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_logs (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES public.agenda_eventos(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
reminder_type TEXT NOT NULL CHECK (reminder_type IN ('24h', '2h')),
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
provider TEXT, -- 'evolution', 'twilio', 'skipped'
skip_reason TEXT, -- quando provider='skipped': opted_out, quiet_hours, no_phone, etc
to_phone TEXT,
provider_message_id TEXT,
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_session_reminder_event_type
ON public.session_reminder_logs (event_id, reminder_type);
CREATE INDEX IF NOT EXISTS idx_session_reminder_tenant_sent
ON public.session_reminder_logs (tenant_id, sent_at DESC);
COMMENT ON TABLE public.session_reminder_logs IS
'Log de lembretes disparados. UNIQUE (event_id, reminder_type) previne duplicata.';
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.session_reminder_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_logs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "reminder_settings: tenant members all" ON public.session_reminder_settings;
CREATE POLICY "reminder_settings: tenant members all"
ON public.session_reminder_settings
FOR ALL
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 = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "reminder_logs: tenant members select" ON public.session_reminder_logs;
CREATE POLICY "reminder_logs: tenant members select"
ON public.session_reminder_logs
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 = session_reminder_logs.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- pg_cron: agenda send-session-reminders a cada 15 minutos
-- ---------------------------------------------------------------------------
-- Uses pg_net.http_post to hit the edge function. O secret do service_role
-- deve estar configurado em app.settings.service_role_key (ou use Vault).
--
-- Como alternativa mais simples: o user pode configurar um cron externo
-- (ex: Supabase Dashboard → Database → Cron) apontando pra edge function.
-- Deixo o schedule abaixo comentado; descomentar em produção quando
-- app.settings.service_role_key e app.settings.supabase_url estiverem setados.
-- ---------------------------------------------------------------------------
-- SELECT cron.schedule(
-- 'session-reminders-every-15min',
-- '*/15 * * * *',
-- $$
-- SELECT net.http_post(
-- url := current_setting('app.settings.supabase_url') || '/functions/v1/send-session-reminders',
-- headers := jsonb_build_object(
-- 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'),
-- 'Content-Type', 'application/json'
-- ),
-- body := '{}'::jsonb
-- );
-- $$
-- );
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,342 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Sistema de créditos WhatsApp (Marco B)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Modelo:
-- - whatsapp_credits_balance → saldo atual por tenant (snapshot)
-- - whatsapp_credits_transactions → extrato (purchase, usage, topup, adj)
-- - whatsapp_credit_packages → pacotes oferecidos (SaaS-managed)
-- - whatsapp_credit_purchases → ordens de compra via Asaas
--
-- Helpers (RPC):
-- - add_whatsapp_credits(tenant, amount, kind, ...) → novo saldo
-- - deduct_whatsapp_credits(tenant, amount, message_id) → boolean
--
-- Creditos so sao deduzidos quando o tenant usa provider='twilio'
-- (Evolution e free).
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Saldo por tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_balance (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
balance INT NOT NULL DEFAULT 0 CHECK (balance >= 0),
lifetime_purchased INT NOT NULL DEFAULT 0,
lifetime_used INT NOT NULL DEFAULT 0,
low_balance_threshold INT NOT NULL DEFAULT 20 CHECK (low_balance_threshold >= 0),
low_balance_alerted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credits_balance_updated_at ON public.whatsapp_credits_balance;
CREATE TRIGGER trg_wa_credits_balance_updated_at
BEFORE UPDATE ON public.whatsapp_credits_balance
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.whatsapp_credits_balance IS
'Saldo atual de creditos WhatsApp por tenant. 1 credito = 1 mensagem Twilio.';
-- ---------------------------------------------------------------------------
-- Extrato (transações)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_transactions (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('purchase', 'usage', 'topup_manual', 'refund', 'adjustment')),
amount INT NOT NULL, -- positivo = credito, negativo = debito
balance_after INT NOT NULL,
-- Referencias opcionais
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL,
purchase_id UUID,
admin_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_tenant_created
ON public.whatsapp_credits_transactions (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_kind
ON public.whatsapp_credits_transactions (tenant_id, kind, created_at DESC);
COMMENT ON TABLE public.whatsapp_credits_transactions IS
'Extrato de creditos WhatsApp. Append-only — nao editar/deletar.';
-- ---------------------------------------------------------------------------
-- Pacotes (global, gerenciado pelo SaaS admin)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 100),
description TEXT,
credits INT NOT NULL CHECK (credits > 0),
price_brl NUMERIC(10,2) NOT NULL CHECK (price_brl > 0),
is_active BOOLEAN NOT NULL DEFAULT true,
is_featured BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_packages_updated_at ON public.whatsapp_credit_packages;
CREATE TRIGGER trg_wa_credit_packages_updated_at
BEFORE UPDATE ON public.whatsapp_credit_packages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_packages_active
ON public.whatsapp_credit_packages (is_active, position, price_brl)
WHERE is_active = true;
COMMENT ON TABLE public.whatsapp_credit_packages IS
'Pacotes de creditos disponiveis pra compra. Gerenciado pelo SaaS admin.';
-- Seed: pacotes padrao
INSERT INTO public.whatsapp_credit_packages (name, description, credits, price_brl, is_featured, position) VALUES
('Iniciante', 'Ideal pra conhecer a plataforma', 100, 49.90, false, 10),
('Profissional', 'Mais vendido pra clínicas pequenas', 500, 199.90, true, 20),
('Clínica', 'Pra clínicas com alto volume', 1500, 499.90, false, 30),
('Enterprise', 'Pacote grande com desconto', 5000, 1499.90, false, 40)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Ordens de compra (Asaas integration)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
package_id UUID REFERENCES public.whatsapp_credit_packages(id) ON DELETE SET NULL,
-- Snapshot do pacote no momento da compra (caso mude de preço/creditos depois)
package_name TEXT NOT NULL,
credits INT NOT NULL CHECK (credits > 0),
amount_brl NUMERIC(10,2) NOT NULL CHECK (amount_brl > 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'failed', 'expired', 'refunded', 'cancelled')),
-- Asaas integration
asaas_customer_id TEXT,
asaas_payment_id TEXT,
asaas_payment_link TEXT,
asaas_pix_qrcode TEXT, -- base64 da imagem
asaas_pix_copy_paste TEXT, -- codigo PIX copia-cola
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_purchases_updated_at ON public.whatsapp_credit_purchases;
CREATE TRIGGER trg_wa_credit_purchases_updated_at
BEFORE UPDATE ON public.whatsapp_credit_purchases
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_tenant
ON public.whatsapp_credit_purchases (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_status
ON public.whatsapp_credit_purchases (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_asaas_payment
ON public.whatsapp_credit_purchases (asaas_payment_id)
WHERE asaas_payment_id IS NOT NULL;
-- FK pra transactions.purchase_id (circular, então define depois)
ALTER TABLE public.whatsapp_credits_transactions
DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_purchase_id_fkey;
ALTER TABLE public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_purchase_id_fkey
FOREIGN KEY (purchase_id) REFERENCES public.whatsapp_credit_purchases(id) ON DELETE SET NULL;
COMMENT ON TABLE public.whatsapp_credit_purchases IS
'Ordens de compra de creditos via Asaas. Webhook atualiza status.';
-- ---------------------------------------------------------------------------
-- RPC: add_whatsapp_credits (SECURITY DEFINER — atualiza saldo + registra tx)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.add_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_kind TEXT,
p_purchase_id UUID DEFAULT NULL,
p_admin_id UUID DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
IF p_kind NOT IN ('purchase', 'topup_manual', 'refund', 'adjustment') THEN
RAISE EXCEPTION 'invalid kind for credit: %', p_kind;
END IF;
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance, lifetime_purchased)
VALUES (p_tenant_id, p_amount, CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
lifetime_purchased = whatsapp_credits_balance.lifetime_purchased
+ CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END,
low_balance_alerted_at = NULL -- reset alerta quando recebe creditos
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, purchase_id, admin_id, note)
VALUES
(p_tenant_id, p_kind, p_amount, v_new_balance, p_purchase_id, p_admin_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RPC: deduct_whatsapp_credits (atomico, falha se saldo insuficiente)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.deduct_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_conversation_message_id BIGINT DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
v_row RECORD;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
-- Lock a linha e valida saldo
SELECT balance, low_balance_threshold
INTO v_row
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
IF v_row.balance < p_amount THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance - p_amount,
lifetime_used = lifetime_used + p_amount
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, conversation_message_id, note)
VALUES
(p_tenant_id, 'usage', -p_amount, v_new_balance, p_conversation_message_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
-- Balance: members do tenant leem, saas_admin tudo
DROP POLICY IF EXISTS "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: select tenant"
ON public.whatsapp_credits_balance 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 = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Settings update: members do tenant podem alterar low_balance_threshold
DROP POLICY IF EXISTS "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: update tenant"
ON public.whatsapp_credits_balance FOR UPDATE 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 = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Transactions: read-only pra tenant members, write via RPC
DROP POLICY IF EXISTS "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions;
CREATE POLICY "wa_credits_tx: select tenant"
ON public.whatsapp_credits_transactions 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 = whatsapp_credits_transactions.tenant_id AND tm.status = 'active'
)
);
-- Packages: todos leem os ativos; saas_admin gerencia
DROP POLICY IF EXISTS "wa_packages: select active" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: select active"
ON public.whatsapp_credit_packages FOR SELECT TO authenticated
USING (is_active = true OR public.is_saas_admin());
DROP POLICY IF EXISTS "wa_packages: manage saas admin" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: manage saas admin"
ON public.whatsapp_credit_packages FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- Purchases: members do tenant leem as proprias
DROP POLICY IF EXISTS "wa_purchases: select tenant" ON public.whatsapp_credit_purchases;
CREATE POLICY "wa_purchases: select tenant"
ON public.whatsapp_credit_purchases 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 = whatsapp_credit_purchases.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================

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