Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce3612135 | |||
| eb9dcc714f | |||
| 6383a550a6 | |||
| c3dac5eeec | |||
| 9acce9c19d | |||
| 91b89b7b5d | |||
| 1082123967 | |||
| 2f72886d4b | |||
| 403b7234a9 | |||
| 52c34cf63a | |||
| f6470718b7 | |||
| 3730b71150 | |||
| d50073da1a | |||
| 03790ecb9e | |||
| cb153165c3 | |||
| c189906c58 | |||
| 5a87c29dd0 | |||
| a2f3b9fae4 | |||
| 1594dc9426 | |||
| 31c4f08451 | |||
| 12d5c3b6dc | |||
| a979bdf1de | |||
| a73b82fa86 | |||
| 98fe183bac | |||
| 8b620f9b04 | |||
| 96f4543138 | |||
| dc2363b4e1 | |||
| 4493e78349 | |||
| cdb9ce10ee | |||
| af2395c723 | |||
| dc7826d0b5 | |||
| 218d342181 | |||
| ee82985dc3 | |||
| 423aa5ac2a | |||
| 1243a12ced | |||
| f079192698 | |||
| 02acc88da5 | |||
| d3620f99ea | |||
| d240c6678f | |||
| 120b1e44d8 | |||
| b6bd6fdd89 | |||
| bedbb9bafc | |||
| 77ef06fde7 | |||
| 5741e10e28 | |||
| d58b939e1c | |||
| c3220f159c | |||
| 003f2eb2a6 | |||
| a85716b0ea | |||
| 6b542cd03a | |||
| 07437c9ff4 | |||
| f17e9ee786 | |||
| 9b21642e15 | |||
| ba8348d4a6 | |||
| a7f6bcbe66 | |||
| 05c6746e33 | |||
| b0b636c660 | |||
| 701d9f4fcc | |||
| 34412c6883 | |||
| 3a42b0696d | |||
| 7dd8cde8b4 | |||
| ec56f9429b | |||
| 89bf181742 | |||
| 342defecde | |||
| fff70e4a71 | |||
| 550c4ade44 | |||
| 473e0f026e | |||
| 9f3a047d6d | |||
| 8bf992910d | |||
| fa2b431a56 | |||
| eb42759979 | |||
| c17c547ed2 | |||
| 4f05c2cf1b | |||
| 512bcc979c | |||
| 61bb0d9c26 | |||
| 6c39c58dc8 | |||
| 4e1ebeba13 | |||
| 51c33e73b9 | |||
| 682840f355 | |||
| c6105df98a | |||
| 402def7539 | |||
| 5dc91614ad | |||
| 597f8c05d5 | |||
| 79425a3c9a | |||
| 87a1ac1358 | |||
| 6860628087 | |||
| 134f562a1f | |||
| bbbb08ba9d | |||
| 17f114f32f | |||
| c9afe8f009 | |||
| c7e311b851 | |||
| 0aabea7753 | |||
| 80cce772db | |||
| f1c24242e0 | |||
| b821db6438 | |||
| 0fafc28581 | |||
| 75e67eae5d | |||
| 9a6eb56827 | |||
| 652571da69 | |||
| 30367392ff | |||
| b40116fe5d | |||
| ffd8eab72d | |||
| dee89ccd84 | |||
| 6a8ee52ad8 | |||
| 7516468f78 | |||
| 20d2b3aee4 | |||
| ae1e1388b9 | |||
| 4024469952 | |||
| 661790d577 | |||
| 6807b447cb | |||
| 034c2c0f3d | |||
| 87833d4ec6 | |||
| 049dd91b9b | |||
| e7e3d1beb1 | |||
| aa587e849c | |||
| ee117eafe6 | |||
| b7f3c23ad6 | |||
| 9c518a2b44 | |||
| d7cd2541e4 | |||
| b1e8e010c0 | |||
| 2dae4a11ae | |||
| e7a9bdab5f | |||
| 36402cd0bf | |||
| 6ae651a8ae | |||
| 114d755f84 | |||
| 19caa42f3b | |||
| 4e42881d5e | |||
| 934c620295 | |||
| 8601ac0d70 | |||
| 3ce22dd236 | |||
| cd67f7e9f5 | |||
| de3898878a | |||
| ee2967a075 | |||
| 0956e4facc | |||
| fbfb95648e | |||
| 388e9a4186 | |||
| 1c2a2b6e19 | |||
| 27467bbb68 | |||
| f94a4ae97f | |||
| 5b345c5598 | |||
| 4da0bc2e11 | |||
| f83315baba | |||
| 7d2a405d05 | |||
| b5e00a7022 | |||
| 272c804335 | |||
| 00c4168393 | |||
| 9ead3fdc42 | |||
| 5965b05378 | |||
| 45984e885b | |||
| 3f3f2acc70 | |||
| 5684297243 | |||
| 16dfa02bd1 | |||
| 079509e001 | |||
| 7dc7dcede0 | |||
| 1e74a115de | |||
| 753182cfad | |||
| 3caf5792f8 | |||
| d6423da9b4 | |||
| ec0a24f5c8 | |||
| fad1f4ebd4 | |||
| 1feb7112ff | |||
| c23d0a574f | |||
| e95ed9b585 | |||
| 41c44272a3 | |||
| dba595fd2d | |||
| af8aee9188 | |||
| 39cf0178e6 | |||
| 279b4f78e8 | |||
| 988a4e5892 | |||
| 8f4e6679eb | |||
| 8e3c09d1b1 | |||
| 8b0e633aac | |||
| 646cec5833 | |||
| 6ad91e7853 | |||
| cf1cd67314 | |||
| 73788c7031 | |||
| 30d09eb2ac | |||
| 88dff50223 | |||
| b040e15c9b | |||
| 42a39ed3ea | |||
| 9e76e4e6ea | |||
| f1d6fbad73 | |||
| a8ab13b201 | |||
| 21c71f75d6 | |||
| 64005a5b07 | |||
| 301a7124a7 | |||
| 5d2c389486 | |||
| 159b80db6c | |||
| 71ee51d38f | |||
| 167e864b8a | |||
| e7c0f6c4f5 | |||
| 8a8d2e05bd | |||
| 1278e93b01 | |||
| 4fc0e3a02b | |||
| ab7526b8d7 | |||
| df61cc4d99 | |||
| f3f0d831d2 | |||
| 558922d1a5 | |||
| 9966b5f175 | |||
| cc7841bd1f | |||
| 250e946084 | |||
| ef3e160b36 | |||
| 95b2535d3d | |||
| 63340d1226 | |||
| 27b5bbed6f | |||
| d1dced242f | |||
| 989c5330f8 | |||
| 7d2307dcf0 | |||
| 6cc094d252 | |||
| 11201e1e5d | |||
| d49248979a | |||
| 48a9700aea | |||
| cf1fa7e361 | |||
| 85ebbf334d | |||
| 25444c1f5f | |||
| 33370018b5 | |||
| 3549a977cc | |||
| df7ab9c5a8 | |||
| a89745f668 | |||
| c605a4f1a2 | |||
| 2ca9cde2ea | |||
| 7c0c1b3528 | |||
| 5db6000c2c | |||
| 5a2d24dd99 | |||
| 0c88cc2e72 | |||
| 6395c4c0b6 | |||
| 56d30b4285 | |||
| dc57caf534 | |||
| 9e4421b7ff | |||
| dad1fd72c2 | |||
| f2fd2e4722 | |||
| abd4f8f34c | |||
| 44135a961f | |||
| ac8308f45b | |||
| 1b5214c90b | |||
| a0948919ef | |||
| e912558769 | |||
| 66441c1744 | |||
| 9c6d77ec56 | |||
| 0dd070c6a5 | |||
| 7572cb3295 | |||
| 72f989f23c | |||
| d8968d9aec | |||
| 684f673cc1 | |||
| e344661d4d | |||
| 6495cefb7e | |||
| 0a24fd6233 | |||
| ef4c4d0fac | |||
| d8ce33f74f | |||
| 6d693a0a3b | |||
| bad828cab3 | |||
| 02af119dc6 | |||
| 48bf2726a5 | |||
| 532204708e | |||
| 387043b3b2 | |||
| f9145442ae | |||
| ee084c2918 | |||
| 97b0ec1ec5 | |||
| 15103eded5 | |||
| 98f7252dcd | |||
| 269b531158 | |||
| 6d9b36d592 | |||
| 957e912a7f | |||
| 86311ef305 | |||
| 269c380d9c | |||
| b331d68572 | |||
| 76b58af9a1 | |||
| 68d601e0f4 | |||
| 629e7ce18e | |||
| 06bce11e1c | |||
| 7b67bd083a | |||
| dac3198873 | |||
| a57cf27a6a | |||
| ffcb8b17f9 | |||
| ff3695fbb1 | |||
| 6a92735366 | |||
| f2b15ce0f7 | |||
| 1bcb969f72 | |||
| ab103ec88b |
+15
@@ -27,6 +27,9 @@ evolution-api/
|
|||||||
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
|
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
|
||||||
database-novo/backups/
|
database-novo/backups/
|
||||||
|
|
||||||
|
# Rascunhos de design locais (Melissa Direção A, etc)
|
||||||
|
layout-scratchs/
|
||||||
|
|
||||||
# Outputs do Playwright
|
# Outputs do Playwright
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
@@ -38,3 +41,15 @@ playwright-report/
|
|||||||
informacoes Gerais.txt
|
informacoes Gerais.txt
|
||||||
pasteds.txt
|
pasteds.txt
|
||||||
commit.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
|
||||||
|
|||||||
@@ -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
|
||||||
+380
-331
@@ -1,399 +1,448 @@
|
|||||||
# HANDOFF — 2026-04-23 (fim do dia)
|
# HANDOFF — 2026-05-20 (C10 ✅ + C11 ✅ + C12 ⏳ deferido · testando C13)
|
||||||
|
|
||||||
Documento de continuidade. **Quando voltar, comece lendo esta página.**
|
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
|
||||||
Todo o estado vive no banco (`/saas/desenvolvimento` → Auditoria/Verificações/Testes).
|
|
||||||
|
> **🎯 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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Estado atual
|
## 🔴 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
|
||||||
| **🔴 Críticos** | **0** ✅ |
|
seguem `financial_exceptions` (regras configuradas pelo terapeuta sobre o
|
||||||
| **🟠 Altos** | **0** ✅ |
|
que acontece com a cobrança nesses casos).
|
||||||
| Vitest | 208/208 |
|
|
||||||
| SQL integration | 33/33 |
|
|
||||||
| E2E (Playwright) | 5/5 |
|
|
||||||
| Migrations totais | **47** (36 → 47) |
|
|
||||||
| Edge functions | **25** (20 → 25) |
|
|
||||||
| Cron jobs ativos | 2 (heartbeat 2min + SLA 5min) |
|
|
||||||
| Commits hoje | **18** (de `f76a2e3` a `f1c97ee`) |
|
|
||||||
|
|
||||||
---
|
Possíveis pacientes pra teste: usar Joyce, Sándor ou outro com cobrança
|
||||||
|
avulsa pendente já criada.
|
||||||
|
|
||||||
## 🎯 O que rolou hoje (2026-04-23)
|
**Esperado** (depende das `financial_exceptions` configuradas no tenant):
|
||||||
|
- Realizada: status muda; cobrança permanece (caminho default)
|
||||||
### ✅ Admin SaaS: ajuste manual de créditos WhatsApp (f76a2e3)
|
- Faltou: pode ter regra → cobrança 100% (paciente paga falta) ou cancela
|
||||||
|
- Cancelado: pode ter regra → cancelar cobrança ou cobrar parcial
|
||||||
- RPC `admin_adjust_whatsapp_credits` (+/-) com `|amount| ≤ 1000` por operação
|
|
||||||
- Remoção só afeta pool cortesia (topup/adjustment/refund) — compras `purchase` são intocáveis (FIFO cortesia primeiro)
|
|
||||||
- Helper RPC `get_whatsapp_removable_balance` pra UI mostrar breakdown
|
|
||||||
- UI em `/saas/addons`: SelectButton Adicionar/Remover, ConfirmDialog com impactados ao desativar pacote
|
|
||||||
|
|
||||||
### ✅ Heartbeat + Reconnect (6.1 + 6.3) — e1f756e / 4e4bac6
|
|
||||||
|
|
||||||
- Tabela `whatsapp_connection_incidents` com UNIQUE parcial (1 aberto por channel)
|
|
||||||
- RPCs `whatsapp_heartbeat_open_incident/resolve/mark_notified`
|
|
||||||
- Edge `whatsapp-heartbeat-check` com threshold configurável (padrão 5min)
|
|
||||||
- **Reconnect automático** (6.3): antes de abrir incident tenta `POST /instance/restart`, espera 3s, re-checa state. Se voltou → resolve. Cooldown 10min/channel.
|
|
||||||
- UI em `/configuracoes/whatsapp-pessoal` ganhou card "Monitoramento de conexão" (toggles alerts/reconnect + threshold + histórico 7d)
|
|
||||||
- Painel SaaS `/saas/whatsapp` mostra badge de incidents + "Verificar tudo agora"
|
|
||||||
- Cron `*/2 * * * *` ativo (job 5)
|
|
||||||
|
|
||||||
### ✅ SLA de conversas (3.4) — 771b636
|
|
||||||
|
|
||||||
- `conversation_sla_rules` (config 1/tenant, threshold 1-1440 min, horário comercial opcional, escopo assigned_only|all)
|
|
||||||
- `conversation_sla_breaches` com UNIQUE parcial 1 aberto/thread
|
|
||||||
- Trigger `trg_sla_resolve_on_outbound` resolve breach automaticamente quando chega outbound
|
|
||||||
- Edge `conversation-sla-check` calcula `businessMinutesElapsed` em TS
|
|
||||||
- UI `/configuracoes/conversas-sla` (config + histórico 7d)
|
|
||||||
- Cron `*/5 * * * *` ativo (job 6)
|
|
||||||
|
|
||||||
### ✅ Saldo baixo WhatsApp — e409ba6
|
|
||||||
|
|
||||||
- Trigger `fn_whatsapp_low_balance_notify` BEFORE UPDATE em `whatsapp_credits_balance`
|
|
||||||
- Dispara quando saldo cruza threshold + anti-spam via `low_balance_alerted_at`
|
|
||||||
- Reset automático quando `add_whatsapp_credits` recredita
|
|
||||||
|
|
||||||
### ✅ Pipeline de alertas robusto — 881fa16 / 5c50db6 / 6db06ab / 4441661 / 36fbc02 / 5f51bc0 / f646efe / 4026415 / 64e7634
|
|
||||||
|
|
||||||
Múltiplos fixes e melhorias do pipeline `system_alert`:
|
|
||||||
- Toast vermelho **sticky** com botão de ação (deeplink ou "Abrir conversa")
|
|
||||||
- Polling a cada 60s + catch-up no `visibilitychange` como fallback pro Realtime
|
|
||||||
- Agrega múltiplas pendentes no catch-up (mostra só mais recente + `+N outros no sino`)
|
|
||||||
- Não redispara toast pra `system_alert` já existentes no mount (F5 limpo)
|
|
||||||
- Sistema de aliases `/conversas` → `/therapist/conversas` ou `/admin/conversas` por role
|
|
||||||
- Browser notification do Chrome/Windows agora leva pro drawer da conversa ao clicar
|
|
||||||
- NotificationItem no sino ganhou botões inline "💬 Conversa" / "Abrir →" + fix NotFound
|
|
||||||
- Notifica **owner_id do channel + admins** (deduplicado)
|
|
||||||
|
|
||||||
### ✅ Analytics 7.1 — adf9208
|
|
||||||
|
|
||||||
- Helper interno `_first_response_runs` identifica "runs" de inbound (sequências do paciente) + delta até próxima outbound
|
|
||||||
- RPCs: `first_response_stats`, `first_response_by_therapist`, `first_response_evolution`
|
|
||||||
- Card `FirstResponseCard.vue` no Clinic e Therapist Dashboards com 3 KPIs + sparkline + ranking
|
|
||||||
|
|
||||||
### ✅ Fluxo de reativação de canal — 881fa16
|
|
||||||
|
|
||||||
- Edge `reactivate-notification-channel` (espelho da deactivate)
|
|
||||||
- SaasWhatsappPage detecta canal soft-deleted e reativa ao salvar
|
|
||||||
- ConfiguracoesWhatsappPage (tenant) mostra card "Reativar WhatsApp Pessoal"
|
|
||||||
- ChooserPage intercepta clique e reativa antes de ir pro setup
|
|
||||||
- Migration RLS `notification_channels` permite ler soft-deleted (donos/saas_admin/membros)
|
|
||||||
|
|
||||||
### ✅ Bot auto-triagem (3.7) — c2c42a1
|
|
||||||
|
|
||||||
- Tabelas `conversation_bots` + `conversation_bot_sessions`
|
|
||||||
- Helper `maybeProcessBot` em `_shared/whatsapp-hooks.ts`
|
|
||||||
- Integrado em `evolution-whatsapp-inbound` E `twilio-whatsapp-inbound`
|
|
||||||
- Página `/configuracoes/conversas-bots` com editor de steps, trigger, keywords, opt-out
|
|
||||||
- Ao terminar: closing + `conversation_notes` com resumo das respostas
|
|
||||||
|
|
||||||
### ✅ Grupo 8 completo — b8ea292
|
|
||||||
|
|
||||||
- **8.2** Botão "Lembrar paciente" no `AgendaEventDialog` — edge `send-session-reminder-manual`
|
|
||||||
- **8.3** Trigger DB em `agenda_eventos` dispara edge `send-session-status-notification` (cancelado/remarcado/confirmado)
|
|
||||||
- **8.4** Intake abandonado: coluna `last_progress_at`, edges `save-intake-progress` + `convert-abandoned-intakes`, RPC `convert_abandoned_intake_to_lead`, autosave no form público
|
|
||||||
|
|
||||||
### ✅ Dashboard SaaS receita créditos — f1c97ee
|
|
||||||
|
|
||||||
- 4 RPCs (`saas_wa_credits_revenue_stats/top_packages/usage_summary/revenue_evolution`) — saas_admin only
|
|
||||||
- Card `SaasCreditsRevenueCard.vue` com 4 KPIs (receita, compras, créditos, consumo) + sparkline + top pacotes
|
|
||||||
- Integrado em `/saas` (SaasDashboard)
|
|
||||||
|
|
||||||
### ✅ Fix lateral — 0f64381
|
|
||||||
|
|
||||||
`send-session-reminders` comparava `provider='evolution'` mas DB tem `'evolution_api'` — caía em `unknown_provider`. Corrigido e validado end-to-end (lembrete chegou pro paciente André Green no celular +55 16 98828 0038).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 ROTEIRO DE TESTES PRA AMANHÃ
|
|
||||||
|
|
||||||
Ordem sugerida (3h estimado com tudo). Cada seção é independente — pode testar em qualquer ordem depois de **0**.
|
|
||||||
|
|
||||||
### 0. Pré-requisitos (5 min)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Reiniciar Supabase functions serve pra carregar 5 edges novas/alteradas
|
|
||||||
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirma no output que aparecem:
|
|
||||||
- `reactivate-notification-channel`
|
|
||||||
- `whatsapp-heartbeat-check`
|
|
||||||
- `conversation-sla-check`
|
|
||||||
- `send-session-reminder-manual`
|
|
||||||
- `send-session-status-notification`
|
|
||||||
- `save-intake-progress`
|
|
||||||
- `convert-abandoned-intakes`
|
|
||||||
|
|
||||||
Também confirme crons:
|
|
||||||
```bash
|
|
||||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "SELECT jobid, schedule, jobname, active FROM cron.job WHERE active=true;"
|
|
||||||
```
|
|
||||||
|
|
||||||
Esperado: 2 jobs ativos (5 e 6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. Admin adjust créditos (~10 min)
|
|
||||||
|
|
||||||
Login como **saas_admin** → `/saas/addons` → aba **Topup WhatsApp**.
|
|
||||||
|
|
||||||
1. Selecionar tenant Bruno Terapeuta
|
|
||||||
2. Card "Breakdown do saldo" mostra removível/protegido
|
|
||||||
3. Tentar **adicionar 1500** → deve recusar (máx 1000)
|
|
||||||
4. Adicionar **500** → ok
|
|
||||||
5. Mudar pra **Remover** → deve respeitar limite removível
|
|
||||||
6. Testar confirmação antes de remover
|
|
||||||
|
|
||||||
Aba **Pacotes WhatsApp**: desativar um pacote → confirmação com N compras / M tenants distintos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Heartbeat + Reconnect (~10 min)
|
|
||||||
|
|
||||||
**2.1. Baseline:** `/saas/whatsapp` → "Verificar tudo agora" → 1 canal, status ok.
|
|
||||||
|
|
||||||
**2.2. Simular queda com Evolution respondendo:**
|
|
||||||
```bash
|
|
||||||
# Faz logout da instância no Evolution (Evolution API continua de pé)
|
|
||||||
curl -X POST http://localhost:8080/instance/logout/agenciapsi-teste -H "apikey: <APIKEY>"
|
|
||||||
```
|
|
||||||
Clica "Verificar tudo agora" 2 vezes. Na segunda, o heartbeat tenta **restart automático** e conecta de novo (precisa escanear QR, mas connection_status volta). Summary deve ter `auto_reconnected: 1`.
|
|
||||||
|
|
||||||
**2.3. Simular queda total (Evolution offline):**
|
|
||||||
```bash
|
|
||||||
docker stop evolution-api # ou o nome do container
|
|
||||||
```
|
|
||||||
Baixa threshold pra 1 min em `/configuracoes/whatsapp-pessoal`. Clica "Verificar tudo agora", espera 1 min, clica de novo. Agora abre incident + toast vermelho + notificação.
|
|
||||||
|
|
||||||
Resubir o container + clica verificar → breach fica "Resolvido".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. SLA de conversas (~15 min)
|
|
||||||
|
|
||||||
`/configuracoes/conversas-sla` → ativa + threshold 1 min + escopo "Todas as conversas" + notify admin ON.
|
|
||||||
|
|
||||||
Apague breaches antigos e dispare manual:
|
|
||||||
```bash
|
|
||||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "DELETE FROM conversation_sla_breaches WHERE resolved_at IS NULL;"
|
|
||||||
SERVICE_KEY=$(supabase status -o env 2>/dev/null | grep SERVICE_ROLE | cut -d'"' -f2)
|
|
||||||
curl -s -X POST http://localhost:54321/functions/v1/conversation-sla-check -H "Authorization: Bearer $SERVICE_KEY" -d '{}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Esperado: abre breaches pras threads inbound sem resposta. Notifica.
|
|
||||||
|
|
||||||
Responda uma das threads no CRM → trigger fecha o breach automaticamente (vê card "Resolvido" em `/configuracoes/conversas-sla`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Bot de triagem (~15 min)
|
|
||||||
|
|
||||||
`/configuracoes/conversas-bots` → ligar + salvar com 4 perguntas default.
|
|
||||||
|
|
||||||
De **outro celular que não seja paciente cadastrado**, manda "Oi" pro WhatsApp conectado.
|
|
||||||
|
|
||||||
Esperado:
|
|
||||||
- Bot responde saudação + primeira pergunta (nome)
|
|
||||||
- Cada resposta avança 1 pergunta
|
|
||||||
- Ao final: closing + nota interna em `conversation_notes` com resumo
|
|
||||||
|
|
||||||
Conferir:
|
Conferir:
|
||||||
```bash
|
- `STATUS_TO_EXCEPTION` mapping em `useAgendaFinanceiro.js`
|
||||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "
|
- `getFinancialExceptionRule(tenantId, exceptionType)` retorna a regra
|
||||||
SELECT current_step, status, collected_data FROM conversation_bot_sessions ORDER BY started_at DESC LIMIT 3;"
|
- `handleStatusChange` orquestra: agenda update + financial adjust
|
||||||
```
|
|
||||||
|
|
||||||
Abra o CRM na thread desse número → no drawer, na aba notas, deve ter o resumo.
|
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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Botão "Lembrar paciente" na agenda (8.2) (~5 min)
|
## 📦 O que foi feito em 20/05 madrugada (C9 + rowGroup financeiro + bubble cobranca-atualizada)
|
||||||
|
|
||||||
Agenda → abrir evento existente (Edit) com paciente que tenha telefone. Footer tem botão verde de WhatsApp.
|
### 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.
|
||||||
|
|
||||||
Clicar → confirmação → toast sucesso → mensagem chega no celular do paciente.
|
### `/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)
|
||||||
|
|
||||||
Teste erros: paciente sem telefone → mensagem clara.
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. Status sessão dispara mensagem (8.3) (~10 min)
|
## 📦 O que foi feito em 19/05 madrugada (C8 + Usar/Revogar saldo + UI de pacote)
|
||||||
|
|
||||||
**Antes:** criar templates custom (ou deixar sem — skip silencioso) pra `cancelamento_sessao` / `remarcacao_sessao` / `confirmacao_sessao` em `/configuracoes/whatsapp` → aba Templates.
|
### 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.
|
||||||
|
|
||||||
No dialog de evento existente, mudar status pra **"Cancelado"** → Salvar.
|
### 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.
|
||||||
|
|
||||||
Trigger DB chama edge, edge resolve template + envia. Conferir:
|
### Handlers Usar/Revogar atômicos
|
||||||
```bash
|
**`onUsarSessao`** em MelissaLayout (aceita payload do popover OU do dialog):
|
||||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "
|
1. Materializa virtual se necessário (preserva `determined_commitment_id` da regra)
|
||||||
SELECT direction, provider, provider_raw, substring(body from 1 for 80) FROM conversation_messages
|
2. Status='realizado' + link `billing_contract_id`
|
||||||
WHERE provider_raw->>'status_change' = 'true' ORDER BY created_at DESC LIMIT 3;"
|
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
|
||||||
|
|
||||||
Se não tem template configurado, a edge retorna `skipped: template_not_found` (silencioso).
|
**`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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7. Intake abandonado → lead (8.4) (~15 min)
|
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
|
||||||
|
|
||||||
1. Gere um link de convite em `/admin/agendamentos-recebidos` ou similar
|
### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira)
|
||||||
2. Abra o link anônimo (incognito) → form público
|
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.
|
||||||
3. Preencha **só nome + telefone** (espera 1.5s pra autosave rodar)
|
|
||||||
4. Feche a aba (não submete)
|
|
||||||
|
|
||||||
Conferir que o intake ficou `in_progress`:
|
### Fase 6 (lock-edit cobrada) ativada em Melissa
|
||||||
```bash
|
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.
|
||||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "
|
|
||||||
SELECT status, nome_completo, telefone, last_progress_at FROM patient_intake_requests ORDER BY updated_at DESC LIMIT 3;"
|
|
||||||
```
|
|
||||||
|
|
||||||
Forçar conversão (sem esperar 30 min):
|
Agora unificado: `occFinancialRecord` carrega em ambos modos:
|
||||||
```bash
|
- Card Sessão / Honorários ganha **Tag** (em vez de Select billingType) quando há cobrança
|
||||||
SERVICE_KEY=$(supabase status -o env 2>/dev/null | grep SERVICE_ROLE | cut -d'"' -f2)
|
- Body do card mostra **Message "Cobrança de R$ X já emitida"** + cadeado
|
||||||
curl -s -X POST http://localhost:54321/functions/v1/convert-abandoned-intakes \
|
- Tipo de cobrança (Particular/Convênio/Gratuito) bloqueado
|
||||||
-H "Authorization: Bearer $SERVICE_KEY" -d '{"idle_minutes": 0}'
|
- Edição de serviços/preço bloqueada
|
||||||
```
|
|
||||||
|
|
||||||
Esperado: `converted: 1`. Abre o CRM → thread nova com o telefone + nota interna com dados coletados.
|
### 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.
|
||||||
|
|
||||||
**Ativar cron** (opcional, a cada 15 min):
|
Fix em `useMelissaAgenda.js _reloadRange`:
|
||||||
```sql
|
- Maps (paymentStateMap, amountMap, rulePaymentMap) inicializados SEMPRE no início
|
||||||
SELECT cron.schedule('convert-abandoned-intakes-every-15min', '*/15 * * * *', $$
|
- Propagação agora roda **independente de realIds.length** (ie, mesmo em semanas só-com-virtuais)
|
||||||
SELECT net.http_post(
|
- Coleta `ruleIdsInView` de TODOS eventos da view (reais + virtuais com recurrence_id)
|
||||||
url := current_setting('app.settings.supabase_url') || '/functions/v1/convert-abandoned-intakes',
|
- Cross-week query: pra cada rule em view, busca QUALQUER evento sibling (inclusive em outras semanas) + seus records paid/pending → determina estado do contrato
|
||||||
headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'), 'Content-Type', 'application/json'),
|
- Propaga estado pra eventos reais (via map) + virtuais (via rulePaymentMap acessado pelo normalize)
|
||||||
body := '{}'::jsonb
|
|
||||||
);
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 8. Saldo baixo WhatsApp (~5 min)
|
## 📦 O que foi feito em 18/05
|
||||||
|
|
||||||
Login como terapeuta → `/configuracoes/creditos-whatsapp` → threshold em 20.
|
### 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()`.
|
||||||
|
|
||||||
```bash
|
### Novo indicador: barra esquerda verde para sessão paga
|
||||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres -c "
|
- Brainstorm de 6 opções; user escolheu #6 (3 canais visuais distintos por estado).
|
||||||
UPDATE whatsapp_credits_balance SET balance=100, low_balance_alerted_at=NULL WHERE tenant_id='bbbbbbbb-0002-0002-0002-000000000002';
|
- `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).
|
||||||
UPDATE whatsapp_credits_balance SET balance=10 WHERE tenant_id='bbbbbbbb-0002-0002-0002-000000000002';"
|
- `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`.
|
||||||
|
|
||||||
Toast vermelho "Saldo baixo" aparece + botão "Ir pra loja →".
|
### 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)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 9. Analytics 1ª resposta (~3 min)
|
## 📦 O que foi feito antes (16/05 noite/madrugada)
|
||||||
|
|
||||||
`/admin` (ClinicDashboard) → card "Tempo de 1ª resposta" com sparkline + ranking terapeutas.
|
### Cenário 1 (Bloqueio) ✅
|
||||||
|
|
||||||
`/therapist` (TherapistDashboard) → card filtrado pelo user logado.
|
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 —").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 10. Dashboard SaaS receita (~3 min)
|
## 🧭 Onde estamos no plano de 9 fases
|
||||||
|
|
||||||
Login como saas_admin → `/saas` → seção nova "Receita de créditos WhatsApp":
|
| Fase | Status |
|
||||||
- KPIs receita/compras/créditos/consumo
|
|---|---|
|
||||||
- Sparkline de evolução
|
| **1** Compromisso SEM paciente | ✅ |
|
||||||
- Ranking top 5 pacotes
|
| **2** Compromisso COM paciente | ✅ testado (C1-C3 done) |
|
||||||
|
| **3** Recorrência + replicar occurrenceMode Rail/Clínica | ⏳ |
|
||||||
Mudar período (30d/90d/6m/12m) → recarrega.
|
| **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 | 📋 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Próxima sessão (se tudo der ok no teste)
|
## 📋 Roteiro de testes restantes (`src/docs/agenda-compromisso-financeiro-cenarios.html`)
|
||||||
|
|
||||||
### Items do backlog original que ficaram
|
| # | Cenário | Status |
|
||||||
|
|---|---|---|
|
||||||
- **5.4 Export LGPD de conversas** — incluir conversas no export de paciente (que já existe)
|
| 1 | Bloqueio (criar + agendar sobre) | ✅ |
|
||||||
- **Tour guiado / onboarding wizard** — refino UX
|
| 2 | Avulsa sem cobrança | ✅ |
|
||||||
- **Rotação de credenciais Twilio** — se subconta vazar, precisa de flow pra regenerar
|
| 3 | Avulsa cobrar ao salvar | ✅ |
|
||||||
- **Retention 5.1** — apagar/anonimizar conversas > X dias **(você pulou — voltar quando for beta fechado)**
|
| 4 | Avulsa "já recebi" no salvar | ✅ |
|
||||||
|
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
|
||||||
### Items novos nascidos desta sessão
|
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
|
||||||
|
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
|
||||||
- **Suporte Twilio no status→msg e lembrete manual** — hoje só Evolution (Twilio retorna `provider_not_supported_yet`)
|
| 8 | Pacote saldo (Otávio 12 × R$ 50) | ✅ |
|
||||||
- **Persistir user "bot" sintético** — hoje `conversation_notes.created_by` usa primeiro admin como hack
|
| 9 | 1 por sessão (Michael Balint 12 × R$ 150) | ✅ |
|
||||||
- **Autosave do form de intake mais robusto** — hoje só 4 campos (nome, telefone, email, onde_nos_conheceu); ideal seria todos os campos preenchidos até então
|
| **10** | **Status change avulsa (realizado/faltou/cancelado)** | 🔴 **PRÓXIMO** |
|
||||||
- **Cron de `convert-abandoned-intakes`** — template pronto, ativar quando testar o fluxo
|
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
|
||||||
- **Bot v2**: fallback quando paciente digita algo que não encaixa (ex: "sim" na pergunta de nome) — hoje aceita qualquer string
|
| 11 | Status change pacote saldo | ⏳ |
|
||||||
|
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
|
||||||
### Pós-beta (deixar pra depois)
|
| 13 | Edit cobrada | ⏳ (parcialmente — lock ativo em Melissa pós-19/05 noite) |
|
||||||
|
|
||||||
- **(a)** Smoke test infra — cloud Supabase + hospedagem. ~2-3h
|
|
||||||
- **(b)** Beta fechado com clínicas
|
|
||||||
- **(c)** Ampliar analytics (conversão por terapeuta, SLA por tag, etc)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Setup Evolution/WhatsApp / Asaas
|
## 📋 Como retomar amanhã (cego)
|
||||||
|
|
||||||
Tudo em **`WHATSAPP_SETUP.md`**. Resumo crítico:
|
1. `git status` — confirmar working tree intacto
|
||||||
|
2. **Ler HANDOFF até o fim**
|
||||||
1. `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env` em terminal separado
|
3. Abrir `src/docs/agenda-compromisso-financeiro-cenarios.html` no browser pra ver o estado atual do doc viva
|
||||||
2. `.env` do functions tem: `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `ASAAS_API_KEY`, `ASAAS_API_URL=https://api-sandbox.asaas.com/v3`
|
4. **Começar pelo Cenário 4** (Joyce, "Já recebi (dar baixa)")
|
||||||
3. Evolution: `/saas/whatsapp` cadastra creds global → `/configuracoes/whatsapp-pessoal` conecta QR
|
5. Cada cenário que passar:
|
||||||
4. Twilio: `/saas/twilio-whatsapp` provisiona subconta → tenant ativa em `/configuracoes/whatsapp-oficial` (usa créditos)
|
- Atualizar status pra ✅ aqui no HANDOFF
|
||||||
|
- Se descobrir bug ou texto divergente, corrigir código + doc na hora
|
||||||
⚠️ Após editar qualquer `supabase/functions/**` precisa reiniciar o `supabase functions serve` — sem hot reload.
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Commits de hoje (cronológico)
|
## 🚨 Pendência IMPORTANTE — não esquecer
|
||||||
|
|
||||||
```
|
**Pós-Fase 9** (quando concluirmos TODAS as fases 1-9):
|
||||||
f76a2e3 Admin SaaS: ajuste manual de créditos WhatsApp (+/-) com proteção de compras
|
- User vai passar prompt específico pra criar **documentação completa da parte de ajuda** do sistema
|
||||||
e1f756e Heartbeat WhatsApp Evolution (Grupo 6.1): detecção + incident + alerta admin
|
- Está em `memory/project_pendencia_doc_ajuda.md`
|
||||||
881fa16 Fluxo de reativação de canal WhatsApp + alerta toast sticky + notify owner
|
- 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)
|
||||||
e409ba6 Saldo baixo WhatsApp: trigger dispara notificação ao cruzar threshold
|
|
||||||
5c50db6 Notifications: fallback de polling + catch-up ao focar a aba
|
**Histórico modalidade='presencial' no DB:**
|
||||||
6db06ab Toast system_alert ganha botão de ação com deeplink
|
- Bug do `pickDbFields` afetou TODAS as sessões avulsas criadas no Melissa até 16/05/2026
|
||||||
4441661 Toast system_alert: agregar no catch-up pra não empilhar enxurrada
|
- Se quiser corrigir histórico, rodar UPDATE manual identificando sessões cuja modalidade visual era online (não há como saber retroativamente — perdido)
|
||||||
771b636 SLA de conversas WhatsApp (Grupo 3.4): config + detecção + alerta
|
- Going forward o fix já cobre
|
||||||
4026415 Notifications: não redispara toast pra system_alert antigas após F5
|
|
||||||
5f51bc0 Fix deeplink /crm/conversas não existe; alias dinâmico por role
|
|
||||||
f646efe Toast SLA: botão "Abrir conversa" abre drawer direto da thread
|
|
||||||
64e7634 NotificationItem: resolve alias + botões inline "Conversa"/"Abrir"
|
|
||||||
36fbc02 Browser notification: click leva pro destino real (drawer ou rota)
|
|
||||||
adf9208 Analytics 7.1: tempo médio de 1ª resposta WhatsApp no dashboard
|
|
||||||
0f64381 Fix send-session-reminders comparava provider='evolution' mas DB guarda 'evolution_api'
|
|
||||||
4e4bac6 6.3 Reconnect automático Evolution antes de abrir incident
|
|
||||||
c2c42a1 3.7 Bot auto-triagem WhatsApp: config por tenant + hook nos inbound
|
|
||||||
b8ea292 Grupo 8: agenda ↔ WhatsApp completo (8.2 lembrar manual, 8.3 status→msg, 8.4 lead)
|
|
||||||
f1c97ee Dashboard SaaS ganha seção de receita de créditos WhatsApp (Asaas)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Stack lembretes
|
## ⚠️ Gotchas duráveis (atualizados)
|
||||||
|
|
||||||
- **DB local:** `docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres`
|
- **`MelissaBloqueios.vue` admin ≠ `BloqueioDialog` (4 modos)** — casos distintos
|
||||||
- **DB como supabase_admin (ALTER POLICY em tabelas owned):**
|
- **`agenda_excecoes` foi dropada** em 13/05
|
||||||
```bash
|
- **`financial_records.type` undefined sem `type` no BASE_SELECT** — fix 14/05 cedo
|
||||||
docker exec -i -e PGPASSWORD=postgres -e PGCLIENTENCODING=UTF8 \
|
- **`financial_records.description` undefined sem `description` no BASE_SELECT** — fix 14/05 noite
|
||||||
supabase_db_agenciapsi-primesakai \
|
- **`handleStatusChange` em `useAgendaFinanceiro.js` está ÓRFÃO** — não reativar
|
||||||
psql -U supabase_admin -d postgres -h localhost -f migration.sql
|
- **`_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
|
||||||
- **Vitest:** `npx vitest run`
|
- **Ocorrência virtual tem `id="rec::<rule>::<date>"`** — detectar via `typeof === 'string' && startsWith('rec::')` antes de query Supabase
|
||||||
- **SQL integration:** `node database-novo/tests/run.cjs`
|
- **`chargeMode` default dinâmico:** `'session'` em avulsa, `'none'` em recorrente
|
||||||
- **Edge functions serve:** `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env`
|
- **Toast atrás do overlay do dialog** — usar Message no topo do dialog em vez de toast quando contexto for dentro de dialog modal
|
||||||
- **Evolution Manager:** `http://localhost:8080/manager/`
|
- **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`
|
||||||
- **Supabase Studio:** `http://localhost:54323`
|
- **`paymentSettlement` foi renomeado** em 16/05 — agora `paymentMethod` (string) + `markPaidNow` (bool). Handler aplica `payment_method` sempre, `status='paid'` só quando markPaidNow=true && method!='link'
|
||||||
- **Asaas sandbox:** `https://sandbox.asaas.com`
|
- **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Memória persistente (carregada automaticamente)
|
## 🧠 Decisões persistidas (memory/)
|
||||||
|
|
||||||
Já saved em `MEMORY.md`:
|
**Indicadores visuais (16/05):**
|
||||||
- Project overview · MVP Assessment · Deploy options
|
- Badge $ no canto: só sessão + paciente + não-virtual + !paid
|
||||||
- Sanitização sempre · Priorização por severidade · Self-hosted > provider externo
|
- Linha popover: 3 textos (a receber pending / a cobrar none / cobrança não gerada)
|
||||||
- Gotcha supabase_admin · Tracking dev_*_items
|
- 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`)
|
||||||
|
|
||||||
## 📌 Bom descanso. Amanhã, testes.
|
**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
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"promptDelete": false
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+33
@@ -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
|
||||||
|
}
|
||||||
Vendored
+22
@@ -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,42 @@
|
|||||||
|
# Freemium / PLG
|
||||||
|
|
||||||
|
Épico iniciado em 2026-06-13, branch `feat/freemium-plg` (sobre [[Migracao Schema-per-Tenant]]). Objetivo: qualquer visitante cria conta gratuita sozinho, confirma e-mail, e o ambiente do tenant é provisionado automaticamente. Plano gratuito limitado + botão "Upgrade PRO". Blueprint-diretor: `novo-rumo.txt` (raiz), vindo do sistema-irmão (sindicato) e adaptado a clínica.
|
||||||
|
|
||||||
|
## Descoberta (Fase 0) — o que já existia
|
||||||
|
|
||||||
|
O sistema já estava ~70-85% pronto:
|
||||||
|
- **Planos free existem**: `clinic_free`, `therapist_free` (+ supervisor/patient) com `plan_features.limits` semeado (`clinic_free` → `clinic_calendar {max_patients:30, max_therapists:5}`, `online_scheduling {sessions_per_month:40}`, `reminders {reminders_per_month:50}`, `documents.upload {max_storage_mb:500}`; 14 features premium OFF).
|
||||||
|
- **Feature gating completo**: `entitlementsStore.js` (views `v_tenant_entitlements`/`v_user_entitlements`), `FeatureGate.vue`, guard `meta.feature` → `/upgrade` (`guards.js:814`), badge PRO no menu.
|
||||||
|
- **Provisionamento schema-per-tenant**: `ensure_personal_tenant`/`provision_account_tenant` → `clone_tenant_template`. Setup Wizard.
|
||||||
|
- **Signup self-service**: `/lp` (pricing dinâmico de `v_public_pricing`) → `/auth/signup` (`Signup.vue:219` `signUp` inline, cria intent só no pago).
|
||||||
|
- RPCs `activate_subscription_from_intent`, `change_subscription_plan`. `tenants.slug` 100% populado.
|
||||||
|
|
||||||
|
**Gap confirmado:** limites semeados mas **ninguém lê/enforça**. Sem confirmação de e-mail (`enable_confirmations=false`), sem /onboarding, signup só coleta email+senha, sem welcome email, sem os extras.
|
||||||
|
|
||||||
|
## Decisões (Fase 0.5)
|
||||||
|
|
||||||
|
1. **Modelo do blueprint** — confirmação de e-mail ON; signup grava escolha em `raw_user_meta_data` + signOut-local + tela "confirme e-mail"; provisionamento+intent viram RPCs idempotentes no 1º login (`auto_provision_free_tenant(p_slug_override)`, `processar_pos_signup`); guard manda logado-sem-tenant → `/onboarding`. Reescreve o signup inline.
|
||||||
|
2. **Pacientes** = recurso limitado. Trigger BEFORE INSERT em `patients` lê limits em runtime, resolve tenant por `TG_TABLE_SCHEMA`, conta linhas vivas, `RAISE 'PLAN_LIMIT_REACHED|patients|<n>'`. clinic_free=30, therapist_free=20. No template + backfill 9 schemas.
|
||||||
|
3. **Slug escolhido** no signup (sugestão sanitizada + `slug_disponivel(p_slug)→{ok,motivo}`), imutável, trava 3 camadas.
|
||||||
|
4. **Todos os 4 extras**: /saas/usuarios + `notify_all_devs`; esqueci-email (magic link por slug, dica mascarada); blacklist (email|slug); root_redirect.
|
||||||
|
|
||||||
|
## Pegadinhas (do blueprint, ⚠️ caras no irmão)
|
||||||
|
|
||||||
|
- **#1** Signup sem sessão (confirmação ON) → tudo com `auth.uid()` quebra em silêncio. Gravar escolha em metadata, processar pós-confirmação.
|
||||||
|
- **#2** signOut `scope:'local'` se não veio sessão — senão vaza sessão anterior e joga no painel errado.
|
||||||
|
- **#3** Logado-sem-tenant nunca cai em painel quebrado → `/onboarding` resolve estados (provisionando, slug-colidiu, pago-aguardando, sem-acesso, erro).
|
||||||
|
- **#4** Sino de notificação singleton precisa re-buscar ao trocar de user (logout+login).
|
||||||
|
|
||||||
|
## Divergência de infra
|
||||||
|
|
||||||
|
Blueprint pede welcome email via **Resend**; aqui é **SMTP/Mailpit** (`process-email-queue`). Reusar o pipeline SMTP existente (best-effort), não Resend.
|
||||||
|
|
||||||
|
## Fases
|
||||||
|
|
||||||
|
- **F1** ✅ DONE (2026-06-13) — therapist_free ganhou max_patients=20; trigger `enforce_patient_plan_limit` em patients (lê `plan_features.limits` em runtime, resolve plano via `tenant_active_plan_id`, conta vivos, RAISE `PLAN_LIMIT_REACHED|patients|n`); helpers globais + wiring + backfill 9 schemas. Front: `utils/planLimit.js` (toast com CTA via grupo system-alerts) nos 3 pontos de criação de paciente + botão **Upgrade PRO** no AppTopbar quando plano é free. Migrations: `20260613000005_*` + `manual/freemium_f1_plan_limits.supabase_admin.sql`. Testado em ROLLBACK (clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado).
|
||||||
|
- **F2** 🟡 NÚCLEO DONE (2026-06-13) — `enable_confirmations=true` (config.toml, gitignored, ativa no restart do stack); RPCs `slug_disponivel`/`auto_provision_free_tenant`/`processar_pos_signup` (manual/freemium_f2_provisioning.supabase_admin.sql, testados em ROLLBACK clínica+terapeuta); **fix de regressão** `log_audit_change` (migration 20260613000006) que quebrava INSERT em tenant_members; Signup.vue reescrito (kind+nome+slug ao vivo+metadata, signOut-local + tela confirme-email); OnboardingPage.vue (provision+estados slug-colidiu/erro); guard → /onboarding; rota registrada. Build OK. **Restam (polish):** welcome email best-effort (infra SMTP schema-per-tenant) + apresentação do free na vitrine (public_name/preço "Grátis"/bullets — os planos já são is_visible=true mas sem nome/preço).
|
||||||
|
- **F3** ✅ DONE (2026-06-13) — 4 extras. DB/edge: `blacklist` (tabela + trigger BEFORE INSERT em auth.users + integra slug_disponivel motivo 'bloqueado'); `saas_list_account_owners()` (donos por tenant, dev-only) + `notify_all_devs` + trigger em subscriptions; `saas_app_config`/`get_root_redirect()`; edge `recover-access` (esqueci-email por slug → magic link, dica mascarada). Front: SaasUsuariosPage (/saas/usuarios, selo Novo 24h) + SaasAppConfigPage (/saas/app-config, blacklist CRUD + toggle root_redirect); esqueci-email dialog no Login; root_redirect no guard ("/" não-logado→/lp|/login, cache TTL); pegadinha #4 (notificationStore.reset no logout). Arquivos: manual/freemium_f3a/b/c + functions/recover-access. Build OK, DB testado em ROLLBACK. ⚠️ edge recover-access precisa deploy (F4).
|
||||||
|
- **F2 polish** ✅ DONE (2026-06-13) — welcome email: edge `send-welcome-email` (dono do tenant, destinatário do JWT, SMTP global/sistema com defaults Mailpit; best-effort fire-and-forget no OnboardingPage só no provision novo). Vitrine: seed `plan_public`+bullets dos free (migration 20260613000007); Landingpage mostra "Grátis para sempre" via `isFreePlan`. ⚠️ send-welcome-email precisa deploy + envs SMTP no hosted (F4). Com isso **F2 está 100%**.
|
||||||
|
- **F4** — Deploy (hosted, dirigido pelo Leonardo). **Runbook completo em `docs/DEPLOY_FREEMIUM_F4.md`** (commit 2f72886): pré-req #0 = schema-per-tenant no hosted antes; migrations 05/06/07 + 5 manual/freemium_f* + Auth dashboard + deploy das 2 edges + secrets SMTP + rebuild + smoke 8 passos + kill-switches.
|
||||||
|
|
||||||
|
Método: commits por assunto; cada migration testada em transação com ROLLBACK antes de aplicar; build a cada bloco front.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Migração Schema-per-Tenant
|
||||||
|
|
||||||
|
**Status:** F6.0 + F6.1 concluídas e verificadas (2026-06-13). Dados dos 9 tenants migrados pros schemas, AINDA espelhados em public (nada dropado), backup em `database-novo/backups/pre-F6/`. Próximo: **F6.2 (rewrite das funções — push dedicado)**, depois checkpoint de teste do app, depois F6.3 (DROP, só com OK do Leonardo). F0-F2 em `main`; F3+/F1b/F5/F6.0-1 no branch.
|
||||||
|
|
||||||
|
## F6.0 + F6.1 — entregue (commit 003f2eb)
|
||||||
|
- F6.0 (migration 20260613000003): clone dos 9 tenants reais (schemas vazios, expostos no PostgREST via trigger F5).
|
||||||
|
- F6.1 (manual `database-novo/manual/f6_1_migrate_data.supabase_admin.sql`, rodar como supabase_admin): copia dados public→schemas com `session_replication_role=replica`. Tabelas com tenant_id por filtro; 3 filhas sem tenant_id (commitment_services, insurance_plan_services, recurrence_rule_services) por JOIN no pai; exclui colunas GENERATED (`net_amount`, `margin_brl`); reset de 4 sequences; ON CONFLICT DO NOTHING.
|
||||||
|
- **Verificado**: contagens public vs schemas batem (35 patients, 37 eventos, 355 mensagens, 54 financeiro, 13 commitment_services…). notification_templates "146" = 144 seeds (16×9) + 2 tenant — esperado.
|
||||||
|
- Gotcha: colunas GENERATED não aceitam INSERT → excluir via `is_generated='NEVER'`. DO block é atômico → erro no meio dá rollback total (re-rodar é seguro com ON CONFLICT).
|
||||||
|
|
||||||
|
## F6.2 — PLANO (rewrite de 66 funções + split notifications) — próximo push
|
||||||
|
Decisões já tomadas: parar antes do DROP pra testar app; split notifications JUNTO na F6.
|
||||||
|
|
||||||
|
Inventário de triggers (81 attachments nas tabelas tenant em public):
|
||||||
|
- **Schema-agnósticos** (só NEW/OLD, sem refs a tabela): família `set_updated_at`/`set_*_updated_at`, `fn_clinical_notes_updated_at`, `prevent_promoting_to_system`, `prevent_system_group_changes`, `patients_validate_member_consistency`, `fn_agenda_regras_semanais_no_overlap` → anexar nos schemas como estão.
|
||||||
|
- **Schema-aware** (~10, escrevem em OUTRA tabela tenant / audit / notifications) → reescrever com `set_config('search_path', TG_TABLE_SCHEMA||',public,pg_temp', true)` e `tenant_id_for_schema(TG_TABLE_SCHEMA)` onde precisam do tenant_id (audit_logs/notifications_sistema são globais): `auto_create_financial_record_from_session`, `sync_busy_mirror_agenda_eventos`, `notify_on_session_status`, `fanout_inbound_message_to_notifications`, `log_audit_change`, `fn_sla_resolve_on_outbound`, `fn_clinical_note_version`, `fn_document_signature_timeline`, `fn_documents_timeline_insert`, `trg_fn_patient_status_history/timeline/risco`, `sync_legacy_email/phone_fields`, `agenda_cfg_sync`, `cancel_notifications_*`, `fn_notify_agenda_status_change`, `trg_fn_financial_records_auto_overdue`.
|
||||||
|
- **`financial_records_inject_tenant` → OBSOLETO** no schema (não há coluna tenant_id) — NÃO anexar.
|
||||||
|
|
||||||
|
Sub-lotes propostos (cada um com smoke test + commit):
|
||||||
|
- **A** ✅ DONE (commit d58b939, migration 20260613000004): `attach_agnostic_triggers(schema)` recria os triggers agnósticos (8 setters updated_at + 2 prevent_*) nos 9 schemas (54 triggers/schema). Smoke: set_updated_at dispara. Wiring no clone fica pro fim da F6.2.
|
||||||
|
- **B** ✅ DONE (commit 5741e10, manual/f6_2b_schema_aware_triggers.supabase_admin.sql — roda como supabase_admin pois trigger fns são owned por supabase_admin). 14 funcs reescritas (set_config search_path dinâmico + tenant_id_for_schema p/ audit_logs global); sync_busy_mirror cross-tenant via tenant_schema_for+EXECUTE format; financial_records_inject_tenant obsoleto (não anexado). Detach dos 14 de public + attach 22 triggers/schema (defs reais, tenant_id removido de WHEN/UPDATE OF). Smoke: sessão→realizado cria financial_record no schema + audit roteia tenant_id certo + timeline OK.
|
||||||
|
- **Gotchas Lote B**: (1) trigger functions owned por supabase_admin → CREATE OR REPLACE só como supabase_admin (vira manual, não db.cjs). (2) Triggers reais tinham `WHEN (new.tenant_id=new.owner_id)` e `UPDATE OF tenant_id,...` → quebram no schema; remover tenant_id dos WHEN/colunas ao re-anexar. (3) Estratégia hybrid: detach de public pra função reescrita não rodar errada lá.
|
||||||
|
- **C** ✅ DONE (commit bedbb9b, manual/f6_2c_notifications_split.supabase_admin.sql). DESCOBERTA: neste projeto TODAS as notifs atuais (inbound_message, session_status, system_alert, new_patient) são tenant-LOCAIS — avisos cross-tenant do SaaS vivem em `global_notices`, não em notifications. Então: notifications fica tenant-local (já nos schemas); `public.notifications_sistema` criado como canal SaaS→tenant FUTURO (vazio hoje) + RLS + realtime + notify_user_sistema(). 4 notif-triggers tenant reescritos schema-aware + detach public + attach (5/schema); notify_on_intake/scheduling disparam em tabelas PUBLIC (F1b) → roteiam pro schema via tenant_schema_for+EXECUTE format; cancel_patient_pending herda search_path do chamador. Smoke: msg inbound → notif no schema, destinatário certo. Frontend notificationStore.js: load 2 fontes + merge por created_at + `_origem`; realtime 2 canais; markRead/archive roteiam por _origem. conversation_messages.id é bigint (gotcha no teste).
|
||||||
|
- **D** ✅ DONE (commit d240c66, manual/f6_2d_user_rpcs.supabase_admin.sql + 18 sites FE; build passa). 14 RPCs (list_my_signatures→F). Helper `_tenant_route(p_tenant_id)` valida + RETORNA schema (não seta — set_config em helper com SET search_path próprio é revertido na saída! cada RPC faz o set_config). Grupo3 RETURNS<tabela>→jsonb (mark_as_paid, create_financial_record_for_session, mark_payout, create_therapist_payout). FE: p_tenant_id de activeTenantId; SETOF→jsonb transparente (nenhum consumidor indexava array). Smoke: mark_as_paid + search_global OK.
|
||||||
|
- **Gotchas Lote D**: (1) set_config em função-helper com `SET search_path` próprio é REVERTIDO ao retornar → helper retorna schema, RPC faz o set_config. (2) %ROWTYPE/RETURNS<tabela_tenant> quebram → RECORD/jsonb. (3) search_global é MISTO (patients/agenda no schema, patient_intake_requests em public/F1b). (4) seed_* chamados por provision ANTES do clone → no-op se schema não existe (fix de ordem no wiring). (5) can_delete_patient SQL sem SET search_path herda do chamador.
|
||||||
|
- Categorias originais (ref):
|
||||||
|
- **CREATE OR REPLACE, já têm p_tenant_id, RETURNS jsonb/void** (sem ripple FE): `delete_commitment_full`, `delete_determined_commitment`, `seed_default_patient_groups`, `seed_determined_commitments` (⚠️ provision_account_tenant chama seed ANTES de clone — inverter ordem no wiring do clone, senão seed escreve em public). 0 chamadas FE.
|
||||||
|
- **DROP+CREATE (novo p_tenant_id 1º param) + FE passa p_tenant_id, RETURNS scalar/jsonb**: `cancel_recurrence_from`(void,1 FE), `cancelar_eventos_serie`(int,0), `split_recurrence_at`(uuid,1), `safe_delete_patient`(jsonb,1), `export_patient_data`(jsonb,1 — toca ~10 tabelas tenant), `search_global`(jsonb STABLE,2), `list_my_signatures`(jsonb,1).
|
||||||
|
- **RETURNS `<tabela_tenant>`/%ROWTYPE → jsonb (ripple FE: consumidores esperam row)**: `mark_as_paid`(SETOF financial_records,3 FE), `create_financial_record_for_session`(SETOF financial_records,6 FE — já tem p_tenant_id), `mark_payout_as_paid`(therapist_payouts,0), `create_therapist_payout`(therapist_payouts,0 — agregação financeira, testar com cuidado).
|
||||||
|
- Owned mix postgres/supabase_admin → rodar migration como supabase_admin.
|
||||||
|
- **E** ✅ DONE (commit 02acc88, manual/f6_2e_cron_rpcs.supabase_admin.sql + 2 edge). E2 (cleanup/unstick/sync_overdue/populate_notification_queue) varrem todos os schemas via loop `FROM tenant_schemas`. E1 (sla_*, whatsapp_heartbeat_*, convert_abandoned_intake_to_lead) per-tenant via service_role: helper `_tenant_schema_unchecked` (SEM is_tenant_member, pq service_role não é membro) + REVOKE de anon/authenticated. first_response_stats/_runs user-facing via _tenant_route. Edge whatsapp-heartbeat/sla ajustadas (admin.rpc + p_tenant_id). Smoke OK.
|
||||||
|
- **Gotchas Lote E**: (1) service_role NÃO é tenant_member → RPCs de cron precisam de helper sem auth-check + REVOKE de authenticated (senão um user chamaria com tenant arbitrário). (2) conversation_messages NÃO tem coluna thread_key (é computada na view) → analytics computa inline. (3) DROP+CREATE de nova assinatura: dropar AMBAS (velha+nova) p/ idempotência.
|
||||||
|
- **F** ✅ DONE (commit 1243a12, manual/f6_2f_anon_token_rpcs.supabase_admin.sql + 2 FE; build passa). Documentos anon resolvem tenant de `document_share_links.tenant_id` (public/F1b); agendador de `agendador_configuracoes.tenant_id`. document/document_signatures/access_logs/agenda no schema; share_links/agendador_* ficam public. %ROWTYPE→RECORD, RETURNS document_signatures→jsonb. sign_document_by_signature_id (paciente logado, NÃO é member): unchecked + auth por LINHA (signatario_id/email/doc do paciente). match_patient_by_phone: unchecked + REVOKE authenticated (só service). list_my_signatures: fan-out cross-schema. RPCs public-only (intake/invite/agendador_gerar_slug) SEM mudança. FE: signByPortal(tenantId,...).
|
||||||
|
- **Gotcha Lote F**: paciente assinante NÃO é tenant_member → autorizar por LINHA (dono da assinatura), não por membership. Anon resolve tenant SEMPRE da tabela public que tem o token+tenant_id.
|
||||||
|
- **G** ✅ DONE (commit ee82985, manual/f6_2g_sql_to_plpgsql.supabase_admin.sql + 3 FE; build passa). 5 funções SQL→plpgsql + p_tenant_id + _tenant_route (get_financial_summary/report, list_financial_records SETOF→jsonb, get_patient_session_counts sem filtro tenant_id). get_entity_primary_phone (interno) herda search_path. can_delete_patient/_first_response_runs já feitas em D/E. FE: p_tenant_id nas 3 RPCs financeiras.
|
||||||
|
|
||||||
|
## ✅✅ F6.2 COMPLETA (2026-06-13) — 66 funções migradas
|
||||||
|
Triggers (A agnósticos + B schema-aware + C notif) + RPCs (D usuário + E cron + F anon/token + G SQL→plpgsql). Tudo smoke-testado, build passa. Próximo: **wiring no clone** + **F6.3 DROP** (com OK do Leonardo).
|
||||||
|
- ✅ **wiring DONE** (commit dc7826d, manual/f6_2h_clone_wiring.supabase_admin.sql): trigger AFTER INSERT em tenant_schemas (trg_attach_business_triggers) dispara os 3 attach pro schema novo → tenant novo nasce com 84 triggers. attach_agnostic agora SELF-CONTAINED (dirigido por colunas, não lê public — sobrevive ao DROP). provision_account_tenant: clone ANTES do seed. Smoke OK.
|
||||||
|
- **F6.3 DROP** 📋 PREPARADA não-aplicada (commit cdb9ce1, manual/f6_3_drop_public_tenant_tables.supabase_admin.sql): pré-flight assert + 2 FKs viram coluna solta (document_share_links.documento_id, whatsapp_credits_transactions.conversation_message_id) + dropa 9 views public + DROP CASCADE das 78 + limpa financial_records_inject_tenant. **BLOQUEADA** pelos itens em aberto abaixo.
|
||||||
|
|
||||||
|
## ✅ SUPERFÍCIE SaaS-ADMIN RESOLVIDA (F6.4, commit dc2363b)
|
||||||
|
RPCs `saas_admin` (manual/f6_4_saas_admin_rpcs.supabase_admin.sql): defaults editados no `_tenant_template` + fan-out pros schemas (saas_list/add/remove_default_feriado; saas_*_default_notif_template; saas_count_notif_template_overrides). Cross-tenant: `saas_list_all_whatsapp_channels` (fan-out, substitui v_twilio_whatsapp_overview). FE: SaasFeriadosPage/SaasNotificationTemplatesPage → RPCs; SaasWhatsappPage → `supabase.schema(tenant_<slug>)` (RLS permite saas_admin) p/ tenant selecionado + RPC p/ overview; getAllChannels → RPC. **Varredura confirma ZERO supabase.from('<tabela_tenant>') público no FE.** F6.3 DESBLOQUEADA (falta só Leonardo testar app + backup). TODOs deixados: stat-cards de feriados (cidade/estado) e incidents-7d viraram 0 (UI degrada sem crash).
|
||||||
|
|
||||||
|
## (histórico) ITENS EM ABERTO antes do F6.3 DROP — RESOLVIDOS acima
|
||||||
|
Superfície **SaaS-admin / cross-tenant** que ainda lê `public.<tabela_tenant>` e quebraria no DROP:
|
||||||
|
1. **SaasWhatsappPage.vue + v_twilio_whatsapp_overview + twilioWhatsappService.getAllChannels()** — admin cross-tenant de canais WhatsApp (notification_channels/whatsapp_connection_incidents). Reescrever fan-out por schema OU usar `public.channel_routing`.
|
||||||
|
2. **SaasNotificationTemplatesPage.vue** — gerencia templates DEFAULT do sistema (tenant_id NULL). Apontar pra `_tenant_template.notification_templates` (os defaults vivem lá agora).
|
||||||
|
3. **SaasFeriadosPage.vue** — gerencia feriados nacionais default. Idem `_tenant_template.feriados`.
|
||||||
|
4. **notification-webhook** (Meta) — conferir fan-out/channel_routing.
|
||||||
|
Decisão de arquitetura: as páginas que editam DEFAULTS do sistema devem editar `_tenant_template` (propaga a tenants novos); as views cross-tenant admin devem fan-out por schema ou usar channel_routing. Resolver, testar, então aplicar F6.3.
|
||||||
|
|
||||||
|
## 🟢 APP TESTÁVEL AGORA (pós-wiring, pré-DROP)
|
||||||
|
Dados nos schemas (F6.1) + 66 funções/triggers/RPCs roteiam (F6.2) + PostgREST expõe (F5) + frontend usa tenantDb (F3) + edge roteia (F4). Os dados ainda estão ESPELHADOS em public (nada dropado). Leonardo deve abrir o app no branch `feat/schema-per-tenant` e testar fluxos reais (agenda, financeiro, pacientes, documentos, notificações). Só após validação → F6.3 DROP.
|
||||||
|
|
||||||
|
## F5 — entregue (commit 6b542cd) — PRIMEIRO teste real via HTTP do PostgREST
|
||||||
|
- `postgres` NÃO é superuser neste stack → não consegue `ALTER ROLE authenticator`. Quem consegue: `supabase_admin` (superuser, conecta com senha `postgres` via `psql -U supabase_admin -h 127.0.0.1`).
|
||||||
|
- `database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql` (aplicar como supabase_admin, fora do db.cjs): `public.refresh_pgrst_schemas()` (SECDEF owned supabase_admin) deriva a lista de `tenant_schemas`, seta `pgrst.db_schemas` in-database na role authenticator, `NOTIFY pgrst reload config/schema`. **Expõe/retira schema SEM restart**; a GUC persiste em pg_db_role_setting (sobrevive a stop/start) e SUPERSEDE o config.toml em runtime.
|
||||||
|
- migration `20260613000002`: trigger em `tenant_schemas` (AFTER INSERT/DELETE/UPDATE, statement-level) dispara o refresh → clone_tenant_template e drop_tenant_schema NÃO precisaram ser tocados.
|
||||||
|
- config.toml (gitignored): baseline `public, graphql_public` + comentário; in-db config supersede.
|
||||||
|
- **E2E via curl**: clone → `pgrst.db_schemas` inclui tenant_x → `GET /rest/v1/patients` com `Accept-Profile: tenant_x` retorna **200** (vs **406** pra schema inexistente); drop → volta 406. Tudo sem restart de container. Primeira validação real do stack F1-F5 pelo caminho HTTP do PostgREST.
|
||||||
|
|
||||||
|
### Gotcha F5
|
||||||
|
- PostgREST in-database config (db-config ligada por padrão, sem `PGRST_DB_CONFIG=false`): `ALTER ROLE authenticator SET pgrst.db_schemas` + `NOTIFY pgrst, 'reload config'` é a via pra schemas dinâmicos sem restart. `reload schema` sozinho NÃO adiciona schema novo à lista exposta — só recarrega o cache dos já expostos.
|
||||||
|
|
||||||
|
## F4 — entregue (branch, commit 9b21642)
|
||||||
|
- `_shared/tenant.ts`: helper das edge functions — `adminClient()` (service_role/public), `tenantDbForId(admin, tenantId)`, `schemaForTenant`, `listTenantSchemas` (crons varrem todos), `resolveTenantByChannel` (webhook→tenant via channel_routing), `tenantSchemaName`
|
||||||
|
- `_shared/whatsapp-hooks.ts` refatorado: hooks de tabela tenant recebem `tdb`; RPCs de crédito (deduct/add_whatsapp_credits) + tenant_members continuam em `supa`+p_tenant_id
|
||||||
|
- 23 edge functions migradas. Categorias:
|
||||||
|
- **inbound** (twilio/evolution): tenant_id da URL → tdb
|
||||||
|
- **crons de fila** (process-notification/email/sms/whatsapp-queue): varrem `listTenantSchemas` e drenam a fila de CADA schema — consequência direta da Q3 (filas viraram per-tenant). Modo single-tenant se `body.tenant_id` vier.
|
||||||
|
- **crons reminders/checks** (send-session-reminders, conversation-sla-check, whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates): loop por tenant
|
||||||
|
- **routing por tenant_id** (send-whatsapp-message, send-session-reminder-manual, twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId; channel-actions sem tenant_id varrem schemas por channel_id (O(n) tenants)
|
||||||
|
- **asaas-***: tenant_id do body → tdb; asaas-webhook fica global
|
||||||
|
- **notification-webhook** (Meta Cloud API): resolve via channel_routing por phone_number_id, fan-out por message_id quando não casa
|
||||||
|
- caller `useAgendaEventLifecycle.js` passa tenant_id pro send-session-reminder-manual (evento vive no schema)
|
||||||
|
- Sem deno local → validado por grep (zero tenant_id em cadeias tdb, clients todos declarados, imports batem). Type-check real só no deploy.
|
||||||
|
|
||||||
|
### ⚠️ DECISÃO PENDENTE — roteamento anon-por-token (bloqueia F5/F6)
|
||||||
|
Fluxos anônimos identificam o tenant por TOKEN/SLUG, não por login, então não sabem o schema: `save-intake-progress` (lê patient_intake_requests por token), intake RPCs (get-intake-invite-info, submit-patient-intake), `AgendadorPublicoPage`+RPCs do agendador (link_slug), document share links (validate_share_token, sign_document_by_token). Opções:
|
||||||
|
- **A** Índice global `public_access_tokens(token_hash→tenant_id)` + triggers de sync (O(1), +1 tabela global + triggers)
|
||||||
|
- **B** RPCs SECURITY DEFINER que varrem schemas pelo token (sem tabela nova, O(n) por request)
|
||||||
|
- **C** Manter as tabelas anon-facing (patient_intake_requests, patient_invites, document_share_links, agendador_configuracoes/solicitacoes) em PUBLIC com RLS por token — sidesteppa o problema; custo: essas não ganham isolamento físico (mas são as menos sensíveis, feitas pra acesso anon)
|
||||||
|
|
||||||
|
## F3 — entregue (branch feat/schema-per-tenant, migration 07)
|
||||||
|
- `src/lib/supabase/tenantClient.js` (`tenantDb()`, `tenantSchemaName()`) + `src/composables/useTenantDb.js`
|
||||||
|
- `tenantStore`: getters `activeTenantSlug`/`activeTenantSchema`; `my_tenants()` RPC agora devolve slug+name (migration 20260612000007)
|
||||||
|
- codemod `scripts/codemod-tenant-db.py`: `supabase.from('<84 tabelas + 6 views tenant>')` → `tenantDb().from(...)` em 139 arquivos (777 chamadas), removeu 173 `.eq('tenant_id')` de cadeias tenant
|
||||||
|
- 4 agentes (2 ondas) fizeram a passada manual: tenant_id fora de payloads/selects/.or/.is; onConflict ajustado (singletons → `'singleton'`); realtime de tabelas tenant aponta pro `activeTenantSchema`; repos dropam tenant_id defensivamente de payloads de callers externos
|
||||||
|
- **descoberta importante: ZERO embeds cross-schema** — todos os FK embeds são tenant→tenant (mesmo schema, ex. `agenda_eventos`→`patients`,`insurance_plans`) ou global→global (`profile_specialties`→`profiles`). O `attachProfiles`/fake-embed do blueprint NÃO é necessário aqui.
|
||||||
|
- gotcha: `AGENDA_EVENT_SELECT` (constante de select) tinha tenant_id — selecionar coluna inexistente quebra PostgREST; varrer constantes `*_SELECT`, não só `.from()`
|
||||||
|
|
||||||
|
### Pendências F3 (fora do escopo, cross-tenant/anon → tratar em F4/F6)
|
||||||
|
- `AgendadorPublicoPage.vue` — scheduler público anon, resolve tenant por `link_slug` (precisa RPC/edge de resolução slug→schema, igual channel_routing)
|
||||||
|
- `Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page.vue` — gerenciam defaults do sistema (tenant_id NULL) ou views cross-tenant; após F6 devem mirar `_tenant_template` ou `channel_routing`. Continuam apontando pra public (funcional até o drop da F6).
|
||||||
|
|
||||||
|
## F2 — entregue (migration 20260612000006)
|
||||||
|
Os 3 únicos pontos de criação de tenant (`provision_account_tenant`, `create_clinic_tenant`, `ensure_personal_tenant_for_user` — este último também acionado pelo trigger de signup `handle_new_user_create_personal_tenant`) agora chamam `clone_tenant_template()` na mesma transação: clone falhou → tenant não nasce. Smoke: ensure_personal criou tenant pessoal `tenant_terapeuta_pessoal` com 84 tabelas + registro, 2ª chamada idempotente, drop limpou tudo. Não há fluxo de exclusão de tenant no sistema (drop_tenant_schema fica pra uso admin/manual).
|
||||||
|
|
||||||
|
## F1 — entregue (migrations 20260612000001–05 em database-novo/migrations/)
|
||||||
|
- `tenants.slug` criado + backfill dos 9 + trigger auto-gera/imutável
|
||||||
|
- Helpers: `tenant_schema_name/for`, `tenant_id_for_schema`, `tenant_schema_checked(p_tenant_id)` (valida `is_tenant_member` — substitui current_tenant_schema do blueprint)
|
||||||
|
- `_tenant_template`: 84 tabelas sem tenant_id, 6 singletons (`singleton boolean PK/UQ` nas configs 1-linha: company_profiles, email_layout_config, conversation_autoreply_settings/bots/sla_rules, session_reminder_settings), 4 sequences locais, 94 FKs (62 intra + 32 pra public/auth), 6 views com placeholders `__SCHEMA__`/`__TENANT_ID__` em `_views`, seeds de sistema (whitelist 8 lookups)
|
||||||
|
- `clone_tenant_template(uuid)` → tabelas+seqs+seeds+FKs+views+RLS (policies com tenant_id EMBUTIDO: `is_tenant_member('<uuid>')` + saas_admin_full)+realtime+grants+trigger routing+registro em `tenant_schemas`
|
||||||
|
- `drop_tenant_schema(uuid)` protegido; `public.channel_routing` (webhook inbound acha tenant do canal) sincronizada por trigger
|
||||||
|
- Smoke: clone tenant_smoke_f1 → 84 tabelas/168 policies/roundtrip/routing sync/singleton rejeitando 2ª linha → drop limpo
|
||||||
|
|
||||||
|
### Gotchas aprendidos na F1
|
||||||
|
- **`postgres` não é superuser no Supabase** → `session_replication_role` proibido; seeds usam retry-loop de FK (rounds). Vale pro F6 (migração de dados): rodar como `supabase_admin` ou retry-loop.
|
||||||
|
- **db.cjs aplicava migration sem `ON_ERROR_STOP`** → rollback silencioso reportado como sucesso. Corrigido (psqlFile agora usa `-v ON_ERROR_STOP=1`).
|
||||||
|
- Linhas operacionais órfãs com tenant_id NULL (intakes/convites/notifs) NÃO são seeds — whitelist explícita.
|
||||||
|
- Clones F1/F2 ainda SEM triggers de negócio (F6) e fora do PostgREST (F5) — `_meta.triggers_pending=true`.
|
||||||
|
|
||||||
|
Migração de multi-tenant RLS-only (tenant_id em cada tabela) para schema físico por tenant (`tenant_<slug>`), seguindo blueprint do projeto irmão (`novo-rumo.txt` na raiz), adaptado.
|
||||||
|
|
||||||
|
## Artefatos
|
||||||
|
- `docs/F0_categorizacao.md` — varredura completa: classificação das 137 tabelas, 66 funções, 6 views, FKs, edge functions, divergências.
|
||||||
|
- `novo-rumo.txt` (raiz) — blueprint original com lições do projeto irmão.
|
||||||
|
|
||||||
|
## Números-chave
|
||||||
|
- 137 tabelas public → 79 tenant-scoped + 5 em decisão (infra mensageria) + 53 globais
|
||||||
|
- 66 funções afetadas (blueprint avisava: listas pré-feitas subestimam — era "29" lá, 66 aqui)
|
||||||
|
- 1 única FK global→tenant problemática: `whatsapp_credits_transactions.conversation_message_id`
|
||||||
|
- 0 policies de tabelas globais usando funções a refatorar
|
||||||
|
- 9 tenants (3 clínicas + 6 therapists), volumetria minúscula (<400 linhas/tabela)
|
||||||
|
|
||||||
|
## Divergências vs blueprint (decisivas)
|
||||||
|
1. **Sem `tenants.slug`** — precisa criar coluna ou usar uuid no nome do schema.
|
||||||
|
2. **Multi-membership**: `profiles.tenant_id` 100% NULL; verdade vive em `tenant_members` (4 users multi-tenant). `current_tenant_schema()` do blueprint não funciona → frontend escolhe schema ([[tenantStore]] já tem `activeTenantId`), segurança via policy com tenant_id embutido por schema + RPCs recebem `p_tenant_id` validado com `is_tenant_member()`.
|
||||||
|
3. **6/9 tenants são terapeutas individuais** — schema por signup; custo operacional do config.toml do PostgREST cresce com tenants.
|
||||||
|
4. `email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` apontam pra **auth.users** (legado) — mapear na migração de dados.
|
||||||
|
5. View `current_tenant_id` é código morto (claim JWT nunca populado).
|
||||||
|
|
||||||
|
## Decisões (2026-06-12)
|
||||||
|
- Q1: **criar `tenants.slug`** → schemas `tenant_<slug>`
|
||||||
|
- Q2: **todo tenant ganha schema** (clínicas e therapists)
|
||||||
|
- Q3: **mensageria tenant-scoped** (isolamento máximo, contra rec. global) → crons varrem tenants em loop; webhooks inbound precisam de índice global `channel_routing` (channel_external_id → tenant_id) pra rotear antes de gravar
|
||||||
|
- Q4: **asaas tenant** (staging `asaas_webhook_events` global roteia)
|
||||||
|
|
||||||
|
Total final: **84 tabelas tenant-scoped, 53 globais.**
|
||||||
|
|
||||||
|
## Fases (tasks #1–#7 na sessão)
|
||||||
|
F0 categorização ✅ · F1 template+helpers · F2 provisionamento · F3 frontend useTenantDb · F4 edge functions · F5 PostgREST config · F6 rewrite funções + migração dados + drops (lotes, backup antes de cada um)
|
||||||
|
|
||||||
|
Relacionados: [[Decisões de Billing da Agenda]], [[Supabase Local]], [[index]]
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
---
|
||||||
|
title: Pesquisa de mercado — fluxo de compromisso e cobrança
|
||||||
|
date: 2026-05-13
|
||||||
|
status: levantamento
|
||||||
|
players: Cliniko, SimplePractice, TherapyNotes
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto do produto
|
||||||
|
|
||||||
|
SaaS BR pra clínicas de psicologia, multi-tenant. Agenda + paciente + recorrência já funcionando. Invariante "cobrança emitida é imutável pelo dialog da agenda" já implementada (padrão SimplePractice). Auditando fase-a-fase o fluxo antes de fechar gaps. Restrições fiscais BR: PIX, NFS-e, LGPD.
|
||||||
|
|
||||||
|
Cross-links: [[recorrencia-agenda]], [[index]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Criação de compromisso SEM paciente
|
||||||
|
|
||||||
|
### Cliniko
|
||||||
|
- **Default:** existe entidade dedicada chamada **Unavailable block**. Não é appointment — não interfere em relatórios clínicos. Funciona como bloqueio puro de calendário (almoço, reunião, férias, manutenção).
|
||||||
|
- **Admin pode:** criar **Unavailable block types** customizados (nome, duração default, cor). Aceita arquivamento individual ("Archive" remove o bloco).
|
||||||
|
- **Fonte:** [Scheduling time off](https://help.cliniko.com/en/articles/1023892-scheduling-time-off), [Changing Your Calendar to Time Blocks](https://help.cliniko.com/en/articles/1024048-changing-your-calendar-to-time-blocks).
|
||||||
|
|
||||||
|
### SimplePractice
|
||||||
|
- **Default:** duas entidades distintas — **Calendar event** (cinza escuro, para reunião, supervisão, tempo pessoal) e **Out of office (OOO) block** (cinza claro, para indisponibilidade que deve bloquear request de agendamento). Calendar events também podem ser recorrentes.
|
||||||
|
- **Admin pode:** marcar evento como recorrente; OOO bloqueia automaticamente o widget de pedidos de horário online.
|
||||||
|
- **Fonte:** [Creating a calendar event](https://support.simplepractice.com/hc/en-us/articles/41930878513933-Creating-a-calendar-event), [Managing out of office blocks](https://support.simplepractice.com/hc/en-us/articles/41931023345165-Managing-out-of-office-blocks).
|
||||||
|
|
||||||
|
### TherapyNotes
|
||||||
|
- **Default:** dois tipos — **Scheduled Event** (atividade não-clínica: reunião, supervisão, treinamento; aparece no calendário do clínico) e **Unavailable** (vetar agendamento de pacientes em horários específicos: férias, almoço, compromisso pessoal). Ambos suportam descrição, duração e recorrência sem vincular paciente.
|
||||||
|
- **Admin pode:** decidir clínico-alvo, frequência (one-time ou recurring), texto livre.
|
||||||
|
- **Fonte:** [Schedule Non-Clinical Events](https://support.therapynotes.com/hc/en-us/articles/30661451456667-Schedule-Non-Clinical-Events), [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
|
||||||
|
|
||||||
|
**Convergência:** os 3 têm entidade não-clínica separada de "appointment" — nunca usam appointment-sem-paciente como hack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Criação de compromisso COM paciente
|
||||||
|
|
||||||
|
### Cliniko
|
||||||
|
- **Default:** appointment exige paciente + appointment type + data/hora + practitioner. Paciente pode ser criado on-the-fly direto do dialog do appointment com apenas nome (descrição/categoria são opcionais).
|
||||||
|
- **Admin pode:** definir custom patient fields opcionais; appointment type carrega billable items default associados.
|
||||||
|
- **Fonte:** [Booking an appointment](https://help.cliniko.com/en/articles/1024061-booking-an-appointment), [Set up appointment types](https://help.cliniko.com/en/articles/1023911-set-up-appointment-types).
|
||||||
|
|
||||||
|
### SimplePractice
|
||||||
|
- **Default:** appointment exige cliente. Existe entidade intermediária chamada **Prospective client / Inquiry** — perfil parcial usado pra leads vindos de contact form ou pedido online. Pode-se enviar intake antes mesmo de aceitar o appointment (perfil definitivo só nasce ao aceitar).
|
||||||
|
- **Admin pode:** mandar link de agendamento; criar task de follow-up; enviar intake; rodar prescreener; converter inquiry em client.
|
||||||
|
- **Fonte:** [Managing prospective clients on the Inquiries page](https://support.simplepractice.com/hc/en-us/articles/33726366744589-Managing-prospective-clients-on-the-Inquiries-page), [Adding a new client](https://support.simplepractice.com/hc/en-us/articles/12416306860429-Adding-a-new-client-and-navigating-your-Clients-and-contacts-list).
|
||||||
|
|
||||||
|
### TherapyNotes
|
||||||
|
- **Default:** appointment clínico exige client + clinician + appointment type + date. Cliente novo precisa pelo menos de **last name**; demais campos (DOB, endereço, e-mail, sexo administrativo, HIPAA acknowledgment) só viram obrigatórios quando se vai submeter claim de plano ou ativar portal.
|
||||||
|
- **Admin pode:** liberar last-name-only para um "stub client" que recebe billable items mas não é submetível a plano até completar cadastro.
|
||||||
|
- **Fonte:** [Add a New Client](https://support.therapynotes.com/hc/en-us/articles/30661347776539-Add-a-New-Client), [Schedule a Clinical Appointment](https://support.therapynotes.com/hc/en-us/articles/30661407698203-Schedule-a-Clinical-Appointment).
|
||||||
|
|
||||||
|
**Convergência:** todos aceitam appointment com cadastro de paciente mínimo. SimplePractice é o único com camada formal de "lead" pré-prontuário.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cobrança / fatura — quando é gerada?
|
||||||
|
|
||||||
|
### Cliniko
|
||||||
|
- **Default:** invoice é **explicitamente criada** pelo usuário a partir do appointment (botão "Create invoice" no card do compromisso). Não há geração automática no agendamento.
|
||||||
|
- **Admin pode:** vincular billable items / produtos a um appointment type, então o "Create invoice" já vem populado. Em fluxo de pagamento online, a invoice é gerada e marcada como paga automaticamente no momento do pagamento confirmando o appointment.
|
||||||
|
- **Fonte:** [Create an invoice](https://help.cliniko.com/en/articles/1023907-create-an-invoice), [Relate billable items and products to an appointment type](https://help.cliniko.com/en/articles/1023847-relate-billable-items-and-products-to-an-appointment-type).
|
||||||
|
|
||||||
|
### SimplePractice
|
||||||
|
- **Default:** geração **automática**, configurável globalmente entre Daily (overnight, à meia-noite do timezone da prática), Monthly ou Manual. Status do appointment determina se vira invoice: apenas appointments com status **Show**, **Late canceled** ou **No show** geram invoice automaticamente.
|
||||||
|
- **Admin pode:** escolher daily/monthly/manual em Settings → Client billing → Client billing documents. Recomendação oficial: Daily quando cobra na hora da sessão; Monthly quando fecha o mês.
|
||||||
|
- **Fonte:** [Setting up your billing and automations](https://support.simplepractice.com/hc/en-us/articles/207925643-Setting-up-your-billing-and-automations), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing), [Best practices for time-of-session billing](https://support.simplepractice.com/hc/en-us/articles/115000837406-Best-practices-for-time-of-session-billing).
|
||||||
|
|
||||||
|
### TherapyNotes
|
||||||
|
- **Default:** billing line item é gerado **quando a nota da sessão é completada e assinada** pelo clínico. Cada appointment tem aba Billing acessível direto do dialog, mas o disparo de claim/invoice depende de note signed.
|
||||||
|
- **Admin pode:** configurar default billing method por payer; o To-Do list cria o lembrete pra submeter claim ou gerar CMS-1500 assim que a nota é assinada.
|
||||||
|
- **Fonte:** [Billing Overview](https://support.therapynotes.com/hc/en-us/articles/30661437130139-Billing-Overview), [Submit Electronic Claims](https://support.therapynotes.com/hc/en-us/articles/30661415430811-Submit-Electronic-Claims), [Quick Start: Billing](https://support.therapynotes.com/hc/en-us/articles/30661397280155-Quick-Start-Billing).
|
||||||
|
|
||||||
|
**Convergência:** ninguém cobra no momento de criar o appointment (futuro). Cliniko = manual sob demanda. SimplePractice = automático pós-sessão (status driven). TherapyNotes = automático pós-assinatura de nota (clinical-doc driven).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recorrência (séries) — billing
|
||||||
|
|
||||||
|
### Cliniko
|
||||||
|
- **Default:** repeating appointment (daily/weekly/fortnightly/monthly). Cada ocorrência é **appointment independente**; invoice continua sendo manual por ocorrência. Pra pacotes, recomenda usar **patient cases + account credit**: cobra o pacote inteiro upfront, o crédito fica no perfil do paciente e é consumido por cada invoice subsequente.
|
||||||
|
- **Admin pode:** decidir entre invoice-por-sessão (manual ou via pagamento online) ou pacote upfront via account credit.
|
||||||
|
- **Fonte:** [Book repeating appointments](https://help.cliniko.com/en/articles/1777286-book-repeating-appointments), [Tracking packages with patient cases and account credit](https://help.cliniko.com/en/articles/6477363-tracking-packages-with-patient-cases-and-account-credit).
|
||||||
|
|
||||||
|
### SimplePractice
|
||||||
|
- **Default:** série de até 100 ocorrências, recorrência semanal/mensal/anual. Cada ocorrência é independente para billing — invoice é criada na ocorrência conforme regra global daily/monthly. Editar uma ocorrência pergunta "just this one" ou "all in series". Ao deletar série inteira incluindo passado, **passa por cima** de ocorrências sem nota ou invoice anexada; ocorrências com invoice/nota são preservadas.
|
||||||
|
- **Admin pode:** ajustar fee de ocorrência já faturada via **fee adjustment invoice** (novo doc que ajusta o saldo, não toca a invoice original — esse é exatamente o padrão "cobrança emitida imutável" já adotado no projeto).
|
||||||
|
- **Fonte:** [Managing recurring appointments](https://support.simplepractice.com/hc/en-us/articles/41930568779021-Managing-recurring-appointments), [Creating invoices](https://support.simplepractice.com/hc/en-us/articles/207925663-Creating-invoices).
|
||||||
|
|
||||||
|
### TherapyNotes
|
||||||
|
- **Default:** recurring appointments indefinidos ou com data-fim. Cada ocorrência tem nota e billing independentes — billing line item nasce com a assinatura de cada nota individualmente.
|
||||||
|
- **Admin pode:** cancelar "só esta" ou "todas futuras" da série; alertas podem ser anexados à série inteira.
|
||||||
|
- **Fonte:** [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
|
||||||
|
|
||||||
|
**Convergência:** os 3 tratam ocorrência como unidade de billing. Pacote upfront é exceção (Cliniko via account credit). Nenhum gera "fatura única da série".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. No-show / cancelamento tardio
|
||||||
|
|
||||||
|
### Cliniko
|
||||||
|
- **Default:** plataforma não impõe fee; fornece ferramenta — terms of use no online booking + janela mínima de cancelamento (lock). Se paciente pagou full upfront online, ele **não consegue** cancelar pelo link; deposit parcial libera cancelamento.
|
||||||
|
- **Admin pode:** configurar minimum notice (várias opções entre "sem restrição" e "vários dias"); redigir política nos terms of use; aplicar fee manualmente via invoice.
|
||||||
|
- **Fonte:** [Restrict when a patient can cancel an appointment](https://help.cliniko.com/en/articles/1150562-restrict-when-a-patient-can-cancel-an-appointment), [Let patients cancel their appointments](https://help.cliniko.com/en/articles/1023945-let-patients-cancel-their-appointments).
|
||||||
|
|
||||||
|
### SimplePractice
|
||||||
|
- **Default:** statuses formais — **No show** e **Late canceled** (ambos billable, ambos geram invoice como qualquer Show quando auto-billing está ativo). Cancelamento dentro da janela permitida vira status não-billable.
|
||||||
|
- **Admin pode:** definir janela (24h ou 48h são presets) em Settings; statuses vão pra Client billing summary; appointments late-canceled aparecem em vermelho no calendário.
|
||||||
|
- **Fonte:** [Setting up your practice's cancellation policy](https://support.simplepractice.com/hc/en-us/articles/360046771271-Setting-up-your-practice-s-cancellation-policy), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing).
|
||||||
|
|
||||||
|
### TherapyNotes
|
||||||
|
- **Default:** **Missed Appointment Note** dedicada — registra ausência e tem checkbox que automaticamente cria billing line item para fee de cancelamento. TherapyPortal mostra warning ao paciente quando ele tenta cancelar fora da janela.
|
||||||
|
- **Admin pode:** habilitar/desabilitar criação automática de fee; configurar valor; texto da política aparece no portal.
|
||||||
|
- **Fonte:** [Complete a Missed Appointment Note](https://support.therapynotes.com/hc/en-us/articles/30661183276315-Complete-a-Missed-Appointment-Note), [TherapyNotes 4.15 release notes](https://blog.therapynotes.com/version-4-15).
|
||||||
|
|
||||||
|
**Convergência:** todos têm conceito de "cobrar pelo no-show". SimplePractice é o mais automatizado (status billable triggera invoice junto com os outros). TherapyNotes é o mais explícito (note dedicada + checkbox). Cliniko é o mais manual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reembolso / cancelamento de cobrança emitida
|
||||||
|
|
||||||
|
### Cliniko
|
||||||
|
- **Default:** invoice criada por engano pode ser **arquivada** (Archive button). **Número fiscal não retorna** — invoice 000001 arquivada não pode ser reemitida com o mesmo número. Reembolso real usa botão **Reverse** que cria credit note com itens negativos; usuário escolhe **Create credit & refund** (devolve dinheiro) ou **Create credit** (vira account credit). Para desfazer um refund, arquiva-se a credit note.
|
||||||
|
- **Fonte:** [Archive an invoice](https://help.cliniko.com/en/articles/1359931-archive-an-invoice), [Recording refunds: an overview](https://help.cliniko.com/en/articles/4372587-recording-refunds-an-overview), [Undo a refund](https://help.cliniko.com/en/articles/4521200-undo-a-refund).
|
||||||
|
|
||||||
|
### SimplePractice
|
||||||
|
- **Default:** invoice paga **não deve ser deletada** (deletar quebra alocação de pagamento). Refund full ou parcial é fluxo separado. Pagamentos cash/check/external podem ser deletados se foram erro; pagamento online com cartão não pode ser deletado, só refunded. Para mudar fee de invoice já emitida, usa **fee adjustment invoice** (novo doc com diff).
|
||||||
|
- **Fonte:** [Navigating client payments](https://support.simplepractice.com/hc/en-us/articles/8497757602957-Navigating-client-payments), [Managing unallocated client payments](https://support.simplepractice.com/hc/en-us/articles/42078634883469-Managing-unallocated-client-payments).
|
||||||
|
|
||||||
|
### TherapyNotes
|
||||||
|
- **Default:** **deletar pagamento ≠ refund** — deletar só remove o registro, não devolve dinheiro. Refund usa botão **Enter Refund** no Patient Accounting do tab Billing. Refund de payer (plano) tem opção dedicada que marca valor negativo automaticamente.
|
||||||
|
- **Fonte:** [Edit, Delete and Refund Client Payments](https://support.therapynotes.com/hc/en-us/articles/30661497068443-Edit-Delete-and-Refund-Client-Payments).
|
||||||
|
|
||||||
|
**Convergência:** os 3 distinguem "anular registro" de "estornar dinheiro". Os 3 preservam histórico fiscal (Cliniko via número não-reaproveitável + credit note; SimplePractice via fee adjustment; TherapyNotes via refund line item). Padrão "cobrança imutável" do projeto está alinhado com o estado da arte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabela comparativa 3 × 6
|
||||||
|
|
||||||
|
| Etapa | Cliniko | SimplePractice | TherapyNotes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1. Compromisso sem paciente | Unavailable block (tipos customizáveis) | Calendar event + OOO block (2 entidades) | Scheduled Event + Unavailable (2 tipos) |
|
||||||
|
| 2. Compromisso com paciente | Quick-create paciente (nome basta) | Lead (Inquiry) → cliente formal | Last name basta; demais campos só pra claim |
|
||||||
|
| 3. Quando gera cobrança | Manual via botão no appointment | Automático overnight (Daily/Monthly/Manual) condicionado a status billable | Quando nota da sessão é assinada |
|
||||||
|
| 4. Recorrência billing | Ocorrência individual ou pacote upfront (account credit) | Série até 100; ocorrência individual; fee adjustment para edit pós-fatura | Ocorrência individual; billing nasce na assinatura de cada nota |
|
||||||
|
| 5. No-show / late cancel | Política em terms of use; lock manual | Statuses billable (No show / Late canceled); janela 24h/48h | Missed Appointment Note com checkbox auto-fee |
|
||||||
|
| 6. Refund / cancel cobrança | Archive + Reverse → credit note | Não deletar invoice paga; fee adjustment + refund | Enter Refund (delete ≠ refund) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consenso de mercado
|
||||||
|
|
||||||
|
1. **Bloqueio de tempo é entidade própria**, separada de appointment. Nunca um appointment "sem paciente".
|
||||||
|
2. **Cadastro mínimo de paciente** (1 campo) é aceito; campos pesados só ficam obrigatórios na hora de cobrar plano ou ativar portal.
|
||||||
|
3. **Recorrência cria ocorrências independentes** para billing; nenhum gera "fatura única da série".
|
||||||
|
4. **Edit de uma ocorrência pergunta "esta / todas / futuras"** — padrão consagrado.
|
||||||
|
5. **Cobrança nunca é gerada na criação do appointment futuro** — sempre depois (sessão, status, nota, ou trigger manual).
|
||||||
|
6. **Cobrança emitida é imutável**; ajustes vêm via documento novo (credit note, fee adjustment invoice, refund line item). Validação direta do invariante do projeto.
|
||||||
|
7. **Deletar pagamento ≠ reembolsar dinheiro** — distinção explícita nos 3.
|
||||||
|
8. **Janela de cancelamento configurável + política em texto livre** é o mínimo.
|
||||||
|
|
||||||
|
## Divergência
|
||||||
|
|
||||||
|
- **Quem aciona a cobrança:** Cliniko = humano clica. SimplePractice = job overnight via status. TherapyNotes = assinatura de nota clínica. Três paradigmas distintos.
|
||||||
|
- **Lead / prospect:** SimplePractice tem entidade formal (Inquiry). Cliniko e TherapyNotes esperam o paciente já ter perfil mínimo.
|
||||||
|
- **No-show fee:** SimplePractice = mais automatizado (status billable). TherapyNotes = mais auditável (note dedicada). Cliniko = mais manual.
|
||||||
|
- **Pacote upfront:** Cliniko documenta explicitamente via account credit. SimplePractice/TherapyNotes não têm pacote nativo — cobram ocorrência a ocorrência.
|
||||||
|
- **Reaproveitamento de número de invoice arquivada:** Cliniko proíbe (alinhado com fiscal BR via NFS-e). Outros não documentam regra equivalente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Perguntas-chave pro produto decidir
|
||||||
|
|
||||||
|
1. **O que dispara a cobrança no fluxo padrão?**
|
||||||
|
a) Manual (humano clica) — máxima auditabilidade, exige disciplina (Cliniko).
|
||||||
|
b) Job automático com base em status do appointment (SimplePractice) — pouco atrito, dependente de status estar correto.
|
||||||
|
c) Assinatura de nota da sessão (TherapyNotes) — vincula clínica e financeira, atrasa cobrança se nota demora.
|
||||||
|
**Trade-off:** quanto mais automático, menos atrito mas mais risco de cobrança errada; quanto mais manual, mais fricção mas auditoria perfeita.
|
||||||
|
|
||||||
|
2. **Devemos ter conceito formal de "lead/contato" antes de prontuário?**
|
||||||
|
a) Sim — entidade Inquiry separada com pipeline (modelo SimplePractice).
|
||||||
|
b) Não — paciente nasce na quick-create do agendamento com nome só (modelo Cliniko/TherapyNotes).
|
||||||
|
**Trade-off:** Inquiry casa com funil comercial mas duplica entidade; quick-create é simples mas dificulta funil de pré-vendas.
|
||||||
|
|
||||||
|
3. **Recorrência cobra cada ocorrência ou suporta pacote upfront?**
|
||||||
|
a) Só ocorrência individual (SimplePractice/TherapyNotes).
|
||||||
|
b) Suporta também pacote upfront com saldo (Cliniko via patient case + account credit).
|
||||||
|
**Trade-off:** pacote upfront atende prática que vende "10 sessões antecipado"; ocorrência-a-ocorrência casa direto com NFS-e brasileira (1 nota por serviço).
|
||||||
|
|
||||||
|
4. **No-show vira invoice automática ou exige ação manual?**
|
||||||
|
a) Automático — status "No show" / "Late canceled" entram no auto-billing como Show (SimplePractice).
|
||||||
|
b) Semi — note dedicada com checkbox que controla geração (TherapyNotes).
|
||||||
|
c) Manual — admin cria invoice de no-show à mão (Cliniko).
|
||||||
|
**Trade-off:** automático reduz perda mas pode constranger paciente sem revisão; manual exige rotina disciplinada.
|
||||||
|
|
||||||
|
5. **Edição de uma ocorrência de série recorrente: o que faz com cobrança já emitida?**
|
||||||
|
a) Bloqueia edição (invariante atual — alinhado com SimplePractice "fee adjustment invoice" preservando original).
|
||||||
|
b) Permite edição com nova cobrança suplementar (delta).
|
||||||
|
c) Permite edição e refaz a cobrança (cancela + recria).
|
||||||
|
**Trade-off:** opção a é a mais defensável fiscalmente (NFS-e já transmitida não pode ser silenciosamente mutada); b atende UX; c é perigoso mas familiar.
|
||||||
|
|
||||||
|
6. **Janela de cancelamento: presets ou livre?**
|
||||||
|
a) Presets (24h / 48h) com texto da política livre (SimplePractice).
|
||||||
|
b) Configuração granular por appointment type (Cliniko).
|
||||||
|
c) Cliente final só vê warning, sem lock (TherapyNotes).
|
||||||
|
**Trade-off:** presets cobrem 90% dos casos; granular casa com clínica que tem terapia de grupo + casal + individual com janelas diferentes.
|
||||||
|
|
||||||
|
7. **Reembolso preserva o documento fiscal original?**
|
||||||
|
a) Sim, sempre — credit note nova, número fiscal original nunca volta (Cliniko + alinhado com NFS-e brasileira: cancelamento ≠ deletar).
|
||||||
|
b) Sim, mas via fee adjustment que não toca a invoice (SimplePractice).
|
||||||
|
c) Sim, refund é line item separado (TherapyNotes).
|
||||||
|
**Trade-off:** modelo brasileiro de NFS-e exige (a) ou (c); SimplePractice (b) só funciona em mercados sem NF transmitida por API.
|
||||||
|
|
||||||
|
8. **Pagamento via PIX (e cartão online) confirma e marca invoice paga automaticamente?**
|
||||||
|
a) Sim — pagamento confirmado dispara appointment confirmado + invoice paga (Cliniko online payment).
|
||||||
|
b) Pagamento é entidade separada que pode ser alocada/desalocada (SimplePractice).
|
||||||
|
**Trade-off:** auto-confirm é UX premium mas exige tolerância a falhas de webhook do PSP; pagamento desalocado é seguro mas exige conciliação.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implicações imediatas pro projeto
|
||||||
|
|
||||||
|
- O invariante "cobrança emitida é imutável" já implementado é consenso de mercado — manter.
|
||||||
|
- "Compromisso sem paciente" precisa virar entidade própria (block/event), não um appointment com paciente null. Ver [[recorrencia-agenda]] para integração com expansão de série.
|
||||||
|
- Recorrência por ocorrência individual é o caminho seguro (cabe em NFS-e). Pacote upfront fica para fase 2.
|
||||||
|
- Disparo de cobrança: avaliar híbrido SimplePractice (status-driven) + TherapyNotes (note-signed), com fallback manual estilo Cliniko.
|
||||||
|
- Perguntas 1, 4, 5, 7, 8 são pré-requisito pra fechar o gap atual de billing antes de F1 de fiscal.
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
---
|
||||||
|
title: Plano de auditoria fase-a-fase — fluxo de compromisso da agenda
|
||||||
|
date: 2026-05-13
|
||||||
|
status: em-andamento
|
||||||
|
related: [[agenda-billing-pesquisa-mercado]], [[recorrencia-agenda]]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Auditoria do ciclo completo de compromisso da agenda, fase-a-fase, validando cada etapa contra a [[agenda-billing-pesquisa-mercado|pesquisa de mercado]] (Cliniko / SimplePractice / TherapyNotes). Cada fase tem 3 entregas: **auditar o que existe**, **decidir o gap**, **codar**.
|
||||||
|
|
||||||
|
## Decisões já tomadas (5 das 8 perguntas)
|
||||||
|
|
||||||
|
| # | Decisão |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Disparo de cobrança: **híbrido configurável** (manual / status-driven / note-signed) |
|
||||||
|
| 4 | No-show: **semi-automático via dialog de confirmação** ao mudar status |
|
||||||
|
| 5 | Edit de cobrada: **bloqueia** (já implementado) |
|
||||||
|
| 7 | Refund: **credit note nova** (alinhado NFS-e) |
|
||||||
|
| 8 | Pagamento: **entidade separada** de financial_records |
|
||||||
|
|
||||||
|
Pendentes: #2 (lead/Inquiry), #3 (pacote upfront), #6 (janela de cancelamento — provavelmente já resolvido por `min_hours_notice` em `financial_exceptions`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plano de 8 fases
|
||||||
|
|
||||||
|
Ordem por dependência ("o que destrava o quê") e por estado atual.
|
||||||
|
|
||||||
|
### ✅ Fase 1 — Compromisso SEM paciente (bloqueio/feriado/exceção) — **CONCLUÍDA 2026-05-13**
|
||||||
|
|
||||||
|
**Auditoria fez:**
|
||||||
|
- ✅ `agenda_excecoes` é tabela órfã (0 referências em src/) — apesar de schema, policies, trigger e enums existentes
|
||||||
|
- ✅ `agenda_bloqueios` é a entidade canônica usada pelos 3 layouts
|
||||||
|
- ✅ `BloqueioDialog` (4 modos: horário/período/dia/feriados) é compartilhado por Melissa Agenda (via `MelissaLayout.vue:2186`), Rail e Clínica
|
||||||
|
- ✅ `MelissaBloqueios.vue` tem form inline próprio pra **admin/edit** (caso de uso legítimo distinto do dialog de 4 modos)
|
||||||
|
- ✅ Bloqueios não eram renderizados no FullCalendar — apenas impediam criação. UX inconsistente vs pausas/feriados que aparecem como background events
|
||||||
|
- ⚠️ Tipos customizáveis de bloqueio: descartado no MVP (sem cliente real)
|
||||||
|
- ⚠️ Robustez de `marcarSessoesParaRemarcar`: adiado pra Fase 5 (status change)
|
||||||
|
|
||||||
|
**Aplicado:**
|
||||||
|
1. Migration `20260513000001_drop_agenda_excecoes.sql` — dropa tabela + 2 enums + trigger; policies caem com CASCADE
|
||||||
|
2. `agendaMappers.js`: nova função `buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd)` — renderiza bloqueios como background events cinza (`#6b728033`), suporta dia-inteiro, com hora, e recorrente semanal
|
||||||
|
3. Novo composable `useAgendaBloqueios.js` — load por owner único OU array (multi-owner pra Clínica), `buildEventsForRange` reutilizável
|
||||||
|
4. Wire em `useMelissaAgenda` + `MelissaAgenda.vue` — bloqueios concatenados ao `fcEvents`
|
||||||
|
5. Wire em `AgendaTerapeutaPage` — bloqueios concatenados ao `calendarEvents`
|
||||||
|
6. Wire em `AgendaClinicaPage` — bloqueios consolidados de todos os ownerIds
|
||||||
|
7. Refs stale removidas de `database-novo/docs/schema_map.md` e `database-novo/db.config.json`
|
||||||
|
|
||||||
|
**Verificação:**
|
||||||
|
- ESLint nos arquivos modificados: 0 errors novos (11 pré-existentes em código não-tocado)
|
||||||
|
- Vitest `agendaMappers.spec.js`: 40/40 tests passed
|
||||||
|
- ⚠️ **Falta rodar a migration no banco local** (pendente de execução manual; arquivo SQL pronto)
|
||||||
|
- ⚠️ **Falta validar visualmente** nos 3 layouts (Melissa/Rail/Clínica) — verificar que bloqueios aparecem em cinza após criar pelo BloqueioDialog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 Fase 2 — Compromisso COM paciente
|
||||||
|
**Estado:** dialog refatorado em 11/05 (cards 40px, picker DataTable, 50/50 layout, 3 estados Sessão/Honorários, conceito Pacote, resumo flutuante). Working tree.
|
||||||
|
|
||||||
|
**Auditar:**
|
||||||
|
- Fluxo de cadastro mínimo de paciente in-line (já existe via `PatientCadastroDialog` quick mode?)
|
||||||
|
- Decidir #2 (Inquiry/lead separado ou só quick-create)
|
||||||
|
- Modalidade presencial/online consistente
|
||||||
|
|
||||||
|
**Gap potencial:**
|
||||||
|
- Quick-create exige só nome ou mais campos? (Cliniko: só nome; TherapyNotes: só last name)
|
||||||
|
- Decisão #2 (Inquiry/lead) — adiar pra v2 provável
|
||||||
|
|
||||||
|
**Codar:** ajustes pequenos, principalmente UX. Provavelmente quase nada novo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 Fase 3 — Recorrência
|
||||||
|
**Estado:** modelo "1 real + N-1 virtual" + `occurrenceMode` no 2º dialog estabilizado em 12/05. Ver [[recorrencia-agenda]].
|
||||||
|
|
||||||
|
**Auditar:**
|
||||||
|
- `occurrenceMode` já replicado em Melissa; falta Rail (`AgendaTerapeutaPage` L1630 + L3080) e Clínica (`AgendaClinicaPage` L1119 + L2398)
|
||||||
|
- Decisão #3 (pacote upfront via account credit) — adiar provável
|
||||||
|
|
||||||
|
**Codar:** replicar `occurrenceMode` em Rail/Clínica. Talvez add de pacote upfront (Cliniko model) numa fase futura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 Fase 4 — Cobrança: modo de disparo configurável (DECISÃO #1)
|
||||||
|
**Estado:** Fase 1 atual ("Gerar cobrança ao salvar") existe como checkbox em criação avulsa+particular. Não tem setting de modo.
|
||||||
|
|
||||||
|
**Auditar:**
|
||||||
|
- Onde vive a config? Card novo em `/configuracoes/excecoes-financeiras` ou página irmã `/configuracoes/cobranca-defaults`?
|
||||||
|
- Granularidade: por tenant (clínica), por owner (terapeuta), ou ambos com herança?
|
||||||
|
|
||||||
|
**Gap:**
|
||||||
|
- Tabela/coluna nova pra `charge_trigger_mode` enum (`manual` / `status_driven` / `note_signed`)
|
||||||
|
- UI de config
|
||||||
|
- Job overnight pra modo `status_driven` (Supabase edge function + cron)
|
||||||
|
- Trigger no signature de nota pra `note_signed` (depende de modulo de notas; nao temos)
|
||||||
|
- Checkbox atual da agenda passa a fazer sentido **só em modo manual** (ou vira override universal?)
|
||||||
|
|
||||||
|
**Codar:**
|
||||||
|
1. Migration: setting de modo (tenant_billing_settings ou colunas em agenda_configuracoes)
|
||||||
|
2. UI de config
|
||||||
|
3. Job pra modo status_driven (avaliar se entra na v1 ou v2)
|
||||||
|
4. Refator do checkbox atual pra respeitar o modo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 Fase 5 — Status change → cobrança com confirm dialog (DECISÃO #4)
|
||||||
|
**Estado:** lógica automática roda em `useAgendaFinanceiro.handleStatusChange`. Consulta regra em `financial_exceptions`, cria/ajusta/cancela `financial_record` SEM perguntar.
|
||||||
|
|
||||||
|
**Auditar:**
|
||||||
|
- Quais status disparam: hoje só `faltou` e `cancelado` (mapping `STATUS_TO_EXCEPTION`)
|
||||||
|
- `professional_cancellation` na tabela mas não no mapping
|
||||||
|
- Onde `handleStatusChange` é chamado (quais entradas de status change disparam)
|
||||||
|
|
||||||
|
**Gap:**
|
||||||
|
- Confirm dialog ao mudar status pra `faltou` / `cancelado`: *"Aplicar cobrança de R$X conforme regra? [Sim / Não / Editar valor]"*
|
||||||
|
- Adicionar `professional_cancellation` ao mapping (status atual da agenda inclui? checar)
|
||||||
|
- Decidir: dialog aparece **sempre** ou só quando `charge_mode !== 'none'`
|
||||||
|
|
||||||
|
**Codar:**
|
||||||
|
1. Dialog componente novo (`AgendaStatusChargeConfirmDialog.vue`)
|
||||||
|
2. Interceptar `handleStatusChange` antes da aplicação automática
|
||||||
|
3. Adicionar `professional_cancellation` no mapping
|
||||||
|
4. Toast diferenciado pra "aplicado/recusado/editado"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 Fase 6 — Edit de cobrada (DECISÃO #5 — JÁ IMPLEMENTADO)
|
||||||
|
**Estado:** `propagateToSerie` filtra por `financial_records` em status imutável. UI lock em `AgendaEventDialog` via `occFinancialRecord`. Working tree.
|
||||||
|
|
||||||
|
**Auditar:** validar contra cenários reais (testar série com 4 sessões, 2 cobradas, 2 abertas; editar template; verificar que cobranças não mudam).
|
||||||
|
|
||||||
|
**Codar:** zero (talvez add de aviso UX se faltar clareza).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 Fase 7 — Pagamento como entidade separada (DECISÃO #8)
|
||||||
|
**Estado:** hoje `financial_records.paid_at` marca pagamento (acoplado). Não tem entidade `payments` independente.
|
||||||
|
|
||||||
|
**Auditar:**
|
||||||
|
- Como financial_records.paid_at é usado hoje (queries de receita, dashboards, conciliação)
|
||||||
|
- Webhook PSP existente? (provável que PIX e cartão sejam manuais hoje)
|
||||||
|
|
||||||
|
**Gap:**
|
||||||
|
- Migration: tabela `payments` (id, amount, method, paid_at, source, allocated_to_record_id NULL-able)
|
||||||
|
- Alocação manual de pagamento "solto" a um financial_record
|
||||||
|
- Pagamento parcial (1 payment cobre N records ou 1 record recebe N payments?)
|
||||||
|
- Repo + composable + UI
|
||||||
|
|
||||||
|
**Codar:** fase pesada — provavelmente sub-dividir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 Fase 8 — Reembolso / credit note (DECISÃO #7)
|
||||||
|
**Estado:** hoje só tem `financial_records.status='cancelled'`. Não preserva original como doc fiscal.
|
||||||
|
|
||||||
|
**Auditar:** processo fiscal atual (já emite NFS-e? quando? como cancela?)
|
||||||
|
|
||||||
|
**Gap:**
|
||||||
|
- Migration: tabela `credit_notes` (id, original_record_id, amount, reason, issued_at)
|
||||||
|
- Constraint: credit note tem valor ≤ |original|
|
||||||
|
- UI no Financeiro pra "Reembolsar"
|
||||||
|
- Integração com NFS-e (pode ser separada)
|
||||||
|
|
||||||
|
**Codar:** fase pesada — provavelmente sub-dividir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟣 Fase 9 — Plano Inicial (entrevista + N sessões regulares)
|
||||||
|
**Estado:** apenas conceito; nada codado.
|
||||||
|
|
||||||
|
**Pedido do user (2026-05-14):** clínica cobra **1 entrevista inicial** (valor X) + **4 sessões regulares** (valor Y cada). É o "plano de entrada" pra novos pacientes. User faz isso manualmente hoje na clínica dele.
|
||||||
|
|
||||||
|
**Conceito:**
|
||||||
|
- Config nas settings da agenda do tenant:
|
||||||
|
- Toggle "Habilitar plano inicial"
|
||||||
|
- Valor entrevista (R$)
|
||||||
|
- Qtd de sessões regulares (default 4)
|
||||||
|
- Valor por sessão regular (R$)
|
||||||
|
- (Opcional) Texto/descrição que aparece no fluxo
|
||||||
|
- Quando user cria 1ª sessão de **paciente novo** (sem histórico):
|
||||||
|
- Sistema oferece: "Aplicar plano inicial? Entrevista R$ X + 4× R$ Y = total R$ Z"
|
||||||
|
- Ao aceitar, materializa 5 sessões com `price` diferenciado: 1ª = X, demais = Y
|
||||||
|
- Pode ser tratado como 1 série recorrente "especial" com 1ª ocorrência destacada
|
||||||
|
- OU como 2 entidades distintas (1 avulsa entrevista + 1 série de 4)
|
||||||
|
|
||||||
|
**Decisões pendentes:**
|
||||||
|
- Estrutura: série única com 1ª diferenciada OU avulsa + série separada?
|
||||||
|
- Onde fica a config: `agenda_configuracoes` (jsonb adicional?) ou tabela nova `intake_plans`?
|
||||||
|
- "Paciente novo" = sem sessões anteriores? Ou marcador manual no cadastro?
|
||||||
|
- Plano único do tenant ou múltiplos planos (avaliação clínica, avaliação neuropsi, etc)?
|
||||||
|
|
||||||
|
**Cabe na Fase 4 (cobrança)?** Não — Fase 4 é só modo de disparo; aqui é estrutura de pacote pré-configurado. Fica como Fase 9 separada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordem sugerida de execução
|
||||||
|
|
||||||
|
| Ordem | Fase | Razão |
|
||||||
|
|---|---|---|
|
||||||
|
| 1ª | **Fase 1** | Curta, validação, define se tem cleanup de tabelas necessário |
|
||||||
|
| 2ª | **Fase 5** | Destrava UX urgente (confirm dialog evita cobrar errado) |
|
||||||
|
| 3ª | **Fase 4** | Híbrido configurável — destrava racional do checkbox atual |
|
||||||
|
| 4ª | **Fase 2** | Quase 100% pronta, validar e finalizar |
|
||||||
|
| 5ª | **Fase 3** | Replicar `occurrenceMode` em Rail/Clínica |
|
||||||
|
| 6ª | **Fase 6** | Já feito; só testar |
|
||||||
|
| 7ª | **Fase 7** | Refator estrutural pesado — entra depois das fases UX |
|
||||||
|
| 8ª | **Fase 8** | Depende fiscal NFS-e — pode ir pra v2 |
|
||||||
|
| 9ª | **Fase 9** | Plano Inicial (entrevista + 4 sessões) — pedido do user, conceito pronto, codar pós-7 |
|
||||||
|
|
||||||
|
## Como cada fase termina
|
||||||
|
|
||||||
|
1. Página da fase na wiki é atualizada com o resultado
|
||||||
|
2. Commit dedicado com prefixo `agenda(fase-N): ...`
|
||||||
|
3. Update no [[index]] da wiki
|
||||||
|
4. Entrada no `log.md`
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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.*
|
||||||
|
- [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4)
|
||||||
|
- [[Freemium PLG]] — signup self-service + Upgrade PRO; plano gratuito limitado (pacientes); confirmação de e-mail + onboarding; branch feat/freemium-plg
|
||||||
@@ -0,0 +1,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'`
|
||||||
@@ -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`
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Dialog — Padrão de Componente
|
# Dialog — Padrão de Componente
|
||||||
|
|
||||||
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
|
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
|
||||||
|
> **Tema-aware**: header e footer respeitam dark/light automaticamente via CSS vars
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,6 +20,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## Estrutura obrigatória
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -44,9 +62,9 @@
|
|||||||
class="dc-dialog w-[50rem]"
|
class="dc-dialog w-[50rem]"
|
||||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||||
:pt="{
|
:pt="{
|
||||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||||
content: { class: '!p-3' },
|
content: { class: '!p-3' },
|
||||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
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' } },
|
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||||
}"
|
}"
|
||||||
@@ -58,14 +76,16 @@
|
|||||||
|
|
||||||
| Chave | O que faz |
|
| Chave | O que faz |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `header` | `!p-3` padding uniforme; `!rounded-t-[12px]` borda top arredondada; `border-b` + `shadow` separador com profundidade; `bg-gray-100` fundo levemente cinza |
|
| `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 |
|
| `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` + `shadow` separador; `bg-gray-100` fundo levemente cinza |
|
| `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 |
|
| `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 |
|
| `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.
|
> 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`
|
## Header — slot `#header`
|
||||||
@@ -89,10 +109,10 @@
|
|||||||
:style="{ backgroundColor: previewBgColor }"
|
:style="{ backgroundColor: previewBgColor }"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="text-base font-semibold truncate">
|
<div class="text-base font-semibold truncate text-[var(--text-color)]">
|
||||||
{{ form.name || (mode === 'create' ? 'Novo item' : 'Editar item') }}
|
{{ form.name || (mode === 'create' ? 'Novo item' : 'Editar item') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-50">
|
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||||
{{ mode === 'create' ? 'Criando novo registro' : 'Editando registro' }}
|
{{ mode === 'create' ? 'Criando novo registro' : 'Editando registro' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +136,8 @@
|
|||||||
</template>
|
</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`
|
## Footer — slot `#footer`
|
||||||
@@ -157,6 +179,20 @@ Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automatic
|
|||||||
<Dialog maximizable ...>
|
<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
|
## Checklist antes de publicar um Dialog
|
||||||
@@ -165,11 +201,13 @@ Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automatic
|
|||||||
- [ ] `maximizable` na prop (botão nativo, sem estado manual)
|
- [ ] `maximizable` na prop (botão nativo, sem estado manual)
|
||||||
- [ ] `class="dc-dialog w-[50rem]"` + `:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"`
|
- [ ] `class="dc-dialog w-[50rem]"` + `:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"`
|
||||||
- [ ] `pt` completo: header, content, footer, pcCloseButton, pcMaximizeButton
|
- [ ] `pt` completo: header, content, footer, pcCloseButton, pcMaximizeButton
|
||||||
- [ ] Header com `bg-gray-100`, `border-b`, shadow e `!rounded-t-[12px]`
|
- [ ] Header com `bg-[var(--surface-ground)]`, `border-b`, e `!rounded-t-[12px]`
|
||||||
- [ ] Footer com `bg-gray-100`, `border-t`, shadow e `!rounded-b-[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
|
- [ ] Botão **Excluir** no header (nunca no footer), desabilitado se nativo
|
||||||
- [ ] Cancelar = `text` + `hover:!text-red-500` | Salvar = primary
|
- [ ] Cancelar = `text` + `hover:!text-red-500` | Salvar = primary
|
||||||
- [ ] Padding do footer via `px-3 py-3` no `div` interno
|
- [ ] Padding do footer via `px-3 py-3` no `div` interno
|
||||||
|
- [ ] Texto usa `text-[var(--text-color)]` e `text-[var(--text-color-secondary)]`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,7 +215,33 @@ Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automatic
|
|||||||
|
|
||||||
| Uso | Classe |
|
| Uso | Classe |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Formulário simples | `w-[36rem]` |
|
| Cadastro rápido / formulário simples | `w-[36rem]` |
|
||||||
| Formulário padrão | `w-[50rem]` ← **padrão** |
|
| Formulário padrão | `w-[50rem]` ← **padrão** |
|
||||||
| Formulário complexo | `w-[70rem]` |
|
| Formulário complexo (multi-coluna) | `w-[70rem]` |
|
||||||
|
| Cadastro completo (paciente, agenda) | `w-[1100px]` |
|
||||||
| Tela cheia | `maximizable` — usuário controla |
|
| 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>
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 **0–N 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
|
||||||
|
1024–1279 (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
|
||||||
|
só `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)
|
||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,7 @@ function psqlFile(filePath) {
|
|||||||
const absPath = path.resolve(filePath);
|
const absPath = path.resolve(filePath);
|
||||||
const content = fs.readFileSync(absPath, 'utf8');
|
const content = fs.readFileSync(absPath, 'utf8');
|
||||||
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
|
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
|
||||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
|
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q -v ON_ERROR_STOP=1`;
|
||||||
return execSync(cmd, {
|
return execSync(cmd, {
|
||||||
input: utf8Content,
|
input: utf8Content,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
"seed_015_document_templates.sql",
|
"seed_015_document_templates.sql",
|
||||||
"seed_030_dev_phases_items.sql",
|
"seed_030_dev_phases_items.sql",
|
||||||
"seed_031_dev_auditoria.sql",
|
"seed_031_dev_auditoria.sql",
|
||||||
"seed_032_dev_competitors.sql"
|
"seed_032_dev_competitors.sql",
|
||||||
|
"seed_040_clinical_note_templates.sql",
|
||||||
|
"seed_050_specialties.sql",
|
||||||
|
"seed_060_consent_forms_extra.sql"
|
||||||
],
|
],
|
||||||
"test_data": [
|
"test_data": [
|
||||||
"seed_020_test_data.sql"
|
"seed_020_test_data.sql"
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
"contact_email_types", "contact_emails"
|
"contact_email_types", "contact_emails"
|
||||||
],
|
],
|
||||||
"Agenda / Agendamento": [
|
"Agenda / Agendamento": [
|
||||||
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
|
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes",
|
||||||
"agenda_online_slots", "agenda_regras_semanais",
|
"agenda_online_slots", "agenda_regras_semanais",
|
||||||
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
|
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
|
||||||
"agendador_configuracoes", "agendador_solicitacoes"
|
"agendador_configuracoes", "agendador_solicitacoes"
|
||||||
@@ -132,7 +135,7 @@
|
|||||||
"notification_templates", "notification_channels", "notification_preferences",
|
"notification_templates", "notification_channels", "notification_preferences",
|
||||||
"notification_logs", "notification_schedules", "notification_queue",
|
"notification_logs", "notification_schedules", "notification_queue",
|
||||||
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
|
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
|
||||||
"twilio_subaccount_usage"
|
"twilio_subaccount_usage", "saas_twilio_config"
|
||||||
],
|
],
|
||||||
"CRM Conversas (WhatsApp)": [
|
"CRM Conversas (WhatsApp)": [
|
||||||
"conversation_messages", "conversation_threads",
|
"conversation_messages", "conversation_threads",
|
||||||
@@ -140,14 +143,30 @@
|
|||||||
"conversation_tags", "conversation_thread_tags",
|
"conversation_tags", "conversation_thread_tags",
|
||||||
"conversation_optouts", "conversation_optout_keywords",
|
"conversation_optouts", "conversation_optout_keywords",
|
||||||
"conversation_autoreply_settings", "conversation_autoreply_log",
|
"conversation_autoreply_settings", "conversation_autoreply_log",
|
||||||
"session_reminder_settings", "session_reminder_logs"
|
"session_reminder_settings", "session_reminder_logs",
|
||||||
|
"conversation_assignments",
|
||||||
|
"conversation_bots", "conversation_bot_sessions",
|
||||||
|
"conversation_sla_rules", "conversation_sla_breaches",
|
||||||
|
"whatsapp_connection_incidents"
|
||||||
],
|
],
|
||||||
"Segurança / Rate limiting": [
|
"Segurança / Auditoria": [
|
||||||
"submission_rate_limits"
|
"submission_rate_limits",
|
||||||
|
"audit_logs",
|
||||||
|
"saas_security_config",
|
||||||
|
"math_challenges",
|
||||||
|
"patient_invite_attempts",
|
||||||
|
"public_submission_attempts"
|
||||||
],
|
],
|
||||||
"Central SaaS (docs/FAQ)": [
|
"Central SaaS (docs/FAQ)": [
|
||||||
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
|
"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": [
|
"Estrutura / Calendário": [
|
||||||
"feriados"
|
"feriados"
|
||||||
]
|
]
|
||||||
@@ -163,8 +182,9 @@
|
|||||||
"Documentos": "#0ea5e9",
|
"Documentos": "#0ea5e9",
|
||||||
"Comunicação / Notificações": "#fbbf24",
|
"Comunicação / Notificações": "#fbbf24",
|
||||||
"CRM Conversas (WhatsApp)": "#25d366",
|
"CRM Conversas (WhatsApp)": "#25d366",
|
||||||
"Segurança / Rate limiting": "#ef4444",
|
"Segurança / Auditoria": "#ef4444",
|
||||||
"Central SaaS (docs/FAQ)": "#c084fc",
|
"Central SaaS (docs/FAQ)": "#c084fc",
|
||||||
|
"Dev / Tracking": "#94a3b8",
|
||||||
"Estrutura / Calendário": "#fb923c"
|
"Estrutura / Calendário": "#fb923c"
|
||||||
},
|
},
|
||||||
"infrastructure": {
|
"infrastructure": {
|
||||||
|
|||||||
@@ -43,13 +43,12 @@ Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-0
|
|||||||
| `module_features` | Features por módulo |
|
| `module_features` | Features por módulo |
|
||||||
| `tenant_modules` | Módulos ativos por tenant |
|
| `tenant_modules` | Módulos ativos por tenant |
|
||||||
|
|
||||||
### Agenda (11 tabelas)
|
### Agenda (10 tabelas)
|
||||||
| Tabela | Descrição |
|
| Tabela | Descrição |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| `agenda_bloqueios` | Bloqueios de horário |
|
| `agenda_bloqueios` | Bloqueios de horário |
|
||||||
| `agenda_configuracoes` | Configurações da agenda por tenant_member |
|
| `agenda_configuracoes` | Configurações da agenda por tenant_member |
|
||||||
| `agenda_eventos` | Eventos da agenda (sessões, bloqueios) |
|
| `agenda_eventos` | Eventos da agenda (sessões, bloqueios) |
|
||||||
| `agenda_excecoes` | Exceções na agenda (horários extras, bloqueios pontuais) |
|
|
||||||
| `agenda_online_slots` | Slots de agendamento online |
|
| `agenda_online_slots` | Slots de agendamento online |
|
||||||
| `agenda_regras_semanais` | Regras semanais de disponibilidade |
|
| `agenda_regras_semanais` | Regras semanais de disponibilidade |
|
||||||
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
|
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ function buildSB(){
|
|||||||
</div>
|
</div>
|
||||||
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
|
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
|
||||||
for(const[d,ts]of Object.entries(D.domains)){
|
for(const[d,ts]of Object.entries(D.domains)){
|
||||||
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
|
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain('\${D.slugs[d]}')">
|
||||||
<div class="sb-dot" style="background:\${gc(d)}"></div>\${escapeHtml(d)}
|
<div class="sb-dot" style="background:\${gc(d)}"></div>\${escapeHtml(d)}
|
||||||
<span class="sb-c">\${ts.length}</span>
|
<span class="sb-c">\${ts.length}</span>
|
||||||
</div>\`;
|
</div>\`;
|
||||||
@@ -349,7 +349,7 @@ function buildMN(){
|
|||||||
<div class="dgrid">\`;
|
<div class="dgrid">\`;
|
||||||
for(const[d,ts]of Object.entries(D.domains)){
|
for(const[d,ts]of Object.entries(D.domains)){
|
||||||
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
|
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
|
||||||
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
|
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain('\${D.slugs[d]}')">
|
||||||
<div class="dc-n">\${escapeHtml(d)}</div>
|
<div class="dc-n">\${escapeHtml(d)}</div>
|
||||||
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
|
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
|
||||||
</div>\`;
|
</div>\`;
|
||||||
@@ -420,7 +420,7 @@ function sel(d){
|
|||||||
dom=d;view='overview';q='';document.getElementById('si').value='';
|
dom=d;view='overview';q='';document.getElementById('si').value='';
|
||||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||||
}
|
}
|
||||||
function scrollToDomain(d){
|
function scrollToDomain(slug){
|
||||||
// Sempre ir pra overview (com todos os domínios visíveis) antes de scrollar
|
// Sempre ir pra overview (com todos os domínios visíveis) antes de scrollar
|
||||||
const needRebuild=view!=='overview'||dom!==null||q;
|
const needRebuild=view!=='overview'||dom!==null||q;
|
||||||
if(needRebuild){
|
if(needRebuild){
|
||||||
@@ -429,7 +429,7 @@ function scrollToDomain(d){
|
|||||||
buildSB();buildMN();
|
buildSB();buildMN();
|
||||||
}
|
}
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
const el=document.getElementById('dom-'+(D.slugs[d]||''));
|
const el=document.getElementById('dom-'+slug);
|
||||||
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
|
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
|
||||||
}, needRebuild?80:0);
|
}, needRebuild?80:0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F5 (parte supabase_admin) — refresh dinâmico dos schemas expostos no PostgREST
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (postgres NÃO é superuser neste stack e não
|
||||||
|
-- consegue ALTER ROLE authenticator). Mesmo padrão do gotcha de `documents`.
|
||||||
|
--
|
||||||
|
-- docker exec -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||||
|
-- psql -U supabase_admin -h 127.0.0.1 -d postgres \
|
||||||
|
-- -f /dev/stdin < database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql
|
||||||
|
--
|
||||||
|
-- A config in-database do PostgREST (db-config, ligada por padrão) lê
|
||||||
|
-- pgrst.db_schemas da role `authenticator`. Setar essa GUC + NOTIFY reload
|
||||||
|
-- expõe/retira schemas tenant SEM restart do container. A GUC persiste em
|
||||||
|
-- pg_db_role_setting (sobrevive a supabase stop/start).
|
||||||
|
--
|
||||||
|
-- A lista é derivada SEMPRE de public.tenant_schemas (fonte da verdade dos
|
||||||
|
-- schemas provisionados). Disparada pelo trigger em tenant_schemas (migration
|
||||||
|
-- 20260613000002) a cada clone/drop de tenant.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.refresh_pgrst_schemas()
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER -- roda como o OWNER (supabase_admin/superuser)
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_list text;
|
||||||
|
BEGIN
|
||||||
|
SELECT string_agg(s, ', ' ORDER BY ord, s)
|
||||||
|
INTO v_list
|
||||||
|
FROM (
|
||||||
|
SELECT 'public'::text AS s, 0 AS ord
|
||||||
|
UNION ALL SELECT 'graphql_public', 1
|
||||||
|
UNION ALL SELECT schema_name, 2 FROM public.tenant_schemas
|
||||||
|
) x;
|
||||||
|
|
||||||
|
-- baseline defensivo se a tabela ainda não existir / vazia
|
||||||
|
IF v_list IS NULL OR v_list = '' THEN
|
||||||
|
v_list := 'public, graphql_public';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
EXECUTE format('ALTER ROLE authenticator SET pgrst.db_schemas = %L', v_list);
|
||||||
|
NOTIFY pgrst, 'reload config'; -- re-lê db_schemas
|
||||||
|
NOTIFY pgrst, 'reload schema'; -- reconstrói o cache de schema
|
||||||
|
RETURN v_list;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Garante owner superuser (caso a função já existisse owned por postgres)
|
||||||
|
ALTER FUNCTION public.refresh_pgrst_schemas() OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.refresh_pgrst_schemas() FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.refresh_pgrst_schemas() TO postgres, service_role;
|
||||||
|
|
||||||
|
-- Seta o baseline imediatamente
|
||||||
|
SELECT public.refresh_pgrst_schemas();
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.1 — Migração de DADOS public -> schemas tenant (cutover)
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (precisa SET session_replication_role=replica
|
||||||
|
-- pra desabilitar checagem de FK durante o bulk insert — postgres não pode).
|
||||||
|
--
|
||||||
|
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||||
|
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-- < database-novo/manual/f6_1_migrate_data.supabase_admin.sql
|
||||||
|
--
|
||||||
|
-- COPIA (não move) os dados de cada tenant pras suas tabelas no schema. Os
|
||||||
|
-- dados continuam em public até o DROP da F6.3. Idempotente via ON CONFLICT
|
||||||
|
-- DO NOTHING (rodar de novo não duplica).
|
||||||
|
--
|
||||||
|
-- * tabelas com tenant_id: INSERT ... SELECT WHERE tenant_id = <id>, sem a
|
||||||
|
-- coluna tenant_id (não existe no schema)
|
||||||
|
-- * 3 filhas sem tenant_id (commitment_services, insurance_plan_services,
|
||||||
|
-- recurrence_rule_services): particionadas via JOIN no pai
|
||||||
|
-- * financial_categories / therapist_payout_records: 0 linhas, ignoradas
|
||||||
|
-- * as 6 tabelas anon-facing (F1b) NÃO existem no schema → naturalmente fora
|
||||||
|
-- * reset de sequences (4 tabelas bigserial) ao final
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
SET session_replication_role = replica;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
t_row record;
|
||||||
|
tab record;
|
||||||
|
v_cols text;
|
||||||
|
v_n bigint;
|
||||||
|
-- filhas sem tenant_id: tabela -> (pai, fk_local, pk_pai)
|
||||||
|
child_joins jsonb := jsonb_build_object(
|
||||||
|
'commitment_services', jsonb_build_object('parent','agenda_eventos','fk','commitment_id'),
|
||||||
|
'insurance_plan_services', jsonb_build_object('parent','insurance_plans','fk','insurance_plan_id'),
|
||||||
|
'recurrence_rule_services', jsonb_build_object('parent','recurrence_rules','fk','rule_id')
|
||||||
|
);
|
||||||
|
cj jsonb;
|
||||||
|
BEGIN
|
||||||
|
FOR t_row IN
|
||||||
|
SELECT t.id AS tenant_id, ts.schema_name
|
||||||
|
FROM public.tenants t
|
||||||
|
JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
|
||||||
|
ORDER BY t.created_at, t.id
|
||||||
|
LOOP
|
||||||
|
FOR tab IN
|
||||||
|
SELECT c.relname AS table_name
|
||||||
|
FROM pg_class c
|
||||||
|
WHERE c.relnamespace = t_row.schema_name::regnamespace
|
||||||
|
AND c.relkind = 'r'
|
||||||
|
AND c.relname NOT LIKE '\_%'
|
||||||
|
ORDER BY c.relname
|
||||||
|
LOOP
|
||||||
|
-- pula se a tabela não existe em public (defensivo)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema='public' AND table_name=tab.table_name) THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- colunas presentes em AMBOS (schema e public): exclui tenant_id
|
||||||
|
-- (some no schema), singleton (só no schema, fica no default) e
|
||||||
|
-- colunas GENERATED (net_amount, margin_brl — não aceitam INSERT)
|
||||||
|
SELECT string_agg(quote_ident(sc.column_name), ', ' ORDER BY sc.ordinal_position)
|
||||||
|
INTO v_cols
|
||||||
|
FROM information_schema.columns sc
|
||||||
|
WHERE sc.table_schema = t_row.schema_name AND sc.table_name = tab.table_name
|
||||||
|
AND sc.is_generated = 'NEVER'
|
||||||
|
AND EXISTS (SELECT 1 FROM information_schema.columns pc
|
||||||
|
WHERE pc.table_schema='public' AND pc.table_name=tab.table_name
|
||||||
|
AND pc.column_name = sc.column_name
|
||||||
|
AND pc.is_generated = 'NEVER');
|
||||||
|
|
||||||
|
IF v_cols IS NULL THEN CONTINUE; END IF;
|
||||||
|
|
||||||
|
cj := child_joins -> tab.table_name;
|
||||||
|
|
||||||
|
IF cj IS NOT NULL THEN
|
||||||
|
-- filha sem tenant_id: particiona via JOIN no pai
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ch '
|
||||||
|
|| 'JOIN public.%I p ON p.id = ch.%I WHERE p.tenant_id = %L '
|
||||||
|
|| 'ON CONFLICT DO NOTHING',
|
||||||
|
t_row.schema_name, tab.table_name, v_cols,
|
||||||
|
(SELECT string_agg('ch.'||quote_ident(x), ', ' ORDER BY ord)
|
||||||
|
FROM (SELECT trim(both ' ' from unnest(string_to_array(v_cols, ','))) AS x,
|
||||||
|
generate_subscripts(string_to_array(v_cols, ','),1) AS ord) y),
|
||||||
|
tab.table_name,
|
||||||
|
(cj->>'parent'), (cj->>'fk'),
|
||||||
|
t_row.tenant_id
|
||||||
|
);
|
||||||
|
ELSIF EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema='public' AND table_name=tab.table_name AND column_name='tenant_id') THEN
|
||||||
|
-- tabela com tenant_id: filtro direto
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
|
||||||
|
t_row.schema_name, tab.table_name, v_cols, v_cols, tab.table_name, t_row.tenant_id
|
||||||
|
);
|
||||||
|
ELSE
|
||||||
|
-- sem tenant_id e não é filha mapeada (financial_categories etc.):
|
||||||
|
-- só migra se tiver 0 dependência de tenant — pula (vazias hoje)
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_n = ROW_COUNT;
|
||||||
|
IF v_n > 0 THEN
|
||||||
|
RAISE NOTICE 'F6.1 %.%: % linhas', t_row.schema_name, tab.table_name, v_n;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Reset de sequences (tabelas bigserial) em cada schema
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
t_row record;
|
||||||
|
r record;
|
||||||
|
v_seq text;
|
||||||
|
BEGIN
|
||||||
|
FOR t_row IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname AS table_name, a.attname AS column_name
|
||||||
|
FROM pg_attrdef d
|
||||||
|
JOIN pg_class c ON c.oid = d.adrelid
|
||||||
|
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||||
|
WHERE c.relnamespace = t_row.schema_name::regnamespace
|
||||||
|
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'
|
||||||
|
LOOP
|
||||||
|
v_seq := pg_get_serial_sequence(format('%I.%I', t_row.schema_name, r.table_name), r.column_name);
|
||||||
|
IF v_seq IS NOT NULL THEN
|
||||||
|
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0) + 1, false)',
|
||||||
|
v_seq, r.column_name, t_row.schema_name, r.table_name);
|
||||||
|
RAISE NOTICE 'F6.1 seq %.% -> %', t_row.schema_name, r.table_name, v_seq;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
SET session_replication_role = origin;
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 Lote B — triggers schema-aware
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (trigger functions sao owned por supabase_admin):
|
||||||
|
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \n-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \n-- < database-novo/manual/f6_2b_schema_aware_triggers.supabase_admin.sql
|
||||||
|
--
|
||||||
|
-- Estratégia hybrid: as funções são reescritas IN PLACE pra operar no schema do
|
||||||
|
-- TG_TABLE_SCHEMA (search_path dinâmico + tenant_id_for_schema). Como ficariam
|
||||||
|
-- erradas nas tabelas de public (TG_TABLE_SCHEMA='public'), DESANEXAMOS dos
|
||||||
|
-- tenant-tables de public e ANEXAMOS só nos schemas. Writes de public via RPCs
|
||||||
|
-- ainda-não-migrados (Lote D) perdem esses side-effects no curto hybrid —
|
||||||
|
-- aceitável (public vai ser dropado na F6.3 e o app lê dos schemas).
|
||||||
|
--
|
||||||
|
-- Exclui os que escrevem em notifications (Lote C, com o split):
|
||||||
|
-- notify_on_session_status, fanout_inbound_message_to_notifications,
|
||||||
|
-- cancel_notifications_on_opt_out/on_session_cancel, fn_notify_agenda_status_change
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1) Rewrites — tabelas tenant via search_path (unqualified); globais com public.
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- audit_logs é GLOBAL → tenant_id vem do schema
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_audit_change()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
|
||||||
|
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
|
||||||
|
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
|
||||||
|
BEGIN
|
||||||
|
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
|
||||||
|
ELSIF TG_OP = 'INSERT' THEN
|
||||||
|
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
|
||||||
|
ELSE
|
||||||
|
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
|
||||||
|
SELECT array_agg(key ORDER BY key) INTO v_changed
|
||||||
|
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
|
||||||
|
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
|
||||||
|
IF v_changed IS NULL THEN RETURN NEW; END IF;
|
||||||
|
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
|
||||||
|
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||||
|
INSERT INTO patient_status_history (patient_id, status_anterior, status_novo, motivo, encaminhado_para, data_saida, alterado_por, alterado_em)
|
||||||
|
VALUES (NEW.id, CASE WHEN TG_OP='INSERT' THEN NULL ELSE OLD.status END, NEW.status, NEW.motivo_saida, NEW.encaminhado_para, NEW.data_saida, auth.uid(), now());
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||||
|
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
|
||||||
|
VALUES (NEW.id, 'status_alterado', 'Status alterado para ' || NEW.status,
|
||||||
|
CASE WHEN TG_OP='INSERT' THEN 'Paciente cadastrado'
|
||||||
|
ELSE 'De ' || OLD.status || ' → ' || NEW.status || CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END END,
|
||||||
|
CASE NEW.status WHEN 'Ativo' THEN 'green' WHEN 'Alta' THEN 'blue' WHEN 'Inativo' THEN 'gray' WHEN 'Encaminhado' THEN 'amber' WHEN 'Arquivado' THEN 'gray' ELSE 'gray' END,
|
||||||
|
auth.uid(), now());
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
|
||||||
|
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
|
||||||
|
VALUES (NEW.id, CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
|
||||||
|
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
|
||||||
|
NEW.risco_nota, CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END, auth.uid(), now());
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.auto_create_financial_record_from_session()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_price numeric(10,2); v_services_total numeric(10,2); v_already_billed boolean;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
IF NEW.status::text <> 'realizado' THEN RETURN NEW; END IF;
|
||||||
|
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.tipo::text <> 'sessao' THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.patient_id IS NULL THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.billing_contract_id IS NOT NULL THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
SELECT billed INTO v_already_billed FROM agenda_eventos WHERE id = NEW.id;
|
||||||
|
IF v_already_billed = TRUE THEN
|
||||||
|
IF EXISTS (SELECT 1 FROM financial_records WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL) THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_price := NULL;
|
||||||
|
IF NEW.recurrence_id IS NOT NULL THEN
|
||||||
|
SELECT COALESCE(SUM(rrs.final_price), 0) INTO v_services_total
|
||||||
|
FROM recurrence_rule_services rrs WHERE rrs.rule_id = NEW.recurrence_id;
|
||||||
|
IF v_services_total > 0 THEN v_price := v_services_total; END IF;
|
||||||
|
IF v_price IS NULL OR v_price = 0 THEN
|
||||||
|
SELECT price INTO v_price FROM recurrence_rules WHERE id = NEW.recurrence_id;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
IF v_price IS NULL OR v_price = 0 THEN v_price := NEW.price; END IF;
|
||||||
|
IF v_price IS NULL OR v_price <= 0 THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, type, amount, discount_amount, final_amount, clinic_fee_pct, clinic_fee_amount, status, due_date)
|
||||||
|
VALUES (NEW.owner_id, NEW.patient_id, NEW.id, 'receita', v_price, 0, v_price, 0, 0, 'pending', (NEW.inicio_em::date + 7));
|
||||||
|
|
||||||
|
UPDATE agenda_eventos SET billed = TRUE WHERE id = NEW.id;
|
||||||
|
RETURN NEW;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%', NEW.id, SQLERRM;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_sla_resolve_on_outbound()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_thread_key text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
v_thread_key := COALESCE(NEW.patient_id::text, 'anon:' || COALESCE(NEW.to_number, 'unknown'));
|
||||||
|
UPDATE conversation_sla_breaches SET resolved_at = now(), resolved_by_message_id = NEW.id
|
||||||
|
WHERE thread_key = v_thread_key AND resolved_at IS NULL;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE next_version integer; reason text;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
SELECT COALESCE(MAX(version_number), 0) + 1 INTO next_version FROM clinical_note_versions WHERE note_id = NEW.id;
|
||||||
|
IF TG_OP = 'INSERT' THEN reason := 'criacao';
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN reason := 'soft_delete';
|
||||||
|
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN reason := 'restore';
|
||||||
|
ELSE reason := 'edicao'; END IF;
|
||||||
|
ELSE reason := 'desconhecido'; END IF;
|
||||||
|
INSERT INTO clinical_note_versions (note_id, version_number, title, content_text, content_structured, change_reason, created_at, created_by)
|
||||||
|
VALUES (NEW.id, next_version, NEW.title, NEW.content_text, NEW.content_structured, reason, now(), COALESCE(NEW.updated_by, NEW.created_by));
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_patient_id uuid; v_doc_nome text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
SELECT d.patient_id, d.nome_original INTO v_patient_id, v_doc_nome FROM documents d WHERE d.id = NEW.documento_id;
|
||||||
|
IF v_patient_id IS NOT NULL THEN
|
||||||
|
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
|
||||||
|
VALUES (v_patient_id, 'documento_assinado', 'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
|
||||||
|
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo), 'green', 'documento', NEW.documento_id, NEW.signatario_id, NEW.assinado_em);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
|
||||||
|
VALUES (NEW.patient_id, 'documento_adicionado', 'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
|
||||||
|
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'), 'blue', 'documento', NEW.id, NEW.uploaded_by, NEW.uploaded_at);
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.sync_legacy_email_fields()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
|
||||||
|
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
|
||||||
|
SELECT email INTO v_primary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
|
||||||
|
SELECT email INTO v_secondary FROM contact_emails WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
|
||||||
|
IF v_entity_type = 'patient' THEN
|
||||||
|
UPDATE patients SET email_principal = v_primary, email_alternativo = v_secondary WHERE id = v_entity_id;
|
||||||
|
ELSIF v_entity_type = 'medico' THEN
|
||||||
|
UPDATE medicos SET email = v_primary WHERE id = v_entity_id;
|
||||||
|
END IF;
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
IF TG_OP = 'DELETE' THEN v_entity_type := OLD.entity_type; v_entity_id := OLD.entity_id;
|
||||||
|
ELSE v_entity_type := NEW.entity_type; v_entity_id := NEW.entity_id; END IF;
|
||||||
|
SELECT number INTO v_primary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id ORDER BY is_primary DESC, position ASC, created_at ASC LIMIT 1;
|
||||||
|
SELECT number INTO v_secondary FROM contact_phones WHERE entity_type = v_entity_type AND entity_id = v_entity_id AND is_primary = false ORDER BY position ASC, created_at ASC LIMIT 1;
|
||||||
|
IF v_entity_type = 'patient' THEN
|
||||||
|
UPDATE patients SET telefone = v_primary, telefone_alternativo = v_secondary WHERE id = v_entity_id;
|
||||||
|
ELSIF v_entity_type = 'medico' THEN
|
||||||
|
UPDATE medicos SET telefone_profissional = v_primary, telefone_pessoal = v_secondary WHERE id = v_entity_id;
|
||||||
|
END IF;
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_agenda_regras_semanais_no_overlap()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_count int;
|
||||||
|
BEGIN
|
||||||
|
IF new.ativo IS false THEN RETURN new; END IF;
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
SELECT count(*) INTO v_count FROM agenda_regras_semanais r
|
||||||
|
WHERE r.owner_id = new.owner_id AND r.dia_semana = new.dia_semana AND r.ativo IS true
|
||||||
|
AND (TG_OP = 'INSERT' OR r.id <> new.id)
|
||||||
|
AND (new.hora_inicio < r.hora_fim AND new.hora_fim > r.hora_inicio);
|
||||||
|
IF v_count > 0 THEN RAISE EXCEPTION 'Janela sobreposta: já existe uma regra ativa nesse intervalo.'; END IF;
|
||||||
|
RETURN new;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- valida member consistency: tenant_id vem do schema; tenant_members é GLOBAL
|
||||||
|
CREATE OR REPLACE FUNCTION public.patients_validate_member_consistency()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_tid uuid; v_tenant_responsible uuid; v_tenant_therapist uuid;
|
||||||
|
BEGIN
|
||||||
|
v_tid := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||||
|
SELECT tenant_id INTO v_tenant_responsible FROM public.tenant_members WHERE id = NEW.responsible_member_id;
|
||||||
|
IF v_tenant_responsible IS NULL THEN RAISE EXCEPTION 'Responsible member not found'; END IF;
|
||||||
|
IF v_tid IS NULL THEN RAISE EXCEPTION 'tenant não resolvido para schema %', TG_TABLE_SCHEMA; END IF;
|
||||||
|
IF v_tenant_responsible <> v_tid THEN RAISE EXCEPTION 'Responsible member must belong to the same tenant'; END IF;
|
||||||
|
IF NEW.patient_scope = 'therapist' THEN
|
||||||
|
IF NEW.therapist_member_id IS NULL THEN RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist'; END IF;
|
||||||
|
SELECT tenant_id INTO v_tenant_therapist FROM public.tenant_members WHERE id = NEW.therapist_member_id;
|
||||||
|
IF v_tenant_therapist IS NULL THEN RAISE EXCEPTION 'Therapist member not found'; END IF;
|
||||||
|
IF v_tenant_therapist <> v_tid THEN RAISE EXCEPTION 'Therapist member must belong to the same tenant'; END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2) sync_busy_mirror — CROSS-TENANT: evento pessoal espelha "Ocupado" nas
|
||||||
|
-- clínicas onde o owner é therapist. Escreve no schema de OUTROS tenants.
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.sync_busy_mirror_agenda_eventos()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_source_tenant uuid;
|
||||||
|
is_personal boolean;
|
||||||
|
should_mirror boolean;
|
||||||
|
v_owner uuid;
|
||||||
|
v_src_id uuid;
|
||||||
|
clinic record;
|
||||||
|
v_cschema text;
|
||||||
|
BEGIN
|
||||||
|
-- anti-recursão: espelho não espelha
|
||||||
|
IF TG_OP <> 'DELETE' THEN
|
||||||
|
IF NEW.mirror_of_event_id IS NOT NULL THEN RETURN NEW; END IF;
|
||||||
|
v_owner := NEW.owner_id; v_src_id := NEW.id;
|
||||||
|
ELSE
|
||||||
|
IF OLD.mirror_of_event_id IS NOT NULL THEN RETURN OLD; END IF;
|
||||||
|
v_owner := OLD.owner_id; v_src_id := OLD.id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_source_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||||
|
is_personal := (v_source_tenant = v_owner); -- convenção: tenant pessoal tem id = owner
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
should_mirror := (OLD.visibility_scope IN ('busy_only','private'));
|
||||||
|
ELSE
|
||||||
|
should_mirror := (NEW.visibility_scope IN ('busy_only','private'));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT is_personal THEN
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- DELETE ou não-deve-espelhar: remove espelhos em todas as clínicas do owner
|
||||||
|
IF TG_OP = 'DELETE' OR NOT should_mirror THEN
|
||||||
|
FOR clinic IN
|
||||||
|
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||||
|
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
|
||||||
|
LOOP
|
||||||
|
v_cschema := public.tenant_schema_for(clinic.tenant_id);
|
||||||
|
IF v_cschema IS NULL THEN CONTINUE; END IF;
|
||||||
|
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
|
||||||
|
v_cschema, v_src_id, 'personal_busy_mirror');
|
||||||
|
END LOOP;
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- INSERT/UPDATE com espelho: upsert "Ocupado" em cada clínica do owner
|
||||||
|
FOR clinic IN
|
||||||
|
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||||
|
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
|
||||||
|
LOOP
|
||||||
|
v_cschema := public.tenant_schema_for(clinic.tenant_id);
|
||||||
|
IF v_cschema IS NULL THEN CONTINUE; END IF;
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO %I.agenda_eventos (owner_id, terapeuta_id, patient_id, tipo, status, titulo, observacoes, inicio_em, fim_em, mirror_of_event_id, mirror_source, visibility_scope, created_at, updated_at) '
|
||||||
|
|| 'VALUES ($1,$1,NULL,$2::public.tipo_evento_agenda,$3::public.status_evento_agenda,$4,NULL,$5,$6,$7,$8,$9,now(),now()) '
|
||||||
|
|| 'ON CONFLICT (mirror_of_event_id) WHERE mirror_of_event_id IS NOT NULL '
|
||||||
|
|| 'DO UPDATE SET owner_id=EXCLUDED.owner_id, terapeuta_id=EXCLUDED.terapeuta_id, tipo=EXCLUDED.tipo, status=EXCLUDED.status, titulo=EXCLUDED.titulo, observacoes=EXCLUDED.observacoes, inicio_em=EXCLUDED.inicio_em, fim_em=EXCLUDED.fim_em, updated_at=now()',
|
||||||
|
v_cschema)
|
||||||
|
USING v_owner, 'bloqueio', 'agendado', 'Ocupado', NEW.inicio_em, NEW.fim_em, v_src_id, 'personal_busy_mirror', 'public';
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- remove espelhos de clínicas onde o vínculo therapist active sumiu
|
||||||
|
FOR clinic IN
|
||||||
|
SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.tenant_members tm
|
||||||
|
WHERE tm.user_id = v_owner AND tm.role='therapist' AND tm.status='active' AND tm.tenant_id = ts.tenant_id
|
||||||
|
)
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
|
||||||
|
clinic.schema_name, v_src_id, 'personal_busy_mirror');
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 3) financial_records_inject_tenant — OBSOLETO no schema (sem coluna tenant_id).
|
||||||
|
-- Mantém em public (legacy) mas NÃO anexa nos schemas.
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 4) Detach dos tenant-tables de public + attach nos schemas
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Detacha das tabelas tenant em public os triggers schema-aware (ficariam errados lá)
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
aware text[] := ARRAY[
|
||||||
|
'log_audit_change','trg_fn_patient_status_history','trg_fn_patient_status_timeline',
|
||||||
|
'trg_fn_patient_risco_timeline','auto_create_financial_record_from_session',
|
||||||
|
'fn_sla_resolve_on_outbound','fn_clinical_note_version','fn_document_signature_timeline',
|
||||||
|
'fn_documents_timeline_insert','sync_legacy_email_fields','sync_legacy_phone_fields',
|
||||||
|
'fn_agenda_regras_semanais_no_overlap','patients_validate_member_consistency',
|
||||||
|
'sync_busy_mirror_agenda_eventos'
|
||||||
|
];
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname AS tab, t.tgname
|
||||||
|
FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
||||||
|
JOIN pg_proc p ON p.oid=t.tgfoid
|
||||||
|
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
|
||||||
|
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Attach nos schemas. Specs derivadas dos triggerdefs REAIS de public, com
|
||||||
|
-- tenant_id removido de WHEN/UPDATE OF (não existe no schema). __T__ = schema.tabela.
|
||||||
|
CREATE OR REPLACE FUNCTION public.attach_schema_aware_triggers(p_schema text)
|
||||||
|
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
specs jsonb := jsonb_build_array(
|
||||||
|
jsonb_build_object('tab','patients','name','trg_patient_status_history','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history()'),
|
||||||
|
jsonb_build_object('tab','patients','name','trg_patient_status_timeline','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline()'),
|
||||||
|
jsonb_build_object('tab','patients','name','trg_patient_risco_timeline','spec','AFTER UPDATE OF risco_elevado ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline()'),
|
||||||
|
jsonb_build_object('tab','patients','name','trg_audit_patients','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||||
|
jsonb_build_object('tab','patients','name','trg_patients_validate_members','spec','BEFORE INSERT OR UPDATE OF responsible_member_id, patient_scope, therapist_member_id ON __T__ FOR EACH ROW EXECUTE FUNCTION public.patients_validate_member_consistency()'),
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_audit_agenda_eventos','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_auto_financial_from_session','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session()'),
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_ins','spec','AFTER INSERT ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND new.visibility_scope = ANY (ARRAY[''busy_only''::text, ''private''::text])) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_upd','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND (new.visibility_scope IS DISTINCT FROM old.visibility_scope OR new.inicio_em IS DISTINCT FROM old.inicio_em OR new.fim_em IS DISTINCT FROM old.fim_em OR new.owner_id IS DISTINCT FROM old.owner_id)) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_del','spec','AFTER DELETE ON __T__ FOR EACH ROW WHEN (old.mirror_of_event_id IS NULL) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||||
|
jsonb_build_object('tab','financial_records','name','trg_audit_financial_records','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||||
|
jsonb_build_object('tab','financial_records','name','trg_financial_records_auto_overdue','spec','BEFORE UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue()'),
|
||||||
|
jsonb_build_object('tab','documents','name','trg_audit_documents','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||||
|
jsonb_build_object('tab','documents','name','trg_documents_timeline_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert()'),
|
||||||
|
jsonb_build_object('tab','document_signatures','name','trg_ds_timeline','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_document_signature_timeline()'),
|
||||||
|
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_clinical_note_version()'),
|
||||||
|
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_update','spec','AFTER UPDATE OF content_text, content_structured, title, deleted_at ON __T__ FOR EACH ROW WHEN (old.content_text IS DISTINCT FROM new.content_text OR old.content_structured IS DISTINCT FROM new.content_structured OR old.title IS DISTINCT FROM new.title OR old.deleted_at IS DISTINCT FROM new.deleted_at) EXECUTE FUNCTION public.fn_clinical_note_version()'),
|
||||||
|
jsonb_build_object('tab','conversation_messages','name','trg_sla_resolve_on_outbound','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound()'),
|
||||||
|
jsonb_build_object('tab','contact_emails','name','trg_contact_emails_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields()'),
|
||||||
|
jsonb_build_object('tab','contact_phones','name','trg_contact_phones_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields()'),
|
||||||
|
jsonb_build_object('tab','agenda_regras_semanais','name','trg_agenda_regras_semanais_no_overlap','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap()'),
|
||||||
|
jsonb_build_object('tab','agenda_configuracoes','name','trg_agenda_cfg_sync','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.agenda_cfg_sync()')
|
||||||
|
);
|
||||||
|
el jsonb; v_count int := 0; v_target text;
|
||||||
|
BEGIN
|
||||||
|
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||||
|
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
|
||||||
|
v_target := format('%I.%I', p_schema, el->>'tab');
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
|
||||||
|
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec', '__T__', v_target);
|
||||||
|
v_count := v_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE r record; v int;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||||
|
v := public.attach_schema_aware_triggers(r.schema_name);
|
||||||
|
RAISE NOTICE 'F6.2B %: % triggers schema-aware', r.schema_name, v;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 Lote C — split de notifications (tenant-local vs SaaS cross-tenant)
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (CREATE OR REPLACE de funções owned por
|
||||||
|
-- postgres E supabase_admin; superuser preserva o owner):
|
||||||
|
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||||
|
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-- < database-novo/manual/f6_2c_notifications_split.supabase_admin.sql
|
||||||
|
--
|
||||||
|
-- Neste projeto, TODAS as notificações atuais (inbound_message, session_status,
|
||||||
|
-- system_alert, new_patient) são tenant-LOCAIS (avisos cross-tenant do SaaS
|
||||||
|
-- vivem em global_notices). Então:
|
||||||
|
-- * notifications continua tenant-local → já vive no schema do tenant (F6.1)
|
||||||
|
-- * public.notifications_sistema é criado como o canal SaaS→tenant / dev
|
||||||
|
-- cross-tenant (vazio hoje; pronto pro futuro: suporte, billing, etc.)
|
||||||
|
-- Triggers de notif reescritos schema-aware; os que disparam em tabelas PUBLIC
|
||||||
|
-- (notify_on_intake, notify_on_scheduling) roteiam pro schema via EXECUTE format.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1) notifications_sistema (GLOBAL, cross-tenant)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.notifications_sistema (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_id uuid NOT NULL, -- destinatário (user do tenant OU dev)
|
||||||
|
tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE, -- contexto (nullable: alerta global)
|
||||||
|
type text NOT NULL,
|
||||||
|
ref_id uuid,
|
||||||
|
ref_table text,
|
||||||
|
payload jsonb,
|
||||||
|
read_at timestamptz,
|
||||||
|
archived boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS notif_sistema_owner_idx ON public.notifications_sistema (owner_id, created_at DESC) WHERE archived = false;
|
||||||
|
|
||||||
|
ALTER TABLE public.notifications_sistema ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS notif_sistema_owner ON public.notifications_sistema;
|
||||||
|
CREATE POLICY notif_sistema_owner ON public.notifications_sistema
|
||||||
|
FOR ALL TO authenticated USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());
|
||||||
|
|
||||||
|
-- realtime
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname='supabase_realtime' AND schemaname='public' AND tablename='notifications_sistema') THEN
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications_sistema;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- helper pro futuro: emite notificação cross-tenant (dev/SaaS -> destinatário)
|
||||||
|
CREATE OR REPLACE FUNCTION public.notify_user_sistema(
|
||||||
|
p_owner_id uuid, p_type text, p_payload jsonb,
|
||||||
|
p_tenant_id uuid DEFAULT NULL, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL)
|
||||||
|
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_id uuid;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.notifications_sistema (owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||||
|
VALUES (p_owner_id, p_tenant_id, p_type, p_ref_id, p_ref_table, p_payload)
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
RETURN v_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2) Rewrites dos triggers de notif (tenant-local) — schema-aware
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.notify_on_session_status()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_nome text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status IN ('faltou','cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
SELECT nome_completo INTO v_nome FROM patients WHERE id = NEW.patient_id LIMIT 1;
|
||||||
|
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||||
|
VALUES (NEW.owner_id, 'session_status', NEW.id, 'agenda_eventos',
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', CASE WHEN NEW.status='faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
|
||||||
|
'detail', COALESCE(v_nome,'Paciente') || ' — ' || to_char(NEW.inicio_em,'DD/MM HH24:MI'),
|
||||||
|
'deeplink', '/therapist/agenda',
|
||||||
|
'avatar_initials', upper(left(COALESCE(v_nome,'?'),2))));
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_target_user uuid; v_title text; v_detail text; v_initials text; v_deeplink text;
|
||||||
|
v_patient_name text; v_payload jsonb; v_tenant uuid;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.direction <> 'inbound' THEN RETURN NEW; END IF;
|
||||||
|
v_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
|
||||||
|
IF NEW.patient_id IS NOT NULL THEN
|
||||||
|
SELECT nome_completo INTO v_patient_name FROM patients WHERE id = NEW.patient_id;
|
||||||
|
END IF;
|
||||||
|
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
|
||||||
|
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
|
||||||
|
IF v_patient_name IS NOT NULL THEN
|
||||||
|
v_initials := upper(left(v_patient_name,1)) || COALESCE(upper(left(split_part(v_patient_name,' ',2),1)),'');
|
||||||
|
ELSE v_initials := '?'; END IF;
|
||||||
|
v_deeplink := '/admin/conversas';
|
||||||
|
v_payload := jsonb_build_object('title',v_title,'detail',v_detail,'avatar_initials',v_initials,
|
||||||
|
'deeplink',v_deeplink,'channel',NEW.channel,'conversation_message_id',NEW.id,
|
||||||
|
'patient_id',NEW.patient_id,'from_number',NEW.from_number);
|
||||||
|
|
||||||
|
-- destinatário: responsável do paciente (tenant_members é GLOBAL)
|
||||||
|
IF NEW.patient_id IS NOT NULL THEN
|
||||||
|
SELECT tm.user_id INTO v_target_user
|
||||||
|
FROM patients p JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
|
||||||
|
WHERE p.id = NEW.patient_id AND tm.status = 'active' LIMIT 1;
|
||||||
|
IF v_target_user IS NOT NULL THEN
|
||||||
|
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||||
|
VALUES (v_target_user, 'inbound_message', NULL, 'conversation_messages', v_payload);
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
-- fallback: fan-out pros admins/therapists ativos do tenant (global)
|
||||||
|
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||||
|
SELECT tm.user_id, 'inbound_message', NULL, 'conversation_messages', v_payload
|
||||||
|
FROM public.tenant_members tm
|
||||||
|
WHERE tm.tenant_id = v_tenant AND tm.status = 'active'
|
||||||
|
AND tm.role IN ('clinic_admin','tenant_admin','therapist');
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- helper de cancelamento: notification_queue é tenant; herda search_path do trigger chamador
|
||||||
|
CREATE OR REPLACE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL, p_evento_id uuid DEFAULT NULL)
|
||||||
|
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE v_canceled integer;
|
||||||
|
BEGIN
|
||||||
|
UPDATE notification_queue SET status='cancelado', updated_at=now()
|
||||||
|
WHERE patient_id = p_patient_id AND status IN ('pendente','processando')
|
||||||
|
AND (p_channel IS NULL OR channel = p_channel)
|
||||||
|
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
|
||||||
|
GET DIAGNOSTICS v_canceled = ROW_COUNT;
|
||||||
|
RETURN v_canceled;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_opt_out()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
|
||||||
|
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'whatsapp');
|
||||||
|
END IF;
|
||||||
|
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
|
||||||
|
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'email');
|
||||||
|
END IF;
|
||||||
|
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
|
||||||
|
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'sms');
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||||
|
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||||
|
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, NULL, NEW.id);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 3) Triggers que disparam em tabelas PUBLIC (intake/scheduling, F1b) —
|
||||||
|
-- roteiam a notificação pro schema do tenant via EXECUTE format
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.notify_on_intake()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_schema text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'new' THEN
|
||||||
|
v_schema := public.tenant_schema_for(NEW.tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RETURN NEW; END IF;
|
||||||
|
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
|
||||||
|
USING NEW.owner_id, 'new_patient', NEW.id, 'patient_intake_requests',
|
||||||
|
jsonb_build_object('title','Novo cadastro externo','detail',COALESCE(NEW.nome_completo,'Paciente'),
|
||||||
|
'deeplink','/therapist/patients/cadastro/recebidos','avatar_initials',upper(left(COALESCE(NEW.nome_completo,'?'),2)));
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.notify_on_scheduling()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_schema text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'pendente' THEN
|
||||||
|
v_schema := public.tenant_schema_for(NEW.tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RETURN NEW; END IF;
|
||||||
|
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
|
||||||
|
USING NEW.owner_id, 'new_scheduling', NEW.id, 'agendador_solicitacoes',
|
||||||
|
jsonb_build_object('title','Nova solicitação de agendamento',
|
||||||
|
'detail', COALESCE(NEW.paciente_nome,'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome,'') || ' — ' || COALESCE(NEW.tipo,''),
|
||||||
|
'deeplink','/therapist/agendamentos-recebidos',
|
||||||
|
'avatar_initials', upper(left(COALESCE(NEW.paciente_nome,'?'),1) || left(COALESCE(NEW.paciente_sobrenome,''),1)));
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 4) Detach dos notif-triggers tenant de public + attach nos schemas (estende
|
||||||
|
-- attach_schema_aware_triggers com os 5 triggers de notif tenant)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
aware text[] := ARRAY['notify_on_session_status','fanout_inbound_message_to_notifications',
|
||||||
|
'cancel_notifications_on_opt_out','cancel_notifications_on_session_cancel'];
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname AS tab, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid
|
||||||
|
JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_proc p ON p.oid=t.tgfoid
|
||||||
|
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
|
||||||
|
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.attach_notif_triggers(p_schema text)
|
||||||
|
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
specs jsonb := jsonb_build_array(
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_notify_on_session_status','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status()'),
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_cancel_notifs_on_session_cancel','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.status IS DISTINCT FROM old.status) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel()'),
|
||||||
|
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_status_notify','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change()'),
|
||||||
|
jsonb_build_object('tab','conversation_messages','name','trg_fanout_inbound_to_notifications','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications()'),
|
||||||
|
jsonb_build_object('tab','notification_preferences','name','trg_cancel_notifs_on_opt_out','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out()')
|
||||||
|
);
|
||||||
|
el jsonb; v_count int := 0; v_target text;
|
||||||
|
BEGIN
|
||||||
|
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||||
|
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
|
||||||
|
v_target := format('%I.%I', p_schema, el->>'tab');
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
|
||||||
|
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec','__T__',v_target);
|
||||||
|
v_count := v_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE r record; v int;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||||
|
v := public.attach_notif_triggers(r.schema_name);
|
||||||
|
RAISE NOTICE 'F6.2C %: % notif triggers', r.schema_name, v;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 Lote D — RPCs user-facing roteadas pro schema do tenant
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (mix de funções owned postgres/supabase_admin).
|
||||||
|
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||||
|
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-- < database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql
|
||||||
|
--
|
||||||
|
-- Padrão: valida is_tenant_member(p_tenant_id) + set_config search_path pro
|
||||||
|
-- schema do tenant; remove `WHERE tenant_id=` e tenant_id de inserts; unqualify
|
||||||
|
-- tabelas tenant; %ROWTYPE→RECORD; RETURNS <tabela_tenant>→jsonb.
|
||||||
|
-- Tabelas que FICAM em public (audit_logs global; patient_intake_requests,
|
||||||
|
-- document_share_links F1b) seguem com `public.` + filtro tenant_id.
|
||||||
|
--
|
||||||
|
-- list_my_signatures é CROSS-TENANT (assinante em vários tenants) → Lote F.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- helper: valida acesso e RETORNA o schema do tenant. NÃO seta search_path
|
||||||
|
-- (set_config feito dentro de helper com SET search_path próprio seria revertido
|
||||||
|
-- na saída do helper). Cada RPC faz: PERFORM set_config('search_path',
|
||||||
|
-- public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
CREATE OR REPLACE FUNCTION public._tenant_route(p_tenant_id uuid)
|
||||||
|
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_schema text;
|
||||||
|
BEGIN
|
||||||
|
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
|
||||||
|
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'Sem permissão no tenant %', p_tenant_id USING ERRCODE='42501';
|
||||||
|
END IF;
|
||||||
|
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
|
||||||
|
RETURN v_schema;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- GRUPO 1 — já têm p_tenant_id, RETURNS jsonb/void (CREATE OR REPLACE)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
|
||||||
|
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found'; END IF;
|
||||||
|
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
|
||||||
|
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
|
||||||
|
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
|
||||||
|
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
|
||||||
|
IF v_parent <> 1 THEN RAISE EXCEPTION 'Parent not deleted'; END IF;
|
||||||
|
RETURN jsonb_build_object('ok',true,'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
|
||||||
|
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found for tenant'; END IF;
|
||||||
|
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
|
||||||
|
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
|
||||||
|
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
|
||||||
|
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
|
||||||
|
IF v_parent <> 1 THEN RAISE EXCEPTION 'Delete did not remove the commitment'; END IF;
|
||||||
|
RETURN jsonb_build_object('ok',true,'tenant_id',p_tenant_id,'commitment_id',p_commitment_id,
|
||||||
|
'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_owner_id uuid; v_schema text;
|
||||||
|
BEGIN
|
||||||
|
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RETURN; END IF; -- schema ainda não existe (chamado antes do clone): no-op
|
||||||
|
SELECT user_id INTO v_owner_id FROM public.tenant_members
|
||||||
|
WHERE tenant_id = p_tenant_id AND role='tenant_admin' AND status='active' LIMIT 1;
|
||||||
|
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
INSERT INTO patient_groups (owner_id, nome, cor, is_system)
|
||||||
|
VALUES (v_owner_id,'Crianças','#60a5fa',true),
|
||||||
|
(v_owner_id,'Adolescentes','#a78bfa',true),
|
||||||
|
(v_owner_id,'Idosos','#34d399',true)
|
||||||
|
ON CONFLICT (owner_id, nome) DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- seed_determined_commitments: idêntico em estrutura, sem tenant_id nos inserts.
|
||||||
|
-- Recriado integralmente.
|
||||||
|
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_id uuid; v_schema text;
|
||||||
|
BEGIN
|
||||||
|
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RETURN; END IF;
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='session') THEN
|
||||||
|
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||||
|
VALUES (true,'session',true,true,'Sessão','Sessão com paciente');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='reading') THEN
|
||||||
|
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||||
|
VALUES (true,'reading',false,true,'Leitura','Praticar leitura');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='supervision') THEN
|
||||||
|
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||||
|
VALUES (true,'supervision',false,true,'Supervisão','Supervisão');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='class') THEN
|
||||||
|
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||||
|
VALUES (true,'class',false,false,'Aula','Dar aula');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='analysis') THEN
|
||||||
|
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||||
|
VALUES (true,'analysis',false,true,'Análise Pessoal','Minha análise pessoal');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='session' LIMIT 1;
|
||||||
|
IF v_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
|
||||||
|
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
VALUES (v_id,'notes','Observação','textarea',false,30);
|
||||||
|
END IF;
|
||||||
|
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='reading' LIMIT 1;
|
||||||
|
IF v_id IS NOT NULL THEN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='book') THEN
|
||||||
|
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'book','Livro','text',false,10);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='author') THEN
|
||||||
|
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'author','Autor','text',false,20);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
|
||||||
|
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'notes','Observação','textarea',false,30);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- GRUPO 2 — novo p_tenant_id (1º param), RETURNS scalar/jsonb (DROP+CREATE)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.cancel_recurrence_from(uuid, date);
|
||||||
|
CREATE FUNCTION public.cancel_recurrence_from(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
UPDATE recurrence_rules
|
||||||
|
SET end_date = p_from_date - INTERVAL '1 day', open_ended = false,
|
||||||
|
status = CASE WHEN p_from_date <= start_date THEN 'cancelado' ELSE status END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_recurrence_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.cancelar_eventos_serie(uuid, timestamptz);
|
||||||
|
CREATE FUNCTION public.cancelar_eventos_serie(p_tenant_id uuid, p_serie_id uuid, p_a_partir_de timestamptz DEFAULT now())
|
||||||
|
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_count integer;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
UPDATE agenda_eventos SET status='cancelado', updated_at=now()
|
||||||
|
WHERE serie_id = p_serie_id AND inicio_em >= p_a_partir_de AND status NOT IN ('realizado','cancelado');
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.split_recurrence_at(uuid, date);
|
||||||
|
CREATE FUNCTION public.split_recurrence_at(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
|
||||||
|
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_old RECORD; v_new_id uuid;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT * INTO v_old FROM recurrence_rules WHERE id = p_recurrence_id;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id; END IF;
|
||||||
|
UPDATE recurrence_rules SET end_date = p_from_date - INTERVAL '1 day', open_ended=false, updated_at=now()
|
||||||
|
WHERE id = p_recurrence_id;
|
||||||
|
INSERT INTO recurrence_rules (owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
|
||||||
|
start_time, end_time, timezone, duration_min, start_date, end_date, max_occurrences, open_ended,
|
||||||
|
modalidade, titulo_custom, observacoes, extra_fields, status)
|
||||||
|
SELECT owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
|
||||||
|
start_time, end_time, timezone, duration_min, p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
|
||||||
|
modalidade, titulo_custom, observacoes, extra_fields, status
|
||||||
|
FROM recurrence_rules WHERE id = p_recurrence_id
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
RETURN v_new_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- can_delete_patient: SQL sem SET search_path → herda o do chamador (schema).
|
||||||
|
-- Unqualified pra resolver no schema do tenant ativo.
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_delete_patient(p_patient_id uuid)
|
||||||
|
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
SELECT NOT EXISTS (
|
||||||
|
SELECT 1 FROM agenda_eventos WHERE patient_id = p_patient_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM recurrence_rules WHERE patient_id = p_patient_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM billing_contracts WHERE patient_id = p_patient_id
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.safe_delete_patient(uuid);
|
||||||
|
CREATE FUNCTION public.safe_delete_patient(p_tenant_id uuid, p_patient_id uuid)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
IF NOT public.can_delete_patient(p_patient_id) THEN
|
||||||
|
RETURN jsonb_build_object('ok',false,'error','has_history',
|
||||||
|
'message','Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.');
|
||||||
|
END IF;
|
||||||
|
-- ownership: owner_id direto ou responsible_member do caller (tenant_members é GLOBAL)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM patients
|
||||||
|
WHERE id = p_patient_id AND (owner_id = auth.uid()
|
||||||
|
OR responsible_member_id IN (SELECT id FROM public.tenant_members WHERE user_id = auth.uid()))) THEN
|
||||||
|
RETURN jsonb_build_object('ok',false,'error','forbidden','message','Sem permissão para excluir este paciente.');
|
||||||
|
END IF;
|
||||||
|
DELETE FROM patients WHERE id = p_patient_id;
|
||||||
|
RETURN jsonb_build_object('ok',true);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.export_patient_data(uuid);
|
||||||
|
CREATE FUNCTION public.export_patient_data(p_tenant_id uuid, p_patient_id uuid)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_patient RECORD; v_caller uuid; v_result jsonb;
|
||||||
|
BEGIN
|
||||||
|
v_caller := auth.uid();
|
||||||
|
IF v_caller IS NULL THEN RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE='28000'; END IF;
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT * INTO v_patient FROM patients WHERE id = p_patient_id;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE='P0002'; END IF;
|
||||||
|
v_result := jsonb_build_object(
|
||||||
|
'export_metadata', jsonb_build_object('generated_at', now(), 'generated_by', v_caller,
|
||||||
|
'tenant_id', p_tenant_id, 'patient_id', p_patient_id,
|
||||||
|
'lgpd_basis','Art. 18, II - portabilidade dos dados do titular',
|
||||||
|
'controller','AgenciaPSI - Clinica responsavel','format_version','1.0'),
|
||||||
|
'paciente', to_jsonb(v_patient),
|
||||||
|
'contatos', COALESCE((SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at) FROM patient_contacts pc WHERE pc.patient_id = p_patient_id),'[]'::jsonb),
|
||||||
|
'contatos_apoio', COALESCE((SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at) FROM patient_support_contacts psc WHERE psc.patient_id = p_patient_id),'[]'::jsonb),
|
||||||
|
'historico_status', COALESCE((SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em) FROM patient_status_history psh WHERE psh.patient_id = p_patient_id),'[]'::jsonb),
|
||||||
|
'timeline', COALESCE((SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em) FROM patient_timeline pt WHERE pt.patient_id = p_patient_id),'[]'::jsonb),
|
||||||
|
'descontos', COALESCE((SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at) FROM patient_discounts pd WHERE pd.patient_id = p_patient_id),'[]'::jsonb),
|
||||||
|
'eventos_agenda', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',ae.id,'tipo',ae.tipo,'status',ae.status,'inicio_em',ae.inicio_em,'fim_em',ae.fim_em) ORDER BY ae.inicio_em) FROM agenda_eventos ae WHERE ae.patient_id = p_patient_id),'[]'::jsonb),
|
||||||
|
'documentos', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',d.id,'nome',d.nome_original,'tipo',d.tipo_documento,'criado_em',d.created_at) ORDER BY d.created_at) FROM documents d WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL),'[]'::jsonb),
|
||||||
|
'financeiro', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',fr.id,'tipo',fr.type,'valor',fr.final_amount,'status',fr.status,'vencimento',fr.due_date) ORDER BY fr.created_at) FROM financial_records fr WHERE fr.patient_id = p_patient_id AND fr.deleted_at IS NULL),'[]'::jsonb)
|
||||||
|
);
|
||||||
|
RETURN v_result;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.search_global(text, text[], integer);
|
||||||
|
CREATE FUNCTION public.search_global(p_tenant_id uuid, p_q text, p_scope text[] DEFAULT NULL, p_limit integer DEFAULT 8)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_q text; v_pattern text; v_limit int;
|
||||||
|
v_patients jsonb:='[]'::jsonb; v_appointments jsonb:='[]'::jsonb; v_documents jsonb:='[]'::jsonb;
|
||||||
|
v_services jsonb:='[]'::jsonb; v_intakes jsonb:='[]'::jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
v_q := nullif(btrim(coalesce(p_q,'')),'');
|
||||||
|
IF v_q IS NULL OR length(v_q) < 2 THEN
|
||||||
|
RETURN jsonb_build_object('patients','[]'::jsonb,'appointments','[]'::jsonb,'documents','[]'::jsonb,'services','[]'::jsonb,'intakes','[]'::jsonb);
|
||||||
|
END IF;
|
||||||
|
v_q := left(v_q,80); v_pattern := '%'||v_q||'%'; v_limit := GREATEST(1, LEAST(coalesce(p_limit,8),20));
|
||||||
|
|
||||||
|
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
|
||||||
|
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_completo,
|
||||||
|
'sublabel',coalesce(nullif(email_principal,''),nullif(telefone,''),''),'avatar_url',avatar_url,
|
||||||
|
'deeplink','/therapist/patients/cadastro/'||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_patients
|
||||||
|
FROM (SELECT p.id,p.nome_completo,p.email_principal,p.telefone,p.avatar_url,
|
||||||
|
GREATEST(similarity(coalesce(p.nome_completo,''),v_q),similarity(coalesce(p.email_principal,''),v_q)*0.7,
|
||||||
|
similarity(coalesce(p.telefone,''),v_q)*0.5,similarity(coalesce(p.cpf,''),v_q)*0.6) AS score
|
||||||
|
FROM patients p WHERE p.nome_completo ILIKE v_pattern OR p.email_principal ILIKE v_pattern OR p.telefone ILIKE v_pattern OR p.cpf ILIKE v_pattern
|
||||||
|
ORDER BY score DESC, p.nome_completo ASC LIMIT v_limit) ranked;
|
||||||
|
END IF;
|
||||||
|
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
|
||||||
|
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',label,
|
||||||
|
'sublabel',trim(both ' · ' from coalesce(patient_name,')')||' · '||to_char(inicio_em,'DD/MM/YYYY HH24:MI')),
|
||||||
|
'deeplink','/therapist/agenda?event='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_appointments
|
||||||
|
FROM (SELECT e.id, coalesce(nullif(e.titulo_custom,''),nullif(e.titulo,''),'Sessão') AS label, e.inicio_em, pat.nome_completo AS patient_name,
|
||||||
|
GREATEST(similarity(coalesce(e.titulo,''),v_q),similarity(coalesce(e.titulo_custom,''),v_q),similarity(coalesce(pat.nome_completo,''),v_q)*0.9) AS score
|
||||||
|
FROM agenda_eventos e LEFT JOIN patients pat ON pat.id = e.patient_id
|
||||||
|
WHERE e.titulo ILIKE v_pattern OR e.titulo_custom ILIKE v_pattern OR pat.nome_completo ILIKE v_pattern
|
||||||
|
ORDER BY score DESC, e.inicio_em DESC LIMIT v_limit) ranked;
|
||||||
|
END IF;
|
||||||
|
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
|
||||||
|
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',nome_original,
|
||||||
|
'sublabel',trim(both ' · ' from coalesce(patient_name,'')||' · '||coalesce(tipo_documento,'')),
|
||||||
|
'deeplink','/therapist/patients/'||patient_id::text||'/documents','score',round(score::numeric,3))),'[]'::jsonb) INTO v_documents
|
||||||
|
FROM (SELECT d.id,d.patient_id,d.nome_original,d.tipo_documento,pat.nome_completo AS patient_name,
|
||||||
|
GREATEST(similarity(coalesce(d.nome_original,''),v_q),similarity(coalesce(d.descricao,''),v_q)*0.7) AS score
|
||||||
|
FROM documents d LEFT JOIN patients pat ON pat.id = d.patient_id
|
||||||
|
WHERE d.nome_original ILIKE v_pattern OR d.descricao ILIKE v_pattern
|
||||||
|
ORDER BY score DESC, d.nome_original ASC LIMIT v_limit) ranked;
|
||||||
|
END IF;
|
||||||
|
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
|
||||||
|
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,'label',name,
|
||||||
|
'sublabel',trim(both ' · ' from 'R$ '||to_char(price,'FM999G999G990D00')||' · '||coalesce(duration_min::text||' min','')),
|
||||||
|
'deeplink','/configuracoes/precificacao','score',round(score::numeric,3))),'[]'::jsonb) INTO v_services
|
||||||
|
FROM (SELECT s.id,s.name,s.price,s.duration_min,
|
||||||
|
GREATEST(similarity(coalesce(s.name,''),v_q),similarity(coalesce(s.description,''),v_q)*0.7) AS score
|
||||||
|
FROM services s WHERE s.active IS TRUE AND (s.name ILIKE v_pattern OR s.description ILIKE v_pattern)
|
||||||
|
ORDER BY score DESC, s.name ASC LIMIT v_limit) ranked;
|
||||||
|
END IF;
|
||||||
|
-- intakes: patient_intake_requests FICA em public (F1b) → qualifica + filtra tenant_id
|
||||||
|
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
|
||||||
|
SELECT coalesce(jsonb_agg(jsonb_build_object('id',id,
|
||||||
|
'label',coalesce(nullif(trim(nome_completo),''),'(sem nome)'),
|
||||||
|
'sublabel',trim(both ' · ' from coalesce(nullif(email_principal,''),nullif(telefone,''),'')||' · '||'recebido '||to_char(created_at,'DD/MM/YYYY')),
|
||||||
|
'deeplink','/therapist/patients/cadastro/recebidos?id='||id::text,'score',round(score::numeric,3))),'[]'::jsonb) INTO v_intakes
|
||||||
|
FROM (SELECT r.id,r.nome_completo,r.email_principal,r.telefone,r.created_at,
|
||||||
|
GREATEST(similarity(coalesce(r.nome_completo,''),v_q),similarity(coalesce(r.email_principal,''),v_q)*0.7,similarity(coalesce(r.telefone,''),v_q)*0.5) AS score
|
||||||
|
FROM public.patient_intake_requests r
|
||||||
|
WHERE r.tenant_id = p_tenant_id AND r.status='new'
|
||||||
|
AND (r.nome_completo ILIKE v_pattern OR r.email_principal ILIKE v_pattern OR r.telefone ILIKE v_pattern)
|
||||||
|
ORDER BY score DESC, r.created_at DESC LIMIT v_limit) ranked;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object('patients',v_patients,'appointments',v_appointments,'documents',v_documents,'services',v_services,'intakes',v_intakes);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- GRUPO 3 — RETURNS <tabela_tenant> → jsonb (ripple no FE)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.mark_as_paid(uuid, text);
|
||||||
|
CREATE FUNCTION public.mark_as_paid(p_tenant_id uuid, p_financial_record_id uuid, p_payment_method text)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT * INTO v_record FROM financial_records WHERE id = p_financial_record_id AND owner_id = auth.uid() AND deleted_at IS NULL;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.'; END IF;
|
||||||
|
IF v_record.status NOT IN ('pending','overdue') THEN RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.'; END IF;
|
||||||
|
UPDATE financial_records SET status='paid', paid_at=now(), payment_method=p_payment_method, updated_at=now()
|
||||||
|
WHERE id = p_financial_record_id RETURNING * INTO v_record;
|
||||||
|
RETURN to_jsonb(v_record);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date);
|
||||||
|
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_existing RECORD; v_new RECORD;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT * INTO v_existing FROM financial_records WHERE agenda_evento_id = p_agenda_evento_id AND deleted_at IS NULL AND status != 'cancelled' LIMIT 1;
|
||||||
|
IF FOUND THEN RETURN to_jsonb(v_existing); END IF;
|
||||||
|
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, amount, discount_amount, final_amount, status, due_date)
|
||||||
|
VALUES (p_owner_id, p_patient_id, p_agenda_evento_id, p_amount, 0, p_amount, 'pending', p_due_date)
|
||||||
|
RETURNING * INTO v_new;
|
||||||
|
UPDATE agenda_eventos SET billed = TRUE WHERE id = p_agenda_evento_id;
|
||||||
|
RETURN to_jsonb(v_new);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.mark_payout_as_paid(uuid);
|
||||||
|
CREATE FUNCTION public.mark_payout_as_paid(p_tenant_id uuid, p_payout_id uuid)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_payout RECORD;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT * INTO v_payout FROM therapist_payouts WHERE id = p_payout_id;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id; END IF;
|
||||||
|
IF NOT public.is_tenant_admin(p_tenant_id) THEN RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.'; END IF;
|
||||||
|
IF v_payout.status <> 'pending' THEN RAISE EXCEPTION 'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.', v_payout.status; END IF;
|
||||||
|
UPDATE therapist_payouts SET status='paid', paid_at=now(), updated_at=now() WHERE id = p_payout_id RETURNING * INTO v_payout;
|
||||||
|
RETURN to_jsonb(v_payout);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.create_therapist_payout(uuid, uuid, date, date);
|
||||||
|
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_payout RECORD; v_total int; v_gross numeric(10,2); v_clinic_fee numeric(10,2); v_net numeric(10,2);
|
||||||
|
BEGIN
|
||||||
|
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
|
||||||
|
RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.';
|
||||||
|
END IF;
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
IF EXISTS (SELECT 1 FROM therapist_payouts WHERE owner_id=p_therapist_id AND period_start=p_period_start AND period_end=p_period_end AND status<>'cancelled') THEN
|
||||||
|
RAISE EXCEPTION 'Já existe um repasse ativo para o período % a % deste terapeuta.', p_period_start, p_period_end;
|
||||||
|
END IF;
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(amount),0), COALESCE(SUM(clinic_fee_amount),0), COALESCE(SUM(net_amount),0)
|
||||||
|
INTO v_total, v_gross, v_clinic_fee, v_net
|
||||||
|
FROM financial_records fr
|
||||||
|
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
|
||||||
|
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
|
||||||
|
IF v_total = 0 THEN RAISE EXCEPTION 'Nenhum registro financeiro elegível encontrado para o período % a %.', p_period_start, p_period_end; END IF;
|
||||||
|
INSERT INTO therapist_payouts (owner_id, period_start, period_end, total_sessions, gross_amount, clinic_fee_total, net_amount, status)
|
||||||
|
VALUES (p_therapist_id, p_period_start, p_period_end, v_total, v_gross, v_clinic_fee, v_net, 'pending')
|
||||||
|
RETURNING * INTO v_payout;
|
||||||
|
INSERT INTO therapist_payout_records (payout_id, financial_record_id)
|
||||||
|
SELECT v_payout.id, fr.id FROM financial_records fr
|
||||||
|
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
|
||||||
|
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
|
||||||
|
RETURN to_jsonb(v_payout);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 Lote E — RPCs de cron/global roteadas/loopadas por tenant
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin.
|
||||||
|
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||||
|
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-- < database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql
|
||||||
|
--
|
||||||
|
-- E1: chamadas per-tenant pelas edge crons → p_tenant_id + set_config search_path
|
||||||
|
-- (helper public._tenant_route do Lote D). Edge ajustada (admin.rpc + p_tenant_id).
|
||||||
|
-- E2: crons sem-arg que varrem TODOS os tenants → loop FROM tenant_schemas.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- helper SEM checagem de auth: resolve schema pra RPCs de SERVIÇO (chamadas por
|
||||||
|
-- service_role/edge, que não é tenant_member). Protegido por REVOKE das RPCs de
|
||||||
|
-- anon/authenticated (só service_role/postgres chamam).
|
||||||
|
CREATE OR REPLACE FUNCTION public._tenant_schema_unchecked(p_tenant_id uuid)
|
||||||
|
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_schema text;
|
||||||
|
BEGIN
|
||||||
|
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
|
||||||
|
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
|
||||||
|
RETURN v_schema;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- E2 — crons globais: varrem todos os schemas
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.cleanup_notification_queue()
|
||||||
|
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_n int; v_total int := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('DELETE FROM %I.notification_queue WHERE status IN (''enviado'',''cancelado'',''ignorado'') AND created_at < now() - interval ''90 days''', t.schema_name);
|
||||||
|
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_total;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.unstick_notification_queue()
|
||||||
|
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_n int; v_total int := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('UPDATE %I.notification_queue SET status=''pendente'', attempts=attempts+1, last_error=''Timeout: preso em processando por >10min'', next_retry_at=now()+interval ''2 minutes'' WHERE status=''processando'' AND updated_at < now() - interval ''10 minutes''', t.schema_name);
|
||||||
|
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_total;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.sync_overdue_financial_records()
|
||||||
|
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_n int; v_total int := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('UPDATE %I.financial_records SET status=''overdue'', updated_at=now() WHERE status=''pending'' AND due_date IS NOT NULL AND due_date < CURRENT_DATE AND deleted_at IS NULL', t.schema_name);
|
||||||
|
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_total;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- populate: complexo (multi-tabela). set_config search_path por tenant; profiles
|
||||||
|
-- é GLOBAL → qualificado. Remove tenant_id do INSERT e do SELECT.
|
||||||
|
CREATE OR REPLACE FUNCTION public.populate_notification_queue()
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
PERFORM set_config('search_path', t.schema_name || ',public,pg_temp', true);
|
||||||
|
INSERT INTO notification_queue (
|
||||||
|
owner_id, agenda_evento_id, patient_id, channel, template_key, schedule_key,
|
||||||
|
resolved_vars, recipient_address, scheduled_at, idempotency_key)
|
||||||
|
SELECT
|
||||||
|
ae.owner_id, ae.id, ae.patient_id, ch.channel,
|
||||||
|
'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
|
||||||
|
ns.schedule_key,
|
||||||
|
jsonb_build_object('nome_paciente', COALESCE(p.nome_completo,'Paciente'),
|
||||||
|
'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','DD/MM/YYYY'),
|
||||||
|
'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','HH24:MI'),
|
||||||
|
'nome_terapeuta', COALESCE(prof.full_name,'Terapeuta'),
|
||||||
|
'modalidade', COALESCE(ae.modalidade,'Presencial'),
|
||||||
|
'titulo', COALESCE(ae.titulo,'Sessão')),
|
||||||
|
CASE ch.channel WHEN 'whatsapp' THEN COALESCE(p.telefone,'') WHEN 'sms' THEN COALESCE(p.telefone,'') WHEN 'email' THEN COALESCE(p.email_principal,'') END,
|
||||||
|
CASE
|
||||||
|
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time < ns.allowed_time_start
|
||||||
|
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
|
||||||
|
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time > ns.allowed_time_end
|
||||||
|
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
|
||||||
|
ELSE ae.inicio_em - (ns.offset_minutes||' minutes')::interval END,
|
||||||
|
ae.id::text||':'||ns.schedule_key||':'||ch.channel||':'||ae.inicio_em::date::text
|
||||||
|
FROM agenda_eventos ae
|
||||||
|
JOIN patients p ON p.id = ae.patient_id
|
||||||
|
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id -- GLOBAL
|
||||||
|
JOIN notification_schedules ns ON ns.owner_id = ae.owner_id AND ns.is_active=true AND ns.deleted_at IS NULL AND ns.trigger_type='before_event' AND ns.event_type='lembrete_sessao'
|
||||||
|
JOIN notification_channels nc ON nc.owner_id = ae.owner_id AND nc.is_active=true AND nc.deleted_at IS NULL
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel='whatsapp'
|
||||||
|
UNION ALL SELECT 'email' WHERE ns.email_enabled AND nc.channel='email'
|
||||||
|
UNION ALL SELECT 'sms' WHERE ns.sms_enabled AND nc.channel='sms') ch
|
||||||
|
LEFT JOIN notification_preferences np ON np.patient_id = ae.patient_id AND np.owner_id = ae.owner_id AND np.deleted_at IS NULL
|
||||||
|
WHERE ae.tipo = 'sessao' AND ae.status NOT IN ('cancelado','realizado') AND ae.inicio_em > now()
|
||||||
|
AND (ae.inicio_em - (ns.offset_minutes||' minutes')::interval) > now()
|
||||||
|
ON CONFLICT (idempotency_key) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- E1 — chamadas per-tenant (p_tenant_id + route)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamptz, p_threshold_minutes integer)
|
||||||
|
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_existing_id uuid; v_new_id uuid;
|
||||||
|
BEGIN
|
||||||
|
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN RAISE EXCEPTION 'tenant_and_thread_required'; END IF;
|
||||||
|
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT id INTO v_existing_id FROM conversation_sla_breaches WHERE thread_key = p_thread_key AND resolved_at IS NULL;
|
||||||
|
IF FOUND THEN
|
||||||
|
UPDATE conversation_sla_breaches SET assigned_to = COALESCE(p_assigned_to, assigned_to), last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at) WHERE id = v_existing_id;
|
||||||
|
RETURN v_existing_id;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO conversation_sla_breaches (thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
|
||||||
|
VALUES (p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes) RETURNING id INTO v_new_id;
|
||||||
|
RETURN v_new_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid);
|
||||||
|
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid,uuid);
|
||||||
|
CREATE FUNCTION public.sla_mark_notified(p_tenant_id uuid, p_breach_id uuid)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
UPDATE conversation_sla_breaches SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_breach_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, text, text, jsonb);
|
||||||
|
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, uuid, text, text, jsonb);
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_tenant_id uuid, p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL, p_details jsonb DEFAULT NULL)
|
||||||
|
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_provider text; v_existing_id uuid; v_new_id uuid;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT provider INTO v_provider FROM notification_channels WHERE id = p_channel_id AND deleted_at IS NULL;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'channel_not_found'; END IF;
|
||||||
|
IF p_kind NOT IN ('disconnected','error','qr_pending','connecting','unknown') THEN RAISE EXCEPTION 'invalid_kind: %', p_kind; END IF;
|
||||||
|
SELECT id INTO v_existing_id FROM whatsapp_connection_incidents WHERE channel_id = p_channel_id AND resolved_at IS NULL;
|
||||||
|
IF FOUND THEN
|
||||||
|
UPDATE whatsapp_connection_incidents SET last_state = COALESCE(p_last_state, last_state), details = COALESCE(p_details, details), kind = p_kind WHERE id = v_existing_id;
|
||||||
|
RETURN v_existing_id;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO whatsapp_connection_incidents (channel_id, provider, kind, last_state, details)
|
||||||
|
VALUES (p_channel_id, v_provider, p_kind, p_last_state, p_details) RETURNING id INTO v_new_id;
|
||||||
|
RETURN v_new_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid);
|
||||||
|
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid,uuid);
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_tenant_id uuid, p_incident_id uuid)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
UPDATE whatsapp_connection_incidents SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_incident_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid);
|
||||||
|
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid,uuid);
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_tenant_id uuid, p_channel_id uuid)
|
||||||
|
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_count int := 0;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
UPDATE whatsapp_connection_incidents SET resolved_at = now(), duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
|
||||||
|
WHERE channel_id = p_channel_id AND resolved_at IS NULL;
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- convert_abandoned_intake_to_lead: resolve o tenant INTERNAMENTE (intake.owner_id
|
||||||
|
-- -> tenant_members). patient_intake_requests FICA em public (F1b). Writes de
|
||||||
|
-- conversation_messages/notes vão pro schema do tenant resolvido.
|
||||||
|
CREATE OR REPLACE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid)
|
||||||
|
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_intake RECORD; v_tenant_id uuid; v_schema text; v_thread_key text; v_phone text;
|
||||||
|
v_note_body text; v_admin_id uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||||
|
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::uuid; END IF;
|
||||||
|
SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_intake.owner_id
|
||||||
|
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END LIMIT 1;
|
||||||
|
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||||
|
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema_not_found'; END IF;
|
||||||
|
|
||||||
|
v_phone := regexp_replace(COALESCE(v_intake.telefone,''),'\D','','g');
|
||||||
|
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55'||v_phone; END IF;
|
||||||
|
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||||
|
v_thread_key := 'anon:'||v_phone;
|
||||||
|
v_note_body := format('📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||||
|
E'\n',E'\n', COALESCE(v_intake.nome_completo,'—'), E'\n', COALESCE(v_intake.telefone,'—'), E'\n',
|
||||||
|
COALESCE(v_intake.email_principal,'—'), E'\n', COALESCE(v_intake.onde_nos_conheceu,'—'), E'\n',E'\n',
|
||||||
|
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'),
|
||||||
|
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'));
|
||||||
|
|
||||||
|
SELECT user_id INTO v_admin_id FROM public.tenant_members
|
||||||
|
WHERE tenant_id = v_tenant_id AND role IN ('tenant_admin','clinic_admin') AND status='active' LIMIT 1;
|
||||||
|
IF v_admin_id IS NULL THEN v_admin_id := v_intake.owner_id; END IF;
|
||||||
|
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
INSERT INTO conversation_messages (channel, direction, from_number, to_number, body, provider, provider_raw, kanban_status)
|
||||||
|
VALUES ('whatsapp','inbound', CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, NULL,
|
||||||
|
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.', COALESCE(v_intake.nome_completo,'Visitante')),
|
||||||
|
'system', jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id), 'awaiting_us');
|
||||||
|
INSERT INTO conversation_notes (thread_key, contact_number, body, created_by)
|
||||||
|
VALUES (v_thread_key, CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, v_note_body, v_admin_id);
|
||||||
|
|
||||||
|
UPDATE public.patient_intake_requests SET status='abandoned_lead', lead_thread_key=v_thread_key, updated_at=now() WHERE id = p_intake_id;
|
||||||
|
RETURN p_intake_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- first_response analytics: routam pelo p_tenant_id (cada função seta o seu próprio
|
||||||
|
-- search_path — _first_response_runs tem SET search_path próprio que resetaria).
|
||||||
|
CREATE OR REPLACE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamptz, p_to timestamptz)
|
||||||
|
RETURNS TABLE(thread_key text, inbound_started_at timestamptz, responded_at timestamptz, response_seconds integer, responder_id uuid)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
RETURN QUERY
|
||||||
|
WITH base AS (
|
||||||
|
SELECT COALESCE(m.patient_id::text, 'anon:' || COALESCE(CASE WHEN m.direction='inbound' THEN m.from_number ELSE m.to_number END, 'unknown')) AS thread_key,
|
||||||
|
m.direction, m.created_at
|
||||||
|
FROM conversation_messages m
|
||||||
|
WHERE m.created_at >= p_from AND m.created_at < p_to
|
||||||
|
),
|
||||||
|
inbound AS (
|
||||||
|
SELECT b.thread_key AS tk, min(b.created_at) AS inbound_started_at
|
||||||
|
FROM base b WHERE b.direction='inbound' GROUP BY b.thread_key
|
||||||
|
)
|
||||||
|
SELECT i.tk, i.inbound_started_at,
|
||||||
|
(SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) AS responded_at,
|
||||||
|
EXTRACT(EPOCH FROM ((SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) - i.inbound_started_at))::int AS response_seconds,
|
||||||
|
a.assigned_to AS responder_id
|
||||||
|
FROM inbound i
|
||||||
|
LEFT JOIN conversation_assignments a ON a.thread_key = i.tk;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamptz DEFAULT (now() - interval '30 days'), p_to timestamptz DEFAULT now(), p_therapist_id uuid DEFAULT NULL)
|
||||||
|
RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_threshold_min integer;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT threshold_minutes INTO v_threshold_min FROM conversation_sla_rules LIMIT 1;
|
||||||
|
v_threshold_min := COALESCE(v_threshold_min, 30);
|
||||||
|
RETURN QUERY
|
||||||
|
WITH runs AS (
|
||||||
|
SELECT r.response_seconds FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||||
|
WHERE r.responded_at IS NOT NULL AND (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||||
|
)
|
||||||
|
SELECT count(*)::int,
|
||||||
|
COALESCE(avg(response_seconds),0)::int,
|
||||||
|
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY response_seconds),0)::int,
|
||||||
|
COALESCE(min(response_seconds),0)::int,
|
||||||
|
COALESCE(max(response_seconds),0)::int,
|
||||||
|
(v_threshold_min*60)::int,
|
||||||
|
count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)::int,
|
||||||
|
CASE WHEN count(*)=0 THEN 0 ELSE round(100.0*count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)/count(*),1) END
|
||||||
|
FROM runs;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- RPCs de serviço (cron/edge): só service_role/postgres. Sem checagem de membership.
|
||||||
|
DO $g$
|
||||||
|
DECLARE fn text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH fn IN ARRAY ARRAY[
|
||||||
|
'sla_open_breach(uuid,text,uuid,timestamptz,integer)',
|
||||||
|
'sla_mark_notified(uuid,uuid)',
|
||||||
|
'whatsapp_heartbeat_open_incident(uuid,uuid,text,text,jsonb)',
|
||||||
|
'whatsapp_heartbeat_mark_notified(uuid,uuid)',
|
||||||
|
'whatsapp_heartbeat_resolve_open_incidents(uuid,uuid)',
|
||||||
|
'convert_abandoned_intake_to_lead(uuid)',
|
||||||
|
'cleanup_notification_queue()','unstick_notification_queue()',
|
||||||
|
'sync_overdue_financial_records()','populate_notification_queue()'
|
||||||
|
] LOOP
|
||||||
|
EXECUTE format('REVOKE ALL ON FUNCTION public.%s FROM PUBLIC, anon, authenticated', fn);
|
||||||
|
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO service_role', fn);
|
||||||
|
END LOOP;
|
||||||
|
END $g$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 Lote F — RPCs anon/token: resolvem tenant por token/slug e roteiam
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin.
|
||||||
|
--
|
||||||
|
-- Visitante anon não está logado → cada RPC resolve o tenant a partir do
|
||||||
|
-- token/slug do registro que VIVE em public (F1b: document_share_links,
|
||||||
|
-- agendador_configuracoes — ambos têm tenant_id), depois set_config search_path
|
||||||
|
-- pro schema só pras tabelas tenant (documents, document_signatures,
|
||||||
|
-- document_access_logs, patients, agenda_*, recurrence_*).
|
||||||
|
-- Tabelas que ficam em public seguem qualificadas (document_share_links,
|
||||||
|
-- agendador_configuracoes/solicitacoes).
|
||||||
|
-- %ROWTYPE de tabelas tenant → RECORD; RETURNS document_signatures → jsonb.
|
||||||
|
-- list_my_signatures é cross-tenant (assinante em vários tenants) → fan-out.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ── Documentos: tenant via document_share_links.tenant_id (public) ──────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE sl public.document_share_links%ROWTYPE; v_doc RECORD; v_token text; v_schema text;
|
||||||
|
BEGIN
|
||||||
|
v_token := nullif(btrim(coalesce(p_token,'')),'');
|
||||||
|
IF v_token IS NULL THEN RAISE EXCEPTION 'token obrigatório' USING ERRCODE='22023'; END IF;
|
||||||
|
SELECT * INTO sl FROM public.document_share_links WHERE token = v_token LIMIT 1;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='28000'; END IF;
|
||||||
|
IF sl.ativo IS NOT TRUE THEN RAISE EXCEPTION 'Link desativado' USING ERRCODE='28000'; END IF;
|
||||||
|
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN RAISE EXCEPTION 'Link expirado' USING ERRCODE='28000'; END IF;
|
||||||
|
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE='28000'; END IF;
|
||||||
|
v_schema := public.tenant_schema_for(sl.tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='28000'; END IF;
|
||||||
|
|
||||||
|
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = sl.id;
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO document_access_logs (documento_id, action, share_link_id)
|
||||||
|
VALUES (sl.documento_id, 'shared_link_access', sl.id);
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||||
|
SELECT * INTO v_doc FROM documents WHERE id = sl.documento_id;
|
||||||
|
RETURN jsonb_build_object('document_id', sl.documento_id, 'bucket', v_doc.storage_bucket,
|
||||||
|
'bucket_path', v_doc.bucket_path, 'nome_original', v_doc.nome_original,
|
||||||
|
'mime_type', v_doc.mime_type, 'tamanho_bytes', v_doc.tamanho_bytes);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(p_token text)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_link public.document_share_links%ROWTYPE; v_doc RECORD; v_sigs jsonb; v_schema text;
|
||||||
|
BEGIN
|
||||||
|
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
|
||||||
|
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
|
||||||
|
IF v_link.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid'); END IF;
|
||||||
|
v_schema := public.tenant_schema_for(v_link.tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'tenant_invalid'); END IF;
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
SELECT * INTO v_doc FROM documents WHERE id = v_link.documento_id AND deleted_at IS NULL LIMIT 1;
|
||||||
|
IF v_doc.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'document_not_found'); END IF;
|
||||||
|
SELECT jsonb_agg(jsonb_build_object('id',s.id,'signatario_tipo',s.signatario_tipo,'signatario_nome',s.signatario_nome,
|
||||||
|
'signatario_email',s.signatario_email,'ordem',s.ordem,'status',s.status,'assinado_em',s.assinado_em) ORDER BY s.ordem) INTO v_sigs
|
||||||
|
FROM document_signatures s WHERE s.documento_id = v_doc.id;
|
||||||
|
RETURN jsonb_build_object('valid', true,
|
||||||
|
'document', jsonb_build_object('id',v_doc.id,'nome_original',v_doc.nome_original,'mime_type',v_doc.mime_type,
|
||||||
|
'tamanho_bytes',v_doc.tamanho_bytes,'bucket_path',v_doc.bucket_path,'storage_bucket',v_doc.storage_bucket,'tipo_documento',v_doc.tipo_documento),
|
||||||
|
'signatures', COALESCE(v_sigs,'[]'::jsonb), 'expira_em', v_link.expira_em, 'usos_restantes', v_link.usos_max - v_link.usos);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.sign_document_by_token(text, uuid, text);
|
||||||
|
CREATE FUNCTION public.sign_document_by_token(p_token text, p_signature_id uuid DEFAULT NULL, p_hash_documento text DEFAULT NULL)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_link public.document_share_links%ROWTYPE; v_sig RECORD; v_ip inet; v_ua text; v_schema text;
|
||||||
|
BEGIN
|
||||||
|
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
|
||||||
|
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
|
||||||
|
IF v_link.id IS NULL THEN RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE='P0002'; END IF;
|
||||||
|
v_schema := public.tenant_schema_for(v_link.tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='P0002'; END IF;
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
IF p_signature_id IS NOT NULL THEN
|
||||||
|
SELECT * INTO v_sig FROM document_signatures WHERE id = p_signature_id AND documento_id = v_link.documento_id AND status IN ('pendente','enviado') LIMIT 1;
|
||||||
|
ELSE
|
||||||
|
SELECT * INTO v_sig FROM document_signatures WHERE documento_id = v_link.documento_id AND status IN ('pendente','enviado') ORDER BY ordem ASC, criado_em ASC LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
IF v_sig.id IS NULL THEN RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE='P0002'; END IF;
|
||||||
|
v_ip := inet_client_addr();
|
||||||
|
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
|
||||||
|
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
|
||||||
|
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
|
||||||
|
WHERE id = v_sig.id RETURNING * INTO v_sig;
|
||||||
|
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = v_link.id;
|
||||||
|
RETURN to_jsonb(v_sig);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- sign_document_by_signature_id: assinante LOGADO (paciente OU therapist) via
|
||||||
|
-- portal. Paciente NÃO é tenant_member → routing UNCHECKED (p_tenant_id vem do
|
||||||
|
-- FE, da própria assinatura listada). Autorização é por LINHA: só assina se for
|
||||||
|
-- o signatário (signatario_id = uid OU email do uid).
|
||||||
|
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, text);
|
||||||
|
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, uuid, text);
|
||||||
|
CREATE FUNCTION public.sign_document_by_signature_id(p_tenant_id uuid, p_signature_id uuid, p_hash_documento text DEFAULT NULL)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_row RECORD; v_ip inet; v_ua text; v_uid uuid; v_email text;
|
||||||
|
BEGIN
|
||||||
|
IF p_signature_id IS NULL THEN RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE='22023'; END IF;
|
||||||
|
v_uid := auth.uid();
|
||||||
|
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
|
||||||
|
SELECT email INTO v_email FROM auth.users WHERE id = v_uid;
|
||||||
|
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
v_ip := inet_client_addr();
|
||||||
|
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
|
||||||
|
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
|
||||||
|
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
|
||||||
|
WHERE id = p_signature_id AND status IN ('pendente','enviado')
|
||||||
|
AND (signatario_id = v_uid OR signatario_email = v_email
|
||||||
|
OR documento_id IN (SELECT d.id FROM documents d JOIN patients p ON p.id = d.patient_id WHERE p.user_id = v_uid))
|
||||||
|
RETURNING * INTO v_row;
|
||||||
|
IF v_row.id IS NULL THEN RAISE EXCEPTION 'Assinatura não encontrada, já processada, ou sem permissão' USING ERRCODE='P0002'; END IF;
|
||||||
|
RETURN to_jsonb(v_row);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- list_my_signatures: cross-tenant. Fan-out por schema (tenant_id injetado do loop).
|
||||||
|
-- document_share_links é GLOBAL (public). Ordenação global é aproximada (por schema).
|
||||||
|
CREATE OR REPLACE FUNCTION public.list_my_signatures(p_status text[] DEFAULT NULL)
|
||||||
|
RETURNS TABLE(signature_id uuid, documento_id uuid, tenant_id uuid, signatario_tipo text, status text, ordem smallint, assinado_em timestamptz, criado_em timestamptz, nome_original text, tipo_documento text, mime_type text, share_token text, share_expira_em timestamptz)
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_uid uuid; t record;
|
||||||
|
BEGIN
|
||||||
|
v_uid := auth.uid();
|
||||||
|
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
|
||||||
|
FOR t IN SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts LOOP
|
||||||
|
RETURN QUERY EXECUTE format(
|
||||||
|
'SELECT s.id, s.documento_id, $2::uuid, s.signatario_tipo, s.status, s.ordem, s.assinado_em, s.criado_em, '
|
||||||
|
|| 'd.nome_original, d.tipo_documento, d.mime_type, sl.token, sl.expira_em '
|
||||||
|
|| 'FROM %1$I.document_signatures s '
|
||||||
|
|| 'JOIN %1$I.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL '
|
||||||
|
|| 'LEFT JOIN LATERAL (SELECT token, expira_em FROM public.document_share_links WHERE documento_id = d.id AND ativo=true AND expira_em > now() AND usos < usos_max ORDER BY criado_em DESC LIMIT 1) sl ON true '
|
||||||
|
|| 'WHERE (s.signatario_id = $1 OR s.signatario_email = (SELECT email FROM auth.users WHERE id=$1) '
|
||||||
|
|| 'OR d.patient_id IN (SELECT p.id FROM %1$I.patients p WHERE p.user_id = $1)) '
|
||||||
|
|| 'AND ($3::text[] IS NULL OR s.status = ANY($3))',
|
||||||
|
t.schema_name)
|
||||||
|
USING v_uid, t.tenant_id, p_status;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── match_patient_by_phone: service (edge), p_tenant_id → unchecked ──────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id uuid, p_phone text)
|
||||||
|
RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_normalized text; v_patient_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_normalized := public.normalize_phone_br(p_phone);
|
||||||
|
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN RETURN NULL; END IF;
|
||||||
|
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone) = v_normalized LIMIT 1;
|
||||||
|
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
|
||||||
|
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_alternativo) = v_normalized LIMIT 1;
|
||||||
|
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
|
||||||
|
SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_responsavel) = v_normalized LIMIT 1;
|
||||||
|
RETURN v_patient_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── Agendador público: tenant via agendador_configuracoes.tenant_id (public) ─
|
||||||
|
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer)
|
||||||
|
RETURNS TABLE(data date, tem_slots boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_antecedencia int; v_agora timestamptz;
|
||||||
|
v_data date; v_data_inicio date; v_data_fim date; v_db_dow int; v_tem_slot boolean; v_bloqueado boolean;
|
||||||
|
BEGIN
|
||||||
|
SELECT c.owner_id, c.tenant_id, c.antecedencia_minima_horas INTO v_owner_id, v_tenant_id, v_antecedencia
|
||||||
|
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
|
||||||
|
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||||
|
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RETURN; END IF;
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
v_agora := now(); v_data_inicio := make_date(p_ano, p_mes, 1);
|
||||||
|
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date; v_data := v_data_inicio;
|
||||||
|
WHILE v_data <= v_data_fim LOOP
|
||||||
|
v_db_dow := extract(dow from v_data::timestamp)::int;
|
||||||
|
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=v_data AND COALESCE(b.data_fim,v_data)>=v_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_bloqueado;
|
||||||
|
IF v_bloqueado THEN v_data := v_data + 1; CONTINUE; END IF;
|
||||||
|
SELECT EXISTS (SELECT 1 FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true
|
||||||
|
AND (v_data::text||' '||s.time::text)::timestamp AT TIME ZONE 'America/Sao_Paulo' >= v_agora + (v_antecedencia||' hours')::interval) INTO v_tem_slot;
|
||||||
|
IF v_tem_slot THEN data := v_data; tem_slots := true; RETURN NEXT; END IF;
|
||||||
|
v_data := v_data + 1;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date)
|
||||||
|
RETURNS TABLE(hora time without time zone, disponivel boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_duracao int; v_antecedencia int; v_agora timestamptz;
|
||||||
|
v_db_dow int; v_slot time; v_slot_fim time; v_slot_ts timestamptz; v_ocupado boolean;
|
||||||
|
v_rule RECORD; v_rule_start_dow int; v_first_occ date; v_day_diff int; v_ex_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT c.owner_id, c.tenant_id, c.duracao_sessao_min, c.antecedencia_minima_horas
|
||||||
|
INTO v_owner_id, v_tenant_id, v_duracao, v_antecedencia
|
||||||
|
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
|
||||||
|
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||||
|
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN RETURN; END IF;
|
||||||
|
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||||
|
v_agora := now(); v_db_dow := extract(dow from p_data::timestamp)::int;
|
||||||
|
IF EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) THEN RETURN; END IF;
|
||||||
|
FOR v_slot IN SELECT s.time FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true ORDER BY s.time LOOP
|
||||||
|
v_slot_fim := v_slot + (v_duracao||' minutes')::interval; v_ocupado := false;
|
||||||
|
v_slot_ts := (p_data::text||' '||v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
|
||||||
|
IF v_slot_ts < v_agora + (v_antecedencia||' hours')::interval THEN v_ocupado := true; END IF;
|
||||||
|
IF NOT v_ocupado THEN
|
||||||
|
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NOT NULL AND b.hora_inicio<v_slot_fim AND b.hora_fim>v_slot AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_ocupado;
|
||||||
|
END IF;
|
||||||
|
IF NOT v_ocupado THEN
|
||||||
|
SELECT EXISTS (SELECT 1 FROM agenda_eventos e WHERE e.owner_id=v_owner_id AND e.status::text NOT IN ('cancelado','faltou') AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date=p_data AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time<v_slot_fim AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time>v_slot) INTO v_ocupado;
|
||||||
|
END IF;
|
||||||
|
IF NOT v_ocupado THEN
|
||||||
|
FOR v_rule IN SELECT r.id, r.start_date::date AS start_date, r.end_date::date AS end_date, r.start_time::time AS start_time, r.end_time::time AS end_time, COALESCE(r.interval,1)::int AS interval
|
||||||
|
FROM recurrence_rules r WHERE r.owner_id=v_owner_id AND r.status='ativo' AND p_data>=r.start_date::date AND (r.end_date IS NULL OR p_data<=r.end_date::date) AND v_db_dow=ANY(r.weekdays) AND r.start_time::time<v_slot_fim AND r.end_time::time>v_slot LOOP
|
||||||
|
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
|
||||||
|
v_first_occ := v_rule.start_date + (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
|
||||||
|
v_day_diff := (p_data - v_first_occ)::int;
|
||||||
|
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
|
||||||
|
v_ex_type := NULL;
|
||||||
|
SELECT ex.type INTO v_ex_type FROM recurrence_exceptions ex WHERE ex.recurrence_id=v_rule.id AND ex.original_date=p_data LIMIT 1;
|
||||||
|
IF v_ex_type IS NULL OR v_ex_type NOT IN ('cancel_session','patient_missed','therapist_canceled','holiday_block','reschedule_session') THEN v_ocupado := true; EXIT; END IF;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
IF NOT v_ocupado THEN
|
||||||
|
SELECT EXISTS (SELECT 1 FROM recurrence_exceptions ex JOIN recurrence_rules r ON r.id=ex.recurrence_id WHERE r.owner_id=v_owner_id AND r.status='ativo' AND ex.type='reschedule_session' AND ex.new_date=p_data AND COALESCE(ex.new_start_time,r.start_time)::time<v_slot_fim AND COALESCE(ex.new_end_time,r.end_time)::time>v_slot) INTO v_ocupado;
|
||||||
|
END IF;
|
||||||
|
IF NOT v_ocupado THEN
|
||||||
|
-- agendador_solicitacoes FICA em public (F1b)
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.agendador_solicitacoes sol WHERE sol.owner_id=v_owner_id AND sol.status='pendente' AND sol.data_solicitada=p_data AND sol.hora_solicitada=v_slot AND (sol.reservado_ate IS NULL OR sol.reservado_ate>v_agora)) INTO v_ocupado;
|
||||||
|
END IF;
|
||||||
|
hora := v_slot; disponivel := NOT v_ocupado; RETURN NEXT;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- match_patient_by_phone só pra service_role (edge)
|
||||||
|
REVOKE ALL ON FUNCTION public.match_patient_by_phone(uuid, text) FROM PUBLIC, anon, authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.match_patient_by_phone(uuid, text) TO service_role;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 Lote G — funções SQL puras → plpgsql + roteamento por tenant
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin.
|
||||||
|
--
|
||||||
|
-- SQL puro não permite set_config dinâmico do search_path (limitação 3 do
|
||||||
|
-- blueprint) → converter pra plpgsql. Adicionam p_tenant_id + _tenant_route.
|
||||||
|
-- RETURNS SETOF <tabela_tenant> → jsonb. get_entity_primary_phone (interno,
|
||||||
|
-- 0 callers) herda search_path do chamador (sem SET, unqualified).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, integer, integer);
|
||||||
|
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, uuid, integer, integer);
|
||||||
|
CREATE FUNCTION public.get_financial_summary(p_tenant_id uuid, p_owner_id uuid, p_year integer, p_month integer)
|
||||||
|
RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0),
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE status IN ('pending','overdue')), 0),
|
||||||
|
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0) - COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
|
||||||
|
COALESCE(SUM(clinic_fee_amount) FILTER (WHERE type='receita' AND status='paid'), 0),
|
||||||
|
COUNT(*) FILTER (WHERE type='receita' AND deleted_at IS NULL),
|
||||||
|
COUNT(*) FILTER (WHERE type='despesa' AND deleted_at IS NULL)
|
||||||
|
FROM financial_records
|
||||||
|
WHERE owner_id = p_owner_id AND deleted_at IS NULL
|
||||||
|
AND EXTRACT(YEAR FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_year
|
||||||
|
AND EXTRACT(MONTH FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_month;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- list_financial_records: RETURNS SETOF financial_records → jsonb (array)
|
||||||
|
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, integer, integer, text, text, uuid, integer, integer);
|
||||||
|
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, uuid, integer, integer, text, text, uuid, integer, integer);
|
||||||
|
CREATE FUNCTION public.list_financial_records(p_tenant_id uuid, p_owner_id uuid, p_year integer DEFAULT NULL, p_month integer DEFAULT NULL, p_type text DEFAULT NULL, p_status text DEFAULT NULL, p_patient_id uuid DEFAULT NULL, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_result jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
SELECT COALESCE(jsonb_agg(row_json), '[]'::jsonb) INTO v_result FROM (
|
||||||
|
SELECT to_jsonb(fr) AS row_json
|
||||||
|
FROM financial_records fr
|
||||||
|
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
|
||||||
|
AND (p_type IS NULL OR fr.type::text = p_type)
|
||||||
|
AND (p_status IS NULL OR fr.status = p_status)
|
||||||
|
AND (p_patient_id IS NULL OR fr.patient_id = p_patient_id)
|
||||||
|
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_year)
|
||||||
|
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_month)
|
||||||
|
ORDER BY COALESCE(fr.paid_at, fr.due_date::timestamptz, fr.created_at) DESC
|
||||||
|
LIMIT p_limit OFFSET p_offset
|
||||||
|
) sub;
|
||||||
|
RETURN v_result;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid[]);
|
||||||
|
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid, uuid[]);
|
||||||
|
CREATE FUNCTION public.get_patient_session_counts(p_tenant_id uuid, p_patient_ids uuid[])
|
||||||
|
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT ae.patient_id, COUNT(*)::int, MAX(ae.inicio_em)
|
||||||
|
FROM agenda_eventos ae
|
||||||
|
WHERE ae.patient_id = ANY(p_patient_ids)
|
||||||
|
GROUP BY ae.patient_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, date, date, text);
|
||||||
|
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, uuid, date, date, text);
|
||||||
|
CREATE FUNCTION public.get_financial_report(p_tenant_id uuid, p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month')
|
||||||
|
RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||||
|
RETURN QUERY
|
||||||
|
WITH base AS (
|
||||||
|
SELECT fr.type, fr.amount, fr.final_amount, fr.status, fr.deleted_at,
|
||||||
|
CASE p_group_by
|
||||||
|
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
|
||||||
|
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
|
||||||
|
WHEN 'category' THEN COALESCE(fr.category_id::text, fr.category, 'sem_categoria')
|
||||||
|
WHEN 'patient' THEN COALESCE(fr.patient_id::text, 'sem_paciente')
|
||||||
|
ELSE NULL END AS gkey,
|
||||||
|
CASE p_group_by
|
||||||
|
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
|
||||||
|
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
|
||||||
|
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
|
||||||
|
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::text, 'Sem paciente')
|
||||||
|
ELSE NULL END AS glabel
|
||||||
|
FROM financial_records fr
|
||||||
|
LEFT JOIN financial_categories fc ON fc.id = fr.category_id
|
||||||
|
LEFT JOIN patients p ON p.id = fr.patient_id
|
||||||
|
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
|
||||||
|
AND COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date) BETWEEN p_start_date AND p_end_date
|
||||||
|
)
|
||||||
|
SELECT gkey, glabel,
|
||||||
|
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0),
|
||||||
|
COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
|
||||||
|
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0) - COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
|
||||||
|
COALESCE(SUM(final_amount) FILTER (WHERE status='pending'),0),
|
||||||
|
COALESCE(SUM(final_amount) FILTER (WHERE status='overdue'),0),
|
||||||
|
COUNT(*)
|
||||||
|
FROM base WHERE gkey IS NOT NULL GROUP BY gkey, glabel ORDER BY gkey ASC;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- get_entity_primary_phone: interno (0 callers). Sem SET search_path → herda do
|
||||||
|
-- chamador (que roteia pro schema). Unqualified. Mantém assinatura.
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid)
|
||||||
|
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
SELECT number FROM contact_phones
|
||||||
|
WHERE entity_type = p_entity_type AND entity_id = p_entity_id
|
||||||
|
ORDER BY is_primary DESC, position ASC, created_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 (wiring) — tenants NOVOS nascem com todos os triggers de negócio
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin.
|
||||||
|
--
|
||||||
|
-- Até aqui os 9 schemas existentes ganharam os triggers via backfills (Lotes
|
||||||
|
-- A/B/C). Um tenant NOVO (clone_tenant_template) só ganhava channel-routing +
|
||||||
|
-- RLS. Este wiring:
|
||||||
|
-- 1. attach_agnostic_triggers → SELF-CONTAINED (dirigido por colunas, não lê
|
||||||
|
-- public; sobrevive ao DROP da F6.3).
|
||||||
|
-- 2. trigger AFTER INSERT em tenant_schemas dispara os 3 attach (agnostic +
|
||||||
|
-- schema_aware + notif) pro schema novo — clone_tenant_template não precisa
|
||||||
|
-- ser tocado (ele insere em tenant_schemas).
|
||||||
|
-- 3. provision_account_tenant: clone ANTES do seed (seed é no-op se o schema
|
||||||
|
-- não existe; precisa do schema criado primeiro).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) attach_agnostic_triggers self-contained ---------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
|
||||||
|
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE r record; v_count int := 0;
|
||||||
|
BEGIN
|
||||||
|
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||||
|
-- set_updated_at em toda tabela do schema que tem coluna updated_at
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname AS tab
|
||||||
|
FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid
|
||||||
|
WHERE c.relnamespace = p_schema::regnamespace AND c.relkind = 'r'
|
||||||
|
AND a.attname = 'updated_at' AND NOT a.attisdropped AND c.relname NOT LIKE '\_%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS set_updated_at ON %I.%I', p_schema, r.tab);
|
||||||
|
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()', p_schema, r.tab);
|
||||||
|
v_count := v_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
-- prevent_* em patient_groups
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = p_schema AND table_name = 'patient_groups') THEN
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS prevent_promoting_to_system ON %I.patient_groups', p_schema);
|
||||||
|
EXECUTE format('CREATE TRIGGER prevent_promoting_to_system BEFORE UPDATE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_promoting_to_system()', p_schema);
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS prevent_system_group_changes ON %I.patient_groups', p_schema);
|
||||||
|
EXECUTE format('CREATE TRIGGER prevent_system_group_changes BEFORE UPDATE OR DELETE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_system_group_changes()', p_schema);
|
||||||
|
v_count := v_count + 2;
|
||||||
|
END IF;
|
||||||
|
RETURN v_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2) trigger de wiring em tenant_schemas -------------------------------------
|
||||||
|
GRANT EXECUTE ON FUNCTION public.attach_agnostic_triggers(text) TO postgres, service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.attach_schema_aware_triggers(text) TO postgres, service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.attach_notif_triggers(text) TO postgres, service_role;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
|
||||||
|
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
|
||||||
|
PERFORM public.attach_notif_triggers(NEW.schema_name);
|
||||||
|
RETURN NULL;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_tenant_schemas_attach ON public.tenant_schemas;
|
||||||
|
CREATE TRIGGER trg_tenant_schemas_attach
|
||||||
|
AFTER INSERT ON public.tenant_schemas
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.trg_attach_business_triggers();
|
||||||
|
|
||||||
|
-- 3) provision_account_tenant: clone ANTES do seed ---------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
|
||||||
|
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id uuid;
|
||||||
|
v_account_type text;
|
||||||
|
v_name text;
|
||||||
|
BEGIN
|
||||||
|
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
|
||||||
|
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM public.tenant_members tm JOIN public.tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = p_user_id AND tm.role = 'tenant_admin' AND tm.status = 'active' AND t.kind = p_kind
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
v_name := COALESCE(NULLIF(TRIM(p_name), ''),
|
||||||
|
(SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
|
||||||
|
FROM public.profiles pr JOIN auth.users au ON au.id = pr.id WHERE pr.id = p_user_id),
|
||||||
|
'Conta');
|
||||||
|
|
||||||
|
INSERT INTO public.tenants (name, kind, created_at) VALUES (v_name, p_kind, now()) RETURNING id INTO v_tenant_id;
|
||||||
|
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||||
|
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
|
||||||
|
UPDATE public.profiles SET account_type = v_account_type WHERE id = p_user_id;
|
||||||
|
|
||||||
|
-- F6 wiring: clone PRIMEIRO (cria o schema), seed DEPOIS (escreve no schema)
|
||||||
|
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||||
|
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||||
|
|
||||||
|
RETURN v_tenant_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# F6.3 — Rollback da migração schema-per-tenant
|
||||||
|
|
||||||
|
> Como voltar atrás. Lê isto ANTES de aplicar a F6.3 (o DROP). A regra de ouro:
|
||||||
|
> **enquanto a F6.3 NÃO foi aplicada, o rollback é trivial e sem perda.**
|
||||||
|
|
||||||
|
## Princípio: a branch é a rede de segurança
|
||||||
|
|
||||||
|
A migração inteira (F3→F6.4) vive na branch `feat/schema-per-tenant`. A `main`
|
||||||
|
**nunca mudou** o modelo antigo — só recebeu F0/F1/F2, que são **aditivas**
|
||||||
|
(criam `tenants.slug`, o schema `_tenant_template`, helpers e o gatilho de
|
||||||
|
provisionamento; não removem nem alteram nada que o app antigo usa). Ou seja:
|
||||||
|
**`git checkout main` te devolve o app funcionando no modelo public**, desde que
|
||||||
|
o banco também volte (ver abaixo).
|
||||||
|
|
||||||
|
O único passo IRREVERSÍVEL por si só é o **DROP** (F6.3). Tudo antes dele é
|
||||||
|
reversível porque os dados continuam **espelhados em public** (a F6.1 COPIA, não
|
||||||
|
move). Por isso o checkpoint é parar ANTES do DROP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cenário 1 — ANTES de aplicar a F6.3 (estado atual) — rollback trivial
|
||||||
|
|
||||||
|
Nada destrutivo foi feito. Public tem todas as tabelas e dados originais. Para
|
||||||
|
abandonar a migração:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. código volta pro modelo antigo
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
# 2. banco: derruba os schemas tenant + artefatos da migração
|
||||||
|
docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||||
|
psql -U supabase_admin -h 127.0.0.1 -d postgres <<'SQL'
|
||||||
|
DO $$ DECLARE r record; BEGIN
|
||||||
|
FOR r IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\_%' OR nspname='_tenant_template' LOOP
|
||||||
|
EXECUTE format('DROP SCHEMA %I CASCADE', r.nspname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
-- limpa o registro + config do PostgREST
|
||||||
|
DELETE FROM public.tenant_schemas;
|
||||||
|
ALTER ROLE authenticator SET pgrst.db_schemas = 'public, graphql_public';
|
||||||
|
NOTIFY pgrst, 'reload config';
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ As FUNÇÕES em public foram reescritas (F6.2) na branch, mas essas mudanças
|
||||||
|
**não foram aplicadas via migration em `main`** — elas vieram dos arquivos
|
||||||
|
`manual/f6_2*.sql` aplicados como supabase_admin no banco LOCAL. Se você quer o
|
||||||
|
banco local 100% igual ao `main`, restaure as funções originais do backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# restaura o estado public pré-migração (funções, triggers, tudo)
|
||||||
|
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
|
||||||
|
< database-novo/backups/pre-F6/public.sql # ou o backup mais antigo (pré-F1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Como na prática o banco é LOCAL e descartável, o caminho mais limpo do Cenário 1
|
||||||
|
é: **`git checkout main` + `node db.cjs reset` (reinstala schema+seeds do zero)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cenário 2 — DEPOIS de aplicar a F6.3 (DROP já feito) — recuperável, com cuidado
|
||||||
|
|
||||||
|
O DROP removeu as 78 tabelas tenant de public. Os dados VIVOS estão nos schemas
|
||||||
|
`tenant_<slug>`. Há duas situações:
|
||||||
|
|
||||||
|
### 2a) Rollback rápido (logo após o DROP, app quase não rodou nos schemas)
|
||||||
|
Os dados em public estavam atualizados até o **backup pré-F6.3**. Se quase nada
|
||||||
|
foi escrito nos schemas depois do DROP, restaure public do backup e volte o código:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
|
||||||
|
< database-novo/backups/pre-F6.3/public.sql # backup TIRADO antes do DROP
|
||||||
|
# + derrubar schemas tenant (bloco SQL do Cenário 1)
|
||||||
|
```
|
||||||
|
PERDE: qualquer escrita feita NOS SCHEMAS entre o DROP e o rollback.
|
||||||
|
|
||||||
|
### 2b) Rollback com dados atualizados (app rodou e acumulou dados nos schemas)
|
||||||
|
Aí os schemas têm a verdade mais nova. Precisa de uma **migração reversa**
|
||||||
|
(schema → public, o inverso da F6.1), re-adicionando `tenant_id`. Roteiro:
|
||||||
|
1. Restaure a ESTRUTURA das 78 tabelas em public (do backup pré-F6.3, sem os
|
||||||
|
dados, ou recriando via o schema dump).
|
||||||
|
2. Para cada tenant, `INSERT INTO public.<tab> (cols + tenant_id) SELECT cols,
|
||||||
|
'<tenant_id>' FROM tenant_<slug>.<tab>` — o inverso exato do
|
||||||
|
`manual/f6_1_migrate_data.supabase_admin.sql` (trocar origem/destino e
|
||||||
|
RE-ADICIONAR a coluna tenant_id com o id do tenant do schema).
|
||||||
|
3. Resetar sequences, recriar as 9 views + 2 FKs, voltar o código (`git checkout main`).
|
||||||
|
|
||||||
|
Esse caminho é trabalhoso — por isso a recomendação forte: **só aplique a F6.3
|
||||||
|
depois de validar o app**, e mantenha o backup pré-F6.3. O DROP é a única coisa
|
||||||
|
que transforma "trivial" em "trabalhoso".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist antes de aplicar a F6.3 (resumo)
|
||||||
|
- [ ] App testado no browser (fluxos autenticados sem erro PGRST/4xx).
|
||||||
|
- [ ] Backup FRESCO: `pg_dump --schema=public > backups/pre-F6.3/public.sql`.
|
||||||
|
- [ ] Branch commitada (rollback de código = `git checkout main`).
|
||||||
|
- [ ] Ciente: pós-DROP, public some; a verdade passa a ser os schemas.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.3 — DROP das tabelas tenant em public (PONTO DE NÃO-RETORNO)
|
||||||
|
--
|
||||||
|
-- 🛑 NÃO APLICAR AINDA. Este arquivo está PREPARADO para revisão. Aplicar só
|
||||||
|
-- depois de:
|
||||||
|
-- (a) Leonardo testar o app no branch e validar os fluxos;
|
||||||
|
-- (b) os ITENS EM ABERTO abaixo resolvidos;
|
||||||
|
-- (c) backup fresco confirmado.
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (DROP CASCADE; tabelas documents/document_* são
|
||||||
|
-- owned por supabase_admin).
|
||||||
|
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||||
|
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-- < database-novo/manual/f6_3_drop_public_tenant_tables.supabase_admin.sql
|
||||||
|
--
|
||||||
|
-- BACKUP ANTES (obrigatório):
|
||||||
|
-- docker exec supabase_db_agenciapsi-primesakai pg_dump -U postgres -d postgres \
|
||||||
|
-- --schema=public --no-owner --no-acl > database-novo/backups/pre-F6.3/public.sql
|
||||||
|
--
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- ✅ ITENS EM ABERTO — RESOLVIDOS (F6.4, commit dc2363b):
|
||||||
|
-- 1. v_twilio_whatsapp_overview / getAllChannels → RPC saas_list_all_whatsapp_
|
||||||
|
-- channels (fan-out). ✓
|
||||||
|
-- 2. SaasFeriadosPage / SaasNotificationTemplatesPage / SaasWhatsappPage →
|
||||||
|
-- RPCs saas_*_default + supabase.schema(tenant_<slug>). ✓
|
||||||
|
-- 3. notification-webhook (Meta) → confirmado: usa tdb/schema (F4). ✓
|
||||||
|
-- 4. AgendadorPublicoPage → RPCs roteadas. ✓
|
||||||
|
-- Varredura FE confirma ZERO supabase.from('<tabela_tenant>') público restante.
|
||||||
|
-- PRÉ-REQUISITO FINAL: Leonardo testar o app + backup fresco.
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ── 0) PRÉ-FLIGHT: aborta se algo essencial não bate ────────────────────────
|
||||||
|
DO $$
|
||||||
|
DECLARE v_tenants int; v_schemas int; v_drop int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_tenants FROM public.tenants;
|
||||||
|
SELECT count(*) INTO v_schemas FROM public.tenant_schemas;
|
||||||
|
IF v_tenants <> v_schemas THEN
|
||||||
|
RAISE EXCEPTION 'F6.3 ABORT: % tenants mas % schemas provisionados — nem todos migrados', v_tenants, v_schemas;
|
||||||
|
END IF;
|
||||||
|
SELECT count(*) INTO v_drop FROM information_schema.tables
|
||||||
|
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%';
|
||||||
|
IF v_drop <> 78 THEN
|
||||||
|
RAISE EXCEPTION 'F6.3 ABORT: _tenant_template tem % tabelas, esperava 78', v_drop;
|
||||||
|
END IF;
|
||||||
|
-- guarda: nenhuma das 6 anon-facing pode estar na lista de drop
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='_tenant_template'
|
||||||
|
AND table_name IN ('patient_intake_requests','patient_invites','patient_invite_attempts',
|
||||||
|
'document_share_links','agendador_configuracoes','agendador_solicitacoes')) THEN
|
||||||
|
RAISE EXCEPTION 'F6.3 ABORT: tabela anon-facing presente no template (não deveria sair de public)';
|
||||||
|
END IF;
|
||||||
|
RAISE NOTICE 'F6.3 pré-flight OK: % tenants = % schemas, 78 tabelas a dropar', v_tenants, v_schemas;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── 1) FKs de tabelas que FICAM → tabelas que SAEM: viram coluna solta ──────
|
||||||
|
-- (validação fica no app/RPC via token; alvo vai pro schema do tenant)
|
||||||
|
ALTER TABLE public.document_share_links DROP CONSTRAINT IF EXISTS document_share_links_documento_id_fkey;
|
||||||
|
ALTER TABLE public.whatsapp_credits_transactions DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_conversation_message_id_fkey;
|
||||||
|
|
||||||
|
-- ── 2) Views public que referenciam tabelas tenant ──────────────────────────
|
||||||
|
-- As 6 do template são recriadas POR SCHEMA (F1). v_patient_engajamento e
|
||||||
|
-- v_patients_risco são dead code (0 uso no FE). v_twilio_whatsapp_overview:
|
||||||
|
-- ⚠️ ver ITEM EM ABERTO #1 — só dropar após reescrever getAllChannels.
|
||||||
|
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
|
||||||
|
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
|
||||||
|
DROP VIEW IF EXISTS public.v_cashflow_projection CASCADE;
|
||||||
|
DROP VIEW IF EXISTS public.v_commitment_totals CASCADE;
|
||||||
|
DROP VIEW IF EXISTS public.v_patient_groups_with_counts CASCADE;
|
||||||
|
DROP VIEW IF EXISTS public.v_tag_patient_counts CASCADE;
|
||||||
|
DROP VIEW IF EXISTS public.v_patient_engajamento CASCADE; -- dead code
|
||||||
|
DROP VIEW IF EXISTS public.v_patients_risco CASCADE; -- dead code
|
||||||
|
DROP VIEW IF EXISTS public.v_twilio_whatsapp_overview CASCADE; -- ⚠️ item #1
|
||||||
|
|
||||||
|
-- ── 3) DROP das 78 tabelas tenant em public (derivado de _tenant_template) ──
|
||||||
|
-- CASCADE leva junto triggers das tabelas + FKs intra-tenant. Dados já estão
|
||||||
|
-- nos schemas (F6.1) — estas são as cópias velhas.
|
||||||
|
DO $$
|
||||||
|
DECLARE r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%'
|
||||||
|
ORDER BY table_name
|
||||||
|
LOOP
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=r.table_name) THEN
|
||||||
|
EXECUTE format('DROP TABLE public.%I CASCADE', r.table_name);
|
||||||
|
RAISE NOTICE 'F6.3 dropou public.%', r.table_name;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── 4) Limpeza de funções obsoletas pós-drop ────────────────────────────────
|
||||||
|
-- financial_records_inject_tenant só fazia sentido em public.financial_records
|
||||||
|
-- (já dropada); não está anexado em nenhum schema.
|
||||||
|
DROP FUNCTION IF EXISTS public.financial_records_inject_tenant() CASCADE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ── 5) PÓS-DROP: verificações manuais (rodar separado) ───────────────────────
|
||||||
|
-- SELECT 'tabelas tenant restantes em public: ' || count(*) FROM information_schema.tables
|
||||||
|
-- WHERE table_schema='public' AND table_name IN (SELECT table_name FROM _tenant_template.information... );
|
||||||
|
-- NOTIFY pgrst, 'reload schema';
|
||||||
|
-- Conferir app: nenhuma query 4xx/PGRST200 no console.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.4 — RPCs SaaS-admin: defaults do sistema (template + fan-out) e views
|
||||||
|
-- cross-tenant (fan-out). Destrava o F6.3 (páginas SaaS deixam de ler
|
||||||
|
-- public.<tabela_tenant>).
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin.
|
||||||
|
--
|
||||||
|
-- Defaults (feriados nacionais, notification_templates is_default): editados
|
||||||
|
-- pelo SaaS no _tenant_template (fonte da verdade, propaga p/ tenants NOVOS no
|
||||||
|
-- clone) E fan-out pros schemas EXISTENTES. Só toca cópias-default (owner_id
|
||||||
|
-- NULL / is_default), preserva overrides do tenant (owner_id próprio).
|
||||||
|
-- Todas gated por is_saas_admin().
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public._assert_saas_admin()
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$ BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Apenas SaaS admin' USING ERRCODE='42501'; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── FERIADOS (defaults nacionais) ───────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_list_default_feriados(p_ano integer)
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',id,'data',data,'nome',nome,'tipo',tipo,'bloqueia_sessoes',bloqueia_sessoes) ORDER BY data),'[]'::jsonb)
|
||||||
|
INTO v FROM _tenant_template.feriados WHERE EXTRACT(YEAR FROM data) = p_ano;
|
||||||
|
RETURN v;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_add_default_feriado(p_data date, p_nome text, p_tipo text DEFAULT 'municipal')
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_owner uuid := auth.uid();
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
INSERT INTO _tenant_template.feriados (owner_id, tipo, nome, data, bloqueia_sessoes)
|
||||||
|
VALUES (v_owner, p_tipo, p_nome, p_data, false) ON CONFLICT (data, nome) DO NOTHING;
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('INSERT INTO %I.feriados (owner_id, tipo, nome, data, bloqueia_sessoes) VALUES ($1,$2,$3,$4,false) ON CONFLICT (data, nome) DO NOTHING', t.schema_name)
|
||||||
|
USING v_owner, p_tipo, p_nome, p_data;
|
||||||
|
END LOOP;
|
||||||
|
RETURN jsonb_build_object('data', p_data, 'nome', p_nome, 'tipo', p_tipo);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_remove_default_feriado(p_data date, p_nome text)
|
||||||
|
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_n int := 0;
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
DELETE FROM _tenant_template.feriados WHERE data = p_data AND nome = p_nome;
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('DELETE FROM %I.feriados WHERE data = $1 AND nome = $2', t.schema_name) USING p_data, p_nome;
|
||||||
|
v_n := v_n + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_n;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── NOTIFICATION_TEMPLATES (defaults) ───────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_list_default_notif_templates()
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(nt) ORDER BY nt.domain, nt.event_type),'[]'::jsonb)
|
||||||
|
INTO v FROM _tenant_template.notification_templates nt WHERE nt.is_default = true AND nt.deleted_at IS NULL;
|
||||||
|
RETURN v;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- upsert por key (defaults têm owner_id NULL). Cria/atualiza no template + schemas.
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_upsert_default_notif_template(p_payload jsonb)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_key text := p_payload->>'key'; v_exists boolean;
|
||||||
|
v_domain text := p_payload->>'domain'; v_channel text := p_payload->>'channel';
|
||||||
|
v_event text := p_payload->>'event_type'; v_body text := p_payload->>'body_text';
|
||||||
|
v_vars jsonb := COALESCE(p_payload->'variables','[]'::jsonb);
|
||||||
|
v_active boolean := COALESCE((p_payload->>'is_active')::boolean, true);
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
IF v_key IS NULL THEN RAISE EXCEPTION 'key obrigatório'; END IF;
|
||||||
|
-- template
|
||||||
|
SELECT EXISTS(SELECT 1 FROM _tenant_template.notification_templates WHERE key=v_key AND owner_id IS NULL AND is_default=true) INTO v_exists;
|
||||||
|
IF v_exists THEN
|
||||||
|
UPDATE _tenant_template.notification_templates SET body_text=v_body, domain=v_domain, channel=v_channel,
|
||||||
|
event_type=v_event, variables=v_vars, is_active=v_active WHERE key=v_key AND owner_id IS NULL AND is_default=true;
|
||||||
|
ELSE
|
||||||
|
INSERT INTO _tenant_template.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
|
||||||
|
VALUES (NULL, v_key, v_domain, v_channel, v_event, v_body, v_vars, true, v_active);
|
||||||
|
END IF;
|
||||||
|
-- fan-out schemas (só a cópia-default; preserva overrides do tenant owner_id<>NULL)
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO %I.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active) '
|
||||||
|
|| 'VALUES (NULL,$1,$2,$3,$4,$5,$6,true,$7) '
|
||||||
|
|| 'ON CONFLICT (owner_id, key, deleted_at) DO UPDATE SET body_text=EXCLUDED.body_text, domain=EXCLUDED.domain, '
|
||||||
|
|| 'channel=EXCLUDED.channel, event_type=EXCLUDED.event_type, variables=EXCLUDED.variables, is_active=EXCLUDED.is_active',
|
||||||
|
t.schema_name) USING v_key, v_domain, v_channel, v_event, v_body, v_vars, v_active;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_set_default_notif_template_active(p_key text, p_active boolean)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record;
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
UPDATE _tenant_template.notification_templates SET is_active=p_active WHERE key=p_key AND owner_id IS NULL AND is_default=true;
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('UPDATE %I.notification_templates SET is_active=$1 WHERE key=$2 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_active, p_key;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_delete_default_notif_template(p_key text)
|
||||||
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record;
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
UPDATE _tenant_template.notification_templates SET deleted_at=now() WHERE key=p_key AND owner_id IS NULL AND is_default=true;
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('UPDATE %I.notification_templates SET deleted_at=now() WHERE key=$1 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_key;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- quantos tenants têm override (tenant_id<>NULL no modelo antigo = owner_id<>NULL aqui) por key
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_count_notif_template_overrides(p_key text)
|
||||||
|
RETURNS integer LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_n int := 0; v_has int;
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
EXECUTE format('SELECT count(*) FROM %I.notification_templates WHERE key=$1 AND owner_id IS NOT NULL AND is_active=true AND deleted_at IS NULL', t.schema_name) INTO v_has USING p_key;
|
||||||
|
v_n := v_n + v_has;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_n;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── WHATSAPP admin (cross-tenant) — substitui v_twilio_whatsapp_overview ─────
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_list_all_whatsapp_channels()
|
||||||
|
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE t record; v_rows jsonb := '[]'::jsonb; v_part jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM public._assert_saas_admin();
|
||||||
|
FOR t IN SELECT ts.tenant_id, ts.schema_name, tn.name AS tenant_name
|
||||||
|
FROM public.tenant_schemas ts JOIN public.tenants tn ON tn.id = ts.tenant_id LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'SELECT COALESCE(jsonb_agg(jsonb_build_object('
|
||||||
|
|| '''id'',nc.id, ''tenant_id'',$1::uuid, ''tenant_name'',$2::text, ''owner_id'',nc.owner_id,'
|
||||||
|
|| '''provider'',nc.provider, ''is_active'',nc.is_active, ''connection_status'',nc.connection_status,'
|
||||||
|
|| '''sender_address'',nc.sender_address, ''twilio_phone_number'',nc.twilio_phone_number,'
|
||||||
|
|| '''last_health_check'',nc.last_health_check,'
|
||||||
|
|| '''open_incident'',(SELECT i.kind FROM %1$I.whatsapp_connection_incidents i WHERE i.channel_id=nc.id AND i.resolved_at IS NULL LIMIT 1)'
|
||||||
|
|| ')),''[]''::jsonb) FROM %1$I.notification_channels nc WHERE nc.channel=''whatsapp'' AND nc.deleted_at IS NULL',
|
||||||
|
t.schema_name) INTO v_part USING t.tenant_id, t.tenant_name;
|
||||||
|
v_rows := v_rows || v_part;
|
||||||
|
END LOOP;
|
||||||
|
RETURN v_rows;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- grants: gated por is_saas_admin internamente, mas exposto a authenticated
|
||||||
|
DO $g$ DECLARE fn text; BEGIN
|
||||||
|
FOREACH fn IN ARRAY ARRAY[
|
||||||
|
'saas_list_default_feriados(integer)','saas_add_default_feriado(date,text,text)','saas_remove_default_feriado(date,text)',
|
||||||
|
'saas_list_default_notif_templates()','saas_upsert_default_notif_template(jsonb)',
|
||||||
|
'saas_set_default_notif_template_active(text,boolean)','saas_delete_default_notif_template(text)',
|
||||||
|
'saas_count_notif_template_overrides(text)','saas_list_all_whatsapp_channels()'
|
||||||
|
] LOOP
|
||||||
|
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO authenticated', fn);
|
||||||
|
END LOOP;
|
||||||
|
END $g$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F1 — Enforcement de limite de plano (pacientes), schema-per-tenant
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (anexa triggers em tabelas tenant + a função de
|
||||||
|
-- wiring trg_attach_business_triggers é owned por supabase_admin).
|
||||||
|
--
|
||||||
|
-- Trigger genérico BEFORE INSERT em <schema>.patients que:
|
||||||
|
-- 1. resolve o tenant pelo NOME DO SCHEMA (TG_TABLE_SCHEMA → tenant_schemas);
|
||||||
|
-- 2. resolve o plano ATIVO do tenant em runtime (clínica via tenant_id;
|
||||||
|
-- pessoal/terapeuta via owner user_id — as 6 subs pessoais têm tenant_id NULL);
|
||||||
|
-- 3. lê o limite max_patients de plan_features.limits EM RUNTIME (mudar o número
|
||||||
|
-- no painel passa a valer sem deploy);
|
||||||
|
-- 4. conta pacientes vivos (status <> 'Arquivado') e dá RAISE parseável
|
||||||
|
-- 'PLAN_LIMIT_REACHED|patients|<limite>' quando já atingiu.
|
||||||
|
--
|
||||||
|
-- Sem plano ativo OU sem limite definido (planos PRO) ⇒ não bloqueia.
|
||||||
|
-- Idempotente (CREATE OR REPLACE + DROP TRIGGER IF EXISTS). Tudo em public
|
||||||
|
-- (subscriptions/plan_features/tenant_schemas são globais) ⇒ sobrevive ao DROP F6.3.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) Resolve o plano ativo de um tenant (clínica OU pessoal) ------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.tenant_active_plan_id(p_tenant_id uuid)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE(
|
||||||
|
-- clínica: subscription chaveada por tenant_id
|
||||||
|
(SELECT vas.plan_id
|
||||||
|
FROM public.v_tenant_active_subscription vas
|
||||||
|
WHERE vas.tenant_id = p_tenant_id),
|
||||||
|
-- pessoal: subscription chaveada pelo owner (user_id), tenant_id NULL
|
||||||
|
(SELECT s.plan_id
|
||||||
|
FROM public.subscriptions s
|
||||||
|
JOIN public.tenant_members tm
|
||||||
|
ON tm.user_id = s.user_id
|
||||||
|
AND tm.tenant_id = p_tenant_id
|
||||||
|
AND tm.status = 'active'
|
||||||
|
WHERE s.status = 'active'
|
||||||
|
AND s.tenant_id IS NULL
|
||||||
|
AND (s.current_period_end IS NULL OR s.current_period_end > now())
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 1)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
ALTER FUNCTION public.tenant_active_plan_id(uuid) OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
-- 2) Lê um limite numérico do plano (busca a chave em qualquer feature) -------
|
||||||
|
-- Ex.: clinic_free guarda max_patients sob clinic_calendar; therapist_free
|
||||||
|
-- sob patients.manage. Retorna o MIN (mais restritivo) se houver mais de um.
|
||||||
|
CREATE OR REPLACE FUNCTION public.plan_feature_limit(p_plan_id uuid, p_limit_key text)
|
||||||
|
RETURNS int
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
SELECT min((pf.limits->>p_limit_key)::int)
|
||||||
|
FROM public.plan_features pf
|
||||||
|
WHERE pf.plan_id = p_plan_id
|
||||||
|
AND pf.enabled
|
||||||
|
AND pf.limits ? p_limit_key
|
||||||
|
AND (pf.limits->>p_limit_key) ~ '^[0-9]+$';
|
||||||
|
$$;
|
||||||
|
ALTER FUNCTION public.plan_feature_limit(uuid, text) OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
-- 3) Trigger function de enforcement -----------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.enforce_patient_plan_limit()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tenant uuid;
|
||||||
|
v_plan uuid;
|
||||||
|
v_limit int;
|
||||||
|
v_count int;
|
||||||
|
BEGIN
|
||||||
|
SELECT tenant_id INTO v_tenant
|
||||||
|
FROM public.tenant_schemas
|
||||||
|
WHERE schema_name = TG_TABLE_SCHEMA;
|
||||||
|
IF v_tenant IS NULL THEN RETURN NEW; END IF; -- schema não-tenant: ignora
|
||||||
|
|
||||||
|
v_plan := public.tenant_active_plan_id(v_tenant);
|
||||||
|
IF v_plan IS NULL THEN RETURN NEW; END IF; -- sem plano ativo: não bloqueia
|
||||||
|
|
||||||
|
v_limit := public.plan_feature_limit(v_plan, 'max_patients');
|
||||||
|
IF v_limit IS NULL THEN RETURN NEW; END IF; -- plano sem limite (PRO): ilimitado
|
||||||
|
|
||||||
|
EXECUTE format(
|
||||||
|
'SELECT count(*) FROM %I.patients WHERE status IS DISTINCT FROM %L',
|
||||||
|
TG_TABLE_SCHEMA, 'Arquivado'
|
||||||
|
) INTO v_count;
|
||||||
|
|
||||||
|
IF v_count >= v_limit THEN
|
||||||
|
RAISE EXCEPTION 'PLAN_LIMIT_REACHED|patients|%', v_limit USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.enforce_patient_plan_limit() OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
-- 4) Attach helper (pendura o trigger no patients de um schema) ---------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.attach_plan_limit_triggers(p_schema text)
|
||||||
|
RETURNS int
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_schema NOT LIKE 'tenant\_%' THEN
|
||||||
|
RAISE EXCEPTION 'schema inválido %', p_schema;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = p_schema AND table_name = 'patients'
|
||||||
|
) THEN
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS enforce_patient_plan_limit ON %I.patients', p_schema);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE TRIGGER enforce_patient_plan_limit BEFORE INSERT ON %I.patients '
|
||||||
|
'FOR EACH ROW EXECUTE FUNCTION public.enforce_patient_plan_limit()', p_schema);
|
||||||
|
RETURN 1;
|
||||||
|
END IF;
|
||||||
|
RETURN 0;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.attach_plan_limit_triggers(text) OWNER TO supabase_admin;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.attach_plan_limit_triggers(text) TO postgres, service_role;
|
||||||
|
|
||||||
|
-- 5) Wiring: tenants NOVOS ganham o trigger de limite no clone ----------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
|
||||||
|
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
|
||||||
|
PERFORM public.attach_notif_triggers(NEW.schema_name);
|
||||||
|
PERFORM public.attach_plan_limit_triggers(NEW.schema_name);
|
||||||
|
RETURN NULL;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
-- 6) Backfill: os 9 schemas já existentes ganham o trigger agora -------------
|
||||||
|
DO $$
|
||||||
|
DECLARE r record; n int := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||||
|
n := n + public.attach_plan_limit_triggers(r.schema_name);
|
||||||
|
END LOOP;
|
||||||
|
RAISE NOTICE 'enforce_patient_plan_limit anexado em % schemas', n;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F2 — RPCs idempotentes do self-service (schema-per-tenant)
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (auto_provision insere em tenants/members/
|
||||||
|
-- subscriptions/profiles + roda clone_tenant_template).
|
||||||
|
--
|
||||||
|
-- Com confirmação de e-mail LIGADA, o signup NÃO tem sessão — então nada que
|
||||||
|
-- dependa de auth.uid() roda no signup. A escolha do usuário (nome, slug, plano,
|
||||||
|
-- intervalo, kind) é gravada no raw_user_meta_data do signUp e PROCESSADA aqui,
|
||||||
|
-- no 1º login pós-confirmação:
|
||||||
|
-- • slug_disponivel(p_slug) → {ok, motivo} (chamável por anon no signup)
|
||||||
|
-- • auto_provision_free_tenant(...) → cria tenant + clone + master + sub free
|
||||||
|
-- • processar_pos_signup() → cria a intenção SÓ pro caminho pago
|
||||||
|
--
|
||||||
|
-- Todas idempotentes. Não há tabela de aceite legal neste sistema (pulado).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) slug_disponivel ---------------------------------------------------------
|
||||||
|
-- Valida formato (mesma regra do generate_tenant_slug), reservados e uso.
|
||||||
|
-- Chamável por ANON (signup acontece antes do login). Não vaza dados de
|
||||||
|
-- tenant além do fato "slug em uso".
|
||||||
|
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v text := lower(trim(coalesce(p_slug, '')));
|
||||||
|
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
|
||||||
|
BEGIN
|
||||||
|
IF length(v) < 3 THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'motivo', 'curto');
|
||||||
|
END IF;
|
||||||
|
IF length(v) > 48 THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'motivo', 'longo');
|
||||||
|
END IF;
|
||||||
|
-- começa com letra, só [a-z0-9_]
|
||||||
|
IF v !~ '^[a-z][a-z0-9_]*$' THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'motivo', 'invalido');
|
||||||
|
END IF;
|
||||||
|
IF v = ANY(v_reservados) THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'motivo', 'reservado');
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso');
|
||||||
|
END IF;
|
||||||
|
-- (F3) blacklist de slug integra aqui via motivo 'bloqueado'
|
||||||
|
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
|
||||||
|
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- 2) auto_provision_free_tenant ----------------------------------------------
|
||||||
|
-- Idempotente: se o usuário já tem tenant ativo, retorna esse. Senão lê o
|
||||||
|
-- raw_user_meta_data, cria o tenant (slug escolhido OU auto), vira master,
|
||||||
|
-- clona o schema e cria a subscription gratuita ativa (XOR conforme target).
|
||||||
|
-- p_slug_override permite a tela /onboarding reescolher o slug se colidiu.
|
||||||
|
CREATE OR REPLACE FUNCTION public.auto_provision_free_tenant(p_slug_override text DEFAULT NULL)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_uid uuid := auth.uid();
|
||||||
|
v_meta jsonb;
|
||||||
|
v_email text;
|
||||||
|
v_kind text;
|
||||||
|
v_acct text;
|
||||||
|
v_name text;
|
||||||
|
v_slug text;
|
||||||
|
v_display text;
|
||||||
|
v_tenant_id uuid;
|
||||||
|
v_plan_key text;
|
||||||
|
v_plan_id uuid;
|
||||||
|
v_target text;
|
||||||
|
v_existing uuid;
|
||||||
|
BEGIN
|
||||||
|
IF v_uid IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- idempotência: já tem tenant ativo?
|
||||||
|
SELECT tm.tenant_id INTO v_existing
|
||||||
|
FROM public.tenant_members tm
|
||||||
|
WHERE tm.user_id = v_uid AND tm.status = 'active'
|
||||||
|
ORDER BY tm.created_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
IF v_existing IS NOT NULL THEN
|
||||||
|
RETURN jsonb_build_object('status', 'exists', 'tenant_id', v_existing,
|
||||||
|
'slug', (SELECT slug FROM public.tenants WHERE id = v_existing));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
|
||||||
|
FROM auth.users au WHERE au.id = v_uid;
|
||||||
|
v_meta := COALESCE(v_meta, '{}'::jsonb);
|
||||||
|
|
||||||
|
-- kind: do metadata, default therapist (maioria). Valida contra os aceitos.
|
||||||
|
v_kind := lower(coalesce(nullif(trim(v_meta->>'account_kind'), ''), 'therapist'));
|
||||||
|
IF v_kind NOT IN ('therapist','clinic_coworking','clinic_reception','clinic_full') THEN
|
||||||
|
v_kind := 'therapist';
|
||||||
|
END IF;
|
||||||
|
v_acct := CASE WHEN v_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||||
|
|
||||||
|
v_display := nullif(trim(v_meta->>'display_name'), '');
|
||||||
|
v_name := coalesce(
|
||||||
|
nullif(trim(v_meta->>'tenant_name'), ''),
|
||||||
|
v_display,
|
||||||
|
split_part(coalesce(v_email, 'conta'), '@', 1),
|
||||||
|
'Conta');
|
||||||
|
|
||||||
|
-- slug: override > metadata > NULL (trigger auto-gera). Valida disponibilidade.
|
||||||
|
v_slug := lower(trim(coalesce(p_slug_override, v_meta->>'tenant_slug', '')));
|
||||||
|
IF v_slug = '' THEN
|
||||||
|
v_slug := NULL;
|
||||||
|
ELSE
|
||||||
|
IF NOT (public.slug_disponivel(v_slug)->>'ok')::boolean THEN
|
||||||
|
RAISE EXCEPTION 'SLUG_TAKEN|%', v_slug USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- cria tenant (trg_tenants_slug respeita slug fornecido; gera se NULL)
|
||||||
|
INSERT INTO public.tenants (name, kind, slug, created_at)
|
||||||
|
VALUES (v_name, v_kind, v_slug, now())
|
||||||
|
RETURNING id, slug INTO v_tenant_id, v_slug;
|
||||||
|
|
||||||
|
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||||
|
VALUES (v_tenant_id, v_uid, 'tenant_admin', 'active', now());
|
||||||
|
|
||||||
|
UPDATE public.profiles
|
||||||
|
SET account_type = v_acct,
|
||||||
|
full_name = COALESCE(full_name, v_display)
|
||||||
|
WHERE id = v_uid;
|
||||||
|
|
||||||
|
-- provisiona o schema físico + seed
|
||||||
|
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||||
|
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||||
|
|
||||||
|
-- subscription gratuita ativa (XOR: clinic→tenant_id; therapist→user_id)
|
||||||
|
v_plan_key := CASE WHEN v_acct = 'therapist' THEN 'therapist_free' ELSE 'clinic_free' END;
|
||||||
|
SELECT id, lower(target) INTO v_plan_id, v_target FROM public.plans WHERE key = v_plan_key;
|
||||||
|
|
||||||
|
INSERT INTO public.subscriptions (plan_id, plan_key, status, interval, source,
|
||||||
|
tenant_id, user_id, started_at, activated_at, current_period_start)
|
||||||
|
VALUES (v_plan_id, v_plan_key, 'active', 'month', 'auto_free',
|
||||||
|
CASE WHEN v_target = 'clinic' THEN v_tenant_id ELSE NULL END,
|
||||||
|
CASE WHEN v_target = 'clinic' THEN NULL ELSE v_uid END,
|
||||||
|
now(), now(), now());
|
||||||
|
|
||||||
|
RETURN jsonb_build_object('status', 'provisioned', 'tenant_id', v_tenant_id,
|
||||||
|
'slug', v_slug, 'kind', v_kind, 'plan_key', v_plan_key);
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.auto_provision_free_tenant(text) OWNER TO supabase_admin;
|
||||||
|
REVOKE ALL ON FUNCTION public.auto_provision_free_tenant(text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.auto_provision_free_tenant(text) TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- 3) processar_pos_signup ----------------------------------------------------
|
||||||
|
-- Caminho PAGO: se o usuário escolheu um plano PRO no signup (metadata),
|
||||||
|
-- registra a intenção (idempotente — uma por usuário+plano 'new'). O caminho
|
||||||
|
-- gratuito não gera intenção. Sem tabela de aceite legal (pulado).
|
||||||
|
CREATE OR REPLACE FUNCTION public.processar_pos_signup()
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_uid uuid := auth.uid();
|
||||||
|
v_meta jsonb;
|
||||||
|
v_email text;
|
||||||
|
v_plan_key text;
|
||||||
|
v_interval text;
|
||||||
|
v_plan record;
|
||||||
|
v_tenant uuid;
|
||||||
|
v_amount int;
|
||||||
|
BEGIN
|
||||||
|
IF v_uid IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
|
||||||
|
FROM auth.users au WHERE au.id = v_uid;
|
||||||
|
v_meta := COALESCE(v_meta, '{}'::jsonb);
|
||||||
|
|
||||||
|
v_plan_key := nullif(trim(v_meta->>'plan_key'), '');
|
||||||
|
v_interval := lower(coalesce(nullif(trim(v_meta->>'billing_interval'), ''), 'month'));
|
||||||
|
IF v_interval NOT IN ('month','year') THEN v_interval := 'month'; END IF;
|
||||||
|
|
||||||
|
-- sem plano escolhido OU plano gratuito → nada a fazer
|
||||||
|
IF v_plan_key IS NULL OR v_plan_key LIKE '%\_free' THEN
|
||||||
|
RETURN jsonb_build_object('status', 'no_intent');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO v_plan FROM public.plans WHERE key = v_plan_key AND is_active;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN jsonb_build_object('status', 'plan_not_found', 'plan_key', v_plan_key);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- idempotência: já existe intent 'new' desse usuário+plano?
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM public.subscription_intents
|
||||||
|
WHERE created_by_user_id = v_uid AND plan_key = v_plan_key AND status = 'new'
|
||||||
|
) THEN
|
||||||
|
RETURN jsonb_build_object('status', 'intent_exists', 'plan_key', v_plan_key);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT tm.tenant_id INTO v_tenant
|
||||||
|
FROM public.tenant_members tm WHERE tm.user_id = v_uid AND tm.status = 'active'
|
||||||
|
ORDER BY tm.created_at ASC LIMIT 1;
|
||||||
|
|
||||||
|
v_amount := CASE WHEN v_interval = 'year'
|
||||||
|
THEN COALESCE(v_plan.price_cents, 0) * 12
|
||||||
|
ELSE COALESCE(v_plan.price_cents, 0) END;
|
||||||
|
|
||||||
|
-- escreve direto na tabela real (a view subscription_intents tem INSTEAD OF
|
||||||
|
-- trigger que não propaga user_id pra _tenant; o serviço do front também
|
||||||
|
-- escreve nas tabelas reais por target).
|
||||||
|
IF lower(v_plan.target) = 'clinic' THEN
|
||||||
|
INSERT INTO public.subscription_intents_tenant
|
||||||
|
(tenant_id, user_id, created_by_user_id, email, plan_id, plan_key,
|
||||||
|
interval, amount_cents, currency, status, source)
|
||||||
|
VALUES
|
||||||
|
(v_tenant, v_uid, v_uid, v_email, v_plan.id, v_plan_key,
|
||||||
|
v_interval, v_amount, 'BRL', 'new', 'signup');
|
||||||
|
ELSE
|
||||||
|
INSERT INTO public.subscription_intents_personal
|
||||||
|
(user_id, created_by_user_id, email, plan_id, plan_key,
|
||||||
|
interval, amount_cents, currency, status, source)
|
||||||
|
VALUES
|
||||||
|
(v_uid, v_uid, v_email, v_plan.id, v_plan_key,
|
||||||
|
v_interval, v_amount, 'BRL', 'new', 'signup');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object('status', 'intent_created', 'plan_key', v_plan_key, 'interval', v_interval);
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.processar_pos_signup() OWNER TO supabase_admin;
|
||||||
|
REVOKE ALL ON FUNCTION public.processar_pos_signup() FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.processar_pos_signup() TO authenticated, service_role;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F3a — Blacklist de e-mails e slugs
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (cria trigger em auth.users + altera
|
||||||
|
-- slug_disponivel, que é owned por supabase_admin).
|
||||||
|
--
|
||||||
|
-- Tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
|
||||||
|
-- trigger BEFORE INSERT em auth.users (não só no front); suporta domínio inteiro
|
||||||
|
-- com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
|
||||||
|
-- Gerida por saas_admin (dev) em Configurações.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.blacklist (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
kind text NOT NULL CHECK (kind IN ('email','slug')),
|
||||||
|
value text NOT NULL,
|
||||||
|
note text,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (kind, value)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- normaliza value (lower+trim) sempre
|
||||||
|
CREATE OR REPLACE FUNCTION public.blacklist_normalize()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.value := lower(trim(NEW.value));
|
||||||
|
IF NEW.value = '' THEN RAISE EXCEPTION 'valor vazio'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
DROP TRIGGER IF EXISTS trg_blacklist_normalize ON public.blacklist;
|
||||||
|
CREATE TRIGGER trg_blacklist_normalize BEFORE INSERT OR UPDATE ON public.blacklist
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.blacklist_normalize();
|
||||||
|
|
||||||
|
-- RLS: só saas_admin gere
|
||||||
|
ALTER TABLE public.blacklist ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS blacklist_saas_admin ON public.blacklist;
|
||||||
|
CREATE POLICY blacklist_saas_admin ON public.blacklist
|
||||||
|
FOR ALL USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||||
|
|
||||||
|
-- helpers ----------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_email_blacklisted(p_email text)
|
||||||
|
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM public.blacklist
|
||||||
|
WHERE kind = 'email'
|
||||||
|
AND value IN (
|
||||||
|
lower(trim(p_email)),
|
||||||
|
'@' || split_part(lower(trim(p_email)), '@', 2)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
ALTER FUNCTION public.is_email_blacklisted(text) OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_slug_blacklisted(p_slug text)
|
||||||
|
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.blacklist WHERE kind = 'slug' AND value = lower(trim(p_slug)));
|
||||||
|
$$;
|
||||||
|
ALTER FUNCTION public.is_slug_blacklisted(text) OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
-- trigger de bloqueio real no cadastro -----------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.enforce_email_blacklist()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.email IS NOT NULL AND public.is_email_blacklisted(NEW.email) THEN
|
||||||
|
RAISE EXCEPTION 'EMAIL_BLOCKED' USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.enforce_email_blacklist() OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_enforce_email_blacklist ON auth.users;
|
||||||
|
CREATE TRIGGER trg_enforce_email_blacklist BEFORE INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.enforce_email_blacklist();
|
||||||
|
|
||||||
|
-- integra no slug_disponivel (motivo 'bloqueado') ------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v text := lower(trim(coalesce(p_slug, '')));
|
||||||
|
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
|
||||||
|
BEGIN
|
||||||
|
IF length(v) < 3 THEN RETURN jsonb_build_object('ok', false, 'motivo', 'curto'); END IF;
|
||||||
|
IF length(v) > 48 THEN RETURN jsonb_build_object('ok', false, 'motivo', 'longo'); END IF;
|
||||||
|
IF v !~ '^[a-z][a-z0-9_]*$' THEN RETURN jsonb_build_object('ok', false, 'motivo', 'invalido'); END IF;
|
||||||
|
IF v = ANY(v_reservados) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'reservado'); END IF;
|
||||||
|
IF public.is_slug_blacklisted(v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'bloqueado'); END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso'); END IF;
|
||||||
|
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
|
||||||
|
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.blacklist TO authenticated;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F3b — /saas/usuarios (donos por tenant) + notificação aos devs
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (lê auth.users.email + cria trigger em
|
||||||
|
-- public.subscriptions; notify_user_sistema é chamada por SECURITY DEFINER).
|
||||||
|
--
|
||||||
|
-- • saas_list_account_owners(): 1 linha por tenant com o DONO (master),
|
||||||
|
-- nome/slug/e-mail/plano + selo "novo" (24h). Dev-only (is_saas_admin).
|
||||||
|
-- • notify_all_devs(): insere em notifications_sistema p/ cada saas_admin.
|
||||||
|
-- • trigger em subscriptions: avisa os devs quando nasce/muda uma assinatura,
|
||||||
|
-- com deeplink pra /saas/usuarios.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) Donos por tenant (dev-only) ---------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.saas_list_account_owners()
|
||||||
|
RETURNS TABLE (
|
||||||
|
tenant_id uuid,
|
||||||
|
slug text,
|
||||||
|
tenant_name text,
|
||||||
|
kind text,
|
||||||
|
owner_id uuid,
|
||||||
|
owner_name text,
|
||||||
|
owner_email text,
|
||||||
|
plan_key text,
|
||||||
|
created_at timestamptz,
|
||||||
|
is_new boolean
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT t.id, t.slug::text, t.name::text, t.kind::text,
|
||||||
|
owner.user_id, pr.full_name::text, au.email::text,
|
||||||
|
COALESCE(vas.plan_key, ps.plan_key)::text,
|
||||||
|
t.created_at,
|
||||||
|
(t.created_at > now() - interval '24 hours')
|
||||||
|
FROM public.tenants t
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT tm.user_id
|
||||||
|
FROM public.tenant_members tm
|
||||||
|
WHERE tm.tenant_id = t.id AND tm.role = 'tenant_admin' AND tm.status = 'active'
|
||||||
|
ORDER BY tm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
) owner ON true
|
||||||
|
LEFT JOIN public.profiles pr ON pr.id = owner.user_id
|
||||||
|
LEFT JOIN auth.users au ON au.id = owner.user_id
|
||||||
|
LEFT JOIN public.v_tenant_active_subscription vas ON vas.tenant_id = t.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT s.plan_key FROM public.subscriptions s
|
||||||
|
WHERE s.user_id = owner.user_id AND s.status = 'active' AND s.tenant_id IS NULL
|
||||||
|
ORDER BY s.created_at DESC LIMIT 1
|
||||||
|
) ps ON true
|
||||||
|
ORDER BY t.created_at DESC;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.saas_list_account_owners() OWNER TO supabase_admin;
|
||||||
|
REVOKE ALL ON FUNCTION public.saas_list_account_owners() FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.saas_list_account_owners() TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- 2) notify_all_devs ----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.notify_all_devs(
|
||||||
|
p_type text, p_payload jsonb, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE r record; n int := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT user_id FROM public.saas_admins LOOP
|
||||||
|
PERFORM public.notify_user_sistema(r.user_id, p_type, p_payload, NULL, p_ref_id, p_ref_table);
|
||||||
|
n := n + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN n;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.notify_all_devs(text, jsonb, uuid, text) OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
-- 3) trigger em subscriptions -------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_notify_devs_subscription()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE v_slug text; v_title text;
|
||||||
|
BEGIN
|
||||||
|
-- só em INSERT ou quando o status muda
|
||||||
|
IF TG_OP = 'UPDATE' AND NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT t.slug INTO v_slug FROM public.tenants t WHERE t.id = NEW.tenant_id;
|
||||||
|
|
||||||
|
v_title := CASE WHEN TG_OP = 'INSERT' THEN 'Nova assinatura' ELSE 'Assinatura atualizada' END;
|
||||||
|
|
||||||
|
PERFORM public.notify_all_devs(
|
||||||
|
'subscription_' || lower(TG_OP),
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', v_title,
|
||||||
|
'detail', NEW.plan_key || ' · ' || NEW.status || COALESCE(' · ' || v_slug, ''),
|
||||||
|
'deeplink', '/saas/usuarios',
|
||||||
|
'plan_key', NEW.plan_key,
|
||||||
|
'status', NEW.status
|
||||||
|
),
|
||||||
|
NEW.id, 'subscriptions'
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
ALTER FUNCTION public.trg_notify_devs_subscription() OWNER TO supabase_admin;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_subscriptions_notify_devs ON public.subscriptions;
|
||||||
|
CREATE TRIGGER trg_subscriptions_notify_devs
|
||||||
|
AFTER INSERT OR UPDATE OF status ON public.subscriptions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.trg_notify_devs_subscription();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F3c — root_redirect (pra onde o visitante não-logado vai na raiz "/")
|
||||||
|
--
|
||||||
|
-- ⚠️ APLICAR COMO supabase_admin (RLS por is_saas_admin).
|
||||||
|
--
|
||||||
|
-- Config singleton saas_app_config + RPC pública get_root_redirect() (anon lê o
|
||||||
|
-- alvo: 'landing' | 'login'). O guard do front usa pra rotear "/". Só saas_admin
|
||||||
|
-- altera (via UPDATE direto, gated por RLS).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.saas_app_config (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true, -- singleton: sempre id=true
|
||||||
|
root_redirect text NOT NULL DEFAULT 'landing' CHECK (root_redirect IN ('landing','login')),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_by uuid,
|
||||||
|
CONSTRAINT saas_app_config_singleton CHECK (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.saas_app_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE public.saas_app_config ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS saas_app_config_read ON public.saas_app_config;
|
||||||
|
CREATE POLICY saas_app_config_read ON public.saas_app_config FOR SELECT USING (true);
|
||||||
|
DROP POLICY IF EXISTS saas_app_config_write ON public.saas_app_config;
|
||||||
|
CREATE POLICY saas_app_config_write ON public.saas_app_config
|
||||||
|
FOR UPDATE USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||||
|
|
||||||
|
GRANT SELECT ON public.saas_app_config TO anon, authenticated;
|
||||||
|
GRANT UPDATE ON public.saas_app_config TO authenticated;
|
||||||
|
|
||||||
|
-- RPC pública: alvo do "/" pra visitante não-logado
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_root_redirect()
|
||||||
|
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT root_redirect FROM public.saas_app_config WHERE id), 'landing');
|
||||||
|
$$;
|
||||||
|
ALTER FUNCTION public.get_root_redirect() OWNER TO supabase_admin;
|
||||||
|
REVOKE ALL ON FUNCTION public.get_root_redirect() FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_root_redirect() TO anon, authenticated, service_role;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Migracao: layout_variant aceita 'melissa'
|
||||||
|
-- ==========================================================================
|
||||||
|
-- O CHECK constraint user_settings_layout_variant_check restringia o valor
|
||||||
|
-- a ('classic', 'rail'). Com a chegada do Layout Melissa (Direção B do
|
||||||
|
-- redesign — wrapper estilo Win11 lockscreen), precisamos aceitar o valor
|
||||||
|
-- 'melissa' tambem.
|
||||||
|
--
|
||||||
|
-- Wire-up real do router (troca do AppLayout pelo MelissaLayout) ainda nao
|
||||||
|
-- foi feito (Fase 5 do roadmap Melissa) — mas a preferencia ja precisa
|
||||||
|
-- persistir desde agora pra UI do /profile funcionar.
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.user_settings
|
||||||
|
DROP CONSTRAINT IF EXISTS user_settings_layout_variant_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.user_settings
|
||||||
|
ADD CONSTRAINT user_settings_layout_variant_check
|
||||||
|
CHECK (layout_variant = ANY (ARRAY['classic'::text, 'rail'::text, 'melissa'::text]));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_settings.layout_variant
|
||||||
|
IS 'classic (sidebar) | rail (mini rail + painel) | melissa (Win11 lockscreen, Beta)';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Migracao: melissa_prefs em user_settings
|
||||||
|
-- ==========================================================================
|
||||||
|
-- Persiste as preferencias do layout Melissa (Direção B) no DB em vez de
|
||||||
|
-- viverem só em localStorage (que perde ao trocar de navegador/dispositivo).
|
||||||
|
--
|
||||||
|
-- Estrutura do JSONB (sanitizado no client antes de salvar):
|
||||||
|
-- {
|
||||||
|
-- "toqueTermino": "sino", // id em melissaToques.js
|
||||||
|
-- "overlayOpacity": 0.35, // 0..0.8 — escurecedor sobre o bg
|
||||||
|
-- "bgImageOpacity": 1, // 0..1 — transparencia da foto custom
|
||||||
|
-- "use24h": true, // formato do relogio
|
||||||
|
-- "cardsAtivos": ["proximo-..."], // ids de cards do resumo
|
||||||
|
-- "cardsLayout": "linha-unica" // 'linha-unica' | 'duas-linhas'
|
||||||
|
-- }
|
||||||
|
--
|
||||||
|
-- bgUrl (data URL da foto) NAO entra aqui — pode ter MBs e estouraria a row.
|
||||||
|
-- Permanece em localStorage até migrarmos pra Supabase Storage.
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.user_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS melissa_prefs jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_settings.melissa_prefs IS
|
||||||
|
'Preferencias do layout Melissa (toque, opacidade overlay/imagem, formato hora, cards). Imagem de fundo permanece no localStorage por ser data URL pesada.';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Migracao: status_evento_agenda + remarcado + confirmado
|
||||||
|
-- ==========================================================================
|
||||||
|
-- O enum tinha so {agendado, realizado, faltou, cancelado, remarcar}, mas
|
||||||
|
-- o codigo e o trigger fn_notify_agenda_status_change (migration 20260423000009)
|
||||||
|
-- referenciam 'remarcado' (state pos-remarcacao) e 'confirmado' (paciente
|
||||||
|
-- confirmou presenca). Tentativas de UPDATE com esses valores falhavam com
|
||||||
|
-- 'invalid input value for enum status_evento_agenda'.
|
||||||
|
--
|
||||||
|
-- ADD VALUE IF NOT EXISTS e idempotente. Dados existentes (status='remarcar')
|
||||||
|
-- continuam validos — esses dois valores sao acrescimo, nao substituicao.
|
||||||
|
--
|
||||||
|
-- Refs:
|
||||||
|
-- - src/features/agenda/pages/AgendaTerapeutaPage.vue:1339 (bloqueio por feriado)
|
||||||
|
-- - src/features/agenda/services/agendaMappers.js:246,256 (cor/icone)
|
||||||
|
-- - migration 20260423000009 (trigger de notificacao)
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
ALTER TYPE public.status_evento_agenda ADD VALUE IF NOT EXISTS 'remarcado';
|
||||||
|
ALTER TYPE public.status_evento_agenda ADD VALUE IF NOT EXISTS 'confirmado';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Fix: cancel_notifications_on_session_cancel referencia 'excluido'
|
||||||
|
-- ==========================================================================
|
||||||
|
-- A funcao trigger comparava NEW.status IN ('cancelado', 'excluido'), mas o
|
||||||
|
-- enum status_evento_agenda nunca teve o valor 'excluido'. Postgres precisa
|
||||||
|
-- fazer cast do literal pro tipo do enum, e o cast falha com:
|
||||||
|
--
|
||||||
|
-- invalid input value for enum status_evento_agenda: "excluido"
|
||||||
|
--
|
||||||
|
-- Isso quebrava QUALQUER UPDATE que mudasse status pra um valor != atual,
|
||||||
|
-- pois o IF tinha que avaliar a expressao com 'excluido'.
|
||||||
|
--
|
||||||
|
-- O front-end nunca usou 'excluido' (statusOptions em AgendaEventDialog.vue
|
||||||
|
-- so tem agendado/realizado/faltou/cancelado/remarcado). Delete e hard delete
|
||||||
|
-- via DELETE — nao tem soft-delete em agenda_eventos. Logo, 'excluido' eh
|
||||||
|
-- codigo morto e pode ser removido.
|
||||||
|
--
|
||||||
|
-- Refs:
|
||||||
|
-- - src/features/agenda/components/AgendaEventDialog.vue:1071 (statusOptions)
|
||||||
|
-- - schema/03_functions/_all.sql:1056 (funcao original)
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||||
|
PERFORM public.cancel_patient_pending_notifications(
|
||||||
|
NEW.patient_id, NULL, NEW.id
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Migracao: campo "Observacao" nativo no commitment Sessao
|
||||||
|
-- ==========================================================================
|
||||||
|
-- Antes: o commitment determinado 'Sessao' (is_native=true, native_key='session')
|
||||||
|
-- nao tinha campos extras default. Os outros nativos (Leitura, Supervisao, Aula,
|
||||||
|
-- Analise Pessoal) ja vinham com 'notes' (Observacao, textarea) — Sessao era
|
||||||
|
-- a unica excecao.
|
||||||
|
--
|
||||||
|
-- O AgendaEventDialog tinha uma textarea hard-coded "Observacao" no form, fora
|
||||||
|
-- do mecanismo de extra_fields. Pra padronizar (e pra que a Observacao da
|
||||||
|
-- sessao siga o mesmo storage que os outros commitments: agenda_eventos.extra_fields),
|
||||||
|
-- a textarea hardcoded foi removida do .vue e Sessao agora ganha 'notes' como
|
||||||
|
-- campo extra default.
|
||||||
|
--
|
||||||
|
-- Esta migracao:
|
||||||
|
-- 1. Adiciona 'notes' (Observacao, textarea) em TODOS os commitments Sessao
|
||||||
|
-- existentes (idempotente — so insere se ainda nao houver).
|
||||||
|
-- 2. Atualiza a funcao seed_determined_commitments pra que novos tenants criados
|
||||||
|
-- daqui pra frente ja venham com 'notes' no Sessao por padrao.
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. Backfill — adiciona 'notes' nos commitments Sessao ja existentes
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
SELECT dc.tenant_id, dc.id, 'notes', 'Observação', 'textarea', false, 30
|
||||||
|
FROM public.determined_commitments dc
|
||||||
|
WHERE dc.is_native = true
|
||||||
|
AND dc.native_key = 'session'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.determined_commitment_fields f
|
||||||
|
WHERE f.commitment_id = dc.id AND f.key = 'notes'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. Forward-fix — funcao seed_determined_commitments inclui 'notes' em Sessao
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
declare
|
||||||
|
v_id uuid;
|
||||||
|
begin
|
||||||
|
-- Sessão (locked + sempre ativa)
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||||
|
) then
|
||||||
|
insert into public.determined_commitments
|
||||||
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
|
values
|
||||||
|
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Leitura
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
|
||||||
|
) then
|
||||||
|
insert into public.determined_commitments
|
||||||
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
|
values
|
||||||
|
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Supervisão
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||||
|
) then
|
||||||
|
insert into public.determined_commitments
|
||||||
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
|
values
|
||||||
|
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Aula
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||||
|
) then
|
||||||
|
insert into public.determined_commitments
|
||||||
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
|
values
|
||||||
|
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Análise pessoal
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||||
|
) then
|
||||||
|
insert into public.determined_commitments
|
||||||
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
|
values
|
||||||
|
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Campos padrão (idempotentes por (commitment_id, key))
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
|
||||||
|
select id into v_id
|
||||||
|
from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Leitura
|
||||||
|
select id into v_id
|
||||||
|
from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'book') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'book', 'Livro', 'text', false, 10);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'author') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'author', 'Autor', 'text', false, 20);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Supervisão
|
||||||
|
select id into v_id
|
||||||
|
from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'supervisor') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'supervisor', 'Supervisor', 'text', false, 10);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'topic') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'topic', 'Assunto', 'text', false, 20);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Aula
|
||||||
|
select id into v_id
|
||||||
|
from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'theme') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'theme', 'Tema', 'text', false, 10);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'group') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'group', 'Turma', 'text', false, 20);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Análise
|
||||||
|
select id into v_id
|
||||||
|
from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'analyst') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'analyst', 'Analista', 'text', false, 10);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'focus') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'focus', 'Foco', 'text', false, 20);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Drop agenda_excecoes (tabela órfã) + tipos relacionados
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- A tabela `public.agenda_excecoes` foi criada num design anterior pra
|
||||||
|
-- representar "exceções no horário de trabalho" (almoço extra, atendimento
|
||||||
|
-- fora do padrão, etc) mas nunca foi integrada à UI. Auditoria em
|
||||||
|
-- 2026-05-13 confirmou 0 referências em src/. As funcionalidades equivalentes
|
||||||
|
-- vivem em:
|
||||||
|
-- - public.agenda_bloqueios — bloqueios (período, dia, horário, feriado)
|
||||||
|
-- - public.agenda_configuracoes.pausas_semanais (jsonb) — pausas semanais
|
||||||
|
-- - public.feriados — feriados nacionais/municipais
|
||||||
|
--
|
||||||
|
-- Esta migration:
|
||||||
|
-- 1) Dropa o trigger tg_agenda_excecoes_updated_at
|
||||||
|
-- 2) Dropa a tabela public.agenda_excecoes (CASCADE pra cair policies)
|
||||||
|
-- 3) Dropa os enums tipo_excecao_agenda e status_excecao_agenda
|
||||||
|
-- (verificados: usados APENAS por agenda_excecoes)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Trigger (idempotente)
|
||||||
|
DROP TRIGGER IF EXISTS tg_agenda_excecoes_updated_at ON public.agenda_excecoes;
|
||||||
|
|
||||||
|
-- 2. Tabela (CASCADE leva policies junto)
|
||||||
|
DROP TABLE IF EXISTS public.agenda_excecoes CASCADE;
|
||||||
|
|
||||||
|
-- 3. Enums órfãos
|
||||||
|
DROP TYPE IF EXISTS public.tipo_excecao_agenda;
|
||||||
|
DROP TYPE IF EXISTS public.status_excecao_agenda;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Adiciona coluna payment_link em financial_records
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Quando a cobrança for paga via gateway externo (Asaas, Stripe, Mercado Pago)
|
||||||
|
-- e o terapeuta escolher "Enviar link de pagamento" no AgendaEventDialog, o
|
||||||
|
-- link de cobrança gerado pelo gateway é salvo aqui. UI da lista do Financeiro
|
||||||
|
-- usa esse campo pra exibir ícone clicável (external-link).
|
||||||
|
--
|
||||||
|
-- Campo nullable: registros sem integração de gateway (PIX manual, dinheiro,
|
||||||
|
-- depósito, cartão maquininha) ficam com payment_link = NULL.
|
||||||
|
--
|
||||||
|
-- Preparação pra Fase 7 (Pagamento como entidade separada) — quando a
|
||||||
|
-- integração Asaas estiver completa, o webhook vai preencher esse campo.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.financial_records
|
||||||
|
ADD COLUMN IF NOT EXISTS payment_link text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.financial_records.payment_link IS
|
||||||
|
'URL externa de cobrança (Asaas/Stripe/etc) quando payment_method indica gateway. Null em pagamentos manuais.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Adiciona coluna default_consume_on_miss em financial_exceptions
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Define o comportamento padrão pro saldo de pacote quando o status muda
|
||||||
|
-- pra "faltou" ou "cancelado":
|
||||||
|
-- true → desconta 1 sessão do pacote (sessions_used += 1) por padrão
|
||||||
|
-- false → não consome saldo (sessão fica disponível pra remarcar)
|
||||||
|
--
|
||||||
|
-- O dialog de confirmação que aparece ao mudar status sugere essa decisão
|
||||||
|
-- mas o terapeuta pode override caso a caso. Padrão começa false (mais
|
||||||
|
-- benevolente ao paciente).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.financial_exceptions
|
||||||
|
ADD COLUMN IF NOT EXISTS default_consume_on_miss boolean DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.financial_exceptions.default_consume_on_miss IS
|
||||||
|
'Default pro toggle "Descontar do saldo" no dialog de status change. false = não consome (paciente pode remarcar); true = consome (sessão perdida).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Adiciona coluna charging_style em billing_contracts
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Identifica como o pacote foi cobrado na criação:
|
||||||
|
-- 'upfront' → 1 financial_record total criado na hora; sessões só
|
||||||
|
-- consomem saldo, não geram nova cobrança
|
||||||
|
-- 'saldo' → sem financial_record na criação; cada sessão realizada
|
||||||
|
-- gera 1 cobrança individual e incrementa sessions_used
|
||||||
|
-- 'per_session'→ N financial_records já criados na materialização da série
|
||||||
|
-- (chargeMode='per_session' do AgendaEventDialog)
|
||||||
|
--
|
||||||
|
-- Sem esse campo, o handler de status change não saberia distinguir entre
|
||||||
|
-- "já tudo pago, só atualizar status" vs "criar cobrança nova".
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.billing_contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS charging_style text DEFAULT 'saldo';
|
||||||
|
|
||||||
|
-- Constraint pra restringir aos 3 valores válidos
|
||||||
|
ALTER TABLE public.billing_contracts
|
||||||
|
DROP CONSTRAINT IF EXISTS billing_contracts_charging_style_chk;
|
||||||
|
ALTER TABLE public.billing_contracts
|
||||||
|
ADD CONSTRAINT billing_contracts_charging_style_chk
|
||||||
|
CHECK (charging_style = ANY (ARRAY['upfront'::text, 'saldo'::text, 'per_session'::text]));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.billing_contracts.charging_style IS
|
||||||
|
'Estilo de cobrança: upfront (1 record total no início), saldo (cobra por sessão realizada), per_session (N records já criados).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- create_financial_record_for_session: idempotência ignora cancelled
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Bug: a função recusava criar um novo financial_record quando já existia
|
||||||
|
-- um record cancelled pro mesmo agenda_evento_id, porque a checagem de
|
||||||
|
-- idempotência só filtrava `deleted_at IS NULL` (e cancel preserva
|
||||||
|
-- deleted_at = NULL pra manter auditoria).
|
||||||
|
--
|
||||||
|
-- Consequência: user cancelava cobrança sem querer e ficava preso — todo
|
||||||
|
-- "Gerar cobrança" subsequente retornava o registro cancelado sem inserir
|
||||||
|
-- nova linha (frontend recebia data, achava sucesso, mas DB ficava como
|
||||||
|
-- estava).
|
||||||
|
--
|
||||||
|
-- Fix: adiciona `AND status != 'cancelled'` na checagem. Cancelled passa a
|
||||||
|
-- ser tratado como "sem cobrança ativa" pra idempotência. Audit history
|
||||||
|
-- continua preservado (rows cancelled permanecem na tabela).
|
||||||
|
--
|
||||||
|
-- Idempotente: CREATE OR REPLACE substitui a função existente.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_financial_record_for_session(
|
||||||
|
p_tenant_id uuid,
|
||||||
|
p_owner_id uuid,
|
||||||
|
p_patient_id uuid,
|
||||||
|
p_agenda_evento_id uuid,
|
||||||
|
p_amount numeric,
|
||||||
|
p_due_date date
|
||||||
|
) RETURNS SETOF public.financial_records
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_existing public.financial_records%ROWTYPE;
|
||||||
|
v_new public.financial_records%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
-- Idempotência: retorna o registro existente se já foi criado.
|
||||||
|
-- Ignora cancelled (treat as "no active record") pra permitir regenerar
|
||||||
|
-- cobrança após cancelamento.
|
||||||
|
SELECT * INTO v_existing
|
||||||
|
FROM public.financial_records
|
||||||
|
WHERE agenda_evento_id = p_agenda_evento_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status != 'cancelled'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
RETURN NEXT v_existing;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Cria o novo registro
|
||||||
|
INSERT INTO public.financial_records (
|
||||||
|
tenant_id,
|
||||||
|
owner_id,
|
||||||
|
patient_id,
|
||||||
|
agenda_evento_id,
|
||||||
|
amount,
|
||||||
|
discount_amount,
|
||||||
|
final_amount,
|
||||||
|
status,
|
||||||
|
due_date
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id,
|
||||||
|
p_owner_id,
|
||||||
|
p_patient_id,
|
||||||
|
p_agenda_evento_id,
|
||||||
|
p_amount,
|
||||||
|
0,
|
||||||
|
p_amount,
|
||||||
|
'pending',
|
||||||
|
p_due_date
|
||||||
|
)
|
||||||
|
RETURNING * INTO v_new;
|
||||||
|
|
||||||
|
-- Marca o evento da agenda como billed = true
|
||||||
|
UPDATE public.agenda_eventos
|
||||||
|
SET billed = TRUE
|
||||||
|
WHERE id = p_agenda_evento_id;
|
||||||
|
|
||||||
|
RETURN NEXT v_new;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Cria tabelas do prontuário clínico
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Núcleo do prontuário: notas clínicas (anamnese, evolução, plano), com
|
||||||
|
-- versionamento (audit trail) e templates (SOAP/DAP/BIRP/livre).
|
||||||
|
--
|
||||||
|
-- Decisões (sessão de modelagem 2026-05-20):
|
||||||
|
-- • Tabela única `clinical_notes` discriminada por `note_type` (não 1 tabela
|
||||||
|
-- por tipo). Templates customizáveis exigem flexibilidade.
|
||||||
|
-- • `content_text` (livre) + `content_structured` (jsonb) coexistem na mesma
|
||||||
|
-- row — UI prioriza conforme template; busca/edit rápido sempre tem text.
|
||||||
|
-- • Versionamento via snapshot completo (não diff) em `clinical_note_versions`
|
||||||
|
-- — restore trivial e audit visualization friendly. Trigger de versionamento
|
||||||
|
-- criado em migration separada.
|
||||||
|
-- • Instrumentos de avaliação (GAD-7, PHQ-9, etc) ficam pra Fase 2.
|
||||||
|
-- • RLS: owner-only (terapeuta responsável). Sem clinic-wide read — CFP exige
|
||||||
|
-- sigilo entre profissionais. Policies em migration separada.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. clinical_notes — núcleo do prontuário
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.clinical_notes (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id uuid NOT NULL,
|
||||||
|
owner_id uuid NOT NULL, -- terapeuta responsável
|
||||||
|
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE RESTRICT,
|
||||||
|
session_event_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
|
||||||
|
note_type text NOT NULL,
|
||||||
|
template_id uuid, -- FK adicionada após criar templates
|
||||||
|
title text,
|
||||||
|
content_text text,
|
||||||
|
content_structured jsonb,
|
||||||
|
pinned boolean DEFAULT false NOT NULL,
|
||||||
|
is_draft boolean DEFAULT false NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
updated_by uuid,
|
||||||
|
deleted_at timestamp with time zone,
|
||||||
|
deleted_by uuid,
|
||||||
|
CONSTRAINT clinical_notes_note_type_check CHECK (note_type IN (
|
||||||
|
'anamnese',
|
||||||
|
'evolucao_sessao',
|
||||||
|
'plano_terapeutico',
|
||||||
|
'observacao_livre',
|
||||||
|
'resumo_caso'
|
||||||
|
)),
|
||||||
|
CONSTRAINT clinical_notes_content_present_check CHECK (
|
||||||
|
content_text IS NOT NULL OR content_structured IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.clinical_notes IS
|
||||||
|
'Notas clínicas do prontuário (anamnese, evolução de sessão, plano, observações). Owner-only via RLS — CFP exige sigilo.';
|
||||||
|
COMMENT ON COLUMN public.clinical_notes.session_event_id IS
|
||||||
|
'Sessão associada (quando aplicável). Anamnese/plano/resumo podem ter NULL.';
|
||||||
|
COMMENT ON COLUMN public.clinical_notes.content_text IS
|
||||||
|
'Conteúdo em texto livre (sempre disponível pra busca/edit rápido).';
|
||||||
|
COMMENT ON COLUMN public.clinical_notes.content_structured IS
|
||||||
|
'Conteúdo em formato estruturado quando há template ativo (jsonb dos campos preenchidos).';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_notes_patient_recent
|
||||||
|
ON public.clinical_notes (tenant_id, patient_id, created_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_notes_owner
|
||||||
|
ON public.clinical_notes (owner_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_notes_session
|
||||||
|
ON public.clinical_notes (session_event_id)
|
||||||
|
WHERE session_event_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_notes_type
|
||||||
|
ON public.clinical_notes (tenant_id, patient_id, note_type)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_notes_pinned
|
||||||
|
ON public.clinical_notes (tenant_id, patient_id)
|
||||||
|
WHERE pinned = true AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. clinical_note_versions — audit trail (snapshot completo)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.clinical_note_versions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
note_id uuid NOT NULL REFERENCES public.clinical_notes(id) ON DELETE CASCADE,
|
||||||
|
tenant_id uuid NOT NULL,
|
||||||
|
version_number integer NOT NULL,
|
||||||
|
title text,
|
||||||
|
content_text text,
|
||||||
|
content_structured jsonb,
|
||||||
|
change_reason text, -- 'criacao' | 'edicao' | livre
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
CONSTRAINT clinical_note_versions_unique UNIQUE (note_id, version_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.clinical_note_versions IS
|
||||||
|
'Snapshot completo de cada versão de clinical_notes. Criado via trigger AFTER INSERT OR UPDATE.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_recent
|
||||||
|
ON public.clinical_note_versions (note_id, version_number DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_audit
|
||||||
|
ON public.clinical_note_versions (created_by, created_at DESC);
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. clinical_note_templates — templates SOAP/DAP/BIRP/anamnese padrão
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.clinical_note_templates (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id uuid, -- NULL = template global do sistema
|
||||||
|
owner_id uuid, -- NULL = template do tenant inteiro
|
||||||
|
key text NOT NULL, -- 'soap', 'dap', 'birp', 'anamnese_padrao', ...
|
||||||
|
name text NOT NULL,
|
||||||
|
note_type text NOT NULL,
|
||||||
|
description text,
|
||||||
|
structure jsonb NOT NULL, -- [{key, label, type, required, hint}]
|
||||||
|
is_system boolean DEFAULT false NOT NULL,
|
||||||
|
is_global boolean DEFAULT false NOT NULL,
|
||||||
|
active boolean DEFAULT true NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT clinical_note_templates_note_type_check CHECK (note_type IN (
|
||||||
|
'anamnese',
|
||||||
|
'evolucao_sessao',
|
||||||
|
'plano_terapeutico',
|
||||||
|
'observacao_livre',
|
||||||
|
'resumo_caso'
|
||||||
|
)),
|
||||||
|
CONSTRAINT clinical_note_templates_scope_check CHECK (
|
||||||
|
-- Sistema: ambos NULL e is_system=true
|
||||||
|
-- Tenant-wide: tenant_id presente, owner_id NULL
|
||||||
|
-- Owner: ambos presentes
|
||||||
|
(is_system = true AND tenant_id IS NULL AND owner_id IS NULL)
|
||||||
|
OR (is_system = false AND tenant_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.clinical_note_templates IS
|
||||||
|
'Templates de notas clínicas. Escopo: sistema (is_system, sem tenant), tenant-wide (tenant_id sem owner), owner (ambos).';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_active
|
||||||
|
ON public.clinical_note_templates (note_type)
|
||||||
|
WHERE active = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_tenant
|
||||||
|
ON public.clinical_note_templates (tenant_id, note_type)
|
||||||
|
WHERE tenant_id IS NOT NULL AND active = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_owner
|
||||||
|
ON public.clinical_note_templates (owner_id, note_type)
|
||||||
|
WHERE owner_id IS NOT NULL AND active = true;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 4. FK de clinical_notes.template_id (criada agora que templates existe)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE public.clinical_notes
|
||||||
|
ADD CONSTRAINT clinical_notes_template_fkey
|
||||||
|
FOREIGN KEY (template_id)
|
||||||
|
REFERENCES public.clinical_note_templates(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- RLS policies do prontuário clínico
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Padrão MAIS RESTRITIVO que agenda — CFP exige sigilo profissional entre
|
||||||
|
-- terapeutas do mesmo tenant. Default: APENAS o owner (terapeuta responsável)
|
||||||
|
-- lê e escreve. Sem clinic-wide read.
|
||||||
|
--
|
||||||
|
-- Compartilhamento com supervisor / outro terapeuta vai requerer policy
|
||||||
|
-- específica baseada em tabela `clinical_note_shares` (Fase 2).
|
||||||
|
--
|
||||||
|
-- Templates seguem regra mais aberta:
|
||||||
|
-- • Sistema (is_system): todos authenticated leem
|
||||||
|
-- • Tenant-wide (tenant_id): membros do tenant leem; tenant_admin edita
|
||||||
|
-- • Owner: só o owner lê/edita
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- clinical_notes — owner only
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE public.clinical_notes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY clinical_notes_owner_select
|
||||||
|
ON public.clinical_notes FOR SELECT TO authenticated
|
||||||
|
USING (owner_id = auth.uid() AND deleted_at IS NULL);
|
||||||
|
|
||||||
|
CREATE POLICY clinical_notes_owner_insert
|
||||||
|
ON public.clinical_notes FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
owner_id = auth.uid()
|
||||||
|
AND public.is_tenant_member(tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY clinical_notes_owner_update
|
||||||
|
ON public.clinical_notes FOR UPDATE TO authenticated
|
||||||
|
USING (owner_id = auth.uid())
|
||||||
|
WITH CHECK (owner_id = auth.uid());
|
||||||
|
|
||||||
|
-- DELETE só por soft-delete (UPDATE deleted_at). Hard delete bloqueado em RLS.
|
||||||
|
-- Backup/admin pode dropar via psql -U supabase_admin se preciso.
|
||||||
|
CREATE POLICY clinical_notes_no_hard_delete
|
||||||
|
ON public.clinical_notes FOR DELETE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- clinical_note_versions — read-only pelo owner da nota
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE public.clinical_note_versions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY clinical_note_versions_owner_select
|
||||||
|
ON public.clinical_note_versions FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.clinical_notes cn
|
||||||
|
WHERE cn.id = clinical_note_versions.note_id
|
||||||
|
AND cn.owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT só via trigger (SECURITY DEFINER). Sem policy de UPDATE/DELETE —
|
||||||
|
-- versões são imutáveis. Trigger usa role bypass.
|
||||||
|
CREATE POLICY clinical_note_versions_no_write
|
||||||
|
ON public.clinical_note_versions FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (false);
|
||||||
|
CREATE POLICY clinical_note_versions_no_update
|
||||||
|
ON public.clinical_note_versions FOR UPDATE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
CREATE POLICY clinical_note_versions_no_delete
|
||||||
|
ON public.clinical_note_versions FOR DELETE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- clinical_note_templates — escopo escalonado
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE public.clinical_note_templates ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- SELECT: sistema (qualquer authenticated) + tenant-wide (membros) + owner (próprio)
|
||||||
|
CREATE POLICY clinical_note_templates_select
|
||||||
|
ON public.clinical_note_templates FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
active = true
|
||||||
|
AND (
|
||||||
|
is_system = true
|
||||||
|
OR (tenant_id IS NOT NULL AND public.is_tenant_member(tenant_id))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT/UPDATE/DELETE: só owner ou tenant_admin do tenant
|
||||||
|
-- Templates do sistema (is_system) nunca alteráveis via UI — só via seed/migration.
|
||||||
|
CREATE POLICY clinical_note_templates_owner_write
|
||||||
|
ON public.clinical_note_templates TO authenticated
|
||||||
|
USING (
|
||||||
|
is_system = false
|
||||||
|
AND (
|
||||||
|
owner_id = auth.uid()
|
||||||
|
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
is_system = false
|
||||||
|
AND (
|
||||||
|
owner_id = auth.uid()
|
||||||
|
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Trigger de versionamento automático de clinical_notes
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- A cada INSERT ou UPDATE relevante em clinical_notes, cria snapshot completo
|
||||||
|
-- em clinical_note_versions. Função é SECURITY DEFINER pra bypassar a RLS
|
||||||
|
-- (que bloqueia INSERT direto em clinical_note_versions).
|
||||||
|
--
|
||||||
|
-- Versionamento dispara em:
|
||||||
|
-- • INSERT — registra criação (version_number = 1)
|
||||||
|
-- • UPDATE em content_text, content_structured ou title — registra edição
|
||||||
|
--
|
||||||
|
-- Mudanças em pinned/is_draft NÃO disparam versionamento (mudança de UI/state,
|
||||||
|
-- não de conteúdo).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
next_version integer;
|
||||||
|
reason text;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||||
|
INTO next_version
|
||||||
|
FROM public.clinical_note_versions
|
||||||
|
WHERE note_id = NEW.id;
|
||||||
|
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
reason := 'criacao';
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
|
||||||
|
reason := 'soft_delete';
|
||||||
|
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN
|
||||||
|
reason := 'restore';
|
||||||
|
ELSE
|
||||||
|
reason := 'edicao';
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
reason := 'desconhecido';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.clinical_note_versions (
|
||||||
|
note_id,
|
||||||
|
tenant_id,
|
||||||
|
version_number,
|
||||||
|
title,
|
||||||
|
content_text,
|
||||||
|
content_structured,
|
||||||
|
change_reason,
|
||||||
|
created_at,
|
||||||
|
created_by
|
||||||
|
) VALUES (
|
||||||
|
NEW.id,
|
||||||
|
NEW.tenant_id,
|
||||||
|
next_version,
|
||||||
|
NEW.title,
|
||||||
|
NEW.content_text,
|
||||||
|
NEW.content_structured,
|
||||||
|
reason,
|
||||||
|
now(),
|
||||||
|
COALESCE(NEW.updated_by, NEW.created_by)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.fn_clinical_note_version() IS
|
||||||
|
'Snapshot completo de clinical_notes a cada INSERT/UPDATE relevante. SECURITY DEFINER bypassa RLS pra escrever em clinical_note_versions (que bloqueia INSERT direto).';
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_clinical_notes_version_insert
|
||||||
|
AFTER INSERT ON public.clinical_notes
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.fn_clinical_note_version();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_clinical_notes_version_update
|
||||||
|
AFTER UPDATE OF content_text, content_structured, title, deleted_at
|
||||||
|
ON public.clinical_notes
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (
|
||||||
|
OLD.content_text IS DISTINCT FROM NEW.content_text
|
||||||
|
OR OLD.content_structured IS DISTINCT FROM NEW.content_structured
|
||||||
|
OR OLD.title IS DISTINCT FROM NEW.title
|
||||||
|
OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at
|
||||||
|
)
|
||||||
|
EXECUTE FUNCTION public.fn_clinical_note_version();
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Trigger para updated_at automático
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_clinical_notes_updated_at()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at := now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_clinical_notes_updated_at
|
||||||
|
BEFORE UPDATE ON public.clinical_notes
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_clinical_note_templates_updated_at
|
||||||
|
BEFORE UPDATE ON public.clinical_note_templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Liga documents a clinical_notes (preenche FK órfã)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- A coluna `documents.session_note_id` existia desde antes apontando pra uma
|
||||||
|
-- tabela `session_notes` que nunca foi criada. Agora que `clinical_notes`
|
||||||
|
-- existe e abrange anamnese/evolução/plano (não só sessão), renomeia pra
|
||||||
|
-- `clinical_note_id` e adiciona FK constraint.
|
||||||
|
--
|
||||||
|
-- PRÉ-CHECK: a query abaixo deve retornar 0 antes de rodar esta migration.
|
||||||
|
-- SELECT count(*) FROM public.documents WHERE session_note_id IS NOT NULL;
|
||||||
|
-- Se houver dados, eles são órfãos (referenciam tabela inexistente) — limpar
|
||||||
|
-- antes de adicionar a FK constraint, ou ela falha.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Limpa eventuais órfãos (FK nunca foi enforced, mas valor pode ter sido
|
||||||
|
-- setado por código no front antes da migration). Defesa em profundidade.
|
||||||
|
UPDATE public.documents
|
||||||
|
SET session_note_id = NULL
|
||||||
|
WHERE session_note_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.clinical_notes cn
|
||||||
|
WHERE cn.id = documents.session_note_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Rename
|
||||||
|
ALTER TABLE public.documents
|
||||||
|
RENAME COLUMN session_note_id TO clinical_note_id;
|
||||||
|
|
||||||
|
-- 3. FK constraint
|
||||||
|
ALTER TABLE public.documents
|
||||||
|
ADD CONSTRAINT documents_clinical_note_fkey
|
||||||
|
FOREIGN KEY (clinical_note_id)
|
||||||
|
REFERENCES public.clinical_notes(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- 4. Index pra reverse lookup (documentos de uma nota)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_clinical_note
|
||||||
|
ON public.documents (clinical_note_id)
|
||||||
|
WHERE clinical_note_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.documents.clinical_note_id IS
|
||||||
|
'Vínculo opcional a uma nota clínica (anexar PDF a anamnese/evolução). Renomeado de session_note_id em 2026-05-20.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- RPC accept_tenant_invite — destrava o fluxo de aceitar convite
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Recebe o token UUID do invite. Em uma transação (SECURITY DEFINER):
|
||||||
|
-- 1. Lê invite ATIVO (não accepted, não revoked, não expired)
|
||||||
|
-- 2. INSERT em tenant_members com role do invite + user_id = auth.uid()
|
||||||
|
-- 3. UPDATE invite com accepted_at + accepted_by
|
||||||
|
--
|
||||||
|
-- Retorna jsonb { ok, tenant_id, role } em sucesso ou throw com mensagem PT-BR.
|
||||||
|
--
|
||||||
|
-- Chamada pelo features/tenantship/services/tenantInvitesRepository.acceptInvite().
|
||||||
|
-- Stub anterior tava jogando erro PT-BR explicando isso. Agora funciona.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.accept_tenant_invite(p_token uuid)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_uid uuid;
|
||||||
|
v_invite record;
|
||||||
|
v_existing_member record;
|
||||||
|
BEGIN
|
||||||
|
-- Quem está aceitando — auth.uid() pega do JWT
|
||||||
|
v_uid := auth.uid();
|
||||||
|
IF v_uid IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Sessão inválida (sem user autenticado).';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 1. Lê invite ativo. Lock via FOR UPDATE pra evitar race.
|
||||||
|
SELECT id, tenant_id, email, role, accepted_at, revoked_at, expires_at
|
||||||
|
INTO v_invite
|
||||||
|
FROM public.tenant_invites
|
||||||
|
WHERE token = p_token
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Convite não encontrado. Verifique o link.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_invite.revoked_at IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'Convite revogado pelo administrador.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_invite.accepted_at IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'Convite já foi aceito anteriormente.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
|
||||||
|
RAISE EXCEPTION 'Convite expirado. Peça um novo ao administrador.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Idempotência: se já é membro do tenant, só marca invite aceito.
|
||||||
|
SELECT id, role, status
|
||||||
|
INTO v_existing_member
|
||||||
|
FROM public.tenant_members
|
||||||
|
WHERE tenant_id = v_invite.tenant_id
|
||||||
|
AND user_id = v_uid
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_existing_member.id IS NULL THEN
|
||||||
|
INSERT INTO public.tenant_members (tenant_id, user_id, role, status)
|
||||||
|
VALUES (v_invite.tenant_id, v_uid, v_invite.role, 'active');
|
||||||
|
ELSIF v_existing_member.status <> 'active' THEN
|
||||||
|
UPDATE public.tenant_members
|
||||||
|
SET status = 'active', role = v_invite.role
|
||||||
|
WHERE id = v_existing_member.id;
|
||||||
|
END IF;
|
||||||
|
-- (se já está ativo, deixa como tá — convite aceito não rebaixa)
|
||||||
|
|
||||||
|
-- 3. Marca invite como aceito
|
||||||
|
UPDATE public.tenant_invites
|
||||||
|
SET accepted_at = now(), accepted_by = v_uid
|
||||||
|
WHERE id = v_invite.id;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'tenant_id', v_invite.tenant_id,
|
||||||
|
'role', v_invite.role
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.accept_tenant_invite(uuid) IS
|
||||||
|
'Aceita convite de membership. SECURITY DEFINER pra criar tenant_members em nome do user logado. Lock FOR UPDATE no invite previne race condition.';
|
||||||
|
|
||||||
|
-- Permite que qualquer authenticated chame (precisa do token UUID válido pra entrar).
|
||||||
|
REVOKE ALL ON FUNCTION public.accept_tenant_invite(uuid) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.accept_tenant_invite(uuid) TO authenticated;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Asaas Gateway — Tier 1 (cobrança de paciente) — schema foundation
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Cria 3 tabelas novas + adiciona 4 colunas em payment_settings.
|
||||||
|
-- Schema preparado pra Fase 3 do ROADMAP (gateway de pagamento).
|
||||||
|
--
|
||||||
|
-- ⚠️ Não habilita o gateway sozinho. Requer:
|
||||||
|
-- - Edge Functions deployadas
|
||||||
|
-- - API keys configuradas em payment_settings
|
||||||
|
-- - Webhook setado no dashboard Asaas
|
||||||
|
--
|
||||||
|
-- Ver: development/02-auditoria/DESIGN_ASAAS_GATEWAY.md
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. asaas_customers — mapping patient ↔ Asaas customer
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.asaas_customers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id uuid NOT NULL,
|
||||||
|
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||||
|
asaas_customer_id text NOT NULL,
|
||||||
|
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
|
||||||
|
-- dados cacheados (sincronizados quando atualizar patient)
|
||||||
|
name text NOT NULL,
|
||||||
|
email text,
|
||||||
|
cpf_cnpj text,
|
||||||
|
phone text,
|
||||||
|
address jsonb,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT asaas_customers_unique_per_env UNIQUE (tenant_id, patient_id, environment)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.asaas_customers IS
|
||||||
|
'Mapping de pacientes para Asaas customers (1:1 por environment). Cacheado pra evitar re-criação a cada cobrança.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asaas_customers_lookup
|
||||||
|
ON public.asaas_customers (tenant_id, patient_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. asaas_payments — 1 row por cobrança gerada
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.asaas_payments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id uuid NOT NULL,
|
||||||
|
financial_record_id uuid NOT NULL REFERENCES public.financial_records(id) ON DELETE CASCADE,
|
||||||
|
asaas_customer_id uuid REFERENCES public.asaas_customers(id),
|
||||||
|
asaas_payment_id text NOT NULL,
|
||||||
|
asaas_invoice_id text,
|
||||||
|
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
|
||||||
|
billing_type text NOT NULL CHECK (billing_type IN ('PIX', 'BOLETO', 'CREDIT_CARD', 'UNDEFINED')),
|
||||||
|
status text NOT NULL,
|
||||||
|
value numeric(10, 2) NOT NULL,
|
||||||
|
net_value numeric(10, 2),
|
||||||
|
due_date date NOT NULL,
|
||||||
|
payment_date timestamptz,
|
||||||
|
invoice_url text,
|
||||||
|
payment_url text,
|
||||||
|
bank_slip_url text,
|
||||||
|
pix_qr_code text,
|
||||||
|
pix_copy_paste text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
cancelled_at timestamptz,
|
||||||
|
CONSTRAINT asaas_payments_unique_per_env UNIQUE (asaas_payment_id, environment)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.asaas_payments IS
|
||||||
|
'Cobranças geradas no Asaas. Status raw do Asaas; mapeamento pra financial_records.status acontece no JS.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asaas_payments_record
|
||||||
|
ON public.asaas_payments (financial_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asaas_payments_lookup
|
||||||
|
ON public.asaas_payments (tenant_id, status, due_date)
|
||||||
|
WHERE cancelled_at IS NULL;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. asaas_webhook_events — idempotência + audit
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.asaas_webhook_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id text,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
asaas_payment_id text,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
processed_at timestamptz,
|
||||||
|
processing_error text,
|
||||||
|
received_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.asaas_webhook_events IS
|
||||||
|
'Audit + idempotência de webhooks Asaas. event_id usado pra dedupe (Asaas faz retry).';
|
||||||
|
|
||||||
|
-- event_id UNIQUE quando preenchido (Asaas nem sempre manda)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_asaas_webhook_events_event_id
|
||||||
|
ON public.asaas_webhook_events (event_id)
|
||||||
|
WHERE event_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_payment
|
||||||
|
ON public.asaas_webhook_events (asaas_payment_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_unprocessed
|
||||||
|
ON public.asaas_webhook_events (received_at)
|
||||||
|
WHERE processed_at IS NULL;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 4. payment_settings — colunas pra config Asaas por tenant
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- API keys plaintext nesta migration. Em produção, mover pra pgsodium/Vault.
|
||||||
|
|
||||||
|
ALTER TABLE public.payment_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS asaas_api_key_sandbox text,
|
||||||
|
ADD COLUMN IF NOT EXISTS asaas_api_key_prod text,
|
||||||
|
ADD COLUMN IF NOT EXISTS asaas_environment text DEFAULT 'sandbox'
|
||||||
|
CHECK (asaas_environment IN ('sandbox', 'prod')),
|
||||||
|
ADD COLUMN IF NOT EXISTS asaas_webhook_token text,
|
||||||
|
ADD COLUMN IF NOT EXISTS asaas_enabled boolean DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.payment_settings.asaas_api_key_sandbox IS
|
||||||
|
'API key Asaas SANDBOX. PLAINTEXT por enquanto — migrar pra Vault em prod.';
|
||||||
|
COMMENT ON COLUMN public.payment_settings.asaas_api_key_prod IS
|
||||||
|
'API key Asaas PRODUÇÃO. PLAINTEXT por enquanto — migrar pra Vault em prod.';
|
||||||
|
COMMENT ON COLUMN public.payment_settings.asaas_environment IS
|
||||||
|
'Qual key usar: sandbox (testes) ou prod (real). Default sandbox por segurança.';
|
||||||
|
COMMENT ON COLUMN public.payment_settings.asaas_webhook_token IS
|
||||||
|
'Token customizado pra webhook receiver validar. Setar mesmo valor no dashboard Asaas.';
|
||||||
|
COMMENT ON COLUMN public.payment_settings.asaas_enabled IS
|
||||||
|
'Flag que controla se gateway Asaas está habilitado pro tenant. Default false (opt-in).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Asaas Gateway — RLS policies
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Owner-scoped: cada terapeuta vê só os customers/payments do seu tenant.
|
||||||
|
-- INSERT/UPDATE bloqueado client-side — só Edge Functions (service role)
|
||||||
|
-- podem escrever. Browser só lê (pra exibir QR code, status, etc).
|
||||||
|
--
|
||||||
|
-- API keys em payment_settings: já tem RLS (não duplica).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- asaas_customers
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE public.asaas_customers ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY asaas_customers_member_select
|
||||||
|
ON public.asaas_customers FOR SELECT TO authenticated
|
||||||
|
USING (public.is_tenant_member(tenant_id));
|
||||||
|
|
||||||
|
-- INSERT/UPDATE/DELETE bloqueados — Edge Functions usam service_role que bypassa RLS
|
||||||
|
CREATE POLICY asaas_customers_no_client_write
|
||||||
|
ON public.asaas_customers FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (false);
|
||||||
|
CREATE POLICY asaas_customers_no_client_update
|
||||||
|
ON public.asaas_customers FOR UPDATE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
CREATE POLICY asaas_customers_no_client_delete
|
||||||
|
ON public.asaas_customers FOR DELETE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- asaas_payments
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE public.asaas_payments ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY asaas_payments_member_select
|
||||||
|
ON public.asaas_payments FOR SELECT TO authenticated
|
||||||
|
USING (public.is_tenant_member(tenant_id));
|
||||||
|
|
||||||
|
CREATE POLICY asaas_payments_no_client_write
|
||||||
|
ON public.asaas_payments FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (false);
|
||||||
|
CREATE POLICY asaas_payments_no_client_update
|
||||||
|
ON public.asaas_payments FOR UPDATE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
CREATE POLICY asaas_payments_no_client_delete
|
||||||
|
ON public.asaas_payments FOR DELETE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- asaas_webhook_events
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- Audit table — saas_admin lê pra debug. Members não veem.
|
||||||
|
ALTER TABLE public.asaas_webhook_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY asaas_webhook_events_saas_admin_select
|
||||||
|
ON public.asaas_webhook_events FOR SELECT TO authenticated
|
||||||
|
USING (public.is_saas_admin());
|
||||||
|
|
||||||
|
CREATE POLICY asaas_webhook_events_no_client_write
|
||||||
|
ON public.asaas_webhook_events FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (false);
|
||||||
|
CREATE POLICY asaas_webhook_events_no_update
|
||||||
|
ON public.asaas_webhook_events FOR UPDATE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
CREATE POLICY asaas_webhook_events_no_delete
|
||||||
|
ON public.asaas_webhook_events FOR DELETE TO authenticated
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Compliance CFP — Tipo de registro profissional (ROADMAP item #5)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Adiciona campos de registro profissional ao perfil. Necessário pra emissão
|
||||||
|
-- de recibos/laudos válidos (CFP exige tipo, número e UF do conselho).
|
||||||
|
--
|
||||||
|
-- Conselhos comuns no Brasil:
|
||||||
|
-- CRP — Psicólogo
|
||||||
|
-- CRM — Médico
|
||||||
|
-- CRFa — Fonoaudiólogo
|
||||||
|
-- CREFITO — Fisioterapeuta / Terapeuta Ocupacional
|
||||||
|
-- CRESS — Assistente Social
|
||||||
|
-- CRN — Nutricionista
|
||||||
|
-- RMS — Residência Multiprofissional (Saúde)
|
||||||
|
-- outro — Catch-all (campo livre na UI)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_registration_type text,
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_registration_number text,
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_registration_uf text;
|
||||||
|
|
||||||
|
-- CHECK não pode ser ADD IF NOT EXISTS — guard com DO block
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'profiles_registration_type_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD CONSTRAINT profiles_registration_type_check CHECK (
|
||||||
|
professional_registration_type IS NULL
|
||||||
|
OR professional_registration_type = ANY (ARRAY[
|
||||||
|
'CRP',
|
||||||
|
'CRM',
|
||||||
|
'CRFa',
|
||||||
|
'CREFITO',
|
||||||
|
'CRESS',
|
||||||
|
'CRN',
|
||||||
|
'RMS',
|
||||||
|
'outro'
|
||||||
|
])
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- UF check (regex pra 2 chars uppercase ou NULL)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'profiles_registration_uf_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD CONSTRAINT profiles_registration_uf_check CHECK (
|
||||||
|
professional_registration_uf IS NULL
|
||||||
|
OR professional_registration_uf ~ '^[A-Z]{2}$'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.profiles.professional_registration_type IS
|
||||||
|
'Tipo de registro profissional. Obrigatório pra emitir recibos/laudos. ROADMAP item #5.';
|
||||||
|
COMMENT ON COLUMN public.profiles.professional_registration_number IS
|
||||||
|
'Número do registro (ex: 06/12345 ou 123456). Formato livre — UI ajuda com mask se relevante.';
|
||||||
|
COMMENT ON COLUMN public.profiles.professional_registration_uf IS
|
||||||
|
'UF do conselho (2 chars uppercase). Alguns conselhos exigem regionalização (CRP 06/SP, CRP 03/BA).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Compliance CFP — Especialidades do profissional (ROADMAP item #9)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Catálogo de especialidades/abordagens + join many-to-many com profiles.
|
||||||
|
-- Profissional pode ter múltiplas especialidades (clínica + jurídica, etc).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.specialties (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key text UNIQUE NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
category text,
|
||||||
|
is_system boolean DEFAULT false NOT NULL,
|
||||||
|
active boolean DEFAULT true NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.specialties IS
|
||||||
|
'Catálogo global de especialidades/abordagens psicológicas (ROADMAP item #9). is_system=true pra entries seedadas.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_specialties_active ON public.specialties (active, category, name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.profile_specialties (
|
||||||
|
profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
specialty_id uuid NOT NULL REFERENCES public.specialties(id) ON DELETE RESTRICT,
|
||||||
|
other_label text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
PRIMARY KEY (profile_id, specialty_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.profile_specialties IS
|
||||||
|
'M:N entre profile e specialty. other_label preenchido só quando specialty.key=outra (custom user-defined).';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_specialties_profile ON public.profile_specialties (profile_id);
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- RLS
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE public.specialties ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.profile_specialties ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- specialties: read-only pra todos authenticated (catálogo público); só saas_admin escreve
|
||||||
|
CREATE POLICY specialties_authenticated_read
|
||||||
|
ON public.specialties FOR SELECT TO authenticated
|
||||||
|
USING (active = true);
|
||||||
|
|
||||||
|
CREATE POLICY specialties_saas_admin_write
|
||||||
|
ON public.specialties TO authenticated
|
||||||
|
USING (public.is_saas_admin())
|
||||||
|
WITH CHECK (public.is_saas_admin());
|
||||||
|
|
||||||
|
-- profile_specialties: cada user gerencia o próprio
|
||||||
|
CREATE POLICY profile_specialties_owner_select
|
||||||
|
ON public.profile_specialties FOR SELECT TO authenticated
|
||||||
|
USING (profile_id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY profile_specialties_owner_insert
|
||||||
|
ON public.profile_specialties FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (profile_id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY profile_specialties_owner_delete
|
||||||
|
ON public.profile_specialties FOR DELETE TO authenticated
|
||||||
|
USING (profile_id = auth.uid());
|
||||||
|
|
||||||
|
-- Tenant_admin pode VER specialties dos membros (pra cards públicos / perfil clínica)
|
||||||
|
CREATE POLICY profile_specialties_tenant_admin_read
|
||||||
|
ON public.profile_specialties FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.tenant_members tm
|
||||||
|
WHERE tm.user_id = profile_specialties.profile_id
|
||||||
|
AND public.is_tenant_admin(tm.tenant_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Compliance CFP #6 — Tipos de consent form (LGPD + Gravação)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Estende o CHECK constraint de document_templates.tipo para acomodar dois
|
||||||
|
-- novos tipos de consent form exigidos pela LGPD e pela prática clínica:
|
||||||
|
-- • termo_lgpd — Consentimento de tratamento de dados pessoais
|
||||||
|
-- • autorizacao_gravacao — Autorização de gravação de sessão (áudio/vídeo)
|
||||||
|
--
|
||||||
|
-- ROADMAP item #1.2 #6 (Biblioteca de consent forms editáveis).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.document_templates
|
||||||
|
DROP CONSTRAINT IF EXISTS dt_tipo_check;
|
||||||
|
|
||||||
|
ALTER TABLE public.document_templates
|
||||||
|
ADD CONSTRAINT dt_tipo_check CHECK (
|
||||||
|
tipo = ANY (ARRAY[
|
||||||
|
'declaracao_comparecimento',
|
||||||
|
'atestado_psicologico',
|
||||||
|
'relatorio_acompanhamento',
|
||||||
|
'recibo_pagamento',
|
||||||
|
'termo_consentimento',
|
||||||
|
'encaminhamento',
|
||||||
|
'contrato_servicos',
|
||||||
|
'tcle',
|
||||||
|
'autorizacao_menor',
|
||||||
|
'laudo_psicologico',
|
||||||
|
'parecer_psicologico',
|
||||||
|
'termo_sigilo',
|
||||||
|
'declaracao_inicio_tratamento',
|
||||||
|
'termo_alta',
|
||||||
|
'tcle_online',
|
||||||
|
'termo_lgpd',
|
||||||
|
'autorizacao_gravacao',
|
||||||
|
'outro'
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.document_templates.tipo IS
|
||||||
|
'Tipo do template. Inclui consent forms (tcle, tcle_online, autorizacao_menor, termo_sigilo, termo_lgpd, autorizacao_gravacao).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Compliance CFP #7 — RPCs de assinatura eletrônica
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Cria 2 RPCs que registram assinatura capturando IP server-side (anti-spoof)
|
||||||
|
-- via inet_client_addr() e user-agent via request headers do Supabase.
|
||||||
|
--
|
||||||
|
-- • sign_document_by_signature_id — paciente logado assina via portal
|
||||||
|
-- • sign_document_by_token — terceiro assina via share link público
|
||||||
|
--
|
||||||
|
-- ROADMAP item #1.2 #7 (Assinatura eletrônica pelo paciente no portal,
|
||||||
|
-- simples, com IP+timestamp). Não usa ICP-Brasil — é assinatura simples
|
||||||
|
-- com audit trail (IP, UA, timestamp, hash SHA-256 do documento).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. sign_document_by_signature_id
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Para signatários LOGADOS no portal/sistema. SECURITY INVOKER — a RLS de
|
||||||
|
-- document_signatures continua aplicando (signatario_id = auth.uid() ou
|
||||||
|
-- tenant_members). RPC só serve pra centralizar captura de IP + UA + hash.
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.sign_document_by_signature_id(
|
||||||
|
p_signature_id uuid,
|
||||||
|
p_hash_documento text DEFAULT NULL
|
||||||
|
) RETURNS public.document_signatures
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_row public.document_signatures;
|
||||||
|
v_ip inet;
|
||||||
|
v_ua text;
|
||||||
|
BEGIN
|
||||||
|
IF p_signature_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Captura IP e UA do request (best-effort — pode vir NULL em alguns ambientes)
|
||||||
|
v_ip := inet_client_addr();
|
||||||
|
BEGIN
|
||||||
|
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_ua := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE public.document_signatures
|
||||||
|
SET status = 'assinado',
|
||||||
|
ip = v_ip,
|
||||||
|
user_agent = v_ua,
|
||||||
|
assinado_em = now(),
|
||||||
|
hash_documento = COALESCE(p_hash_documento, hash_documento),
|
||||||
|
atualizado_em = now()
|
||||||
|
WHERE id = p_signature_id
|
||||||
|
AND status IN ('pendente', 'enviado')
|
||||||
|
RETURNING * INTO v_row;
|
||||||
|
|
||||||
|
IF v_row.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Assinatura não encontrada ou já processada' USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_row;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.sign_document_by_signature_id(uuid, text) IS
|
||||||
|
'Assinatura via portal logado. Captura IP/UA server-side. RLS aplica (SECURITY INVOKER).';
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.sign_document_by_signature_id(uuid, text) TO authenticated;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. sign_document_by_token
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Para signatários NÃO LOGADOS via share link público. SECURITY DEFINER —
|
||||||
|
-- bypassa RLS. Valida o share_link (token, ativo, expira_em, usos_max),
|
||||||
|
-- localiza o signatário PENDENTE associado ao documento (signatario_email
|
||||||
|
-- opcional p/ desambiguar quando há múltiplos), assina, incrementa usos.
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.sign_document_by_token(
|
||||||
|
p_token text,
|
||||||
|
p_signature_id uuid DEFAULT NULL,
|
||||||
|
p_hash_documento text DEFAULT NULL
|
||||||
|
) RETURNS public.document_signatures
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_link public.document_share_links;
|
||||||
|
v_sig public.document_signatures;
|
||||||
|
v_ip inet;
|
||||||
|
v_ua text;
|
||||||
|
BEGIN
|
||||||
|
IF p_token IS NULL OR length(p_token) < 32 THEN
|
||||||
|
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Valida share_link
|
||||||
|
SELECT * INTO v_link
|
||||||
|
FROM public.document_share_links
|
||||||
|
WHERE token = p_token
|
||||||
|
AND ativo = true
|
||||||
|
AND expira_em > now()
|
||||||
|
AND usos < usos_max
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_link.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Localiza a signature pendente do documento. Se p_signature_id veio,
|
||||||
|
-- é desambiguação (multi-signatário); senão pega a primeira pendente
|
||||||
|
-- por ordem.
|
||||||
|
IF p_signature_id IS NOT NULL THEN
|
||||||
|
SELECT * INTO v_sig
|
||||||
|
FROM public.document_signatures
|
||||||
|
WHERE id = p_signature_id
|
||||||
|
AND documento_id = v_link.documento_id
|
||||||
|
AND status IN ('pendente', 'enviado')
|
||||||
|
LIMIT 1;
|
||||||
|
ELSE
|
||||||
|
SELECT * INTO v_sig
|
||||||
|
FROM public.document_signatures
|
||||||
|
WHERE documento_id = v_link.documento_id
|
||||||
|
AND status IN ('pendente', 'enviado')
|
||||||
|
ORDER BY ordem ASC, criado_em ASC
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_sig.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Captura IP/UA
|
||||||
|
v_ip := inet_client_addr();
|
||||||
|
BEGIN
|
||||||
|
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
v_ua := NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Assina
|
||||||
|
UPDATE public.document_signatures
|
||||||
|
SET status = 'assinado',
|
||||||
|
ip = v_ip,
|
||||||
|
user_agent = v_ua,
|
||||||
|
assinado_em = now(),
|
||||||
|
hash_documento = COALESCE(p_hash_documento, hash_documento),
|
||||||
|
atualizado_em = now()
|
||||||
|
WHERE id = v_sig.id
|
||||||
|
RETURNING * INTO v_sig;
|
||||||
|
|
||||||
|
-- Incrementa contador de usos do share_link
|
||||||
|
UPDATE public.document_share_links
|
||||||
|
SET usos = usos + 1
|
||||||
|
WHERE id = v_link.id;
|
||||||
|
|
||||||
|
RETURN v_sig;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.sign_document_by_token(text, uuid, text) IS
|
||||||
|
'Assinatura via share link público. SECURITY DEFINER — valida token, captura IP/UA, incrementa usos. p_signature_id é opcional pra desambiguar multi-signatário.';
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.sign_document_by_token(text, uuid, text) TO anon, authenticated;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. get_signable_document_by_token
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- View helper que retorna info do documento + signatários pendentes via token,
|
||||||
|
-- sem assinar. Permite a página pública renderizar antes do click.
|
||||||
|
-- SECURITY DEFINER porque share_link tem RLS pública mas documents+signatures
|
||||||
|
-- têm RLS por owner/tenant.
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(
|
||||||
|
p_token text
|
||||||
|
) RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_link public.document_share_links;
|
||||||
|
v_doc public.documents;
|
||||||
|
v_sigs jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF p_token IS NULL OR length(p_token) < 32 THEN
|
||||||
|
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO v_link
|
||||||
|
FROM public.document_share_links
|
||||||
|
WHERE token = p_token
|
||||||
|
AND ativo = true
|
||||||
|
AND expira_em > now()
|
||||||
|
AND usos < usos_max
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_link.id IS NULL THEN
|
||||||
|
RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO v_doc
|
||||||
|
FROM public.documents
|
||||||
|
WHERE id = v_link.documento_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_doc.id IS NULL THEN
|
||||||
|
RETURN jsonb_build_object('valid', false, 'error', 'document_not_found');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', s.id,
|
||||||
|
'signatario_tipo', s.signatario_tipo,
|
||||||
|
'signatario_nome', s.signatario_nome,
|
||||||
|
'signatario_email', s.signatario_email,
|
||||||
|
'ordem', s.ordem,
|
||||||
|
'status', s.status,
|
||||||
|
'assinado_em', s.assinado_em
|
||||||
|
) ORDER BY s.ordem
|
||||||
|
) INTO v_sigs
|
||||||
|
FROM public.document_signatures s
|
||||||
|
WHERE s.documento_id = v_doc.id;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'valid', true,
|
||||||
|
'document', jsonb_build_object(
|
||||||
|
'id', v_doc.id,
|
||||||
|
'nome_original', v_doc.nome_original,
|
||||||
|
'mime_type', v_doc.mime_type,
|
||||||
|
'tamanho_bytes', v_doc.tamanho_bytes,
|
||||||
|
'bucket_path', v_doc.bucket_path,
|
||||||
|
'storage_bucket', v_doc.storage_bucket,
|
||||||
|
'tipo_documento', v_doc.tipo_documento
|
||||||
|
),
|
||||||
|
'signatures', COALESCE(v_sigs, '[]'::jsonb),
|
||||||
|
'expira_em', v_link.expira_em,
|
||||||
|
'usos_restantes', v_link.usos_max - v_link.usos
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.get_signable_document_by_token(text) IS
|
||||||
|
'Retorna documento + signatários pendentes via token. Usado pela página pública antes de assinar.';
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_signable_document_by_token(text) TO anon, authenticated;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Compliance CFP #7 — RPC list_my_signatures (portal do paciente)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Retorna as solicitações de assinatura do paciente logado (auth.uid()
|
||||||
|
-- associado a patients.user_id). SECURITY DEFINER pra bypassar a RLS de
|
||||||
|
-- document_signatures (que hoje só libera pra tenant_members).
|
||||||
|
--
|
||||||
|
-- Cada item já vem com o share_link.token associado, pra que o portal
|
||||||
|
-- aponte direto pra /shared/document/:token onde o usuário vai assinar.
|
||||||
|
-- O link público é gerado quando o terapeuta solicita a assinatura.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.list_my_signatures(
|
||||||
|
p_status text[] DEFAULT NULL
|
||||||
|
) RETURNS TABLE (
|
||||||
|
signature_id uuid,
|
||||||
|
documento_id uuid,
|
||||||
|
tenant_id uuid,
|
||||||
|
signatario_tipo text,
|
||||||
|
status text,
|
||||||
|
ordem smallint,
|
||||||
|
assinado_em timestamptz,
|
||||||
|
criado_em timestamptz,
|
||||||
|
-- Documento
|
||||||
|
nome_original text,
|
||||||
|
tipo_documento text,
|
||||||
|
mime_type text,
|
||||||
|
-- Share link (primeiro válido encontrado pro doc)
|
||||||
|
share_token text,
|
||||||
|
share_expira_em timestamptz
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_uid uuid;
|
||||||
|
BEGIN
|
||||||
|
v_uid := auth.uid();
|
||||||
|
IF v_uid IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Sessão inválida' USING ERRCODE = '28000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
s.id AS signature_id,
|
||||||
|
s.documento_id AS documento_id,
|
||||||
|
s.tenant_id AS tenant_id,
|
||||||
|
s.signatario_tipo AS signatario_tipo,
|
||||||
|
s.status AS status,
|
||||||
|
s.ordem AS ordem,
|
||||||
|
s.assinado_em AS assinado_em,
|
||||||
|
s.criado_em AS criado_em,
|
||||||
|
d.nome_original AS nome_original,
|
||||||
|
d.tipo_documento AS tipo_documento,
|
||||||
|
d.mime_type AS mime_type,
|
||||||
|
sl.token AS share_token,
|
||||||
|
sl.expira_em AS share_expira_em
|
||||||
|
FROM public.document_signatures s
|
||||||
|
JOIN public.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT token, expira_em
|
||||||
|
FROM public.document_share_links
|
||||||
|
WHERE documento_id = d.id
|
||||||
|
AND ativo = true
|
||||||
|
AND expira_em > now()
|
||||||
|
AND usos < usos_max
|
||||||
|
ORDER BY criado_em DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sl ON true
|
||||||
|
WHERE (
|
||||||
|
-- signatario_id direto (quando registrado)
|
||||||
|
s.signatario_id = v_uid
|
||||||
|
OR
|
||||||
|
-- Fallback: paciente pelo email (quando signatario_id veio NULL)
|
||||||
|
s.signatario_email = (SELECT email FROM auth.users WHERE id = v_uid)
|
||||||
|
OR
|
||||||
|
-- Fallback: paciente pelo patient_id (documents.patient_id -> patients.user_id)
|
||||||
|
d.patient_id IN (SELECT p.id FROM public.patients p WHERE p.user_id = v_uid)
|
||||||
|
)
|
||||||
|
AND (p_status IS NULL OR s.status = ANY (p_status))
|
||||||
|
ORDER BY
|
||||||
|
CASE s.status
|
||||||
|
WHEN 'pendente' THEN 0
|
||||||
|
WHEN 'enviado' THEN 1
|
||||||
|
WHEN 'assinado' THEN 2
|
||||||
|
WHEN 'recusado' THEN 3
|
||||||
|
WHEN 'expirado' THEN 4
|
||||||
|
ELSE 99
|
||||||
|
END,
|
||||||
|
s.criado_em DESC;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.list_my_signatures(text[]) IS
|
||||||
|
'Lista signatures do paciente logado (auth.uid()) cruzando por signatario_id, email ou patient.user_id. Inclui share_token pra link de assinatura.';
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.list_my_signatures(text[]) TO authenticated;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- ROADMAP #1.4 #14 — Recibo profissional usa terapeuta_registro genérico
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- O template recibo_pagamento (seed_015) usa "Psicólogo(a) — CRP {{terapeuta_crp}}".
|
||||||
|
-- Como agora suportamos múltiplos conselhos (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS)
|
||||||
|
-- via #5 (migration 20260521000003), o recibo precisa ser CFP-agnóstico.
|
||||||
|
--
|
||||||
|
-- Esta migration substitui no recibo_pagamento:
|
||||||
|
-- "Psicólogo(a) — CRP {{terapeuta_crp}}" → "{{terapeuta_registro}}"
|
||||||
|
-- e atualiza variaveis[] removendo terapeuta_crp + adicionando terapeuta_registro.
|
||||||
|
--
|
||||||
|
-- {{terapeuta_registro}} é auto-formatado server-side como "CRP 12345/SP",
|
||||||
|
-- "CRM 12345/SP" etc, então não precisa de "Psicólogo(a) —" hardcoded.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE public.document_templates
|
||||||
|
SET corpo_html = REPLACE(
|
||||||
|
corpo_html,
|
||||||
|
'Psicólogo(a) — CRP {{terapeuta_crp}}',
|
||||||
|
'{{terapeuta_registro}}'
|
||||||
|
),
|
||||||
|
variaveis = ARRAY(
|
||||||
|
SELECT DISTINCT v FROM (
|
||||||
|
SELECT unnest(variaveis) v
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'terapeuta_registro'
|
||||||
|
) sub
|
||||||
|
WHERE v <> 'terapeuta_crp'
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE tipo = 'recibo_pagamento' AND is_global = true;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Compliance CFP #5 — campo livre quando tipo de registro = 'outro'
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Migration 20260521000003 adicionou professional_registration_type com CHECK
|
||||||
|
-- limitado a 8 valores (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro). Quando o
|
||||||
|
-- profissional escolhe 'outro', precisa informar qual conselho/instituição
|
||||||
|
-- (ex: associações privadas, conselhos não-listados).
|
||||||
|
--
|
||||||
|
-- Esta migration adiciona professional_registration_type_other (text livre),
|
||||||
|
-- que só é preenchido quando type = 'outro'.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS professional_registration_type_other text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.profiles.professional_registration_type_other IS
|
||||||
|
'Nome livre do conselho/instituição quando professional_registration_type = ''outro''. Aparece em recibos/laudos no lugar do tipo padrão.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F1.1 — Schema-per-tenant: coluna tenants.slug
|
||||||
|
-- Plano: novo-rumo.txt + docs/F0_categorizacao.md (decisão Q1)
|
||||||
|
-- Slug é a base do nome do schema físico: tenant_<slug>. Unico e imutável.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS slug text;
|
||||||
|
|
||||||
|
-- Geração de slug a partir do nome (sanitizado pra identificador Postgres)
|
||||||
|
CREATE OR REPLACE FUNCTION public.generate_tenant_slug(p_name text)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
base text;
|
||||||
|
cand text;
|
||||||
|
n int := 1;
|
||||||
|
BEGIN
|
||||||
|
base := lower(coalesce(nullif(trim(p_name), ''), 'tenant'));
|
||||||
|
base := translate(base,
|
||||||
|
'áàâãäåéèêëíìîïóòôõöúùûüçñýÿ',
|
||||||
|
'aaaaaaeeeeiiiiooooouuuucnyy');
|
||||||
|
base := regexp_replace(base, '[^a-z0-9]+', '_', 'g');
|
||||||
|
base := regexp_replace(base, '^_+|_+$', '', 'g');
|
||||||
|
base := left(base, 48);
|
||||||
|
IF base = '' OR base !~ '^[a-z]' THEN
|
||||||
|
base := 't_' || base;
|
||||||
|
base := left(base, 48);
|
||||||
|
END IF;
|
||||||
|
cand := base;
|
||||||
|
WHILE EXISTS (SELECT 1 FROM public.tenants WHERE slug = cand) LOOP
|
||||||
|
n := n + 1;
|
||||||
|
cand := left(base, 44) || '_' || n;
|
||||||
|
END LOOP;
|
||||||
|
RETURN cand;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Backfill dos tenants existentes
|
||||||
|
DO $$
|
||||||
|
DECLARE r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT id, name FROM public.tenants WHERE slug IS NULL ORDER BY created_at, id LOOP
|
||||||
|
UPDATE public.tenants SET slug = public.generate_tenant_slug(r.name) WHERE id = r.id;
|
||||||
|
RAISE NOTICE 'tenant % -> slug %', r.id, (SELECT slug FROM public.tenants WHERE id = r.id);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE public.tenants ALTER COLUMN slug SET NOT NULL;
|
||||||
|
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_key UNIQUE (slug);
|
||||||
|
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z][a-z0-9_]{1,47}$');
|
||||||
|
|
||||||
|
-- Auto-gera no INSERT (provisionamento atual não conhece slug); imutável no UPDATE
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_tenants_slug()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
IF NEW.slug IS NULL OR trim(NEW.slug) = '' THEN
|
||||||
|
NEW.slug := public.generate_tenant_slug(NEW.name);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF NEW.slug IS DISTINCT FROM OLD.slug THEN
|
||||||
|
RAISE EXCEPTION 'tenants.slug é imutável (tenant %, % -> %)', OLD.id, OLD.slug, NEW.slug;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_tenants_slug_ins ON public.tenants;
|
||||||
|
CREATE TRIGGER trg_tenants_slug_ins BEFORE INSERT ON public.tenants
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_tenants_slug_upd ON public.tenants;
|
||||||
|
CREATE TRIGGER trg_tenants_slug_upd BEFORE UPDATE OF slug ON public.tenants
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F1.2 — Schema-per-tenant: helpers de resolução de schema
|
||||||
|
-- Adaptação ao modelo multi-membership deste projeto (docs/F0_categorizacao.md D2):
|
||||||
|
-- profiles.tenant_id é NULL; membership vive em tenant_members (multi-tenant).
|
||||||
|
-- Logo NÃO existe current_tenant_schema() — RPCs recebem p_tenant_id explícito
|
||||||
|
-- e validam via tenant_schema_checked(p_tenant_id).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- slug -> nome de schema (validado). Retorna NULL se slug inválido.
|
||||||
|
CREATE OR REPLACE FUNCTION public.tenant_schema_name(p_slug text)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE sql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN p_slug ~ '^[a-z][a-z0-9_]{1,47}$' THEN 'tenant_' || p_slug
|
||||||
|
ELSE NULL
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- tenant_id -> nome de schema
|
||||||
|
CREATE OR REPLACE FUNCTION public.tenant_schema_for(p_tenant_id uuid)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
SELECT public.tenant_schema_name(t.slug) FROM public.tenants t WHERE t.id = p_tenant_id;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- nome de schema -> tenant_id (CRÍTICO pra triggers: a coluna tenant_id não
|
||||||
|
-- existe mais nas tabelas tenant; o schema é a identidade)
|
||||||
|
CREATE OR REPLACE FUNCTION public.tenant_id_for_schema(p_schema text)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
SELECT t.id FROM public.tenants t WHERE public.tenant_schema_name(t.slug) = p_schema;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Resolve schema de um tenant COM validação de acesso do usuário logado.
|
||||||
|
-- Substitui o current_tenant_schema() do blueprint (que assumia 1 tenant/usuário).
|
||||||
|
CREATE OR REPLACE FUNCTION public.tenant_schema_checked(p_tenant_id uuid)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_schema text;
|
||||||
|
BEGIN
|
||||||
|
IF p_tenant_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'tenant_schema_checked: p_tenant_id obrigatório';
|
||||||
|
END IF;
|
||||||
|
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'acesso negado ao tenant %', p_tenant_id
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||||
|
IF v_schema IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'tenant % não encontrado ou slug inválido', p_tenant_id;
|
||||||
|
END IF;
|
||||||
|
RETURN v_schema;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F1.3 — Schema-per-tenant: construção do schema _tenant_template
|
||||||
|
--
|
||||||
|
-- Clona a ESTRUTURA das 84 tabelas tenant-scoped (docs/F0_categorizacao.md §1)
|
||||||
|
-- a partir de public, SEM a coluna tenant_id:
|
||||||
|
-- * PK/UNIQUE compostos perdem tenant_id; PK/UNIQUE que eram SÓ (tenant_id)
|
||||||
|
-- viram coluna `singleton boolean` (tabela de config 1-linha-por-tenant)
|
||||||
|
-- * índices parciais WHERE tenant_id IS [NOT] NULL são fundidos/deduplicados
|
||||||
|
-- * sequences bigserial são localizadas no template (não compartilham public)
|
||||||
|
-- * FKs locais apontam pro template; FKs pra tabelas globais ficam em public/auth
|
||||||
|
-- * linhas-default do sistema (tenant_id IS NULL) viram SEED do template e
|
||||||
|
-- são copiadas pra cada tenant no clone
|
||||||
|
-- * 6 views adaptadas ficam registradas em _tenant_template._views com
|
||||||
|
-- placeholders __SCHEMA__ / __TENANT_ID__ (instanciadas no clone)
|
||||||
|
--
|
||||||
|
-- O template NUNCA é exposto no PostgREST nem recebe dados de tenant.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS _tenant_template CASCADE;
|
||||||
|
CREATE SCHEMA _tenant_template;
|
||||||
|
|
||||||
|
-- Helper interno: remove tenant_id de uma definição de índice e simplifica
|
||||||
|
-- predicados parciais que testavam tenant_id.
|
||||||
|
CREATE FUNCTION _tenant_template._adapt_indexdef(p_def text)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
DECLARE d text := p_def;
|
||||||
|
BEGIN
|
||||||
|
-- coluna no início/meio/fim da lista
|
||||||
|
d := regexp_replace(d, '\(tenant_id,\s*', '(', 'g');
|
||||||
|
d := regexp_replace(d, ',\s*tenant_id\)', ')', 'g');
|
||||||
|
d := regexp_replace(d, ',\s*tenant_id,', ',', 'g');
|
||||||
|
-- predicados parciais
|
||||||
|
d := replace(d, '(tenant_id IS NOT NULL) AND ', '');
|
||||||
|
d := replace(d, ' AND (tenant_id IS NOT NULL)', '');
|
||||||
|
d := replace(d, ' WHERE ((tenant_id IS NOT NULL))', '');
|
||||||
|
d := replace(d, ' WHERE (tenant_id IS NOT NULL)', '');
|
||||||
|
d := replace(d, '(tenant_id IS NULL) AND ', '');
|
||||||
|
d := replace(d, ' AND (tenant_id IS NULL)', '');
|
||||||
|
d := replace(d, ' WHERE ((tenant_id IS NULL))', '');
|
||||||
|
d := replace(d, ' WHERE (tenant_id IS NULL)', '');
|
||||||
|
RETURN d;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabs text[] := ARRAY[
|
||||||
|
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||||
|
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||||
|
'agendador_configuracoes','agendador_solicitacoes',
|
||||||
|
'asaas_customers','asaas_payments',
|
||||||
|
'billing_contracts',
|
||||||
|
'clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||||
|
'commitment_services','commitment_time_logs',
|
||||||
|
'company_profiles',
|
||||||
|
'contact_email_types','contact_emails','contact_phones','contact_types',
|
||||||
|
'conversation_assignments','conversation_autoreply_log','conversation_autoreply_settings',
|
||||||
|
'conversation_bot_sessions','conversation_bots','conversation_messages','conversation_notes',
|
||||||
|
'conversation_optout_keywords','conversation_optouts','conversation_sla_breaches',
|
||||||
|
'conversation_sla_rules','conversation_tags','conversation_thread_tags',
|
||||||
|
'determined_commitment_fields','determined_commitments',
|
||||||
|
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||||
|
'document_templates','documents',
|
||||||
|
'email_layout_config','email_templates_tenant',
|
||||||
|
'feriados',
|
||||||
|
'financial_categories','financial_exceptions','financial_records',
|
||||||
|
'insurance_plan_services','insurance_plans',
|
||||||
|
'medicos',
|
||||||
|
'notification_channels','notification_logs','notification_preferences','notification_queue',
|
||||||
|
'notification_schedules','notification_templates','notifications',
|
||||||
|
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||||
|
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||||
|
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||||
|
'payment_settings','professional_pricing',
|
||||||
|
'recurrence_exceptions','recurrence_rule_services','recurrence_rules',
|
||||||
|
'services',
|
||||||
|
'session_reminder_logs','session_reminder_settings',
|
||||||
|
'therapist_payout_records','therapist_payouts',
|
||||||
|
'twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||||
|
];
|
||||||
|
t text;
|
||||||
|
r record;
|
||||||
|
r2 record;
|
||||||
|
v_def text;
|
||||||
|
v_sig text;
|
||||||
|
v_seq text;
|
||||||
|
v_cols text;
|
||||||
|
v_remaining text;
|
||||||
|
v_n int;
|
||||||
|
seen_sigs text[];
|
||||||
|
pending text[] := ARRAY[]::text[];
|
||||||
|
failed text[];
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
|
||||||
|
|
||||||
|
IF array_length(tabs, 1) <> 84 THEN
|
||||||
|
RAISE EXCEPTION 'lista de tabelas tenant deveria ter 84, tem %', array_length(tabs, 1);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- PASS 1: clonar estrutura
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
FOREACH t IN ARRAY tabs LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = t AND table_type = 'BASE TABLE') THEN
|
||||||
|
RAISE EXCEPTION 'tabela public.% não existe — lista F0 desatualizada', t;
|
||||||
|
END IF;
|
||||||
|
EXECUTE format('CREATE TABLE _tenant_template.%I (LIKE public.%I INCLUDING ALL)', t, t);
|
||||||
|
END LOOP;
|
||||||
|
RAISE NOTICE 'PASS 1 ok: % tabelas clonadas', array_length(tabs, 1);
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- PASS 2: localizar sequences (defaults nextval apontando pra public)
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname AS tab, a.attname AS col,
|
||||||
|
pg_get_expr(d.adbin, d.adrelid) AS def
|
||||||
|
FROM pg_attrdef d
|
||||||
|
JOIN pg_class c ON c.oid = d.adrelid
|
||||||
|
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||||
|
WHERE c.relnamespace = '_tenant_template'::regnamespace
|
||||||
|
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''public.%'
|
||||||
|
LOOP
|
||||||
|
v_seq := r.tab || '_' || r.col || '_seq';
|
||||||
|
EXECUTE format('CREATE SEQUENCE _tenant_template.%I', v_seq);
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
|
||||||
|
r.tab, r.col, '_tenant_template.' || v_seq);
|
||||||
|
EXECUTE format('ALTER SEQUENCE _tenant_template.%I OWNED BY _tenant_template.%I.%I',
|
||||||
|
v_seq, r.tab, r.col);
|
||||||
|
RAISE NOTICE 'PASS 2: sequence local _tenant_template.% (%.%)', v_seq, r.tab, r.col;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- PASS 3: drop tenant_id + recriar constraints/índices sem a coluna
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
FOREACH t IN ARRAY tabs LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||||
|
AND attname = 'tenant_id' AND NOT attisdropped) THEN
|
||||||
|
CONTINUE; -- joins/children sem tenant_id (commitment_services etc.)
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3a. capturar PK/UNIQUE que contêm tenant_id (no template)
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS _f1_cons (tab text, conname text, contype char, remaining text) ON COMMIT DROP;
|
||||||
|
DELETE FROM _f1_cons WHERE tab = t;
|
||||||
|
INSERT INTO _f1_cons
|
||||||
|
SELECT t, con.conname, con.contype,
|
||||||
|
(SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY k.ord)
|
||||||
|
FROM unnest(con.conkey) WITH ORDINALITY k(attnum, ord)
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
||||||
|
WHERE a.attname <> 'tenant_id')
|
||||||
|
FROM pg_constraint con
|
||||||
|
WHERE con.conrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||||
|
AND con.contype IN ('p', 'u')
|
||||||
|
AND EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||||
|
WHERE a.attname = 'tenant_id');
|
||||||
|
|
||||||
|
-- 3b. capturar índices "soltos" (não-constraint) que usam tenant_id
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS _f1_idx (tab text, idxname text, def text) ON COMMIT DROP;
|
||||||
|
DELETE FROM _f1_idx WHERE tab = t;
|
||||||
|
INSERT INTO _f1_idx
|
||||||
|
SELECT t, c2.relname, pg_get_indexdef(i.indexrelid)
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_class c2 ON c2.oid = i.indexrelid
|
||||||
|
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_constraint cc WHERE cc.conindid = i.indexrelid)
|
||||||
|
AND pg_get_indexdef(i.indexrelid) ~ '\mtenant_id\M';
|
||||||
|
|
||||||
|
-- 3c. drop da coluna (leva junto constraints/índices que a usam)
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I DROP COLUMN tenant_id CASCADE', t);
|
||||||
|
|
||||||
|
-- 3d. recriar PK/UNIQUE
|
||||||
|
FOR r IN SELECT * FROM _f1_cons WHERE tab = t LOOP
|
||||||
|
IF r.remaining IS NULL OR r.remaining = '' THEN
|
||||||
|
-- era PK/UNIQUE exatamente (tenant_id): tabela 1-linha-por-tenant
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I ADD COLUMN singleton boolean NOT NULL DEFAULT true', t);
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I CHECK (singleton = true)',
|
||||||
|
t, t || '_singleton_chk');
|
||||||
|
IF r.contype = 'p' THEN
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I PRIMARY KEY (singleton)',
|
||||||
|
t, r.conname);
|
||||||
|
ELSE
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I UNIQUE (singleton)',
|
||||||
|
t, r.conname);
|
||||||
|
END IF;
|
||||||
|
RAISE NOTICE 'PASS 3: %.% era (tenant_id) -> singleton (%)', t, r.conname,
|
||||||
|
CASE r.contype WHEN 'p' THEN 'PK' ELSE 'UNIQUE' END;
|
||||||
|
ELSE
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s (%s)',
|
||||||
|
t, r.conname,
|
||||||
|
CASE r.contype WHEN 'p' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END,
|
||||||
|
r.remaining);
|
||||||
|
RAISE NOTICE 'PASS 3: %.% recriado sem tenant_id -> (%)', t, r.conname, r.remaining;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 3e. recriar índices soltos transformados (com dedupe)
|
||||||
|
seen_sigs := ARRAY[]::text[];
|
||||||
|
-- assinaturas dos índices que já existem na tabela (pós-recriação de constraints)
|
||||||
|
FOR r2 IN
|
||||||
|
SELECT regexp_replace(pg_get_indexdef(i.indexrelid),
|
||||||
|
'^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '') AS sig
|
||||||
|
FROM pg_index i
|
||||||
|
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||||
|
LOOP
|
||||||
|
seen_sigs := seen_sigs || r2.sig;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
FOR r IN SELECT * FROM _f1_idx WHERE tab = t LOOP
|
||||||
|
-- índice cuja ÚNICA coluna era tenant_id: descartar
|
||||||
|
IF r.def ~ '\(tenant_id\)( WHERE .*)?$' THEN
|
||||||
|
RAISE NOTICE 'PASS 3: índice % descartado (era só tenant_id)', r.idxname;
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
v_def := _tenant_template._adapt_indexdef(r.def);
|
||||||
|
v_sig := regexp_replace(v_def, '^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '');
|
||||||
|
IF v_sig = ANY (seen_sigs) THEN
|
||||||
|
RAISE NOTICE 'PASS 3: índice % deduplicado', r.idxname;
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
EXECUTE v_def;
|
||||||
|
seen_sigs := seen_sigs || v_sig;
|
||||||
|
RAISE NOTICE 'PASS 3: índice % recriado: %', r.idxname, v_sig;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- PASS 4: FKs (a partir das FKs reais de public)
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
FOR r IN
|
||||||
|
SELECT con.conname,
|
||||||
|
cl.relname AS tab,
|
||||||
|
ns2.nspname AS fschema,
|
||||||
|
cl2.relname AS ftab,
|
||||||
|
pg_get_constraintdef(con.oid) AS def,
|
||||||
|
EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||||
|
WHERE a.attname = 'tenant_id') AS uses_tenant_id
|
||||||
|
FROM pg_constraint con
|
||||||
|
JOIN pg_class cl ON cl.oid = con.conrelid
|
||||||
|
JOIN pg_class cl2 ON cl2.oid = con.confrelid
|
||||||
|
JOIN pg_namespace ns2 ON ns2.oid = cl2.relnamespace
|
||||||
|
WHERE con.contype = 'f'
|
||||||
|
AND cl.relnamespace = 'public'::regnamespace
|
||||||
|
AND cl.relname = ANY (tabs)
|
||||||
|
ORDER BY cl.relname, con.conname
|
||||||
|
LOOP
|
||||||
|
IF r.uses_tenant_id THEN
|
||||||
|
RAISE NOTICE 'PASS 4: FK %.% descartada (coluna tenant_id removida)', r.tab, r.conname;
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
v_def := r.def;
|
||||||
|
IF r.fschema = 'public' AND r.ftab = ANY (tabs) THEN
|
||||||
|
-- alvo também é tenant-scoped -> referência intra-template
|
||||||
|
v_def := regexp_replace(v_def,
|
||||||
|
' REFERENCES (public\.)?' || r.ftab || '\(',
|
||||||
|
' REFERENCES _tenant_template.' || r.ftab || '(');
|
||||||
|
END IF;
|
||||||
|
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s', r.tab, r.conname, v_def);
|
||||||
|
END LOOP;
|
||||||
|
RAISE NOTICE 'PASS 4 ok: FKs recriadas';
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- PASS 5: seeds — linhas-default do sistema (tenant_id IS NULL em public)
|
||||||
|
-- APENAS tabelas de lookup/template (whitelist): linhas operacionais órfãs
|
||||||
|
-- com tenant_id NULL (intakes, convites, notifs) NÃO são defaults.
|
||||||
|
-- Sem session_replication_role (postgres não é superuser no Supabase):
|
||||||
|
-- resolve ordem de FK por tentativa-e-repetição em rounds.
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
FOREACH t IN ARRAY ARRAY[
|
||||||
|
'clinical_note_templates','contact_email_types','contact_types',
|
||||||
|
'conversation_optout_keywords','conversation_tags','document_templates',
|
||||||
|
'notification_templates','feriados'
|
||||||
|
] LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = t AND column_name = 'tenant_id') THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
EXECUTE format('SELECT count(*) FROM public.%I WHERE tenant_id IS NULL', t) INTO v_n;
|
||||||
|
IF v_n = 0 THEN CONTINUE; END IF;
|
||||||
|
pending := pending || t;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
WHILE coalesce(array_length(pending, 1), 0) > 0 LOOP
|
||||||
|
failed := ARRAY[]::text[];
|
||||||
|
FOREACH t IN ARRAY pending LOOP
|
||||||
|
SELECT string_agg(quote_ident(c.column_name), ', ' ORDER BY c.ordinal_position)
|
||||||
|
INTO v_cols
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema = '_tenant_template' AND c.table_name = t
|
||||||
|
AND c.column_name <> 'singleton'
|
||||||
|
AND EXISTS (SELECT 1 FROM information_schema.columns p
|
||||||
|
WHERE p.table_schema = 'public' AND p.table_name = t
|
||||||
|
AND p.column_name = c.column_name);
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('INSERT INTO _tenant_template.%I (%s) SELECT %s FROM public.%I WHERE tenant_id IS NULL',
|
||||||
|
t, v_cols, v_cols, t);
|
||||||
|
RAISE NOTICE 'PASS 5: linhas-default semeadas em _tenant_template.%', t;
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
failed := failed || t;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
IF array_length(failed, 1) = array_length(pending, 1) THEN
|
||||||
|
RAISE EXCEPTION 'PASS 5: dependência circular/externa nos seeds: %', failed;
|
||||||
|
END IF;
|
||||||
|
pending := failed;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Metadados do template
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE _tenant_template._meta (key text PRIMARY KEY, value jsonb NOT NULL);
|
||||||
|
INSERT INTO _tenant_template._meta VALUES
|
||||||
|
('template_version', '1'::jsonb),
|
||||||
|
('built_from', '"docs/F0_categorizacao.md"'::jsonb),
|
||||||
|
('triggers_pending', 'true'::jsonb); -- triggers de negócio só na F6
|
||||||
|
|
||||||
|
-- Tabelas que entram na publication supabase_realtime a cada clone
|
||||||
|
-- (espelha o estado atual da publication em public: conversation_messages, notifications)
|
||||||
|
CREATE TABLE _tenant_template._realtime_tables (table_name text PRIMARY KEY);
|
||||||
|
INSERT INTO _tenant_template._realtime_tables
|
||||||
|
SELECT tablename FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'supabase_realtime' AND schemaname = 'public'
|
||||||
|
AND tablename IN (
|
||||||
|
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||||
|
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||||
|
'agendador_configuracoes','agendador_solicitacoes','asaas_customers','asaas_payments',
|
||||||
|
'billing_contracts','clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||||
|
'commitment_services','commitment_time_logs','company_profiles','contact_email_types',
|
||||||
|
'contact_emails','contact_phones','contact_types','conversation_assignments',
|
||||||
|
'conversation_autoreply_log','conversation_autoreply_settings','conversation_bot_sessions',
|
||||||
|
'conversation_bots','conversation_messages','conversation_notes','conversation_optout_keywords',
|
||||||
|
'conversation_optouts','conversation_sla_breaches','conversation_sla_rules','conversation_tags',
|
||||||
|
'conversation_thread_tags','determined_commitment_fields','determined_commitments',
|
||||||
|
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||||
|
'document_templates','documents','email_layout_config','email_templates_tenant','feriados',
|
||||||
|
'financial_categories','financial_exceptions','financial_records','insurance_plan_services',
|
||||||
|
'insurance_plans','medicos','notification_channels','notification_logs','notification_preferences',
|
||||||
|
'notification_queue','notification_schedules','notification_templates','notifications',
|
||||||
|
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||||
|
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||||
|
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||||
|
'payment_settings','professional_pricing','recurrence_exceptions','recurrence_rule_services',
|
||||||
|
'recurrence_rules','services','session_reminder_logs','session_reminder_settings',
|
||||||
|
'therapist_payout_records','therapist_payouts','twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Views adaptadas (instanciadas pelo clone com __SCHEMA__ / __TENANT_ID__)
|
||||||
|
CREATE TABLE _tenant_template._views (
|
||||||
|
view_name text PRIMARY KEY,
|
||||||
|
position int NOT NULL,
|
||||||
|
definition text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO _tenant_template._views VALUES
|
||||||
|
('conversation_threads', 1, $vw$
|
||||||
|
CREATE VIEW __SCHEMA__.conversation_threads WITH (security_invoker = true) AS
|
||||||
|
WITH base AS (
|
||||||
|
SELECT cm.id, cm.patient_id, cm.channel, cm.body, cm.direction,
|
||||||
|
cm.kanban_status, cm.read_at, cm.created_at,
|
||||||
|
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
|
||||||
|
COALESCE(cm.patient_id::text,
|
||||||
|
'anon:' || COALESCE(CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
|
||||||
|
'unknown')) AS thread_key
|
||||||
|
FROM __SCHEMA__.conversation_messages cm
|
||||||
|
), latest AS (
|
||||||
|
SELECT DISTINCT ON (base.thread_key)
|
||||||
|
base.thread_key, base.patient_id, base.channel, base.contact_number,
|
||||||
|
base.body AS last_message_body, base.direction AS last_message_direction,
|
||||||
|
base.kanban_status, base.created_at AS last_message_at
|
||||||
|
FROM base
|
||||||
|
ORDER BY base.thread_key, base.created_at DESC
|
||||||
|
), counts AS (
|
||||||
|
SELECT base.thread_key, count(*) AS message_count,
|
||||||
|
count(*) FILTER (WHERE base.direction = 'inbound' AND base.read_at IS NULL) AS unread_count
|
||||||
|
FROM base
|
||||||
|
GROUP BY base.thread_key
|
||||||
|
)
|
||||||
|
SELECT '__TENANT_ID__'::uuid AS tenant_id,
|
||||||
|
l.thread_key, l.patient_id, p.nome_completo AS patient_name,
|
||||||
|
l.contact_number, l.channel, c.message_count, c.unread_count,
|
||||||
|
l.last_message_at, l.last_message_body, l.last_message_direction,
|
||||||
|
l.kanban_status, ca.assigned_to, ca.assigned_at
|
||||||
|
FROM latest l
|
||||||
|
JOIN counts c ON c.thread_key = l.thread_key
|
||||||
|
LEFT JOIN __SCHEMA__.patients p ON p.id = l.patient_id
|
||||||
|
LEFT JOIN __SCHEMA__.conversation_assignments ca ON ca.thread_key = l.thread_key
|
||||||
|
$vw$),
|
||||||
|
('audit_log_unified', 2, $vw$
|
||||||
|
CREATE VIEW __SCHEMA__.audit_log_unified WITH (security_invoker = true) AS
|
||||||
|
SELECT 'audit:' || al.id::text AS uid, al.tenant_id, al.user_id, al.entity_type, al.entity_id, al.action,
|
||||||
|
CASE al.action
|
||||||
|
WHEN 'insert' THEN 'Criou ' || al.entity_type
|
||||||
|
WHEN 'update' THEN ('Alterou ' || al.entity_type) || COALESCE((' (' || array_to_string(al.changed_fields, ', ')) || ')', '')
|
||||||
|
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
|
||||||
|
END AS description,
|
||||||
|
al.created_at AS occurred_at, 'audit_logs' AS source,
|
||||||
|
jsonb_build_object('old_values', al.old_values, 'new_values', al.new_values, 'changed_fields', al.changed_fields) AS details
|
||||||
|
FROM public.audit_logs al
|
||||||
|
WHERE al.tenant_id = '__TENANT_ID__'::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'doc_access:' || dal.id::text, '__TENANT_ID__'::uuid, dal.user_id, 'document', dal.documento_id::text, dal.acao,
|
||||||
|
CASE dal.acao
|
||||||
|
WHEN 'visualizou' THEN 'Visualizou documento'
|
||||||
|
WHEN 'baixou' THEN 'Baixou documento'
|
||||||
|
WHEN 'imprimiu' THEN 'Imprimiu documento'
|
||||||
|
WHEN 'compartilhou' THEN 'Compartilhou documento'
|
||||||
|
WHEN 'assinou' THEN 'Assinou documento'
|
||||||
|
ELSE dal.acao
|
||||||
|
END,
|
||||||
|
dal.acessado_em, 'document_access_logs',
|
||||||
|
jsonb_build_object('ip', dal.ip::text, 'user_agent', dal.user_agent)
|
||||||
|
FROM __SCHEMA__.document_access_logs dal
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'psh:' || psh.id::text, '__TENANT_ID__'::uuid, psh.alterado_por, 'patient_status', psh.patient_id::text, 'status_change',
|
||||||
|
((('Status do paciente: ' || COALESCE(psh.status_anterior, '—')) || ' → ') || psh.status_novo) || COALESCE((' (' || psh.motivo) || ')', ''),
|
||||||
|
psh.alterado_em, 'patient_status_history',
|
||||||
|
jsonb_build_object('status_anterior', psh.status_anterior, 'status_novo', psh.status_novo, 'motivo', psh.motivo,
|
||||||
|
'encaminhado_para', psh.encaminhado_para, 'data_saida', psh.data_saida)
|
||||||
|
FROM __SCHEMA__.patient_status_history psh
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'notif:' || nl.id::text, '__TENANT_ID__'::uuid, nl.owner_id, 'notification', nl.patient_id::text, nl.status,
|
||||||
|
((('Notificação ' || nl.channel) || ' ') || nl.status) || COALESCE(' para ' || nl.recipient_address, ''),
|
||||||
|
nl.created_at, 'notification_logs',
|
||||||
|
jsonb_build_object('channel', nl.channel, 'template_key', nl.template_key, 'status', nl.status,
|
||||||
|
'provider', nl.provider, 'failure_reason', nl.failure_reason)
|
||||||
|
FROM __SCHEMA__.notification_logs nl
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'addon:' || at.id::text, at.tenant_id, at.admin_user_id, 'addon_transaction', at.id::text, at.type,
|
||||||
|
CASE at.type
|
||||||
|
WHEN 'purchase' THEN (('Compra de ' || at.amount) || ' créditos de ') || at.addon_type
|
||||||
|
WHEN 'consumption' THEN (('Consumo de ' || abs(at.amount)) || ' crédito(s) ') || at.addon_type
|
||||||
|
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
|
||||||
|
WHEN 'refund' THEN (('Reembolso de ' || abs(at.amount)) || ' créditos ') || at.addon_type
|
||||||
|
ELSE (at.type || ' ') || at.addon_type
|
||||||
|
END,
|
||||||
|
at.created_at, 'addon_transactions',
|
||||||
|
jsonb_build_object('addon_type', at.addon_type, 'amount', at.amount, 'balance_after', at.balance_after,
|
||||||
|
'price_cents', at.price_cents, 'payment_reference', at.payment_reference)
|
||||||
|
FROM public.addon_transactions at
|
||||||
|
WHERE at.tenant_id = '__TENANT_ID__'::uuid
|
||||||
|
$vw$),
|
||||||
|
('v_cashflow_projection', 3, $vw$
|
||||||
|
CREATE VIEW __SCHEMA__.v_cashflow_projection WITH (security_invoker = true) AS
|
||||||
|
SELECT gs.mes,
|
||||||
|
to_char(gs.mes, 'YYYY-MM') AS mes_label,
|
||||||
|
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS receitas_projetadas,
|
||||||
|
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS despesas_projetadas,
|
||||||
|
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'pending'), 0) AS receitas_pendentes,
|
||||||
|
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'overdue'), 0) AS receitas_vencidas,
|
||||||
|
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'pending'), 0) AS despesas_pendentes,
|
||||||
|
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'overdue'), 0) AS despesas_vencidas,
|
||||||
|
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0)
|
||||||
|
- COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS saldo_projetado,
|
||||||
|
count(fr.id) FILTER (WHERE fr.status = ANY (ARRAY['pending', 'overdue'])) AS count_registros
|
||||||
|
FROM generate_series(date_trunc('month', CURRENT_DATE::timestamp with time zone)::date::timestamp with time zone,
|
||||||
|
(date_trunc('month', CURRENT_DATE::timestamp with time zone) + '5 mons'::interval)::date::timestamp with time zone,
|
||||||
|
'1 mon'::interval) gs(mes)
|
||||||
|
LEFT JOIN __SCHEMA__.financial_records fr
|
||||||
|
ON fr.deleted_at IS NULL
|
||||||
|
AND (fr.status = ANY (ARRAY['pending', 'overdue']))
|
||||||
|
AND date_trunc('month', fr.due_date::timestamp with time zone)::date = gs.mes
|
||||||
|
GROUP BY gs.mes
|
||||||
|
ORDER BY gs.mes
|
||||||
|
$vw$),
|
||||||
|
('v_commitment_totals', 4, $vw$
|
||||||
|
CREATE VIEW __SCHEMA__.v_commitment_totals WITH (security_invoker = true) AS
|
||||||
|
SELECT '__TENANT_ID__'::uuid AS tenant_id,
|
||||||
|
c.id AS commitment_id,
|
||||||
|
COALESCE(sum(l.minutes), 0)::integer AS total_minutes
|
||||||
|
FROM __SCHEMA__.determined_commitments c
|
||||||
|
LEFT JOIN __SCHEMA__.commitment_time_logs l ON l.commitment_id = c.id
|
||||||
|
GROUP BY c.id
|
||||||
|
$vw$),
|
||||||
|
('v_patient_groups_with_counts', 5, $vw$
|
||||||
|
CREATE VIEW __SCHEMA__.v_patient_groups_with_counts WITH (security_invoker = true) AS
|
||||||
|
SELECT pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at,
|
||||||
|
COALESCE(count(pgp.patient_id), 0)::integer AS patients_count
|
||||||
|
FROM __SCHEMA__.patient_groups pg
|
||||||
|
LEFT JOIN __SCHEMA__.patient_group_patient pgp ON pgp.patient_group_id = pg.id
|
||||||
|
GROUP BY pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at
|
||||||
|
$vw$),
|
||||||
|
('v_tag_patient_counts', 6, $vw$
|
||||||
|
CREATE VIEW __SCHEMA__.v_tag_patient_counts WITH (security_invoker = true) AS
|
||||||
|
SELECT t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at,
|
||||||
|
COALESCE(count(ppt.patient_id), 0)::integer AS pacientes_count,
|
||||||
|
COALESCE(count(ppt.patient_id), 0)::integer AS patient_count
|
||||||
|
FROM __SCHEMA__.patient_tags t
|
||||||
|
LEFT JOIN __SCHEMA__.patient_patient_tag ppt ON ppt.tag_id = t.id AND ppt.owner_id = t.owner_id
|
||||||
|
GROUP BY t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at
|
||||||
|
$vw$);
|
||||||
|
|
||||||
|
-- Valida as views instanciando no próprio template (tenant nulo)
|
||||||
|
DO $$
|
||||||
|
DECLARE r record;
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
|
||||||
|
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
|
||||||
|
EXECUTE replace(replace(r.definition, '__SCHEMA__', '_tenant_template'),
|
||||||
|
'__TENANT_ID__', '00000000-0000-0000-0000-000000000000');
|
||||||
|
RAISE NOTICE 'view _tenant_template.% validada', r.view_name;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F1.4 — Schema-per-tenant: clone/drop + registro + roteamento de canais
|
||||||
|
--
|
||||||
|
-- * public.tenant_schemas — registro dos schemas provisionados (alimenta o
|
||||||
|
-- gerador do config.toml na F5)
|
||||||
|
-- * public.channel_routing — índice global de roteamento: webhooks inbound
|
||||||
|
-- (Twilio/Evolution) precisam descobrir o tenant do canal ANTES de saber o
|
||||||
|
-- schema (decisão Q3: notification_channels mora no schema do tenant).
|
||||||
|
-- Mantido por trigger em cada tenant_<slug>.notification_channels.
|
||||||
|
-- * clone_tenant_template(tenant_id) — instancia tenant_<slug> a partir do
|
||||||
|
-- _tenant_template: tabelas + sequences locais + FKs + seeds + views + RLS
|
||||||
|
-- (policies com tenant_id EMBUTIDO — modelo multi-membership) + realtime +
|
||||||
|
-- grants + trigger de roteamento.
|
||||||
|
-- * drop_tenant_schema(tenant_id) — protegido (assert tenant_%).
|
||||||
|
--
|
||||||
|
-- NOTA: clones criados na F1/F2 ainda NÃO têm triggers de negócio (F6) e não
|
||||||
|
-- estão expostos no PostgREST (F5). _meta.triggers_pending registra isso.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Registro de schemas provisionados
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.tenant_schemas (
|
||||||
|
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||||
|
schema_name text NOT NULL UNIQUE,
|
||||||
|
template_version int NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.tenant_schemas ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_schemas_select ON public.tenant_schemas;
|
||||||
|
CREATE POLICY tenant_schemas_select ON public.tenant_schemas
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (public.is_tenant_member(tenant_id) OR public.is_saas_admin());
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Índice global de roteamento de canais (webhook inbound -> tenant)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS public.channel_routing (
|
||||||
|
channel_id uuid PRIMARY KEY,
|
||||||
|
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||||
|
channel text NOT NULL,
|
||||||
|
provider text,
|
||||||
|
sender_address text,
|
||||||
|
twilio_subaccount_sid text,
|
||||||
|
twilio_phone_number text,
|
||||||
|
metadata jsonb,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS channel_routing_tenant_idx ON public.channel_routing (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS channel_routing_sender_idx ON public.channel_routing (sender_address) WHERE sender_address IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS channel_routing_twilio_phone_idx ON public.channel_routing (twilio_phone_number) WHERE twilio_phone_number IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS channel_routing_twilio_sid_idx ON public.channel_routing (twilio_subaccount_sid) WHERE twilio_subaccount_sid IS NOT NULL;
|
||||||
|
|
||||||
|
-- Tabela de infra: só service_role (edge functions) e saas admin enxergam
|
||||||
|
ALTER TABLE public.channel_routing ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS channel_routing_saas_admin ON public.channel_routing;
|
||||||
|
CREATE POLICY channel_routing_saas_admin ON public.channel_routing
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (public.is_saas_admin())
|
||||||
|
WITH CHECK (public.is_saas_admin());
|
||||||
|
|
||||||
|
-- Trigger anexado a cada tenant_<slug>.notification_channels pelo clone
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_sync_channel_routing()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||||
|
IF v_tenant_id IS NULL THEN
|
||||||
|
RAISE WARNING 'trg_sync_channel_routing: schema % sem tenant correspondente', TG_TABLE_SCHEMA;
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END IF;
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
DELETE FROM public.channel_routing WHERE channel_id = OLD.id;
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO public.channel_routing AS cr
|
||||||
|
(channel_id, tenant_id, channel, provider, sender_address,
|
||||||
|
twilio_subaccount_sid, twilio_phone_number, metadata, is_active, updated_at)
|
||||||
|
VALUES
|
||||||
|
(NEW.id, v_tenant_id, NEW.channel, NEW.provider, NEW.sender_address,
|
||||||
|
NEW.twilio_subaccount_sid, NEW.twilio_phone_number, NEW.metadata,
|
||||||
|
COALESCE(NEW.is_active, false) AND NEW.deleted_at IS NULL, now())
|
||||||
|
ON CONFLICT (channel_id) DO UPDATE SET
|
||||||
|
tenant_id = EXCLUDED.tenant_id,
|
||||||
|
channel = EXCLUDED.channel,
|
||||||
|
provider = EXCLUDED.provider,
|
||||||
|
sender_address = EXCLUDED.sender_address,
|
||||||
|
twilio_subaccount_sid = EXCLUDED.twilio_subaccount_sid,
|
||||||
|
twilio_phone_number = EXCLUDED.twilio_phone_number,
|
||||||
|
metadata = EXCLUDED.metadata,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- clone_tenant_template
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.clone_tenant_template(p_tenant_id uuid)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_slug text;
|
||||||
|
v_schema text;
|
||||||
|
v_version int;
|
||||||
|
t text;
|
||||||
|
r record;
|
||||||
|
v_def text;
|
||||||
|
v_seq text;
|
||||||
|
v_n int;
|
||||||
|
v_pending text[];
|
||||||
|
v_failed text[];
|
||||||
|
BEGIN
|
||||||
|
SELECT slug INTO v_slug FROM public.tenants WHERE id = p_tenant_id;
|
||||||
|
IF v_slug IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'clone_tenant_template: tenant % não existe ou sem slug', p_tenant_id;
|
||||||
|
END IF;
|
||||||
|
v_schema := public.tenant_schema_name(v_slug);
|
||||||
|
IF v_schema IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'clone_tenant_template: slug % inválido', v_slug;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
|
||||||
|
RAISE EXCEPTION 'clone_tenant_template: schema % já existe', v_schema;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '_tenant_template') THEN
|
||||||
|
RAISE EXCEPTION 'clone_tenant_template: _tenant_template não existe (rode a F1.3)';
|
||||||
|
END IF;
|
||||||
|
SELECT (value)::int INTO v_version FROM _tenant_template._meta WHERE key = 'template_version';
|
||||||
|
|
||||||
|
EXECUTE format('CREATE SCHEMA %I', v_schema);
|
||||||
|
|
||||||
|
-- nomes qualificados nas definições geradas pelo catálogo
|
||||||
|
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
|
||||||
|
|
||||||
|
-- 1. tabelas
|
||||||
|
FOR r IN
|
||||||
|
SELECT table_name AS tab FROM information_schema.tables
|
||||||
|
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||||
|
AND table_name NOT LIKE '\_%'
|
||||||
|
ORDER BY table_name
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('CREATE TABLE %I.%I (LIKE _tenant_template.%I INCLUDING ALL)',
|
||||||
|
v_schema, r.tab, r.tab);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 2. sequences locais (defaults que apontam pro template)
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname AS tab, a.attname AS col
|
||||||
|
FROM pg_attrdef d
|
||||||
|
JOIN pg_class c ON c.oid = d.adrelid
|
||||||
|
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||||
|
WHERE c.relnamespace = v_schema::regnamespace
|
||||||
|
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''_tenant_template.%'
|
||||||
|
LOOP
|
||||||
|
v_seq := r.tab || '_' || r.col || '_seq';
|
||||||
|
EXECUTE format('CREATE SEQUENCE %I.%I', v_schema, v_seq);
|
||||||
|
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
|
||||||
|
v_schema, r.tab, r.col, format('%I.%I', v_schema, v_seq));
|
||||||
|
EXECUTE format('ALTER SEQUENCE %I.%I OWNED BY %I.%I.%I',
|
||||||
|
v_schema, v_seq, v_schema, r.tab, r.col);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 3. seeds (linhas-default do sistema guardadas no template)
|
||||||
|
-- Sem session_replication_role (postgres não é superuser no Supabase):
|
||||||
|
-- ordem de FK resolvida por tentativa-e-repetição em rounds.
|
||||||
|
v_pending := ARRAY[]::text[];
|
||||||
|
FOR r IN
|
||||||
|
SELECT table_name AS tab FROM information_schema.tables
|
||||||
|
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||||
|
AND table_name NOT LIKE '\_%'
|
||||||
|
ORDER BY table_name
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('SELECT count(*) FROM _tenant_template.%I', r.tab) INTO v_n;
|
||||||
|
IF v_n > 0 THEN v_pending := v_pending || r.tab; END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
WHILE coalesce(array_length(v_pending, 1), 0) > 0 LOOP
|
||||||
|
v_failed := ARRAY[]::text[];
|
||||||
|
FOR r IN SELECT unnest(v_pending) AS tab LOOP
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('INSERT INTO %I.%I SELECT * FROM _tenant_template.%I',
|
||||||
|
v_schema, r.tab, r.tab);
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
v_failed := v_failed || r.tab;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
IF array_length(v_failed, 1) = array_length(v_pending, 1) THEN
|
||||||
|
RAISE EXCEPTION 'clone_tenant_template: dependência circular nos seeds: %', v_failed;
|
||||||
|
END IF;
|
||||||
|
v_pending := v_failed;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 4. FKs (intra-schema e pra public/auth)
|
||||||
|
FOR r IN
|
||||||
|
SELECT cl.relname AS tab, con.conname, pg_get_constraintdef(con.oid) AS def
|
||||||
|
FROM pg_constraint con
|
||||||
|
JOIN pg_class cl ON cl.oid = con.conrelid
|
||||||
|
WHERE con.contype = 'f'
|
||||||
|
AND cl.relnamespace = '_tenant_template'::regnamespace
|
||||||
|
ORDER BY cl.relname, con.conname
|
||||||
|
LOOP
|
||||||
|
v_def := replace(r.def, ' REFERENCES _tenant_template.', format(' REFERENCES %I.', v_schema));
|
||||||
|
EXECUTE format('ALTER TABLE %I.%I ADD CONSTRAINT %I %s', v_schema, r.tab, r.conname, v_def);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 5. views (placeholders __SCHEMA__ / __TENANT_ID__)
|
||||||
|
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
|
||||||
|
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
|
||||||
|
EXECUTE replace(replace(r.definition, '__SCHEMA__', quote_ident(v_schema)),
|
||||||
|
'__TENANT_ID__', p_tenant_id::text);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 6. RLS: tenant_id embutido (multi-membership: o usuário só enxerga
|
||||||
|
-- schemas de tenants onde tenant_members o lista como ativo)
|
||||||
|
FOR r IN
|
||||||
|
SELECT table_name AS tab FROM information_schema.tables
|
||||||
|
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||||
|
AND table_name NOT LIKE '\_%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', v_schema, r.tab);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY tenant_member_full ON %I.%I FOR ALL TO authenticated USING (public.is_tenant_member(%L::uuid)) WITH CHECK (public.is_tenant_member(%L::uuid))',
|
||||||
|
v_schema, r.tab, p_tenant_id, p_tenant_id);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY saas_admin_full ON %I.%I FOR ALL TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin())',
|
||||||
|
v_schema, r.tab);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 7. trigger de roteamento de canais
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE TRIGGER trg_channel_routing AFTER INSERT OR UPDATE OR DELETE ON %I.notification_channels FOR EACH ROW EXECUTE FUNCTION public.trg_sync_channel_routing()',
|
||||||
|
v_schema);
|
||||||
|
|
||||||
|
-- 8. realtime
|
||||||
|
FOR r IN SELECT table_name FROM _tenant_template._realtime_tables LOOP
|
||||||
|
EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE %I.%I', v_schema, r.table_name);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 9. grants (espelha o padrão do Supabase pra schemas expostos)
|
||||||
|
EXECUTE format('GRANT USAGE ON SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||||
|
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||||
|
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||||
|
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO anon, authenticated, service_role', v_schema);
|
||||||
|
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO anon, authenticated, service_role', v_schema);
|
||||||
|
|
||||||
|
INSERT INTO public.tenant_schemas (tenant_id, schema_name, template_version)
|
||||||
|
VALUES (p_tenant_id, v_schema, COALESCE(v_version, 1));
|
||||||
|
|
||||||
|
RETURN v_schema;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- drop_tenant_schema
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.drop_tenant_schema(p_tenant_id uuid)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_schema text;
|
||||||
|
BEGIN
|
||||||
|
SELECT schema_name INTO v_schema FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
|
||||||
|
IF v_schema IS NULL THEN
|
||||||
|
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||||
|
END IF;
|
||||||
|
IF v_schema IS NULL OR v_schema NOT LIKE 'tenant\_%' THEN
|
||||||
|
RAISE EXCEPTION 'drop_tenant_schema: schema inválido pra tenant % (%)', p_tenant_id, v_schema;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
|
||||||
|
RAISE EXCEPTION 'drop_tenant_schema: schema % não existe', v_schema;
|
||||||
|
END IF;
|
||||||
|
DELETE FROM public.channel_routing WHERE tenant_id = p_tenant_id;
|
||||||
|
DELETE FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
|
||||||
|
EXECUTE format('DROP SCHEMA %I CASCADE', v_schema);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Clone/drop são operações de provisionamento: só service_role (edge) e postgres
|
||||||
|
REVOKE ALL ON FUNCTION public.clone_tenant_template(uuid) FROM PUBLIC, anon, authenticated;
|
||||||
|
REVOKE ALL ON FUNCTION public.drop_tenant_schema(uuid) FROM PUBLIC, anon, authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.clone_tenant_template(uuid) TO service_role;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.drop_tenant_schema(uuid) TO service_role;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F1.5 — Correção dos seeds do _tenant_template
|
||||||
|
--
|
||||||
|
-- O PASS 5 da F1.3 semeou TODA linha com tenant_id IS NULL de public — mas
|
||||||
|
-- patient_intake_requests (2), patient_invites (1) e notifications (2) eram
|
||||||
|
-- dados operacionais órfãos, não defaults do sistema. Cada tenant novo nasceria
|
||||||
|
-- com esses registros fantasmas.
|
||||||
|
--
|
||||||
|
-- Whitelist canônica de seeds do template (lookups/templates do sistema):
|
||||||
|
-- clinical_note_templates, contact_email_types, contact_types,
|
||||||
|
-- conversation_optout_keywords, conversation_tags, document_templates,
|
||||||
|
-- notification_templates, feriados
|
||||||
|
--
|
||||||
|
-- (20260612000003 foi corrigida em retrospecto pra instalações do zero;
|
||||||
|
-- esta migration corrige bancos que já aplicaram a versão original.)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DELETE FROM _tenant_template.patient_intake_requests;
|
||||||
|
DELETE FROM _tenant_template.patient_invites;
|
||||||
|
DELETE FROM _tenant_template.notifications;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F2 — Schema-per-tenant: provisionamento cria o schema físico
|
||||||
|
--
|
||||||
|
-- Os 3 pontos de criação de tenant passam a chamar clone_tenant_template()
|
||||||
|
-- logo após inserir em tenants/tenant_members. Tudo na mesma transação:
|
||||||
|
-- se o clone falhar, o tenant não nasce (atomicidade).
|
||||||
|
--
|
||||||
|
-- Pontos cobertos (F0 §levantamento — não há outros INSERT INTO tenants):
|
||||||
|
-- * provision_account_tenant — wizard de cadastro (therapist/clinic_*)
|
||||||
|
-- * create_clinic_tenant — criação avulsa de clínica
|
||||||
|
-- * ensure_personal_tenant_for_user — tenant pessoal (kind='saas'),
|
||||||
|
-- chamado também pelo trigger de signup (handle_new_user_create_personal_tenant)
|
||||||
|
--
|
||||||
|
-- Decisão Q2: TODO tenant ganha schema, inclusive therapist e pessoal.
|
||||||
|
-- Clones nascem sem triggers de negócio (F6) e fora do PostgREST (F5).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_clinic_tenant(p_name text)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $function$
|
||||||
|
declare
|
||||||
|
v_uid uuid;
|
||||||
|
v_tenant uuid;
|
||||||
|
v_name text;
|
||||||
|
begin
|
||||||
|
v_uid := auth.uid();
|
||||||
|
if v_uid is null then
|
||||||
|
raise exception 'Not authenticated';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_name := nullif(trim(coalesce(p_name, '')), '');
|
||||||
|
if v_name is null then
|
||||||
|
v_name := 'Clínica';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.tenants (name, kind, created_at)
|
||||||
|
values (v_name, 'clinic', now())
|
||||||
|
returning id into v_tenant;
|
||||||
|
|
||||||
|
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||||
|
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||||
|
|
||||||
|
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||||
|
perform public.clone_tenant_template(v_tenant);
|
||||||
|
|
||||||
|
return v_tenant;
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.ensure_personal_tenant_for_user(p_user_id uuid)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $function$
|
||||||
|
declare
|
||||||
|
v_uid uuid;
|
||||||
|
v_existing uuid;
|
||||||
|
v_tenant uuid;
|
||||||
|
v_email text;
|
||||||
|
v_name text;
|
||||||
|
begin
|
||||||
|
v_uid := p_user_id;
|
||||||
|
if v_uid is null then
|
||||||
|
raise exception 'Missing user id';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- só considera tenant pessoal (kind='saas')
|
||||||
|
select tm.tenant_id
|
||||||
|
into v_existing
|
||||||
|
from public.tenant_members tm
|
||||||
|
join public.tenants t on t.id = tm.tenant_id
|
||||||
|
where tm.user_id = v_uid
|
||||||
|
and tm.status = 'active'
|
||||||
|
and t.kind = 'saas'
|
||||||
|
order by tm.created_at desc
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_existing is not null then
|
||||||
|
return v_existing;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select email into v_email
|
||||||
|
from auth.users
|
||||||
|
where id = v_uid;
|
||||||
|
|
||||||
|
v_name := coalesce(split_part(v_email, '@', 1), 'Conta');
|
||||||
|
|
||||||
|
insert into public.tenants (name, kind, created_at)
|
||||||
|
values (v_name || ' (Pessoal)', 'saas', now())
|
||||||
|
returning id into v_tenant;
|
||||||
|
|
||||||
|
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||||
|
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||||
|
|
||||||
|
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||||
|
perform public.clone_tenant_template(v_tenant);
|
||||||
|
|
||||||
|
return v_tenant;
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $function$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id uuid;
|
||||||
|
v_account_type text;
|
||||||
|
v_name text;
|
||||||
|
BEGIN
|
||||||
|
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
|
||||||
|
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.tenant_members tm
|
||||||
|
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||||
|
WHERE tm.user_id = p_user_id
|
||||||
|
AND tm.role = 'tenant_admin'
|
||||||
|
AND tm.status = 'active'
|
||||||
|
AND t.kind = p_kind
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_name := COALESCE(
|
||||||
|
NULLIF(TRIM(p_name), ''),
|
||||||
|
(
|
||||||
|
SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
|
||||||
|
FROM public.profiles pr
|
||||||
|
JOIN auth.users au ON au.id = pr.id
|
||||||
|
WHERE pr.id = p_user_id
|
||||||
|
),
|
||||||
|
'Conta'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.tenants (name, kind, created_at)
|
||||||
|
VALUES (v_name, p_kind, now())
|
||||||
|
RETURNING id INTO v_tenant_id;
|
||||||
|
|
||||||
|
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||||
|
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
|
||||||
|
|
||||||
|
UPDATE public.profiles
|
||||||
|
SET account_type = v_account_type
|
||||||
|
WHERE id = p_user_id;
|
||||||
|
|
||||||
|
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||||
|
|
||||||
|
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||||
|
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||||
|
|
||||||
|
RETURN v_tenant_id;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F3 — my_tenants() passa a devolver slug (e nome) do tenant
|
||||||
|
--
|
||||||
|
-- O frontend resolve o schema físico do tenant ativo no cliente:
|
||||||
|
-- tenantStore guarda memberships de my_tenants(); slug -> 'tenant_<slug>'.
|
||||||
|
-- Campo extra é inofensivo pro frontend atual (main) que ignora colunas novas.
|
||||||
|
-- (mudança de RETURNS TABLE exige DROP + CREATE)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.my_tenants();
|
||||||
|
|
||||||
|
CREATE FUNCTION public.my_tenants()
|
||||||
|
RETURNS TABLE(tenant_id uuid, role text, status text, kind text, slug text, tenant_name text)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $function$
|
||||||
|
select
|
||||||
|
tm.tenant_id,
|
||||||
|
tm.role,
|
||||||
|
tm.status,
|
||||||
|
t.kind,
|
||||||
|
t.slug,
|
||||||
|
t.name
|
||||||
|
from public.tenant_members tm
|
||||||
|
join public.tenants t on t.id = tm.tenant_id
|
||||||
|
where tm.user_id = auth.uid();
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.my_tenants() TO authenticated;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F1b — Decisão de roteamento anon: 6 tabelas anon-facing FICAM em public
|
||||||
|
--
|
||||||
|
-- Fluxos anônimos identificam o tenant por TOKEN/SLUG (não por login), então
|
||||||
|
-- não conseguem resolver o schema físico. Decisão (2026-06-13, opção C):
|
||||||
|
-- manter essas tabelas em public com tenant_id + RLS por token, como hoje.
|
||||||
|
--
|
||||||
|
-- patient_intake_requests — intake de paciente por convite (token)
|
||||||
|
-- patient_invites — tokens de convite
|
||||||
|
-- patient_invite_attempts — rate-limit anon dos convites
|
||||||
|
-- document_share_links — assinatura pública de documento (token)
|
||||||
|
-- agendador_configuracoes — agendador público (link_slug)
|
||||||
|
-- agendador_solicitacoes — solicitação criada por visitante anon
|
||||||
|
--
|
||||||
|
-- Logo, REMOVE essas 6 do _tenant_template (não viram schema do tenant).
|
||||||
|
-- O clone_tenant_template itera as tabelas do template dinamicamente, então
|
||||||
|
-- novos clones nascem sem elas automaticamente. Classificação final:
|
||||||
|
-- 78 tenant-scoped + 59 globais (era 84 + 53).
|
||||||
|
--
|
||||||
|
-- Nota F6: public.document_share_links.documento_id tem FK -> documents, que
|
||||||
|
-- VAI pro schema do tenant. No drop de public.documents (F6), essa FK precisa
|
||||||
|
-- virar coluna solta (uuid sem constraint) — o RPC valida via token. Idem
|
||||||
|
-- qualquer FK public->tenant dessas 6 (registrar no lote de FKs da F6).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
anon_tabs text[] := ARRAY[
|
||||||
|
'patient_intake_requests','patient_invites','patient_invite_attempts',
|
||||||
|
'document_share_links','agendador_configuracoes','agendador_solicitacoes'
|
||||||
|
];
|
||||||
|
t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY anon_tabs LOOP
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = '_tenant_template' AND table_name = t) THEN
|
||||||
|
EXECUTE format('DROP TABLE _tenant_template.%I CASCADE', t);
|
||||||
|
RAISE NOTICE 'F1b: _tenant_template.% removida (fica em public)', t;
|
||||||
|
END IF;
|
||||||
|
-- defensivo: tira do registro de realtime do template, se estiver lá
|
||||||
|
DELETE FROM _tenant_template._realtime_tables WHERE table_name = t;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
UPDATE _tenant_template._meta SET value = '2'::jsonb WHERE key = 'template_version';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F5 — Trigger que re-expõe schemas tenant no PostgREST a cada clone/drop
|
||||||
|
--
|
||||||
|
-- public.refresh_pgrst_schemas() (criada em manual/f5_pgrst_refresh_schemas.
|
||||||
|
-- supabase_admin.sql, owned por supabase_admin) seta pgrst.db_schemas + NOTIFY.
|
||||||
|
-- Este trigger em tenant_schemas a dispara automaticamente — clone_tenant_template
|
||||||
|
-- e drop_tenant_schema NÃO precisam ser tocados (eles inserem/removem em
|
||||||
|
-- tenant_schemas, o que aciona o refresh no COMMIT).
|
||||||
|
--
|
||||||
|
-- PRÉ-REQUISITO: aplicar f5_pgrst_refresh_schemas.supabase_admin.sql ANTES desta
|
||||||
|
-- migration (a função precisa existir e estar owned por supabase_admin).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||||
|
WHERE n.nspname = 'public' AND p.proname = 'refresh_pgrst_schemas'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'F5: public.refresh_pgrst_schemas() não existe — aplique manual/f5_pgrst_refresh_schemas.supabase_admin.sql primeiro';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Trigger function (owned por postgres) só delega pro refresh (SECDEF supabase_admin)
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_refresh_pgrst_schemas()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM public.refresh_pgrst_schemas();
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_tenant_schemas_pgrst_refresh ON public.tenant_schemas;
|
||||||
|
CREATE TRIGGER trg_tenant_schemas_pgrst_refresh
|
||||||
|
AFTER INSERT OR DELETE OR UPDATE OF schema_name ON public.tenant_schemas
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION public.trg_refresh_pgrst_schemas();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.0 — Clona os schemas dos tenants JÁ EXISTENTES (cutover)
|
||||||
|
--
|
||||||
|
-- Até aqui só tenants criados PÓS-F2 ganhavam schema. Os 9 tenants que já
|
||||||
|
-- existiam precisam dos seus schemas (ainda vazios — dados migram na F6.1).
|
||||||
|
-- Idempotente: só clona quem não está em tenant_schemas. Cada clone dispara
|
||||||
|
-- o trigger da F5 (expõe no PostgREST).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
v_schema text;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT t.id, t.slug
|
||||||
|
FROM public.tenants t
|
||||||
|
LEFT JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
|
||||||
|
WHERE ts.tenant_id IS NULL
|
||||||
|
ORDER BY t.created_at, t.id
|
||||||
|
LOOP
|
||||||
|
v_schema := public.clone_tenant_template(r.id);
|
||||||
|
RAISE NOTICE 'F6.0: tenant % (%) -> %', r.id, r.slug, v_schema;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- F6.2 Lote A — anexa triggers schema-agnósticos aos schemas tenant
|
||||||
|
--
|
||||||
|
-- O clone (LIKE INCLUDING ALL) NÃO copia triggers. As tabelas tenant nos
|
||||||
|
-- schemas precisam dos triggers de negócio. Lote A: os PROVADAMENTE
|
||||||
|
-- schema-agnósticos (só mexem em NEW/OLD, não referenciam outras tabelas) —
|
||||||
|
-- seguros pra anexar sem reescrever a função:
|
||||||
|
-- família updated_at (8) + prevent_promoting_to_system +
|
||||||
|
-- prevent_system_group_changes
|
||||||
|
-- Os schema-aware (financeiro/audit/notif/timeline/sync) vêm no Lote B.
|
||||||
|
--
|
||||||
|
-- attach_agnostic_triggers(schema) recria, no schema dado, os triggers de
|
||||||
|
-- public cuja função está na whitelist agnóstica. A função do trigger continua
|
||||||
|
-- sendo a de public (agnóstica → funciona em qualquer schema). Backfill dos 9;
|
||||||
|
-- o wiring no clone_tenant_template acontece no fim da F6.2 (com todos prontos).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
|
||||||
|
RETURNS int
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
agnostic text[] := ARRAY[
|
||||||
|
'set_updated_at','fn_clinical_notes_updated_at','set_insurance_plans_updated_at',
|
||||||
|
'set_medicos_updated_at','set_services_updated_at','set_updated_at_recurrence',
|
||||||
|
'update_payment_settings_updated_at','update_professional_pricing_updated_at',
|
||||||
|
'prevent_promoting_to_system','prevent_system_group_changes'
|
||||||
|
];
|
||||||
|
r record;
|
||||||
|
v_def text;
|
||||||
|
v_count int := 0;
|
||||||
|
BEGIN
|
||||||
|
IF p_schema NOT LIKE 'tenant\_%' THEN
|
||||||
|
RAISE EXCEPTION 'attach_agnostic_triggers: schema inválido %', p_schema;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname AS tab, t.tgname, pg_get_triggerdef(t.oid) AS def
|
||||||
|
FROM pg_trigger t
|
||||||
|
JOIN pg_class c ON c.oid = t.tgrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_proc p ON p.oid = t.tgfoid
|
||||||
|
WHERE n.nspname = 'public' AND NOT t.tgisinternal
|
||||||
|
AND p.proname = ANY(agnostic)
|
||||||
|
AND EXISTS (SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = p_schema AND table_name = c.relname)
|
||||||
|
LOOP
|
||||||
|
-- redireciona o ON public.<tab> pro schema do tenant (a função fica em public)
|
||||||
|
v_def := replace(r.def, 'ON public.' || r.tab || ' ', 'ON ' || p_schema || '.' || r.tab || ' ');
|
||||||
|
IF v_def = r.def THEN
|
||||||
|
RAISE EXCEPTION 'attach_agnostic_triggers: não consegui redirecionar % (%.%)', r.tgname, p_schema, r.tab;
|
||||||
|
END IF;
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', r.tgname, p_schema, r.tab);
|
||||||
|
EXECUTE v_def;
|
||||||
|
v_count := v_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Backfill dos 9 schemas existentes
|
||||||
|
DO $$
|
||||||
|
DECLARE r record; v int;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||||
|
v := public.attach_agnostic_triggers(r.schema_name);
|
||||||
|
RAISE NOTICE 'F6.2A %: % triggers agnósticos', r.schema_name, v;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F1 — limite de pacientes do plano therapist_free
|
||||||
|
--
|
||||||
|
-- clinic_free já traz max_patients=30 (em plan_features.limits da feature
|
||||||
|
-- clinic_calendar, semeado). O therapist_free não tinha limite de pacientes.
|
||||||
|
-- Pendura max_patients=20 na feature 'patients.manage' (a que o therapist_free
|
||||||
|
-- já possui, enabled).
|
||||||
|
--
|
||||||
|
-- REGRA DE OURO: referenciar plano/feature POR KEY via subquery, nunca por uuid
|
||||||
|
-- hardcoded (uuids divergem entre ambientes). Idempotente (merge no jsonb).
|
||||||
|
-- O enforcement em runtime (trigger) está em manual/freemium_f1_plan_limits.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE public.plan_features pf
|
||||||
|
SET limits = COALESCE(pf.limits, '{}'::jsonb) || jsonb_build_object('max_patients', 20)
|
||||||
|
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
|
||||||
|
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
|
||||||
|
|
||||||
|
-- Sanidade: garante que o limite ficou gravado (1 linha afetada esperada).
|
||||||
|
DO $$
|
||||||
|
DECLARE v int;
|
||||||
|
BEGIN
|
||||||
|
SELECT (pf.limits->>'max_patients')::int INTO v
|
||||||
|
FROM public.plan_features pf
|
||||||
|
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
|
||||||
|
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
|
||||||
|
IF v IS DISTINCT FROM 20 THEN
|
||||||
|
RAISE EXCEPTION 'therapist_free max_patients esperado 20, obtido %', v;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Fix (regressão schema-per-tenant): log_audit_change quebra INSERT em tabelas
|
||||||
|
-- GLOBAIS auditadas.
|
||||||
|
--
|
||||||
|
-- log_audit_change deriva o tenant via tenant_id_for_schema(TG_TABLE_SCHEMA).
|
||||||
|
-- Para tabelas em tenant_<slug> isso resolve certo. Mas o trigger também está
|
||||||
|
-- em public.tenant_members (tabela global) — e tenant_id_for_schema('public')
|
||||||
|
-- retorna NULL, violando audit_logs.tenant_id (NOT NULL). Resultado: QUALQUER
|
||||||
|
-- INSERT em tenant_members falhava (provisionamento, aceite de convite).
|
||||||
|
--
|
||||||
|
-- Fix: quando o schema não resolve um tenant (tabela global), usa o tenant_id
|
||||||
|
-- da própria linha (tenant_members.tenant_id). Se ainda assim for NULL, não
|
||||||
|
-- audita — mas NUNCA quebra a operação de negócio.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_audit_change()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||||
|
AS $function$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
|
||||||
|
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
|
||||||
|
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
|
||||||
|
BEGIN
|
||||||
|
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||||
|
|
||||||
|
-- tabela global (public.*): cai no tenant_id da própria linha, se existir
|
||||||
|
IF v_tenant_id IS NULL THEN
|
||||||
|
v_tenant_id := NULLIF(to_jsonb(COALESCE(NEW, OLD)) ->> 'tenant_id', '')::uuid;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- sem tenant resolvível → não audita, mas não quebra a operação
|
||||||
|
IF v_tenant_id IS NULL THEN
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
|
||||||
|
ELSIF TG_OP = 'INSERT' THEN
|
||||||
|
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
|
||||||
|
ELSE
|
||||||
|
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
|
||||||
|
SELECT array_agg(key ORDER BY key) INTO v_changed
|
||||||
|
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
|
||||||
|
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
|
||||||
|
IF v_changed IS NULL THEN RETURN NEW; END IF;
|
||||||
|
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
|
||||||
|
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END $function$;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Freemium F2 (polish) — apresentação do plano gratuito na vitrine pública
|
||||||
|
--
|
||||||
|
-- Os planos free já eram is_visible em v_public_pricing, mas sem plan_public
|
||||||
|
-- (nome/descrição/bullets) e sem preço — renderizavam sem nome/valor. Este seed
|
||||||
|
-- dá um cartão "Grátis" decente. Referência por KEY (subquery), idempotente.
|
||||||
|
-- O preço "Grátis" é tratado no front (Landingpage isFreePlan).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||||
|
SELECT id, 'Grátis',
|
||||||
|
'Comece sem custo: o essencial pra organizar sua agenda, pacientes e prontuário.',
|
||||||
|
'Grátis', false, true, 0
|
||||||
|
FROM public.plans WHERE key = 'clinic_free'
|
||||||
|
ON CONFLICT (plan_id) DO UPDATE
|
||||||
|
SET public_name = EXCLUDED.public_name,
|
||||||
|
public_description = EXCLUDED.public_description,
|
||||||
|
badge = EXCLUDED.badge,
|
||||||
|
is_visible = true,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||||
|
SELECT id, 'Grátis',
|
||||||
|
'Pra terapeutas individuais: agenda, pacientes e prontuário sem custo.',
|
||||||
|
'Grátis', false, true, 0
|
||||||
|
FROM public.plans WHERE key = 'therapist_free'
|
||||||
|
ON CONFLICT (plan_id) DO UPDATE
|
||||||
|
SET public_name = EXCLUDED.public_name,
|
||||||
|
public_description = EXCLUDED.public_description,
|
||||||
|
badge = EXCLUDED.badge,
|
||||||
|
is_visible = true,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- bullets (idempotente: limpa os dos free e re-insere)
|
||||||
|
DELETE FROM public.plan_public_bullets
|
||||||
|
WHERE plan_id IN (SELECT id FROM public.plans WHERE key IN ('clinic_free','therapist_free'));
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||||
|
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||||
|
FROM public.plans p
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
VALUES
|
||||||
|
('Agenda completa e prontuário', true, 1),
|
||||||
|
('Até 30 pacientes ativos', false, 2),
|
||||||
|
('Documentos e lembretes básicos', false, 3),
|
||||||
|
('Agendamento online', false, 4)
|
||||||
|
) AS b(text, highlight, sort_order)
|
||||||
|
WHERE p.key = 'clinic_free';
|
||||||
|
|
||||||
|
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||||
|
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||||
|
FROM public.plans p
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
VALUES
|
||||||
|
('Agenda completa e prontuário', true, 1),
|
||||||
|
('Até 20 pacientes ativos', false, 2),
|
||||||
|
('Documentos e lembretes básicos', false, 3),
|
||||||
|
('Agendamento online', false, 4)
|
||||||
|
) AS b(text, highlight, sort_order)
|
||||||
|
WHERE p.key = 'therapist_free';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+1699
-122
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
-- Extensions
|
-- Extensions
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:33.041Z
|
-- Gerado automaticamente em 2026-05-11T16:53:49.849Z
|
||||||
-- Total: 10
|
-- Total: 10
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
|
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- All Functions
|
-- All Functions
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.921Z
|
||||||
-- Total: 192
|
-- Total: 211
|
||||||
|
|
||||||
CREATE FUNCTION auth.email() RETURNS text
|
CREATE FUNCTION auth.email() RETURNS text
|
||||||
LANGUAGE sql STABLE
|
LANGUAGE sql STABLE
|
||||||
@@ -287,6 +287,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
|
|||||||
select 'ok'::text;
|
select 'ok'::text;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
WITH msgs AS (
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.tenant_id,
|
||||||
|
m.direction,
|
||||||
|
m.created_at,
|
||||||
|
m.patient_id,
|
||||||
|
m.from_number,
|
||||||
|
m.to_number,
|
||||||
|
-- mesma logica da view conversation_threads
|
||||||
|
COALESCE(
|
||||||
|
m.patient_id::text,
|
||||||
|
'anon:' || COALESCE(
|
||||||
|
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
|
||||||
|
'unknown'
|
||||||
|
)
|
||||||
|
) AS tk
|
||||||
|
FROM public.conversation_messages m
|
||||||
|
WHERE m.tenant_id = p_tenant_id
|
||||||
|
AND m.direction IN ('inbound', 'outbound')
|
||||||
|
AND m.created_at >= p_from
|
||||||
|
AND m.created_at <= p_to
|
||||||
|
),
|
||||||
|
with_prev AS (
|
||||||
|
SELECT *,
|
||||||
|
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
|
||||||
|
FROM msgs
|
||||||
|
),
|
||||||
|
run_starts AS (
|
||||||
|
-- Primeira mensagem de cada "run inbound"
|
||||||
|
SELECT tk, tenant_id, created_at AS inbound_started_at
|
||||||
|
FROM with_prev
|
||||||
|
WHERE direction = 'inbound'
|
||||||
|
AND (prev_direction IS NULL OR prev_direction = 'outbound')
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.tk AS thread_key,
|
||||||
|
r.inbound_started_at,
|
||||||
|
o.created_at AS responded_at,
|
||||||
|
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
|
||||||
|
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
|
||||||
|
a.assigned_to AS responder_id
|
||||||
|
FROM run_starts r
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT created_at
|
||||||
|
FROM public.conversation_messages m2
|
||||||
|
WHERE m2.tenant_id = r.tenant_id
|
||||||
|
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
|
||||||
|
AND m2.direction = 'outbound'
|
||||||
|
AND m2.created_at > r.inbound_started_at
|
||||||
|
ORDER BY m2.created_at
|
||||||
|
LIMIT 1
|
||||||
|
) o ON true
|
||||||
|
LEFT JOIN public.conversation_assignments a
|
||||||
|
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
|
||||||
|
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
|
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -451,6 +513,95 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_new_balance INT;
|
||||||
|
v_current_balance INT;
|
||||||
|
v_topup_net INT;
|
||||||
|
v_usage_total INT;
|
||||||
|
v_removable INT;
|
||||||
|
v_clean_note TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_tenant_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'tenant_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_amount IS NULL OR p_amount = 0 THEN
|
||||||
|
RAISE EXCEPTION 'amount_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF ABS(p_amount) > 1000 THEN
|
||||||
|
RAISE EXCEPTION 'amount_exceeds_limit_1000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_admin_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'admin_id_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
|
||||||
|
IF v_clean_note IS NOT NULL THEN
|
||||||
|
v_clean_note := LEFT(v_clean_note, 500);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_amount > 0 THEN
|
||||||
|
-- ADICIONAR
|
||||||
|
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
|
||||||
|
VALUES (p_tenant_id, p_amount)
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
|
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
|
||||||
|
low_balance_alerted_at = NULL
|
||||||
|
RETURNING balance INTO v_new_balance;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
-- REMOVER (amount < 0)
|
||||||
|
SELECT balance INTO v_current_balance
|
||||||
|
FROM public.whatsapp_credits_balance
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'tenant_has_no_balance';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||||
|
|
||||||
|
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind = 'usage';
|
||||||
|
|
||||||
|
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||||
|
v_removable := LEAST(v_removable, v_current_balance);
|
||||||
|
|
||||||
|
IF ABS(p_amount) > v_removable THEN
|
||||||
|
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE public.whatsapp_credits_balance
|
||||||
|
SET balance = balance + p_amount -- p_amount ja e negativo
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
RETURNING balance INTO v_new_balance;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.whatsapp_credits_transactions
|
||||||
|
(tenant_id, kind, amount, balance_after, admin_id, note)
|
||||||
|
VALUES
|
||||||
|
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
|
||||||
|
|
||||||
|
RETURN v_new_balance;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
|
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
SET search_path TO 'public'
|
SET search_path TO 'public'
|
||||||
@@ -1053,9 +1204,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
|
|||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.status IN ('cancelado', 'excluido')
|
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||||
AND OLD.status NOT IN ('cancelado', 'excluido')
|
|
||||||
THEN
|
|
||||||
PERFORM public.cancel_patient_pending_notifications(
|
PERFORM public.cancel_patient_pending_notifications(
|
||||||
NEW.patient_id, NULL, NEW.id
|
NEW.patient_id, NULL, NEW.id
|
||||||
);
|
);
|
||||||
@@ -1429,6 +1578,101 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_intake RECORD;
|
||||||
|
v_tenant_id UUID;
|
||||||
|
v_thread_key TEXT;
|
||||||
|
v_phone TEXT;
|
||||||
|
v_note_body TEXT;
|
||||||
|
v_admin_id UUID;
|
||||||
|
v_msg_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||||
|
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::UUID; END IF;
|
||||||
|
|
||||||
|
-- Tenant_id vem via owner_id (tenant_members)
|
||||||
|
SELECT tenant_id INTO v_tenant_id
|
||||||
|
FROM public.tenant_members
|
||||||
|
WHERE user_id = v_intake.owner_id
|
||||||
|
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||||
|
|
||||||
|
-- Normaliza telefone pra thread_key
|
||||||
|
v_phone := regexp_replace(COALESCE(v_intake.telefone, ''), '\D', '', 'g');
|
||||||
|
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55' || v_phone; END IF;
|
||||||
|
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||||
|
v_thread_key := 'anon:' || v_phone;
|
||||||
|
|
||||||
|
-- Nota com dados coletados
|
||||||
|
v_note_body := format(
|
||||||
|
'📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||||
|
E'\n', E'\n',
|
||||||
|
COALESCE(v_intake.nome_completo, '—'),
|
||||||
|
E'\n',
|
||||||
|
COALESCE(v_intake.telefone, '—'),
|
||||||
|
E'\n',
|
||||||
|
COALESCE(v_intake.email_principal, '—'),
|
||||||
|
E'\n',
|
||||||
|
COALESCE(v_intake.onde_nos_conheceu, '—'),
|
||||||
|
E'\n', E'\n',
|
||||||
|
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI'),
|
||||||
|
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pega 1 admin do tenant pra preencher created_by da nota
|
||||||
|
SELECT user_id INTO v_admin_id
|
||||||
|
FROM public.tenant_members
|
||||||
|
WHERE tenant_id = v_tenant_id
|
||||||
|
AND role IN ('tenant_admin', 'clinic_admin')
|
||||||
|
AND status = 'active'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_admin_id IS NULL THEN
|
||||||
|
v_admin_id := v_intake.owner_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
|
||||||
|
INSERT INTO public.conversation_messages
|
||||||
|
(tenant_id, channel, direction, from_number, to_number, body, provider,
|
||||||
|
provider_raw, kanban_status)
|
||||||
|
VALUES (
|
||||||
|
v_tenant_id, 'whatsapp', 'inbound',
|
||||||
|
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||||
|
NULL,
|
||||||
|
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.',
|
||||||
|
COALESCE(v_intake.nome_completo, 'Visitante')),
|
||||||
|
'system',
|
||||||
|
jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id),
|
||||||
|
'awaiting_us'
|
||||||
|
) RETURNING id INTO v_msg_id;
|
||||||
|
|
||||||
|
-- Cria nota interna
|
||||||
|
INSERT INTO public.conversation_notes
|
||||||
|
(tenant_id, thread_key, contact_number, body, created_by)
|
||||||
|
VALUES (
|
||||||
|
v_tenant_id, v_thread_key,
|
||||||
|
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||||
|
v_note_body, v_admin_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Atualiza intake
|
||||||
|
UPDATE public.patient_intake_requests
|
||||||
|
SET status = 'abandoned_lead',
|
||||||
|
lead_thread_key = v_thread_key,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_intake_id;
|
||||||
|
|
||||||
|
RETURN p_intake_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -3032,6 +3276,84 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
r.responder_id AS therapist_id,
|
||||||
|
COUNT(*)::INT AS runs_count,
|
||||||
|
AVG(r.response_seconds)::INT AS avg_seconds,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
|
||||||
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||||
|
WHERE r.responder_id IS NOT NULL
|
||||||
|
GROUP BY r.responder_id
|
||||||
|
ORDER BY avg_seconds ASC;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
WITH runs AS (
|
||||||
|
SELECT r.inbound_started_at, r.response_seconds
|
||||||
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||||
|
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
-- Janela alinhada a p_from: bucket_index * N dias + p_from
|
||||||
|
p_from + (
|
||||||
|
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||||
|
* p_bucket_days * interval '1 day'
|
||||||
|
) AS bucket_start,
|
||||||
|
response_seconds
|
||||||
|
FROM runs
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start,
|
||||||
|
COUNT(*)::INT AS runs_count,
|
||||||
|
AVG(response_seconds)::INT AS avg_seconds
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_start
|
||||||
|
ORDER BY bucket_start;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_threshold_seconds INT;
|
||||||
|
BEGIN
|
||||||
|
-- Pega threshold do SLA (se habilitado)
|
||||||
|
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
|
||||||
|
INTO v_threshold_seconds
|
||||||
|
FROM public.conversation_sla_rules
|
||||||
|
WHERE tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH runs AS (
|
||||||
|
SELECT r.response_seconds, r.responder_id
|
||||||
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||||
|
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::INT AS runs_count,
|
||||||
|
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
|
||||||
|
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
|
||||||
|
COALESCE(MIN(response_seconds), 0) AS min_seconds,
|
||||||
|
COALESCE(MAX(response_seconds), 0) AS max_seconds,
|
||||||
|
v_threshold_seconds AS sla_threshold_seconds,
|
||||||
|
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
|
||||||
|
CASE
|
||||||
|
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
|
||||||
|
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
|
||||||
|
END AS sla_compliance_rate
|
||||||
|
FROM runs;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
|
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -3138,6 +3460,146 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_url TEXT;
|
||||||
|
v_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- So dispara se status realmente mudou
|
||||||
|
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
|
||||||
|
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
|
||||||
|
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Precisa de paciente vinculado (senao nao tem telefone)
|
||||||
|
IF NEW.patient_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Busca settings
|
||||||
|
BEGIN
|
||||||
|
v_url := current_setting('app.settings.supabase_url', true);
|
||||||
|
v_key := current_setting('app.settings.service_role_key', true);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Settings nao configuradas — silencioso
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_url IS NULL OR v_key IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Fire and forget (pg_net)
|
||||||
|
PERFORM net.http_post(
|
||||||
|
url := v_url || '/functions/v1/send-session-status-notification',
|
||||||
|
headers := jsonb_build_object(
|
||||||
|
'Authorization', 'Bearer ' || v_key,
|
||||||
|
'Content-Type', 'application/json'
|
||||||
|
),
|
||||||
|
body := jsonb_build_object(
|
||||||
|
'event_id', NEW.id,
|
||||||
|
'old_status', OLD.status,
|
||||||
|
'new_status', NEW.status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_thread_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- So processa outbound
|
||||||
|
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
-- Calcula thread_key no mesmo padrao da view conversation_threads
|
||||||
|
v_thread_key := COALESCE(
|
||||||
|
NEW.patient_id::text,
|
||||||
|
'anon:' || COALESCE(NEW.to_number, 'unknown')
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE public.conversation_sla_breaches
|
||||||
|
SET resolved_at = now(),
|
||||||
|
resolved_by_message_id = NEW.id
|
||||||
|
WHERE tenant_id = NEW.tenant_id
|
||||||
|
AND thread_key = v_thread_key
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_detail TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
|
||||||
|
IF NEW.balance < NEW.low_balance_threshold
|
||||||
|
AND NEW.low_balance_alerted_at IS NULL THEN
|
||||||
|
|
||||||
|
v_detail := format(
|
||||||
|
'Saldo atual: %s credito(s). Alerta configurado em %s. '
|
||||||
|
'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
|
||||||
|
NEW.balance,
|
||||||
|
NEW.low_balance_threshold
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
|
||||||
|
INSERT INTO public.notifications
|
||||||
|
(owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
NEW.tenant_id,
|
||||||
|
'system_alert',
|
||||||
|
NEW.tenant_id,
|
||||||
|
'whatsapp_credits_balance',
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', 'Saldo de WhatsApp baixo',
|
||||||
|
'detail', v_detail,
|
||||||
|
'severity', 'warn',
|
||||||
|
'deeplink', '/configuracoes/creditos-whatsapp'
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT owner_id AS user_id
|
||||||
|
FROM public.notification_channels
|
||||||
|
WHERE tenant_id = NEW.tenant_id
|
||||||
|
AND channel = 'whatsapp'
|
||||||
|
AND is_active = true
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT user_id
|
||||||
|
FROM public.tenant_members
|
||||||
|
WHERE tenant_id = NEW.tenant_id
|
||||||
|
AND role IN ('clinic_admin', 'tenant_admin')
|
||||||
|
AND status = 'active'
|
||||||
|
) u
|
||||||
|
WHERE u.user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Anti-spam: so alerta de novo depois que add_whatsapp_credits
|
||||||
|
-- reseta alerted_at pra NULL (acontece em purchase/topup)
|
||||||
|
NEW.low_balance_alerted_at := now();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
|
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
SET search_path TO 'public'
|
SET search_path TO 'public'
|
||||||
@@ -3456,6 +3918,48 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_balance INT := 0;
|
||||||
|
v_topup_net INT := 0;
|
||||||
|
v_usage_total INT := 0;
|
||||||
|
v_removable INT := 0;
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COALESCE(b.balance, 0) INTO v_balance
|
||||||
|
FROM public.whatsapp_credits_balance b
|
||||||
|
WHERE b.tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
v_balance := COALESCE(v_balance, 0);
|
||||||
|
|
||||||
|
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||||
|
|
||||||
|
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind = 'usage';
|
||||||
|
|
||||||
|
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||||
|
v_removable := LEAST(v_removable, v_balance);
|
||||||
|
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
v_balance,
|
||||||
|
v_removable,
|
||||||
|
GREATEST(0, v_balance - v_removable),
|
||||||
|
v_topup_net,
|
||||||
|
v_usage_total;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
|
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
AS $$
|
AS $$
|
||||||
@@ -4689,6 +5193,120 @@ begin
|
|||||||
end;
|
end;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH purchases AS (
|
||||||
|
SELECT p.paid_at, p.amount_brl
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
p_from + (
|
||||||
|
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||||
|
* p_bucket_days * interval '1 day'
|
||||||
|
) AS bucket_start,
|
||||||
|
amount_brl
|
||||||
|
FROM purchases
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
SUM(amount_brl)::NUMERIC AS revenue_brl
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_start
|
||||||
|
ORDER BY bucket_start;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
|
||||||
|
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
|
||||||
|
CASE WHEN COUNT(*) = 0 THEN 0
|
||||||
|
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
|
||||||
|
END AS avg_ticket_brl
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
p.package_id,
|
||||||
|
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
|
||||||
|
-- atual pra consolidar pacotes renomeados
|
||||||
|
COALESCE(
|
||||||
|
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
|
||||||
|
p.package_name
|
||||||
|
) AS package_name,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
|
||||||
|
SUM(p.credits)::INT AS credits_sold
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to
|
||||||
|
GROUP BY p.package_id, p.package_name
|
||||||
|
ORDER BY revenue_brl DESC
|
||||||
|
LIMIT 10;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
|
||||||
|
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
|
||||||
|
COALESCE(SUM(balance), 0)::INT AS current_balance,
|
||||||
|
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
|
||||||
|
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
|
||||||
|
END AS usage_rate,
|
||||||
|
COUNT(*)::INT AS tenants_with_balance
|
||||||
|
FROM public.whatsapp_credits_balance;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -5006,7 +5624,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
|
|||||||
declare
|
declare
|
||||||
v_id uuid;
|
v_id uuid;
|
||||||
begin
|
begin
|
||||||
-- Sess??o (locked + sempre ativa)
|
-- Sessão (locked + sempre ativa)
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||||
@@ -5014,7 +5632,7 @@ begin
|
|||||||
insert into public.determined_commitments
|
insert into public.determined_commitments
|
||||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
values
|
values
|
||||||
(p_tenant_id, true, 'session', true, true, 'Sess??o', 'Sess??o com paciente');
|
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Leitura
|
-- Leitura
|
||||||
@@ -5028,7 +5646,7 @@ begin
|
|||||||
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Supervis??o
|
-- Supervisão
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||||
@@ -5036,10 +5654,10 @@ begin
|
|||||||
insert into public.determined_commitments
|
insert into public.determined_commitments
|
||||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
values
|
values
|
||||||
(p_tenant_id, true, 'supervision', false, true, 'Supervis??o', 'Supervis??o');
|
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Aula ??? (corrigido)
|
-- Aula
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||||
@@ -5050,7 +5668,7 @@ begin
|
|||||||
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- An??lise pessoal
|
-- Análise pessoal
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||||
@@ -5058,13 +5676,26 @@ begin
|
|||||||
insert into public.determined_commitments
|
insert into public.determined_commitments
|
||||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
values
|
values
|
||||||
(p_tenant_id, true, 'analysis', false, true, 'An??lise Pessoal', 'Minha an??lise pessoal');
|
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
-- Campos padr??o (idempotentes por (commitment_id, key))
|
-- Campos padrão (idempotentes por (commitment_id, key))
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
|
||||||
|
select id into v_id
|
||||||
|
from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
-- Leitura
|
-- Leitura
|
||||||
select id into v_id
|
select id into v_id
|
||||||
from public.determined_commitments
|
from public.determined_commitments
|
||||||
@@ -5084,11 +5715,11 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Supervis??o
|
-- Supervisão
|
||||||
select id into v_id
|
select id into v_id
|
||||||
from public.determined_commitments
|
from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||||
@@ -5107,7 +5738,7 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
@@ -5130,11 +5761,11 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- An??lise
|
-- Análise
|
||||||
select id into v_id
|
select id into v_id
|
||||||
from public.determined_commitments
|
from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||||
@@ -5153,7 +5784,7 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
@@ -5335,6 +5966,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
|
|||||||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.conversation_sla_breaches
|
||||||
|
SET notified_at = now(),
|
||||||
|
notification_count = notification_count + 1
|
||||||
|
WHERE id = p_breach_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_existing_id UUID;
|
||||||
|
v_new_id UUID;
|
||||||
|
BEGIN
|
||||||
|
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'tenant_and_thread_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||||
|
SELECT id INTO v_existing_id
|
||||||
|
FROM public.conversation_sla_breaches
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND thread_key = p_thread_key
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
UPDATE public.conversation_sla_breaches
|
||||||
|
SET assigned_to = COALESCE(p_assigned_to, assigned_to),
|
||||||
|
last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at)
|
||||||
|
WHERE id = v_existing_id;
|
||||||
|
RETURN v_existing_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.conversation_sla_breaches
|
||||||
|
(tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
|
||||||
|
VALUES
|
||||||
|
(p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
RETURN v_new_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
|
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
SET search_path TO 'public'
|
SET search_path TO 'public'
|
||||||
@@ -6698,6 +7378,87 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET notified_at = now(),
|
||||||
|
notification_count = notification_count + 1
|
||||||
|
WHERE id = p_incident_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id UUID;
|
||||||
|
v_provider TEXT;
|
||||||
|
v_existing_id UUID;
|
||||||
|
v_new_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Busca tenant/provider do channel
|
||||||
|
SELECT tenant_id, provider INTO v_tenant_id, v_provider
|
||||||
|
FROM public.notification_channels
|
||||||
|
WHERE id = p_channel_id
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'channel_not_found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_kind NOT IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown') THEN
|
||||||
|
RAISE EXCEPTION 'invalid_kind: %', p_kind;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||||
|
SELECT id INTO v_existing_id
|
||||||
|
FROM public.whatsapp_connection_incidents
|
||||||
|
WHERE channel_id = p_channel_id
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
-- Atualiza o incident existente com detalhes frescos
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET last_state = COALESCE(p_last_state, last_state),
|
||||||
|
details = COALESCE(p_details, details),
|
||||||
|
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
|
||||||
|
WHERE id = v_existing_id;
|
||||||
|
RETURN v_existing_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Abre novo
|
||||||
|
INSERT INTO public.whatsapp_connection_incidents
|
||||||
|
(tenant_id, channel_id, provider, kind, last_state, details)
|
||||||
|
VALUES
|
||||||
|
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
RETURN v_new_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_count INT := 0;
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET resolved_at = now(),
|
||||||
|
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::INT
|
||||||
|
WHERE channel_id = p_channel_id
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
|
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
|
||||||
LANGUAGE sql STABLE
|
LANGUAGE sql STABLE
|
||||||
AS $$
|
AS $$
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Functions: auth
|
-- Functions: auth
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.941Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
|
||||||
-- Total: 4
|
-- Total: 4
|
||||||
|
|
||||||
CREATE FUNCTION auth.email() RETURNS text
|
CREATE FUNCTION auth.email() RETURNS text
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Functions: extensions
|
-- Functions: extensions
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.942Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
|
||||||
-- Total: 6
|
-- Total: 6
|
||||||
|
|
||||||
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
|
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Functions: pgbouncer
|
-- Functions: pgbouncer
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.943Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
|
||||||
-- Total: 1
|
-- Total: 1
|
||||||
|
|
||||||
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
|
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- Functions: public
|
-- Functions: public
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.944Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.918Z
|
||||||
-- Total: 153
|
-- Total: 172
|
||||||
|
|
||||||
CREATE FUNCTION public.__rls_ping() RETURNS text
|
CREATE FUNCTION public.__rls_ping() RETURNS text
|
||||||
LANGUAGE sql STABLE
|
LANGUAGE sql STABLE
|
||||||
@@ -8,6 +8,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
|
|||||||
select 'ok'::text;
|
select 'ok'::text;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
WITH msgs AS (
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.tenant_id,
|
||||||
|
m.direction,
|
||||||
|
m.created_at,
|
||||||
|
m.patient_id,
|
||||||
|
m.from_number,
|
||||||
|
m.to_number,
|
||||||
|
-- mesma logica da view conversation_threads
|
||||||
|
COALESCE(
|
||||||
|
m.patient_id::text,
|
||||||
|
'anon:' || COALESCE(
|
||||||
|
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
|
||||||
|
'unknown'
|
||||||
|
)
|
||||||
|
) AS tk
|
||||||
|
FROM public.conversation_messages m
|
||||||
|
WHERE m.tenant_id = p_tenant_id
|
||||||
|
AND m.direction IN ('inbound', 'outbound')
|
||||||
|
AND m.created_at >= p_from
|
||||||
|
AND m.created_at <= p_to
|
||||||
|
),
|
||||||
|
with_prev AS (
|
||||||
|
SELECT *,
|
||||||
|
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
|
||||||
|
FROM msgs
|
||||||
|
),
|
||||||
|
run_starts AS (
|
||||||
|
-- Primeira mensagem de cada "run inbound"
|
||||||
|
SELECT tk, tenant_id, created_at AS inbound_started_at
|
||||||
|
FROM with_prev
|
||||||
|
WHERE direction = 'inbound'
|
||||||
|
AND (prev_direction IS NULL OR prev_direction = 'outbound')
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.tk AS thread_key,
|
||||||
|
r.inbound_started_at,
|
||||||
|
o.created_at AS responded_at,
|
||||||
|
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
|
||||||
|
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
|
||||||
|
a.assigned_to AS responder_id
|
||||||
|
FROM run_starts r
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT created_at
|
||||||
|
FROM public.conversation_messages m2
|
||||||
|
WHERE m2.tenant_id = r.tenant_id
|
||||||
|
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
|
||||||
|
AND m2.direction = 'outbound'
|
||||||
|
AND m2.created_at > r.inbound_started_at
|
||||||
|
ORDER BY m2.created_at
|
||||||
|
LIMIT 1
|
||||||
|
) o ON true
|
||||||
|
LEFT JOIN public.conversation_assignments a
|
||||||
|
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
|
||||||
|
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
|
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -172,6 +234,95 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_new_balance INT;
|
||||||
|
v_current_balance INT;
|
||||||
|
v_topup_net INT;
|
||||||
|
v_usage_total INT;
|
||||||
|
v_removable INT;
|
||||||
|
v_clean_note TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_tenant_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'tenant_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_amount IS NULL OR p_amount = 0 THEN
|
||||||
|
RAISE EXCEPTION 'amount_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF ABS(p_amount) > 1000 THEN
|
||||||
|
RAISE EXCEPTION 'amount_exceeds_limit_1000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_admin_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'admin_id_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
|
||||||
|
IF v_clean_note IS NOT NULL THEN
|
||||||
|
v_clean_note := LEFT(v_clean_note, 500);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_amount > 0 THEN
|
||||||
|
-- ADICIONAR
|
||||||
|
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
|
||||||
|
VALUES (p_tenant_id, p_amount)
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
|
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
|
||||||
|
low_balance_alerted_at = NULL
|
||||||
|
RETURNING balance INTO v_new_balance;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
-- REMOVER (amount < 0)
|
||||||
|
SELECT balance INTO v_current_balance
|
||||||
|
FROM public.whatsapp_credits_balance
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'tenant_has_no_balance';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||||
|
|
||||||
|
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind = 'usage';
|
||||||
|
|
||||||
|
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||||
|
v_removable := LEAST(v_removable, v_current_balance);
|
||||||
|
|
||||||
|
IF ABS(p_amount) > v_removable THEN
|
||||||
|
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE public.whatsapp_credits_balance
|
||||||
|
SET balance = balance + p_amount -- p_amount ja e negativo
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
RETURNING balance INTO v_new_balance;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.whatsapp_credits_transactions
|
||||||
|
(tenant_id, kind, amount, balance_after, admin_id, note)
|
||||||
|
VALUES
|
||||||
|
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
|
||||||
|
|
||||||
|
RETURN v_new_balance;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
|
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
SET search_path TO 'public'
|
SET search_path TO 'public'
|
||||||
@@ -774,9 +925,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
|
|||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.status IN ('cancelado', 'excluido')
|
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||||
AND OLD.status NOT IN ('cancelado', 'excluido')
|
|
||||||
THEN
|
|
||||||
PERFORM public.cancel_patient_pending_notifications(
|
PERFORM public.cancel_patient_pending_notifications(
|
||||||
NEW.patient_id, NULL, NEW.id
|
NEW.patient_id, NULL, NEW.id
|
||||||
);
|
);
|
||||||
@@ -1150,6 +1299,101 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_intake RECORD;
|
||||||
|
v_tenant_id UUID;
|
||||||
|
v_thread_key TEXT;
|
||||||
|
v_phone TEXT;
|
||||||
|
v_note_body TEXT;
|
||||||
|
v_admin_id UUID;
|
||||||
|
v_msg_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||||
|
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::UUID; END IF;
|
||||||
|
|
||||||
|
-- Tenant_id vem via owner_id (tenant_members)
|
||||||
|
SELECT tenant_id INTO v_tenant_id
|
||||||
|
FROM public.tenant_members
|
||||||
|
WHERE user_id = v_intake.owner_id
|
||||||
|
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||||
|
|
||||||
|
-- Normaliza telefone pra thread_key
|
||||||
|
v_phone := regexp_replace(COALESCE(v_intake.telefone, ''), '\D', '', 'g');
|
||||||
|
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55' || v_phone; END IF;
|
||||||
|
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||||
|
v_thread_key := 'anon:' || v_phone;
|
||||||
|
|
||||||
|
-- Nota com dados coletados
|
||||||
|
v_note_body := format(
|
||||||
|
'📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||||
|
E'\n', E'\n',
|
||||||
|
COALESCE(v_intake.nome_completo, '—'),
|
||||||
|
E'\n',
|
||||||
|
COALESCE(v_intake.telefone, '—'),
|
||||||
|
E'\n',
|
||||||
|
COALESCE(v_intake.email_principal, '—'),
|
||||||
|
E'\n',
|
||||||
|
COALESCE(v_intake.onde_nos_conheceu, '—'),
|
||||||
|
E'\n', E'\n',
|
||||||
|
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI'),
|
||||||
|
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pega 1 admin do tenant pra preencher created_by da nota
|
||||||
|
SELECT user_id INTO v_admin_id
|
||||||
|
FROM public.tenant_members
|
||||||
|
WHERE tenant_id = v_tenant_id
|
||||||
|
AND role IN ('tenant_admin', 'clinic_admin')
|
||||||
|
AND status = 'active'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_admin_id IS NULL THEN
|
||||||
|
v_admin_id := v_intake.owner_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
|
||||||
|
INSERT INTO public.conversation_messages
|
||||||
|
(tenant_id, channel, direction, from_number, to_number, body, provider,
|
||||||
|
provider_raw, kanban_status)
|
||||||
|
VALUES (
|
||||||
|
v_tenant_id, 'whatsapp', 'inbound',
|
||||||
|
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||||
|
NULL,
|
||||||
|
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.',
|
||||||
|
COALESCE(v_intake.nome_completo, 'Visitante')),
|
||||||
|
'system',
|
||||||
|
jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id),
|
||||||
|
'awaiting_us'
|
||||||
|
) RETURNING id INTO v_msg_id;
|
||||||
|
|
||||||
|
-- Cria nota interna
|
||||||
|
INSERT INTO public.conversation_notes
|
||||||
|
(tenant_id, thread_key, contact_number, body, created_by)
|
||||||
|
VALUES (
|
||||||
|
v_tenant_id, v_thread_key,
|
||||||
|
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||||
|
v_note_body, v_admin_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Atualiza intake
|
||||||
|
UPDATE public.patient_intake_requests
|
||||||
|
SET status = 'abandoned_lead',
|
||||||
|
lead_thread_key = v_thread_key,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_intake_id;
|
||||||
|
|
||||||
|
RETURN p_intake_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -2753,6 +2997,84 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
r.responder_id AS therapist_id,
|
||||||
|
COUNT(*)::INT AS runs_count,
|
||||||
|
AVG(r.response_seconds)::INT AS avg_seconds,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
|
||||||
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||||
|
WHERE r.responder_id IS NOT NULL
|
||||||
|
GROUP BY r.responder_id
|
||||||
|
ORDER BY avg_seconds ASC;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
WITH runs AS (
|
||||||
|
SELECT r.inbound_started_at, r.response_seconds
|
||||||
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||||
|
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
-- Janela alinhada a p_from: bucket_index * N dias + p_from
|
||||||
|
p_from + (
|
||||||
|
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||||
|
* p_bucket_days * interval '1 day'
|
||||||
|
) AS bucket_start,
|
||||||
|
response_seconds
|
||||||
|
FROM runs
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start,
|
||||||
|
COUNT(*)::INT AS runs_count,
|
||||||
|
AVG(response_seconds)::INT AS avg_seconds
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_start
|
||||||
|
ORDER BY bucket_start;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_threshold_seconds INT;
|
||||||
|
BEGIN
|
||||||
|
-- Pega threshold do SLA (se habilitado)
|
||||||
|
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
|
||||||
|
INTO v_threshold_seconds
|
||||||
|
FROM public.conversation_sla_rules
|
||||||
|
WHERE tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH runs AS (
|
||||||
|
SELECT r.response_seconds, r.responder_id
|
||||||
|
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||||
|
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::INT AS runs_count,
|
||||||
|
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
|
||||||
|
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
|
||||||
|
COALESCE(MIN(response_seconds), 0) AS min_seconds,
|
||||||
|
COALESCE(MAX(response_seconds), 0) AS max_seconds,
|
||||||
|
v_threshold_seconds AS sla_threshold_seconds,
|
||||||
|
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
|
||||||
|
CASE
|
||||||
|
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
|
||||||
|
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
|
||||||
|
END AS sla_compliance_rate
|
||||||
|
FROM runs;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
|
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -2859,6 +3181,146 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_url TEXT;
|
||||||
|
v_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- So dispara se status realmente mudou
|
||||||
|
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
|
||||||
|
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
|
||||||
|
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Precisa de paciente vinculado (senao nao tem telefone)
|
||||||
|
IF NEW.patient_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Busca settings
|
||||||
|
BEGIN
|
||||||
|
v_url := current_setting('app.settings.supabase_url', true);
|
||||||
|
v_key := current_setting('app.settings.service_role_key', true);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Settings nao configuradas — silencioso
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_url IS NULL OR v_key IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Fire and forget (pg_net)
|
||||||
|
PERFORM net.http_post(
|
||||||
|
url := v_url || '/functions/v1/send-session-status-notification',
|
||||||
|
headers := jsonb_build_object(
|
||||||
|
'Authorization', 'Bearer ' || v_key,
|
||||||
|
'Content-Type', 'application/json'
|
||||||
|
),
|
||||||
|
body := jsonb_build_object(
|
||||||
|
'event_id', NEW.id,
|
||||||
|
'old_status', OLD.status,
|
||||||
|
'new_status', NEW.status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_thread_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- So processa outbound
|
||||||
|
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
-- Calcula thread_key no mesmo padrao da view conversation_threads
|
||||||
|
v_thread_key := COALESCE(
|
||||||
|
NEW.patient_id::text,
|
||||||
|
'anon:' || COALESCE(NEW.to_number, 'unknown')
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE public.conversation_sla_breaches
|
||||||
|
SET resolved_at = now(),
|
||||||
|
resolved_by_message_id = NEW.id
|
||||||
|
WHERE tenant_id = NEW.tenant_id
|
||||||
|
AND thread_key = v_thread_key
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_detail TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
|
||||||
|
IF NEW.balance < NEW.low_balance_threshold
|
||||||
|
AND NEW.low_balance_alerted_at IS NULL THEN
|
||||||
|
|
||||||
|
v_detail := format(
|
||||||
|
'Saldo atual: %s credito(s). Alerta configurado em %s. '
|
||||||
|
'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
|
||||||
|
NEW.balance,
|
||||||
|
NEW.low_balance_threshold
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
|
||||||
|
INSERT INTO public.notifications
|
||||||
|
(owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
NEW.tenant_id,
|
||||||
|
'system_alert',
|
||||||
|
NEW.tenant_id,
|
||||||
|
'whatsapp_credits_balance',
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', 'Saldo de WhatsApp baixo',
|
||||||
|
'detail', v_detail,
|
||||||
|
'severity', 'warn',
|
||||||
|
'deeplink', '/configuracoes/creditos-whatsapp'
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT owner_id AS user_id
|
||||||
|
FROM public.notification_channels
|
||||||
|
WHERE tenant_id = NEW.tenant_id
|
||||||
|
AND channel = 'whatsapp'
|
||||||
|
AND is_active = true
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT user_id
|
||||||
|
FROM public.tenant_members
|
||||||
|
WHERE tenant_id = NEW.tenant_id
|
||||||
|
AND role IN ('clinic_admin', 'tenant_admin')
|
||||||
|
AND status = 'active'
|
||||||
|
) u
|
||||||
|
WHERE u.user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Anti-spam: so alerta de novo depois que add_whatsapp_credits
|
||||||
|
-- reseta alerted_at pra NULL (acontece em purchase/topup)
|
||||||
|
NEW.low_balance_alerted_at := now();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
|
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
SET search_path TO 'public'
|
SET search_path TO 'public'
|
||||||
@@ -3177,6 +3639,48 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_balance INT := 0;
|
||||||
|
v_topup_net INT := 0;
|
||||||
|
v_usage_total INT := 0;
|
||||||
|
v_removable INT := 0;
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COALESCE(b.balance, 0) INTO v_balance
|
||||||
|
FROM public.whatsapp_credits_balance b
|
||||||
|
WHERE b.tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
v_balance := COALESCE(v_balance, 0);
|
||||||
|
|
||||||
|
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||||
|
|
||||||
|
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||||
|
FROM public.whatsapp_credits_transactions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND kind = 'usage';
|
||||||
|
|
||||||
|
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||||
|
v_removable := LEAST(v_removable, v_balance);
|
||||||
|
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
v_balance,
|
||||||
|
v_removable,
|
||||||
|
GREATEST(0, v_balance - v_removable),
|
||||||
|
v_topup_net,
|
||||||
|
v_usage_total;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
|
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
AS $$
|
AS $$
|
||||||
@@ -4410,6 +4914,120 @@ begin
|
|||||||
end;
|
end;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH purchases AS (
|
||||||
|
SELECT p.paid_at, p.amount_brl
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
p_from + (
|
||||||
|
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||||
|
* p_bucket_days * interval '1 day'
|
||||||
|
) AS bucket_start,
|
||||||
|
amount_brl
|
||||||
|
FROM purchases
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
SUM(amount_brl)::NUMERIC AS revenue_brl
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_start
|
||||||
|
ORDER BY bucket_start;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
|
||||||
|
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
|
||||||
|
CASE WHEN COUNT(*) = 0 THEN 0
|
||||||
|
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
|
||||||
|
END AS avg_ticket_brl
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
p.package_id,
|
||||||
|
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
|
||||||
|
-- atual pra consolidar pacotes renomeados
|
||||||
|
COALESCE(
|
||||||
|
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
|
||||||
|
p.package_name
|
||||||
|
) AS package_name,
|
||||||
|
COUNT(*)::INT AS purchases_count,
|
||||||
|
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
|
||||||
|
SUM(p.credits)::INT AS credits_sold
|
||||||
|
FROM public.whatsapp_credit_purchases p
|
||||||
|
WHERE p.status = 'paid'
|
||||||
|
AND p.paid_at >= p_from
|
||||||
|
AND p.paid_at <= p_to
|
||||||
|
GROUP BY p.package_id, p.package_name
|
||||||
|
ORDER BY revenue_brl DESC
|
||||||
|
LIMIT 10;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.is_saas_admin() THEN
|
||||||
|
RAISE EXCEPTION 'permission_denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
|
||||||
|
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
|
||||||
|
COALESCE(SUM(balance), 0)::INT AS current_balance,
|
||||||
|
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
|
||||||
|
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
|
||||||
|
END AS usage_rate,
|
||||||
|
COUNT(*)::INT AS tenants_with_balance
|
||||||
|
FROM public.whatsapp_credits_balance;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
AS $$
|
AS $$
|
||||||
@@ -4727,7 +5345,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
|
|||||||
declare
|
declare
|
||||||
v_id uuid;
|
v_id uuid;
|
||||||
begin
|
begin
|
||||||
-- Sess??o (locked + sempre ativa)
|
-- Sessão (locked + sempre ativa)
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||||
@@ -4735,7 +5353,7 @@ begin
|
|||||||
insert into public.determined_commitments
|
insert into public.determined_commitments
|
||||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
values
|
values
|
||||||
(p_tenant_id, true, 'session', true, true, 'Sess??o', 'Sess??o com paciente');
|
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Leitura
|
-- Leitura
|
||||||
@@ -4749,7 +5367,7 @@ begin
|
|||||||
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Supervis??o
|
-- Supervisão
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||||
@@ -4757,10 +5375,10 @@ begin
|
|||||||
insert into public.determined_commitments
|
insert into public.determined_commitments
|
||||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
values
|
values
|
||||||
(p_tenant_id, true, 'supervision', false, true, 'Supervis??o', 'Supervis??o');
|
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Aula ??? (corrigido)
|
-- Aula
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||||
@@ -4771,7 +5389,7 @@ begin
|
|||||||
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- An??lise pessoal
|
-- Análise pessoal
|
||||||
if not exists (
|
if not exists (
|
||||||
select 1 from public.determined_commitments
|
select 1 from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||||
@@ -4779,13 +5397,26 @@ begin
|
|||||||
insert into public.determined_commitments
|
insert into public.determined_commitments
|
||||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||||
values
|
values
|
||||||
(p_tenant_id, true, 'analysis', false, true, 'An??lise Pessoal', 'Minha an??lise pessoal');
|
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
-- Campos padr??o (idempotentes por (commitment_id, key))
|
-- Campos padrão (idempotentes por (commitment_id, key))
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
|
||||||
|
select id into v_id
|
||||||
|
from public.determined_commitments
|
||||||
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_id is not null then
|
||||||
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
-- Leitura
|
-- Leitura
|
||||||
select id into v_id
|
select id into v_id
|
||||||
from public.determined_commitments
|
from public.determined_commitments
|
||||||
@@ -4805,11 +5436,11 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- Supervis??o
|
-- Supervisão
|
||||||
select id into v_id
|
select id into v_id
|
||||||
from public.determined_commitments
|
from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||||
@@ -4828,7 +5459,7 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
@@ -4851,11 +5482,11 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- An??lise
|
-- Análise
|
||||||
select id into v_id
|
select id into v_id
|
||||||
from public.determined_commitments
|
from public.determined_commitments
|
||||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||||
@@ -4874,7 +5505,7 @@ begin
|
|||||||
|
|
||||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
@@ -5056,6 +5687,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
|
|||||||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.conversation_sla_breaches
|
||||||
|
SET notified_at = now(),
|
||||||
|
notification_count = notification_count + 1
|
||||||
|
WHERE id = p_breach_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_existing_id UUID;
|
||||||
|
v_new_id UUID;
|
||||||
|
BEGIN
|
||||||
|
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'tenant_and_thread_required';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||||
|
SELECT id INTO v_existing_id
|
||||||
|
FROM public.conversation_sla_breaches
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND thread_key = p_thread_key
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
UPDATE public.conversation_sla_breaches
|
||||||
|
SET assigned_to = COALESCE(p_assigned_to, assigned_to),
|
||||||
|
last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at)
|
||||||
|
WHERE id = v_existing_id;
|
||||||
|
RETURN v_existing_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.conversation_sla_breaches
|
||||||
|
(tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
|
||||||
|
VALUES
|
||||||
|
(p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
RETURN v_new_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
|
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
|
||||||
LANGUAGE plpgsql SECURITY DEFINER
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
SET search_path TO 'public'
|
SET search_path TO 'public'
|
||||||
@@ -6419,6 +7099,87 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET notified_at = now(),
|
||||||
|
notification_count = notification_count + 1
|
||||||
|
WHERE id = p_incident_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tenant_id UUID;
|
||||||
|
v_provider TEXT;
|
||||||
|
v_existing_id UUID;
|
||||||
|
v_new_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Busca tenant/provider do channel
|
||||||
|
SELECT tenant_id, provider INTO v_tenant_id, v_provider
|
||||||
|
FROM public.notification_channels
|
||||||
|
WHERE id = p_channel_id
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'channel_not_found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_kind NOT IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown') THEN
|
||||||
|
RAISE EXCEPTION 'invalid_kind: %', p_kind;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||||
|
SELECT id INTO v_existing_id
|
||||||
|
FROM public.whatsapp_connection_incidents
|
||||||
|
WHERE channel_id = p_channel_id
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
-- Atualiza o incident existente com detalhes frescos
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET last_state = COALESCE(p_last_state, last_state),
|
||||||
|
details = COALESCE(p_details, details),
|
||||||
|
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
|
||||||
|
WHERE id = v_existing_id;
|
||||||
|
RETURN v_existing_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Abre novo
|
||||||
|
INSERT INTO public.whatsapp_connection_incidents
|
||||||
|
(tenant_id, channel_id, provider, kind, last_state, details)
|
||||||
|
VALUES
|
||||||
|
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
RETURN v_new_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_count INT := 0;
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.whatsapp_connection_incidents
|
||||||
|
SET resolved_at = now(),
|
||||||
|
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::INT
|
||||||
|
WHERE channel_id = p_channel_id
|
||||||
|
AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
|
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
|
||||||
LANGUAGE sql STABLE
|
LANGUAGE sql STABLE
|
||||||
AS $$
|
AS $$
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Functions: realtime
|
-- Functions: realtime
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.949Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.919Z
|
||||||
-- Total: 12
|
-- Total: 12
|
||||||
|
|
||||||
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
|
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Functions: storage
|
-- Functions: storage
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.920Z
|
||||||
-- Total: 15
|
-- Total: 15
|
||||||
|
|
||||||
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
|
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Functions: supabase_functions
|
-- Functions: supabase_functions
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.920Z
|
||||||
-- Total: 1
|
-- Total: 1
|
||||||
|
|
||||||
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
|
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Tables: Addons / Créditos
|
-- Tables: Addons / Créditos
|
||||||
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
|
-- Gerado automaticamente em 2026-05-11T16:53:50.927Z
|
||||||
-- Total: 7
|
-- Total: 7
|
||||||
|
|
||||||
CREATE TABLE public.addon_credits (
|
CREATE TABLE public.addon_credits (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user