Compare commits
293 Commits
3.7.0
..
114d755f84
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 463d71ce44 | |||
| f1c97ee906 | |||
| b8ea292ef1 | |||
| c2c42a1620 | |||
| 4e4bac622c | |||
| 0f643817c2 | |||
| adf9208d2d | |||
| 36fbc02e9f | |||
| 64e76343fc | |||
| f646efe522 | |||
| 5f51bc068e | |||
| 4026415401 | |||
| 771b636cee | |||
| 4441661f62 | |||
| 6db06abfc2 | |||
| 5c50db6704 | |||
| e409ba64ef | |||
| 881fa16c27 | |||
| e1f756ea82 | |||
| f76a2e3033 | |||
| 2644e60bb6 | |||
| 037ba3721f | |||
| d6eb992f71 | |||
| 7c20b518d4 | |||
| d088a89fb7 | |||
| 0658e2e9bf | |||
| bfe148ef12 | |||
| 3f1786c9bf | |||
| 53a4980396 | |||
| a89d1f5560 | |||
| 29ed349cf2 | |||
| d6d2fe29d1 | |||
| 66f67cd40f | |||
| 84d65e49c0 | |||
| f66f6f3fde | |||
| ee09b30987 | |||
| 587079e414 | |||
| 06fb369beb | |||
| f4b185ae17 | |||
| f733db8436 | |||
| d58dc21297 | |||
| b1c0cb47c0 | |||
| 89b4ecaba1 | |||
| 6eff67bf22 | |||
| 62e79e243a | |||
| 3a671b1e9e | |||
| b3bb817e3f | |||
| 676042268b | |||
| ec6b6ef53a | |||
| 76a3b60333 | |||
| 410c08d693 | |||
| a4b2c96b0d | |||
| a47200fdf7 | |||
| 7c32ae1f6f | |||
| db99863fac | |||
| c2ef85fcab | |||
| deea8861f8 | |||
| 319f976d2b | |||
| 13a50a3af3 | |||
| 23bcf922ab | |||
| e1ecd23050 | |||
| 2f5b71a3eb | |||
| 22ba8601f3 | |||
| 4a8745b497 | |||
| d5ec7dba67 | |||
| 7c54176132 | |||
| 03ef1236f0 | |||
| 7ac2ba9013 | |||
| 5f951584c7 | |||
| 817ffa0d62 | |||
| 9585f62298 | |||
| aad48fca63 | |||
| 59f3ebffe7 | |||
| 6fd2e4d96e | |||
| 1c65a74541 | |||
| a5aafc1d34 | |||
| 0f42b3760d | |||
| c4dec65f2a | |||
| 6f85c751de | |||
| 411fecb517 | |||
| 4c7b0c0f5d | |||
| 3ba6d75db2 | |||
| b00460a670 | |||
| 51c93b25d0 | |||
| c4c2d47d54 | |||
| cb0f98582a | |||
| fa5009486a | |||
| 2e3cfcc0d5 | |||
| 894b9171e8 | |||
| a611dd0f27 | |||
| d45aee8fdb | |||
| 74003ad08d | |||
| 767dd205b8 | |||
| 9cab42f25d | |||
| 49e82095c9 | |||
| fa8ce31973 | |||
| 2b4e4469ab | |||
| fa0e82c015 | |||
| 93dee17880 | |||
| d6b96b741a | |||
| d8beda271f | |||
| 9b229a2554 | |||
| d97b058bf8 | |||
| fa23cdfda2 | |||
| 4711b6868c | |||
| 72ef6a60b6 | |||
| 20eedb4b2b | |||
| d8bf9b3bde | |||
| bbf6f86814 | |||
| 7c7cc7ce70 | |||
| 3efe7ae222 | |||
| 0e3130d2a6 | |||
| 2726aeb8a1 | |||
| a60346c2da | |||
| 34a4d4a98d | |||
| 8e983a455a | |||
| cafcd0449e | |||
| a7bec8d7a5 | |||
| ef6f80dfb1 | |||
| 1f5aeb1a21 | |||
| 38a0ad350f | |||
| b9cc8fb314 | |||
| 73d2b5667c | |||
| 04ed9999ca | |||
| 44f689cfea | |||
| 184b795add | |||
| 9f1d6f08e3 | |||
| 4c1770dd8c | |||
| 410d566bb5 | |||
| 6bf5c27e45 | |||
| eb7c5ae148 | |||
| 75eb336ad5 | |||
| e427fcb580 | |||
| 796486fc6f | |||
| 0aef0fd936 | |||
| 6d5236160b | |||
| 7e284e31c6 | |||
| 41192f3e31 |
@@ -0,0 +1,13 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_SUPABASE_URL=http://127.0.0.1:54321
|
||||
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
|
||||
@@ -0,0 +1,4 @@
|
||||
VITE_SUPABASE_URL=http://127.0.0.1:54321
|
||||
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
|
||||
VITE_QA_MODE=true
|
||||
VITE_QA_PASS=123Mudar@
|
||||
@@ -3,6 +3,9 @@ require('@rushstack/eslint-patch/modern-module-resolution');
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-prettier'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
|
||||
+51
-32
@@ -1,36 +1,55 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.log*
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
dist/
|
||||
dist-*/
|
||||
.DS_Store
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.eslintcache
|
||||
api-generator/typedoc.json
|
||||
**/.DS_Store
|
||||
Dev-documentacao/
|
||||
supabase/*
|
||||
!supabase/functions/
|
||||
# Mas os .env dentro de functions NÃO vão pro git (sobrescreve a negação acima)
|
||||
supabase/functions/.env
|
||||
supabase/functions/.env.local
|
||||
supabase/functions/.env.*
|
||||
evolution-api/
|
||||
|
||||
# Themes
|
||||
public/themes/soho-light/
|
||||
public/themes/soho-dark/
|
||||
public/themes/viva-light/
|
||||
public/themes/viva-dark/
|
||||
public/themes/mira/
|
||||
public/themes/nano/
|
||||
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
|
||||
database-novo/backups/
|
||||
|
||||
# Rascunhos de design locais (Melissa Direção A, etc)
|
||||
layout-scratchs/
|
||||
|
||||
# Outputs do Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Config local do Claude Code (cada dev tem o seu)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Notas locais do dev e rascunhos de commit — não subir
|
||||
informacoes Gerais.txt
|
||||
pasteds.txt
|
||||
commit.txt
|
||||
|
||||
# Graphify outputs — regeneráveis via /graphify ou graphify update
|
||||
graphify-out/
|
||||
**/graphify-out/
|
||||
|
||||
# Obsidian: ignorar binarios do app, comitar SO o vault Brain/
|
||||
Obsidian/*
|
||||
!Obsidian/Brain/
|
||||
|
||||
# Estado local do Obsidian (workspace, hot-keys do dev) — não compartilhar
|
||||
Obsidian/Brain/.obsidian/workspace*.json
|
||||
Obsidian/Brain/.obsidian/hotkeys.json
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": [
|
||||
"development"
|
||||
],
|
||||
"hints": {
|
||||
"compat-api/css": [
|
||||
"default",
|
||||
{
|
||||
"ignore": [
|
||||
"background-color: color-mix(in srgb, var(--primary-color) 50%, transparent)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"printWidth": 250,
|
||||
"bracketSameLine": false
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
### Observação sobre `tenant_admin` com UUID coincidente
|
||||
|
||||
Foi identificado que o registro de `tenant_members` possui:
|
||||
|
||||
- `tenant_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
|
||||
- `user_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
|
||||
- `role = tenant_admin`
|
||||
|
||||
À primeira vista pode parecer inconsistência, mas não é.
|
||||
|
||||
Verificação realizada:
|
||||
O UUID `816b24fe-a0c3-4409-b79b-c6c0a6935d03` existe em `auth.users`
|
||||
(email: admin@agenciapsi.com.br).
|
||||
|
||||
Portanto:
|
||||
|
||||
- `tenant_members.user_id` referencia corretamente `auth.users.id`
|
||||
- Não há violação de integridade referencial
|
||||
- O registro é válido
|
||||
|
||||
Trata-se de um caso em que:
|
||||
|
||||
- O usuário administrador principal possui um UUID específico
|
||||
- O tenant foi criado com o mesmo UUID
|
||||
- O administrador é `tenant_admin` desse próprio tenant
|
||||
|
||||
Esse padrão não quebra a arquitetura multi-tenant e é funcionalmente válido.
|
||||
A coincidência entre `tenant_id` e `user_id` é apenas estrutural, não conceitual.
|
||||
|
||||
Conclusão:
|
||||
Nenhuma correção estrutural é necessária.
|
||||
+149
-8
@@ -1,14 +1,155 @@
|
||||
# Changelog
|
||||
# CHANGELOG — Banco de Dados AgênciaPsi
|
||||
|
||||
## 3.7.0 (2023-05-06)
|
||||
Registro histórico de todas as migrations aplicadas no banco.
|
||||
Formato: data | arquivo | o que mudou | por quê
|
||||
|
||||
- Upgrade to PrimeVue 3.28.0
|
||||
---
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
## [001] — 2026-03-03
|
||||
**Arquivo:** `migration_001.sql`
|
||||
**Seed:** `seed_001.sql`
|
||||
|
||||
## 3.6.0 (2023-04-12)
|
||||
### Contexto
|
||||
O schema original foi construído de forma incremental e acumulou
|
||||
inconsistências no modelo de identidade. Usuários não tinham um
|
||||
tipo de conta definido formalmente, tenants não distinguiam
|
||||
terapeuta de clínica, e não existia suporte a paciente como
|
||||
tipo de conta de plataforma.
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
### O que mudou
|
||||
|
||||
- Upgrade to PrimeVue 3.26.1
|
||||
- Upgrade to vite 4.2.1
|
||||
#### `profiles`
|
||||
- ✅ Adicionada coluna `account_type text NOT NULL DEFAULT 'free'`
|
||||
- Valores: `free | patient | therapist | clinic`
|
||||
- Imutável após sair de `free` (trigger `trg_account_type_immutable`)
|
||||
- Usuários com role=`patient` migrados para `account_type='patient'`
|
||||
- Usuários com tenant `saas` ativo migrados para `account_type='therapist'`
|
||||
|
||||
#### `tenants`
|
||||
- ✅ Novos valores aceitos em `kind`:
|
||||
- `therapist` → terapeuta individual (substitui `saas`)
|
||||
- `clinic_coworking` → clínica tipo 1: gestão de salas
|
||||
- `clinic_reception` → clínica tipo 2: secretaria + múltiplas agendas
|
||||
- `clinic_full` → clínica tipo 3: coworking + secretaria
|
||||
- ✅ `kind` agora é imutável após criação (trigger `trg_tenant_kind_immutable`)
|
||||
- ✅ 10 tenants `saas` órfãos (sem admin, sem subscriptions) deletados
|
||||
- ✅ Tenants `saas` com admin ativo migrados para `kind='therapist'`
|
||||
- ⚠️ `saas` e `clinic` (legados) mantidos no CHECK por compatibilidade.
|
||||
Não criar novos tenants com esses kinds.
|
||||
|
||||
#### `plans`
|
||||
- ✅ Adicionado `patient` como valor válido em `target`
|
||||
- ✅ Inserido plano `patient_free` (gratuito, target=patient)
|
||||
|
||||
#### Novas funções
|
||||
| Função | Descrição |
|
||||
|--------|-----------|
|
||||
| `provision_account_tenant(user_id, kind, name?)` | Cria tenant + membership + atualiza account_type. Chamar no onboarding. |
|
||||
| `is_therapist_tenant(tenant_id)` | Retorna true se tenant é do tipo therapist |
|
||||
| `is_clinic_tenant(tenant_id)` | Atualizada: inclui todos os subtipos de clínica |
|
||||
| `guard_tenant_kind_immutable()` | Trigger: bloqueia alteração de tenants.kind |
|
||||
| `guard_account_type_immutable()` | Trigger: bloqueia alteração de account_type após escolha |
|
||||
| `guard_patient_cannot_own_tenant()` | Trigger: bloqueia paciente de ser tenant_admin/therapist |
|
||||
|
||||
#### Funções atualizadas
|
||||
| Função | O que mudou |
|
||||
|--------|-------------|
|
||||
| `handle_new_user()` | Agora insere `account_type='free'` |
|
||||
| `handle_new_user_create_personal_tenant()` | Desabilitada — tenant criado no onboarding |
|
||||
| `ensure_personal_tenant()` | Busca por `kind IN ('therapist','saas')` e delega para `provision_account_tenant` |
|
||||
|
||||
### Regras de negócio agora garantidas no banco
|
||||
1. **Paciente é para sempre paciente** — `account_type` imutável após escolha
|
||||
2. **Terapeuta nunca vira clínica e vice-versa** — `tenants.kind` imutável
|
||||
3. **Paciente não pode ter tenant** — trigger bloqueia na inserção
|
||||
4. **Cada tipo de conta tem seu tipo de tenant** — `provision_account_tenant` garante
|
||||
|
||||
### Usuários de seed (apenas dev/staging)
|
||||
| Email | Tipo | Tenant |
|
||||
|-------|------|--------|
|
||||
| paciente@agenciapsi.com.br | patient | nenhum |
|
||||
| terapeuta@agenciapsi.com.br | therapist | tenant próprio (therapist) + vinculado à Clínica 3 |
|
||||
| clinica1@agenciapsi.com.br | clinic | clinic_coworking |
|
||||
| clinica2@agenciapsi.com.br | clinic | clinic_reception |
|
||||
| clinica3@agenciapsi.com.br | clinic | clinic_full |
|
||||
| saas@agenciapsi.com.br | saas_admin | nenhum |
|
||||
> Senha de todos: `Teste@123`
|
||||
|
||||
---
|
||||
|
||||
## [002] — seed_002.sql
|
||||
|
||||
**Arquivo:** `Novo-DB/seed_002.sql`
|
||||
|
||||
### O que cria
|
||||
|
||||
#### Migration embutida
|
||||
- ✅ `profiles.platform_roles text[] NOT NULL DEFAULT '{}'` — adicionada via `ADD COLUMN IF NOT EXISTS` (idempotente)
|
||||
|
||||
#### Usuários de teste
|
||||
| Email | Senha | Papel | Tenant |
|
||||
|-------|-------|-------|--------|
|
||||
| `supervisor@agenciapsi.com.br` | `Teste@123` | `supervisor` em `tenant_members` | Clínica Bem Estar (Full) |
|
||||
| `editor@agenciapsi.com.br` | `Teste@123` | `therapist` em `tenant_members` + `platform_roles = '{editor}'` | Clínica Bem Estar (Full) |
|
||||
|
||||
UUIDs reservados:
|
||||
- Supervisor: `aaaaaaaa-0007-0007-0007-000000000007`
|
||||
- Editor: `aaaaaaaa-0008-0008-0008-000000000008`
|
||||
|
||||
---
|
||||
|
||||
## [PENDENTE] — Migration necessária: `platform_roles` em `profiles`
|
||||
|
||||
**Contexto:**
|
||||
Implementação das áreas de **Supervisor** (papel de tenant) e **Editor** (papel de plataforma).
|
||||
O papel de Editor é atribuído pelo `saas_admin` e armazenado diretamente no perfil do usuário,
|
||||
independente de qual tenant ele pertence.
|
||||
|
||||
### O que precisa ser aplicado no banco
|
||||
|
||||
#### `profiles`
|
||||
- ⚠️ **Adicionar coluna** `platform_roles text[] NOT NULL DEFAULT '{}'`
|
||||
- Armazena papéis globais de plataforma. Ex.: `'{editor}'`
|
||||
- Quem pode escrever: somente `saas_admin` (via RLS ou função privilegiada)
|
||||
- Quem pode ter: qualquer usuário autenticado, **exceto** `account_type = 'patient'`
|
||||
- Valores previstos: `editor` (mais podem ser adicionados futuramente)
|
||||
|
||||
#### SQL sugerido
|
||||
```sql
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS platform_roles text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Comentário descritivo
|
||||
COMMENT ON COLUMN public.profiles.platform_roles IS
|
||||
'Papéis globais de plataforma, independentes de tenant. Ex: editor de microlearning. Atribuído pelo saas_admin.';
|
||||
|
||||
-- RLS: somente saas_admin pode atualizar platform_roles (exemplo)
|
||||
-- CREATE POLICY "saas_admin pode atualizar platform_roles"
|
||||
-- ON public.profiles FOR UPDATE
|
||||
-- USING (auth.uid() IN (SELECT id FROM public.profiles WHERE role = 'saas_admin'))
|
||||
-- WITH CHECK (true);
|
||||
```
|
||||
|
||||
#### `tenant_members` (sem alteração necessária)
|
||||
- O papel `supervisor` já é suportado como valor text em `tenant_members.role`.
|
||||
- Nenhuma alteração de schema é necessária — basta inserir memberships com `role = 'supervisor'`.
|
||||
|
||||
### Impacto se não aplicado
|
||||
- Área do Editor (`/editor`) fica inacessível a todos (coluna ausente → `platform_roles` vem `null` → acesso negado).
|
||||
- Área do Supervisor (`/supervisor`) funciona normalmente — não depende desta migration.
|
||||
|
||||
---
|
||||
|
||||
## Futuro — registrado mas não implementado
|
||||
|
||||
### Vínculo Terapeuta ↔ Clínica (a implementar)
|
||||
- Terapeuta autoriza explicitamente que secretaria gerencie suas sessões
|
||||
- Permissão só válida se clínica tiver `kind IN ('clinic_reception', 'clinic_full')`
|
||||
- Secretaria acessa apenas sessões — não prontuário nem anotações
|
||||
- Dissociação bloqueada se houver `agenda_eventos` futuros (`inicio_em > now()`)
|
||||
- Após dissociação: cada parte fica com seus próprios pacientes
|
||||
- Requer: coluna de permissão no vínculo + função de dissociação com validação
|
||||
|
||||
---
|
||||
|
||||
*Última atualização: 2026-03-03*
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
## Navegação de Contexto
|
||||
|
||||
Quando precisar entender o código, documentos ou quaisquer arquivos deste projeto:
|
||||
|
||||
1. SEMPRE consulte o grafo de conhecimento primeiro: `/graphify query "sua pergunta"`
|
||||
2. Só leia arquivos brutos se eu disser explicitamente "leia o arquivo" ou "veja o arquivo bruto"
|
||||
3. Use `graphify-out/wiki/index.md` como ponto de entrada para navegar pela estrutura
|
||||
|
||||
---
|
||||
|
||||
## Para a Equipe — Sistema de Wiki/Grafo
|
||||
|
||||
> Este projeto usa um grafo de conhecimento (graphify) + wiki curado (wiki-brain) pra acelerar o trabalho do Claude e da equipe. O grafo mapeia o código automaticamente; a wiki acumula decisões, gotchas e blueprints.
|
||||
|
||||
### Estrutura
|
||||
- `graphify-out/` — gerado automaticamente. **Não commitar** (já no .gitignore). Cada dev gera o seu localmente.
|
||||
- `graph.html` — visualização interativa, abre no browser
|
||||
- `graph.json` — dados brutos do grafo (consultáveis via `graphify query`)
|
||||
- `wiki/` — 477+ artigos auto-gerados (1 por comunidade + god nodes), cross-linkados em estilo Obsidian
|
||||
- `GRAPH_REPORT.md` — relatório auditável: god nodes, comunidades, conexões surpreendentes
|
||||
- `Obsidian/Brain/` — vault Obsidian compartilhado. **É commitado**.
|
||||
- `wiki/` — páginas curadas pela equipe (decisões, blueprints, gotchas) — esse é o que cresce com o tempo
|
||||
- `raw/` — fontes imutáveis ingeridas (PDFs, links, transcrições)
|
||||
- `log.md` — registro cronológico do que foi ingerido/decidido
|
||||
|
||||
### Setup local (uma vez por dev)
|
||||
1. Garantir Python 3.10+ instalado
|
||||
2. Instalar graphify: `pip install graphifyy`
|
||||
3. Verificar: `graphify --help` (deve listar comandos)
|
||||
4. Pronto. As skills `/graphify` e `/wiki-brain` já vêm com o Claude Code instaladas via `~/.claude/skills/`.
|
||||
|
||||
### Como acessar (modos de visualização)
|
||||
- **Visual rápido** (sem instalar nada): `start graphify-out\graph.html` no Windows ou `xdg-open` no Linux. Abre no browser, navegação por arrastar e clicar.
|
||||
- **Wiki crawlable**: abrir Obsidian → "Open another vault" → apontar pra `agenciapsi-primesakai\graphify-out\wiki\`. Navega com cliques nos `[[wikilinks]]` e graph view nativa.
|
||||
- **Wiki curado**: abrir Obsidian → "Open another vault" → apontar pra `agenciapsi-primesakai\Obsidian\Brain\`. É onde a equipe edita.
|
||||
- **Via Claude**: digitar `/graphify query "sua pergunta"` em qualquer sessão — retorna BFS no grafo, citando fontes.
|
||||
|
||||
### Como regenerar (cada dev no seu local)
|
||||
- **Full rebuild** (lê o código + LLM, ~1M tokens, demorado): `/graphify D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\src` no Claude Code. Roda quando há mudança grande de arquitetura.
|
||||
- **Update incremental** (só AST, sem LLM, rápido): `graphify update D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\src` no terminal. Roda depois de commits que mudam código mas não a arquitetura.
|
||||
- **Só recluster** (sem reextração): `graphify cluster-only D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\src --no-viz`
|
||||
|
||||
### Como contribuir pro wiki curado (`Obsidian/Brain/wiki/`)
|
||||
- Tomou uma decisão arquitetural? Criou um blueprint? Achou um gotcha? Anota lá.
|
||||
- Pedir pro Claude: "ingere essa decisão sobre X no wiki" — ele cria a página com cross-links e atualiza `index.md`.
|
||||
- Cross-link agressivo: usar `[[Nome da Página]]` (sintaxe Obsidian). Página sem links de entrada é beco sem saída.
|
||||
- Commitar quando salvar — é conhecimento compartilhado da equipe.
|
||||
|
||||
### Convenções importantes
|
||||
- **Nunca editar `graphify-out/` à mão** — é regenerado, qualquer mudança é perdida
|
||||
- **Nunca modificar `Obsidian/Brain/raw/`** — fontes são imutáveis
|
||||
- **Sempre commitar mudanças em `Obsidian/Brain/wiki/`** — é onde o conhecimento composto vive
|
||||
- Antes de sessão grande de Claude, considerar `graphify update src/` pra grafo atualizado
|
||||
|
||||
---
|
||||
|
||||
## Context Navigation (Wiki-Brain)
|
||||
|
||||
You have access to a personal wiki at `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain`. This is the user's compounding knowledge base. Use it as your primary context source.
|
||||
|
||||
When you need to understand the codebase, docs, past work, or any stored knowledge:
|
||||
|
||||
1. **ALWAYS query the knowledge graph first:** `graphify query "your question"` (run from `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain`).
|
||||
2. **Use `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/wiki/index.md`** as your navigation entrypoint for browsing the wiki structure.
|
||||
3. **Use `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/graphify-out/wiki/index.md`** if it exists — it's the auto-generated Graphify wiki index.
|
||||
4. **Only read raw files in `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/raw/`** if the user explicitly says "read the raw file" or the graph query doesn't have the answer.
|
||||
|
||||
## Wiki-Brain Session Rules
|
||||
|
||||
**Ingesting sources.** When the user drops a file into `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/raw/` and asks you to ingest it, follow `/wiki-brain ingest` — read the source, summarize, create/update wiki pages, cross-link aggressively, update `wiki/index.md`, append to `log.md`.
|
||||
|
||||
**Every session must end with a log entry.** Before ending a session, append one line to `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\Obsidian\Brain/log.md` in this exact format:
|
||||
|
||||
```
|
||||
## [YYYY-MM-DD HH:MM] session | <3-8 word session title>
|
||||
Touched: <comma-separated wiki pages, or "none">
|
||||
```
|
||||
|
||||
**If the session produced durable knowledge** (decisions made, things learned, project state changed, problems solved) — update or create relevant wiki pages with that knowledge before ending. Cross-link with `[[Page Name]]`. Update `wiki/index.md`.
|
||||
|
||||
**If the session was trivial** (one-off fix, routine task, exploratory chatter) — skip the wiki update. Just append the log line.
|
||||
|
||||
**Never modify files in `raw/`.** Sources are immutable.
|
||||
**Claude owns `wiki/` entirely.** Update it, don't ask permission for each page — just report what changed.
|
||||
**Always update `wiki/index.md`** when you create or rename a wiki page.
|
||||
**Cross-link aggressively.** `[[Page Name]]` Obsidian syntax. A page with no inbound links is a dead-end.
|
||||
|
||||
## Wiki-Brain Commands Available
|
||||
|
||||
- `/wiki-brain` — status menu
|
||||
- `/wiki-brain ingest <file>` — ingest a source
|
||||
- `/wiki-brain query "<q>"` — query the graph + wiki
|
||||
- `/wiki-brain lint` — health-check the wiki
|
||||
- `/wiki-brain rebuild` — force a Graphify rebuild
|
||||
- `/wiki-brain doctor` — verify install
|
||||
- `/recall` — show last 5 activities + read linked pages
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Docker Setup — Projetos Locais
|
||||
|
||||
## Tabela Resumo
|
||||
|
||||
| Projeto | Container(s) | Porta Host | Rede | Volume(s) |
|
||||
|---|---|---|---|---|
|
||||
| **AgenciaPsi** | `agenciapsi_app` | `5173` → Vite dev | `agenciapsi_net` | `agenciapsi_node_modules` |
|
||||
| | `agenciapsi_mysql` | `3307` → MySQL | `agenciapsi_net` | `agenciapsi_mysql_data` |
|
||||
| **Evolution API** | `evolution_api` | `8080` → API | `agenciapsi_net` (external) | — |
|
||||
| | `evolution_db` | interno | `agenciapsi_net` | `evolution_db_data` |
|
||||
| | `evolution_redis` | interno | `agenciapsi_net` | — |
|
||||
| | `evolution_mailpit` | `1025` SMTP / `8025` Web | `agenciapsi_net` | — |
|
||||
| **Supabase AgenciaPsi** | `supabase_*_agenciapsi-primesakai` | `54321` API / `54322` PG / `54323` Studio | — | volumes internos |
|
||||
| **Sakai-Vue** | `sakaivue_app` | `5174` → Vite dev | `sakaivue_net` | `sakaivue_node_modules` |
|
||||
| | `sakaivue_mysql` | `3308` → MySQL | `sakaivue_net` | `sakaivue_mysql_data` |
|
||||
| **Supabase Sakai-Vue** | `supabase_*_sakai-vue` | `54331` API / `54332` PG / `54333` Studio | — | volumes internos |
|
||||
| **Gisaf Local** | `gisaf_mysql` | `3309` → MySQL | `gisaf_net` | `gisaf_mysql_data` |
|
||||
|
||||
## Mapa de Portas
|
||||
|
||||
| Porta | Serviço |
|
||||
|---|---|
|
||||
| 3307 | AgenciaPsi MySQL |
|
||||
| 3308 | Sakai-Vue MySQL |
|
||||
| 3309 | Gisaf MySQL |
|
||||
| 5173 | AgenciaPsi Vite dev |
|
||||
| 5174 | Sakai-Vue Vite dev |
|
||||
| 8080 | Evolution API |
|
||||
| 1025 | Mailpit SMTP |
|
||||
| 8025 | Mailpit Web UI |
|
||||
| 54321 | Supabase AgenciaPsi — Kong (API) |
|
||||
| 54322 | Supabase AgenciaPsi — PostgreSQL |
|
||||
| 54323 | Supabase AgenciaPsi — Studio |
|
||||
| 54327 | Supabase AgenciaPsi — Analytics |
|
||||
| 54331 | Supabase Sakai-Vue — Kong (API) |
|
||||
| 54332 | Supabase Sakai-Vue — PostgreSQL |
|
||||
| 54333 | Supabase Sakai-Vue — Studio |
|
||||
| 54337 | Supabase Sakai-Vue — Analytics |
|
||||
|
||||
## Ordem de Start
|
||||
|
||||
```bash
|
||||
# 1. AgenciaPsi (cria a rede agenciapsi_net)
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
|
||||
docker compose up -d
|
||||
|
||||
# 2. Supabase AgenciaPsi (porta 54321)
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
|
||||
npx supabase start
|
||||
|
||||
# 3. Evolution API (depende da rede agenciapsi_net)
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api"
|
||||
docker compose up -d
|
||||
|
||||
# 4. Sakai-Vue
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
|
||||
docker compose up -d
|
||||
|
||||
# 5. Supabase Sakai-Vue (porta 54331)
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
|
||||
npx supabase start
|
||||
|
||||
# 6. Gisaf Local
|
||||
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Parar tudo
|
||||
|
||||
```bash
|
||||
# Na ordem inversa
|
||||
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local" && docker compose down
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && npx supabase stop
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && docker compose down
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api" && docker compose down
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && npx supabase stop
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && docker compose down
|
||||
```
|
||||
|
||||
## Caminhos dos docker-compose.yml
|
||||
|
||||
| Projeto | Caminho |
|
||||
|---|---|
|
||||
| AgenciaPsi | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\docker-compose.yml` |
|
||||
| Evolution API | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\evolution-api\docker-compose.yml` |
|
||||
| Sakai-Vue | `D:\leonohama\UniaoApp.com.br\Sistema\sakai-vue\docker-compose.yml` |
|
||||
| Gisaf Local | `D:\leonohama\UniaoApp.com.br\Gisaf Local\docker-compose.yml` |
|
||||
|
||||
## DBeaver — Conexões MySQL
|
||||
|
||||
| Conexão | Host | Port | Database | User | Password |
|
||||
|---|---|---|---|---|---|
|
||||
| Gisaf | `localhost` | `3309` | `sindsp` | `sindsp` | `marlboro` |
|
||||
| AgenciaPsi | `localhost` | `3307` | `agenciapsi` | `agenciapsi` | `agenciapsi123` |
|
||||
| Sakai-Vue | `localhost` | `3308` | `sakaivue` | `sakaivue` | `sakaivue123` |
|
||||
|
||||
Para criar cada conexão: **Database → New Database Connection → MySQL → preencher dados → Test Connection → Finish**
|
||||
|
||||
## Supabase — Instancias Locais
|
||||
|
||||
Cada projeto tem sua propria instancia Supabase (schemas diferentes, nao podem compartilhar).
|
||||
|
||||
| Projeto | API URL | Studio | PostgreSQL | Anon Key |
|
||||
|---|---|---|---|---|
|
||||
| AgenciaPsi | `http://127.0.0.1:54321` | `http://127.0.0.1:54323` | `127.0.0.1:54322` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
|
||||
| Sakai-Vue | `http://127.0.0.1:54331` | `http://127.0.0.1:54333` | `127.0.0.1:54332` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
|
||||
|
||||
**Resetar banco (aplica migrations + seed):**
|
||||
|
||||
```bash
|
||||
# AgenciaPsi
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
|
||||
npx supabase db reset
|
||||
|
||||
# Sakai-Vue
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
|
||||
npx supabase db reset
|
||||
```
|
||||
|
||||
### Sakai-Vue — Usuarios de teste
|
||||
|
||||
| Email | Senha | Role |
|
||||
|---|---|---|
|
||||
| `dev@sistema.com.br` | `Dev@12345` | dev |
|
||||
| `master@tenant.com.br` | `Master@12345` | master |
|
||||
| `admin@tenant.com.br` | `Admin@12345` | admin |
|
||||
| `chefe@tenant.com.br` | `Chefe@12345` | chefe_setor |
|
||||
| `servidor@tenant.com.br` | `Servidor@12345` | servidor |
|
||||
| `leitura@tenant.com.br` | `Leitura@12345` | leitura |
|
||||
|
||||
## Importar dump SQL no Gisaf
|
||||
|
||||
```bash
|
||||
# Via CLI (já feito)
|
||||
docker exec -i gisaf_mysql mysql -usindsp -pmarlboro sindsp < "D:/leonohama/UniaoApp.com.br/Gisaf Local/Dump20260330.sql"
|
||||
```
|
||||
|
||||
Ou via DBeaver: conectar no banco `sindsp` → **Tools → Execute SQL Script** → selecionar `Dump20260330.sql`
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
# HANDOFF — 2026-05-20 (C10 ✅ + C11 ✅ + C12 ⏳ deferido · testando C13)
|
||||
|
||||
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
|
||||
|
||||
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** C10 e C11 fechados.
|
||||
> **C12 fluxo crítico OK no DB mas UX confusa** — adiado pra iterar
|
||||
> pós-Rail/Clínica (memória project_c12_antecipar_iterar). Agora
|
||||
> **testando C13** (edit cobrada — invariante imutabilidade SimplePractice).
|
||||
> Implementação JÁ existe (Fase 6 do commit 1feb711 — Message com cadeado +
|
||||
> AgendaEventoFinanceiroPanel embedded). Só validação visual + persistência.
|
||||
|
||||
> **🟢 14 COMMITS NO DIA**. C10 (5/5), C11 (4/4), C12 deferred (DB OK),
|
||||
> reverse transition trava implementada, popover watch sync implementado.
|
||||
> Pós-C13: replicar Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
|
||||
> + iterar C12 UX + doc de ajuda (pendência separada).
|
||||
|
||||
### C13 — passos de teste (próximo)
|
||||
Paciente: **João Almeida Martins** (sessão 20/05 9:00 realizada + paid R$ 40 maquininha) ou **André Green 20/05** (paid PIX).
|
||||
|
||||
Esperado ao abrir o AgendaEventDialog:
|
||||
- Message azul com cadeado: "Cobrança de R$ X já emitida..."
|
||||
- AgendaEventoFinanceiroPanel renderiza embaixo do Message
|
||||
- Card "Aplicar alterações em" oculto (v-if="!occFinancialRecord")
|
||||
- Só horário/observações editáveis; valor/serviços/tipo travados
|
||||
|
||||
### C11 sub-test results
|
||||
| # | Teste | DB validado |
|
||||
|---|---|---|
|
||||
| 11A | Realizada + markPaid PIX | sessions_used 0→1, record paid R$ 40 PIX |
|
||||
| 11B | Falta + Descontar saldo | sessions_used 1→2, sem multa |
|
||||
| 11C | Falta + Multa SEM consumir | sessions_used stays 2, multa pending R$ 30 |
|
||||
| 11D | Cancelado + default_consume_on_miss=true | sessions_used 2→3, sem multa (>2h) |
|
||||
|
||||
### Bugs descobertos + corrigidos durante C11
|
||||
- UI "Como cobrar?" com options "Já recebi" misturadas → refatorado pra "Já recebi?" radio Sim/Não + select condicional
|
||||
- `billing_contracts` sem coluna `updated_at` → UPDATE falhava silently em Promise.allSettled (root cause do saldo não incrementar). Trocado pra awaits sequenciais com error handling explícito
|
||||
- Reverse transitions deixavam multa órfã → dialog reverse implementado com radio "cancelar pending" + "devolver saldo" + warning pra paid
|
||||
- Botão "Gerar cobrança" em sessão encerrada → bloqueado
|
||||
- Lock total em cancelado/faltou: Editar sessão some, status mudanças disabled exceto Agendada (recovery)
|
||||
- Label "A cobrar R$ X" em pacote saldo state=none → "Aguardando uso do pacote"
|
||||
- Badge $ amber em pacote saldo state=none → suprimido
|
||||
- billing_contract_id não amarrado em alguns flows → link universal antes dos blocos forward
|
||||
- Reverse saldo decrementar: refresh sessions_used FRESH do DB antes do UPDATE (anti-race)
|
||||
|
||||
### Pendências mapeadas pós-C13
|
||||
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot. Fix: guardar ev.id, derivar via computed
|
||||
- ~~Reverse transitions~~ ✓ implementado ahead of schedule
|
||||
- **Cleanup teste**: Otto sessão 5364f631 leftover (não-critical)
|
||||
|
||||
### C10 sub-test results
|
||||
| # | Teste | DB validado | Notas |
|
||||
|---|---|---|---|
|
||||
| A | Realizada sem markPaid | ✅ status=realizado, record=pending | Bubble do C9 funcionou |
|
||||
| A2 | Realizada + markPaid maquininha | ✅ status=realizado, record=paid, payment_method=cartao_maquininha, paid_at set | João Almeida |
|
||||
| B | Faltou + multa R$ 30 (fixed_fee) | ✅ original cancelled + nova multa "Multa por falta · sessão dd/mm/aa" | Otto Rank |
|
||||
| C | Cancelado >2h antecedência | ✅ original cancelled, sem multa | Otto / Karen |
|
||||
| C2 | Cancelado tardio (<2h) full charge | ✅ original cancelled + nova "Taxa de cancelamento tardio · sessão dd/mm/aa" | Karen Horney |
|
||||
|
||||
### Pendências mapeadas durante C10 — pós-C13
|
||||
- **Reverse transitions**: faltou/cancelado → agendado deixa multa órfã. Implementar confirm dialog oferecendo auto-cancelar multa.
|
||||
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot, não acompanha _paymentStateMap. Fix: guardar ev.id, derivar via computed.
|
||||
- **Cleanup teste**: Otto sessão 5364f631 às 19:30 UTC tem record pending R$ 40 leftover do teste A original. Apagar quando convenient.
|
||||
|
||||
Memórias relevantes:
|
||||
- `project_agenda_reverse_transitions.md`
|
||||
- `project_melissa_popover_snapshot.md`
|
||||
|
||||
### Code-fix aplicado em 20/05 (pré-C10)
|
||||
- **`useMelissaAgenda.js:1450-1505`** — `_applyStatusDecisions` agora cancela
|
||||
o `ctx.pendingRecord` quando faltou/cancelado (com ou sem multa). Antes
|
||||
inseria a multa mas DEIXAVA o original pending → cobrança dupla
|
||||
(R$ 200 + R$ 30 = R$ 230). Audit trail vai em `notes` do record
|
||||
cancelado, descrição da multa nova carrega data: "Multa por falta · sessão 20/05/26".
|
||||
- **`useAgendaFinanceiro.js:59`** — fix dormente `'fixed'` → `'fixed_fee'`
|
||||
(off-by-key contra schema; path nunca exercitado na Melissa, mas iria
|
||||
quebrar se algum dia fosse).
|
||||
|
||||
### Financial exceptions seedadas (tenant Bruno Terapeuta / owner Leonardo)
|
||||
- `patient_no_show` → `fixed_fee R$ 30`
|
||||
- `patient_cancellation` → `full`, `min_hours_notice=2`, `default_consume_on_miss=true`
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 10 (Status change AVULSA)
|
||||
|
||||
Doc HTML diz: testar status change numa sessão avulsa com cobrança pendente,
|
||||
mudando entre realizado / faltou / cancelado. As consequências financeiras
|
||||
seguem `financial_exceptions` (regras configuradas pelo terapeuta sobre o
|
||||
que acontece com a cobrança nesses casos).
|
||||
|
||||
Possíveis pacientes pra teste: usar Joyce, Sándor ou outro com cobrança
|
||||
avulsa pendente já criada.
|
||||
|
||||
**Esperado** (depende das `financial_exceptions` configuradas no tenant):
|
||||
- Realizada: status muda; cobrança permanece (caminho default)
|
||||
- Faltou: pode ter regra → cobrança 100% (paciente paga falta) ou cancela
|
||||
- Cancelado: pode ter regra → cancelar cobrança ou cobrar parcial
|
||||
|
||||
Conferir:
|
||||
- `STATUS_TO_EXCEPTION` mapping em `useAgendaFinanceiro.js`
|
||||
- `getFinancialExceptionRule(tenantId, exceptionType)` retorna a regra
|
||||
- `handleStatusChange` orquestra: agenda update + financial adjust
|
||||
|
||||
Após C10: C11 (status change pacote saldo — usar a infra do Usar/Revogar)
|
||||
→ C12 (antecipar pagamento) → C13 (edit cobrada).
|
||||
|
||||
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e
|
||||
**Clínica** (`AgendaClinicaPage.vue`).
|
||||
|
||||
---
|
||||
|
||||
## 📦 O que foi feito em 20/05 madrugada (C9 + rowGroup financeiro + bubble cobranca-atualizada)
|
||||
|
||||
### Cenário 9 ✅ (Per-session — Michael Balint 12 × R$ 150)
|
||||
Testado e passou. Criou-se 1 rule + 12 agenda_eventos materializadas + 12 financial_records pending. Sem billing_contract. Cada sessão com badge $ amber individual. **Sem nenhuma `linha de pacote`** no popover (não tem contract → não aparece). Conforme esperado.
|
||||
|
||||
### `/melissa/financeiro-lancamentos` agrupado por paciente
|
||||
- DataTable com `rowGroupMode='subheader'` + `groupRowsBy='patient_id'`
|
||||
- Default: todos os grupos da página expandidos (watcher popula `expandedGroups` com unique patient_ids quando `recordsGrouped` muda)
|
||||
- Header de grupo: avatar pequeno + nome + badge "N lançamento(s)"
|
||||
- Click no chevron contrai/expande (auto via PrimeVue `expandableRowGroups`)
|
||||
- Sort estável: ordena outer por nome do paciente, preserva inner order (pai → filhos de multas/taxas)
|
||||
|
||||
### Bubble-up `@cobranca-atualizada`
|
||||
Antes: `AgendaEventoFinanceiroPanel.@cobranca-atualizada` disparava só `loadOccFinancialRecord` (interno do dialog). O `_paymentStateMap` da agenda ficava stale → card no FC só atualizava ao trocar de view.
|
||||
|
||||
Agora: `AgendaEventDialog._onCobrancaAtualizada` faz duas coisas:
|
||||
1. `loadOccFinancialRecord()` — refresca estado interno do dialog
|
||||
2. `emit('cobranca-atualizada')` — bubble pra MelissaLayout
|
||||
|
||||
MelissaLayout escuta nos 2 dialogs (principal + occurrenceMode) e chama `onCobrancaAtualizada` que dispara `M.refetch() + refetchEventosHoje()`. Resultado: card na agenda passa pra borda verde imediatamente após marcar pago.
|
||||
|
||||
---
|
||||
|
||||
## 📦 O que foi feito em 19/05 madrugada (C8 + Usar/Revogar saldo + UI de pacote)
|
||||
|
||||
### Cenário 8 ✅ (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
|
||||
Testado e validado. Contract criado com `charging_style='saldo'`, 0 events materializadas, 0 records. Modelo Cliniko: sessões materializam on-demand via Usar.
|
||||
|
||||
### UI do pacote (saldo + upfront)
|
||||
- **`_ruleContractMap`** em useMelissaAgenda: bulk-load agora popula contract info (id, style, totalSessions, sessionsUsed, packagePrice) por `recurrence_id`. Query usa `recurrence_rules.patient_id` como fonte autoritativa (cobre saldo sem materializadas).
|
||||
- **Normalize** injeta `contract` no evento → popover acessa via `ev.contract`.
|
||||
- **Popover** (`MelissaEventoPanel`): nova linha colorida abaixo do payment:
|
||||
- Saldo: violeta `"Pacote saldo · N/M usadas"` + botão verde **"Usar"** (paymentState=none) OU vermelho **"Revogar"** (paymentState=pending)
|
||||
- Upfront: verde `"Pacote · N/M realizadas"` (sem botão; cobrança já tratada)
|
||||
- **AgendaEventDialog**: info card mt-4 (saldo violeta / upfront emerald) com header (pacote+contador), body (total/per-session/restam), botão "Usar agora" ou "Revogar uso", hint explicando o modelo. Gateado por `occFinancialLoading` (spinner durante carga) pra evitar piscar entre estados.
|
||||
|
||||
### Handlers Usar/Revogar atômicos
|
||||
**`onUsarSessao`** em MelissaLayout (aceita payload do popover OU do dialog):
|
||||
1. Materializa virtual se necessário (preserva `determined_commitment_id` da regra)
|
||||
2. Status='realizado' + link `billing_contract_id`
|
||||
3. `create_financial_record_for_session` RPC com per-session amount
|
||||
4. Incrementa `billing_contracts.sessions_used`
|
||||
5. Se atingiu total → contract `status='completed'`
|
||||
6. Toast verde + fecha popover/dialog
|
||||
|
||||
**`onRevogarSessao`** desfaz tudo:
|
||||
1. Cancela financial_record (status='cancelled')
|
||||
2. Decrementa sessions_used (não fica negativo)
|
||||
3. Reativa contract se estava completed
|
||||
4. Status volta pra 'agendado'
|
||||
5. Bloqueia se record já está `paid` (precisa estorno formal pelo Financeiro)
|
||||
6. **Backfill** de `determined_commitment_id` se NULL (fix de legado)
|
||||
|
||||
### Fix: enum status_evento_agenda
|
||||
Era `'realizada'` no insert/update, DB exige `'realizado'` (masculino). Corrigido em todas as ocorrências.
|
||||
|
||||
### Fix: campo "Título" indevido no dialog
|
||||
Sessão sem `determined_commitment_id` → `selectedCommitment=null` → `isSessionEvent=false` → mostra campo Título (que é só pra não-sessão). Fix:
|
||||
- Materialize do Usar inclui `determined_commitment_id` da regra
|
||||
- Update path do Usar (sessão real após revogar) backfilla via query da rule
|
||||
- Revogar também backfilla — garante consistência mesmo sem novo Usar
|
||||
- SQL massivo de backfill disponível no HANDOFF pra limpar rows legadas
|
||||
|
||||
### Fix: "Gerar fatura" não cabe em sessão de saldo
|
||||
Hide do botão "Gerar fatura" no popover quando há `contractInfo`. Geraria cobrança solta sem incrementar saldo → duplicação. Fluxo correto: usar "Usar".
|
||||
|
||||
### Recorrências Aplicadas: cores + badges
|
||||
- Header stats: total **azul**, realizadas **verde**, faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
|
||||
- Pills: badge sólido por status (Realizado=emerald-600, Faltou=amber-600, Cancelado=stone-500, Remarcado=violet-600)
|
||||
|
||||
### Race condition no dialog
|
||||
- AgendaEventDialog mostrava botões "Usar"/"Revogar" baseado em `occFinancialRecord` que carrega async
|
||||
- Durante load (~500ms), botão errado podia aparecer → snap pro correto depois
|
||||
- Fix: spinner "Verificando estado…" enquanto `occFinancialLoading=true`; botões só renderizam após
|
||||
- Popover decidiu manter como está (race window pequena, fechar/reabrir resolve)
|
||||
|
||||
---
|
||||
|
||||
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
|
||||
|
||||
### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira)
|
||||
Testado e validado. Usuária criou Ana, R$ 200/sessão × 4 = R$ 800, marcou como pago em dinheiro pelo Financeiro. Visualização correta em mês AND em semana navegando pelas 4 semanas.
|
||||
|
||||
### Fase 6 (lock-edit cobrada) ativada em Melissa
|
||||
Antes: `loadOccFinancialRecord` tinha guard `if (!props.occurrenceMode) return;` — só carregava em Rail/Clínica (edição de ocorrência). Em Melissa, `sessionPaymentRecord` paralelo alimentava só o Resumo lateral, sem trigger de lock.
|
||||
|
||||
Agora unificado: `occFinancialRecord` carrega em ambos modos:
|
||||
- Card Sessão / Honorários ganha **Tag** (em vez de Select billingType) quando há cobrança
|
||||
- Body do card mostra **Message "Cobrança de R$ X já emitida"** + cadeado
|
||||
- Tipo de cobrança (Particular/Convênio/Gratuito) bloqueado
|
||||
- Edição de serviços/preço bloqueada
|
||||
|
||||
### Propagação cross-week de pacote upfront pago/pendente
|
||||
**Bug descoberto durante C7:** ao navegar pra semanas futuras (onde só virtual da Ana 2/3/4 aparecia, sem real event paid na view), o `_rulePaymentMap` era zerado pelo else branch do bulk-load → virtuais perdiam estado paid.
|
||||
|
||||
Fix em `useMelissaAgenda.js _reloadRange`:
|
||||
- Maps (paymentStateMap, amountMap, rulePaymentMap) inicializados SEMPRE no início
|
||||
- Propagação agora roda **independente de realIds.length** (ie, mesmo em semanas só-com-virtuais)
|
||||
- Coleta `ruleIdsInView` de TODOS eventos da view (reais + virtuais com recurrence_id)
|
||||
- Cross-week query: pra cada rule em view, busca QUALQUER evento sibling (inclusive em outras semanas) + seus records paid/pending → determina estado do contrato
|
||||
- Propaga estado pra eventos reais (via map) + virtuais (via rulePaymentMap acessado pelo normalize)
|
||||
|
||||
### Atalho "Gerar fatura" no popover
|
||||
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (`paymentVariant === 'none' && !is_occurrence`)
|
||||
- Click → `gerarCobrancaManual` direto, fecha popover pra impedir double-click
|
||||
- Tooltip: "Gerar fatura agora"
|
||||
|
||||
### Info de pacote no popover
|
||||
- Header agora mostra `Sessão · Pacote · N sessões` (computed `seriesLabel` lê de `_raw` do rule)
|
||||
|
||||
### Botão "Excluir série inteira"
|
||||
- Novo emit `delete-series` em `MelissaEventoPanel` + botão ao lado de "Excluir sessão" quando evento tem `recurrence_id`
|
||||
- Handler `onDeleteSeries` em MelissaLayout faz hard delete: `financial_records` pendentes → `agenda_eventos` materializados → `recurrence_rules` (CASCADE leva exceptions + rule_services)
|
||||
- Bloqueia se algum record tem `status='paid'` (estornar primeiro)
|
||||
|
||||
### RPC `create_financial_record_for_session` ignora cancelled
|
||||
**Migration 20260519000001:** idempotência da RPC passou a filtrar `AND status != 'cancelled'` além de `deleted_at IS NULL`. Antes: cancelar cobrança sem querer → todo "Gerar fatura" subsequente retornava o cancelado em vez de criar nova. Toast verde mentindo.
|
||||
|
||||
Memória durável em `memory/project_rpc_idempotency_cancelled.md`.
|
||||
|
||||
### `cancel_session` exception some da agenda
|
||||
- `useRecurrence.expandRules` agora pula ocorrência com `exception.type === 'cancel_session'` (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha)
|
||||
- `patient_missed` / `therapist_canceled` / `holiday_block` permanecem visíveis como histórico
|
||||
|
||||
### `recurrence_exceptions` cancel idempotente
|
||||
- Cancel de ocorrência (virtual e materializada) usa `upsert` com `onConflict: 'recurrence_id,original_date'` — não quebra mais com unique violation quando há exception zumbi de tentativa anterior.
|
||||
|
||||
### Visualização paid/pending de upfront em virtuais
|
||||
- `MelissaEventoPanel.showPaymentRow` antes excluía virtuais incondicionalmente. Agora só esconde quando `paymentState === 'none'` (saldo/sem pacote continua limpo; upfront propagado mostra).
|
||||
- `MelissaAgenda.fcEvents`: removida exigência de `!is_occurrence` no `isPaidSession` e no badge $ pendente. Virtuais herdadas via propagação mostram borda verde/badge amber.
|
||||
|
||||
### `onVerLancamentos` cobre virtual de upfront
|
||||
- Antes: virtual sempre toast "Sem lançamentos". Agora: busca records via siblings da série pra encontrar o do pacote. Saldo/sem pacote continua com toast.
|
||||
|
||||
### Confirmação 3 decisões UX (não codar)
|
||||
Antes de C7, user perguntou e concordou:
|
||||
1. Editar serviço já lançado e pago → **NÃO** (cobrança fiscal imutável)
|
||||
2. Alternar Particular/Convênio/Gratuito em série com cobrança ativa → **NÃO** (mesma razão)
|
||||
3. "Gerar fatura" extra em sessão coberta por contrato upfront → **NÃO** (duplicaria cobrança)
|
||||
Tudo isso o lock-edit (Fase 6 ativada acima) cobre.
|
||||
|
||||
---
|
||||
|
||||
## 📦 O que foi feito em 18/05
|
||||
|
||||
### Cenário 4 (Joyce · "Já recebi") ✅
|
||||
- Testado e passou: toast "Cobrança paga R$ 180,00 recebido via PIX", record nasceu `paid + payment_method=pix + paid_at=now()`.
|
||||
|
||||
### Novo indicador: barra esquerda verde para sessão paga
|
||||
- Brainstorm de 6 opções; user escolheu #6 (3 canais visuais distintos por estado).
|
||||
- `MelissaAgenda.vue:395-419` — computa `isPaidSession` (sessão+paciente+não-virtual+`paymentState==='paid'`) e adiciona classe `ma-evt--paid` ao FC event (combina com `ma-evt--inactive-patient` se ambos).
|
||||
- `MelissaAgenda.vue:2325-2335` — CSS força `border-left-color: #10b981 !important` (emerald-500, 4px). `!important` necessário porque FC seta `borderColor` inline. Trata também list view (`.fc-list-event-dot`).
|
||||
- Doc HTML atualizado: legenda "Indicadores visuais" agora descreve **3 estados** (pendente / pago / neutro) com 3 mocks empilhados; estado-alvo do C4 reescrito mencionando a barra verde.
|
||||
- Decisão salva em `memory/project_agenda_payment_indicators.md`.
|
||||
|
||||
### Linha "Cobrança" no popover + Resumo do dialog
|
||||
- **Popover `MelissaEventoPanel`** — antes só mostrava amber "A receber R$ X" pra pendente. Agora cobre os 3 estados, com cor + ícone por variante:
|
||||
- `paid` → `pi-check-circle` verde, label **"Pago · R$ X,XX"**
|
||||
- `pending` → `pi-dollar` amber, label **"A receber R$ X (cobrança pendente)"** (mantido)
|
||||
- `none` → `pi-dollar` amber, label **"A cobrar R$ X"** ou **"Cobrança ainda não gerada"** (mantido)
|
||||
- CSS reescrito em 3 modificadores `.evento-row--pay-{paid|pending|none}` (com dark mode).
|
||||
- **Resumo lateral do `AgendaEventDialog`** — nova linha entre `pi-clock` e `pi-map-marker` em ambas as cópias (mobile inline + desktop floating).
|
||||
- Novo ref `sessionPaymentRecord` em `useAgendaEventLifecycle.js:104+` (sem guard de `occurrenceMode`, contrário ao `occFinancialRecord` que continua só pra Rail/Clínica). Loader `loadSessionPaymentRecord` chamado no mesmo lifecycle.
|
||||
- Computed `paymentSummary` em `AgendaEventDialog.vue:951+` retorna `{icon, cls, label}` pra 5 casos: paid (verde + paid_at), overdue (vermelho + due_date), pending (amber + due_date), sem cobrança c/ valor (neutro), sem cobrança s/ valor (neutro).
|
||||
- `@cobranca-atualizada` do `AgendaEventoFinanceiroPanel` agora também dispara `loadSessionPaymentRecord` pra a linha refrescar.
|
||||
- **Importante:** `occFinancialRecord` (que aciona lock-edit) NÃO foi tocado de propósito — esse é território da Fase 6/C13 (Edit cobrada). Manter dois refs separados evita ativar lock prematuro em Melissa.
|
||||
|
||||
### Preparação do C5 (Sándor + Unimed Nacional) — UX de convênio refinado (3 issues)
|
||||
|
||||
User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
|
||||
|
||||
1. **Botão "Cadastrar" do procedimento navegava pra `/pages/notfound`**
|
||||
- Root cause: `goToConveniosConfig` em `AgendaEventDialog.vue` prefixava com `/therapist` ou `/admin`, mas `/configuracoes/*` é rota **raiz** sob `AppLayout` (sibling, não filho). Em Melissa, convênios mora dentro do próprio layout via `secao: 'cfg-convenios'` (sem URL própria).
|
||||
- Fix descartado: o user não queria sair da agenda. Em vez disso, criamos um quick-create inline (ver #2). `goToConveniosConfig` foi removida (dead code virou armadilha).
|
||||
|
||||
2. **Quick-create de procedimento inline (sem sair da agenda)**
|
||||
- Novo componente `InsurancePlanServiceQuickCreateDialog.vue` (modelo do `InsurancePlanQuickCreateDialog`). 2 campos: nome do procedimento + valor que o convênio paga. Insere em `insurance_plan_services` pro `insurance_plan_id` ativo.
|
||||
- Wiring em `useAgendaEventLifecycle.js`: novo `planServiceQuickDlgOpen` + `openPlanServiceQuickCreate()` + `onPlanServiceCreated(service)`. Após criar, recarrega `loadInsurancePlans` e **auto-seleciona** o novo procedimento **só quando nada estava selecionado antes** (preserva escolha quando user já tinha selecionado X e está só cadastrando Y pra próxima).
|
||||
- UI refatorada (`AgendaEventDialog.vue:3110+`): a caixa cinza com botão "Cadastrar" agora aparece **sempre** que um convênio está selecionado. Quando 0 procedimentos: **"Este convênio ainda não tem procedimentos cadastrados."** Quando 1+: **"Se quiser adicionar mais procedimentos a este convênio:"**.
|
||||
- `planServiceQuickDlgOpen` adicionado ao `anyChildDialogOpen` pra esconder o Resumo flutuante enquanto o quick-create está aberto.
|
||||
|
||||
3. **Botão "+ Novo convênio" faltando em `/melissa/cfg-convenios` (e na rota canônica também)**
|
||||
- Root cause: `ConfiguracoesConveniosPage.vue` tinha o form de "Novo convênio" condicionado a `addingNew === true`, mas **nenhum botão setava esse flag**. Empty state mandava "Clique em 'Novo convênio'" sem botão pra clicar.
|
||||
- Fix: toolbar simples no topo do template `<template v-else>` com `<Button label="Novo convênio" icon="pi pi-plus" @click="addingNew = true">`. Empty state corrigida pra apontar pro botão certo.
|
||||
|
||||
### Hint contextual abaixo do card Sessão / Honorários
|
||||
|
||||
- User pediu mensagem clarificando que "Nº da guia" é opcional em convênio.
|
||||
- **Tentativa 1 (errou o lugar):** coloquei o hint em `AgendaEventDialog.vue:1826` dentro do bloco `v-if="occurrenceMode"` (só edita ocorrência em Rail/Clínica). User não viu.
|
||||
- **Tentativa 2 (correta):** adicionado em `AgendaEventDialog.vue:2305+` (fluxo principal Melissa, fora do occurrenceMode). Mantive a tentativa 1 também — não atrapalha, só ativa em outro contexto.
|
||||
- Texto: convênio = **"Nº da guia é opcional — você pode salvar a sessão e preencher depois, quando o convênio responder."** Gratuito = **"Sessão gratuita — nenhum lançamento será gerado no Financeiro."** Particular = sem hint (não há ambiguidade).
|
||||
- Condição: `isSessionEvent && !occFinancialRecord && billingType === 'convenio'|'gratuito'`. Esconde quando há cobrança paga/pendente (lock-edit) — Message do panel já cobre.
|
||||
- CSS: `.aed-billing-hint` em `AgendaEventDialog.vue:3558+` — barra esquerda primary, fundo neutro leve, fonte 0.78rem.
|
||||
- Label do "Nº da Guia" no service-picker dialog também ganhou **(opcional)**.
|
||||
|
||||
---
|
||||
|
||||
## 📦 O que foi feito antes (16/05 noite/madrugada)
|
||||
|
||||
### Cenário 1 (Bloqueio) ✅
|
||||
|
||||
1. **Fix `bloqueioCobrindo is not defined`** — função estava no escopo de `useMelissaAgenda` mas `onSelectTime` mora no `_buildHandlers` (outro escopo). Passada via `deps`. Mesmo padrão que `_openStatusDialog`.
|
||||
2. **Soft warn dentro do dialog** em vez de toast atrás do overlay — novo ref `dialogBlockOverlap` no composable + nova prop `blockOverlapWarning` no `AgendaEventDialog` + Message warn no topo do step 1. Reset nos outros openers (`onCreateEvento`, `onCreateEventoForPatient`, `onEditEvento`).
|
||||
3. **Doc HTML Cenário 1 expandido** em 1a (criar bloqueio) + 1b (agendar sobre bloqueio), com mock visual da Message + comparação com agendador público (que veta).
|
||||
|
||||
### Cenário 2 (Avulsa sem cobrança) ✅
|
||||
|
||||
4. **Fonte da hint chargeMode** subiu de `0.72rem` → `0.8125rem` (acima de `text-xs`).
|
||||
5. **Card Frequência avulsa** refeito — antes era empty state convidando configurar; agora renderiza com `.aed-pay-summary` (mesma estrutura do estado configurado: "Tipo: Avulsa · Sessão única, sem repetição" + botão Editar).
|
||||
6. Doc HTML Cenário 2 atualizado.
|
||||
|
||||
### Cenário 3 (Avulsa cobrar ao salvar) ✅
|
||||
|
||||
7. **Refactor payment: `paymentSettlement` → `paymentMethod` + `markPaidNow`**
|
||||
- UI antiga misturava método e status num único Select ("Já recebi — PIX").
|
||||
- Agora 2 controles: Select forma (Enviar link / PIX / Dinheiro / Depósito / Cartão maquininha — SEM prefixo "Já recebi —") + SelectButton status (Cobrança pendente / Já recebi (dar baixa)).
|
||||
- SelectButton só aparece quando método ≠ link (Asaas só liquida via webhook).
|
||||
- Watcher força `markPaidNow=false` se voltar pra 'link'.
|
||||
- Wire: AgendaEventDialog → useAgendaEventActions → useMelissaAgenda (handler avulsa + `_createPackageContract`).
|
||||
8. **Indicadores visuais de pagamento** (novidade da sessão):
|
||||
- Bulk-load de `financial_records` em `_reloadRange` etapa 4 (1 query única, mapa eventId → 'paid' | 'pending' | 'none').
|
||||
- `normalizeForMelissa` agora injeta `paymentState` + `price` no evento.
|
||||
- **Badge $ no canto** dos eventos da agenda — círculo amber 16px no canto superior direito. Só pra sessão + paciente + não-virtual + paymentState !== 'paid'.
|
||||
- **Linha "A receber"** no popover (`MelissaEventoPanel`) — texto adaptativo: "A receber R$ X (cobrança pendente)" se pending, "A cobrar R$ X" se none, "Cobrança ainda não gerada" se sem valor.
|
||||
9. **🐛 Bug fix `pickDbFields` faltando `modalidade`** — sessões avulsas eram salvas sem modalidade, DB caía no default 'presencial' independente da escolha. Adicionado ao whitelist em `useMelissaAgenda.js:74`. **TODAS as sessões avulsas criadas no Melissa antes desse fix estão como 'presencial' no DB** — pode precisar rodar UPDATE manual no banco se quiser corrigir histórico. Gotcha salvo em `memory/project_pickdbfields_whitelist.md`.
|
||||
10. **Doc HTML atualizada amplamente**:
|
||||
- Nova seção topo `★ Indicadores visuais de pagamento` com mocks (badge $ + linha popover) e link em violeta no TOC.
|
||||
- Caixa violeta "Indicadores visuais" em cada cenário relevante (C2-C9).
|
||||
- C4 ganhou caixa verde "estado-alvo" (sem badge, sem linha — pago).
|
||||
- Receita do C3 e C4 atualizadas com os 3 controles (Cobrança ao salvar / Forma de pagamento / Status do pagamento) e opções limpas (sem prefixo "Já recebi —").
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Onde estamos no plano de 9 fases
|
||||
|
||||
| Fase | Status |
|
||||
|---|---|
|
||||
| **1** Compromisso SEM paciente | ✅ |
|
||||
| **2** Compromisso COM paciente | ✅ testado (C1-C3 done) |
|
||||
| **3** Recorrência + replicar occurrenceMode Rail/Clínica | ⏳ |
|
||||
| **4** Modo disparo cobrança híbrido | ⚠️ parcial |
|
||||
| **5** Status change → confirm dialog | 🔄 Melissa codado + indicadores visuais done; falta testar (C10-C12) + replicar Rail/Clínica |
|
||||
| **6** Edit cobrada | ✅ |
|
||||
| **7** Pagamento separado | ⏳ |
|
||||
| **8** Refund/credit note | ⏳ |
|
||||
| **9** Plano Inicial | 📋 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Roteiro de testes restantes (`src/docs/agenda-compromisso-financeiro-cenarios.html`)
|
||||
|
||||
| # | Cenário | Status |
|
||||
|---|---|---|
|
||||
| 1 | Bloqueio (criar + agendar sobre) | ✅ |
|
||||
| 2 | Avulsa sem cobrança | ✅ |
|
||||
| 3 | Avulsa cobrar ao salvar | ✅ |
|
||||
| 4 | Avulsa "já recebi" no salvar | ✅ |
|
||||
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
|
||||
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
|
||||
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
|
||||
| 8 | Pacote saldo (Otávio 12 × R$ 50) | ✅ |
|
||||
| 9 | 1 por sessão (Michael Balint 12 × R$ 150) | ✅ |
|
||||
| **10** | **Status change avulsa (realizado/faltou/cancelado)** | 🔴 **PRÓXIMO** |
|
||||
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
|
||||
| 11 | Status change pacote saldo | ⏳ |
|
||||
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
|
||||
| 13 | Edit cobrada | ⏳ (parcialmente — lock ativo em Melissa pós-19/05 noite) |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Como retomar amanhã (cego)
|
||||
|
||||
1. `git status` — confirmar working tree intacto
|
||||
2. **Ler HANDOFF até o fim**
|
||||
3. Abrir `src/docs/agenda-compromisso-financeiro-cenarios.html` no browser pra ver o estado atual do doc viva
|
||||
4. **Começar pelo Cenário 4** (Joyce, "Já recebi (dar baixa)")
|
||||
5. Cada cenário que passar:
|
||||
- Atualizar status pra ✅ aqui no HANDOFF
|
||||
- Se descobrir bug ou texto divergente, corrigir código + doc na hora
|
||||
6. Quando todos os 13 passarem: replicar em **Rail** e **Clínica**
|
||||
7. Adicionar `professional_cancellation` no `STATUS_TO_EXCEPTION`
|
||||
8. Marcar Fase 5 como ✅
|
||||
9. Decidir Fase 4 (modo disparo cobrança híbrido) OU Fase 3 (replicar occurrenceMode)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Pendência IMPORTANTE — não esquecer
|
||||
|
||||
**Pós-Fase 9** (quando concluirmos TODAS as fases 1-9):
|
||||
- User vai passar prompt específico pra criar **documentação completa da parte de ajuda** do sistema
|
||||
- Está em `memory/project_pendencia_doc_ajuda.md`
|
||||
- O doc `agenda-compromisso-financeiro-cenarios.html` já está sendo escrito de forma que vira a doc final pra usuário (cada teste validado vira parte da doc)
|
||||
|
||||
**Histórico modalidade='presencial' no DB:**
|
||||
- Bug do `pickDbFields` afetou TODAS as sessões avulsas criadas no Melissa até 16/05/2026
|
||||
- Se quiser corrigir histórico, rodar UPDATE manual identificando sessões cuja modalidade visual era online (não há como saber retroativamente — perdido)
|
||||
- Going forward o fix já cobre
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Gotchas duráveis (atualizados)
|
||||
|
||||
- **`MelissaBloqueios.vue` admin ≠ `BloqueioDialog` (4 modos)** — casos distintos
|
||||
- **`agenda_excecoes` foi dropada** em 13/05
|
||||
- **`financial_records.type` undefined sem `type` no BASE_SELECT** — fix 14/05 cedo
|
||||
- **`financial_records.description` undefined sem `description` no BASE_SELECT** — fix 14/05 noite
|
||||
- **`handleStatusChange` em `useAgendaFinanceiro.js` está ÓRFÃO** — não reativar
|
||||
- **`_openStatusDialog` + `bloqueioCobrindo` + `dialogBlockOverlap`** declarados no `useMelissaAgenda` mas usados em `_buildHandlers` — passados via `deps`. **NÃO ESQUECER ao replicar em Rail/Clínica**
|
||||
- **`billing_contracts.charging_style`** distingue upfront/saldo/per_session
|
||||
- **Ocorrência virtual tem `id="rec::<rule>::<date>"`** — detectar via `typeof === 'string' && startsWith('rec::')` antes de query Supabase
|
||||
- **`chargeMode` default dinâmico:** `'session'` em avulsa, `'none'` em recorrente
|
||||
- **Toast atrás do overlay do dialog** — usar Message no topo do dialog em vez de toast quando contexto for dentro de dialog modal
|
||||
- **Cuidado com `pickDbFields` whitelist** — `useMelissaAgenda.js:74` descarta campos não listados silenciosamente. Sintoma: campo escolhido na UI mas DB tem valor default. Memória: `memory/project_pickdbfields_whitelist.md`
|
||||
- **`paymentSettlement` foi renomeado** em 16/05 — agora `paymentMethod` (string) + `markPaidNow` (bool). Handler aplica `payment_method` sempre, `status='paid'` só quando markPaidNow=true && method!='link'
|
||||
- **Bulk-load de paymentState em `_reloadRange` etapa 4** — 1 query única em `financial_records` mapeada por `agenda_evento_id`. Anota `paymentState` no normalize. Badge na agenda + linha popover lêem daqui
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Decisões persistidas (memory/)
|
||||
|
||||
**Indicadores visuais (16/05):**
|
||||
- Badge $ no canto: só sessão + paciente + não-virtual + !paid
|
||||
- Linha popover: 3 textos (a receber pending / a cobrar none / cobrança não gerada)
|
||||
- Bulk-load 1x por _reloadRange, não query por evento
|
||||
- Ocorrências virtuais sempre paymentState='none' (cobertas por contrato)
|
||||
|
||||
**Payment refactor (16/05):**
|
||||
- Separar método (forma) de status (já pago?) — controles independentes na UI
|
||||
- Método 'link' (Asaas) força markPaidNow=false (gateway externo)
|
||||
- Wire format: `arg.paymentMethod` + `arg.markPaidNow` (no lugar de `arg.paymentSettlement`)
|
||||
|
||||
**Bugs evitar repetir:**
|
||||
- Sempre adicionar campo novo ao `pickDbFields.allowed` quando adicionar coluna em agenda_eventos
|
||||
- Sempre adicionar campo novo ao `BASE_SELECT` quando query custom
|
||||
- Detectar `is_occurrence` ou `rec::` antes de query por UUID
|
||||
- Refs/funções do composable principal NÃO ficam acessíveis em `_buildHandlers` — passar via `deps`
|
||||
- Toast dentro de dialog modal fica atrás do overlay — usar Message
|
||||
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,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,32 @@
|
||||
# Wiki Index
|
||||
|
||||
This is the catalog of every page in your wiki. Claude updates it automatically.
|
||||
|
||||
**Pattern:** `- [[Page Name]] — one-line summary`
|
||||
|
||||
---
|
||||
|
||||
## Entities
|
||||
|
||||
_(people, places, organizations, products — pages that describe a thing)_
|
||||
|
||||
## Concepts
|
||||
|
||||
_(ideas, frameworks, patterns, principles — pages that describe a concept)_
|
||||
|
||||
- [[recorrencia-agenda]] — modelo "1 real + N-1 virtual", materialização ao mudar status, view `listAll`, visual de paciente inativo
|
||||
|
||||
## Sources
|
||||
|
||||
_(summaries of specific sources you've ingested)_
|
||||
|
||||
## Analyses
|
||||
|
||||
_(synthesized answers to questions you've asked, filed back as pages)_
|
||||
|
||||
- [[agenda-billing-pesquisa-mercado]] — comparativo Cliniko / SimplePractice / TherapyNotes do ciclo compromisso→cobrança (6 etapas), consenso/divergência e 8 perguntas-chave pro produto
|
||||
- [[agenda-compromisso-fluxo]] — plano de auditoria fase-a-fase (8 fases) do ciclo de compromisso da agenda; ordem de execução + decisões já tomadas
|
||||
|
||||
---
|
||||
|
||||
*This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.*
|
||||
@@ -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'`
|
||||
@@ -1,29 +1,3 @@
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
Sakai is an application template for Vue based on the [create-vue](https://github.com/vuejs/create-vue), the recommended way to start a Vite-powered Vue projects.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
Visit the [documentation](https://sakai.primevue.org/documentation) to get started.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Guia de Testes — AgenciaPsi
|
||||
|
||||
## Testes Automatizados
|
||||
|
||||
### Pré-requisito
|
||||
Vitest já instalado (`npm install` resolve). Não precisa de banco, Supabase ou variáveis de ambiente.
|
||||
|
||||
### Comandos
|
||||
|
||||
| Comando | Descrição |
|
||||
|---|---|
|
||||
| `npm test` | Roda todos os testes uma vez e exibe resultado |
|
||||
| `npm run test:watch` | Modo watch — re-roda ao salvar arquivos |
|
||||
| `npm run test:ui` | Abre UI visual no browser (`http://localhost:51204`) |
|
||||
|
||||
### Arquivos de teste
|
||||
|
||||
| Arquivo | O que cobre |
|
||||
|---|---|
|
||||
| `src/features/agenda/composables/__tests__/useRecurrence.spec.js` | Geração de datas por tipo de regra, max_occurrences global, exceções, remarcação cross-range |
|
||||
| `src/features/agenda/services/__tests__/agendaMappers.spec.js` | Mapeamento para FullCalendar, ícones de status, cores, buildNextSessions, minutesToDuration, buildWeeklyBreakBackgroundEvents |
|
||||
|
||||
### Quando rodar
|
||||
- Antes de commitar qualquer mudança em `useRecurrence.js` ou `agendaMappers.js`
|
||||
- Ao adicionar novo tipo de frequência (mensal, quinzenal, etc.)
|
||||
- Ao mexer em exceções de recorrência
|
||||
- Em CI/CD antes do deploy
|
||||
|
||||
---
|
||||
|
||||
## Testes Manuais
|
||||
|
||||
### Preparação
|
||||
1. Limpar dados de teste no banco:
|
||||
```sql
|
||||
TRUNCATE TABLE recurrence_exceptions CASCADE;
|
||||
TRUNCATE TABLE recurrence_rules CASCADE;
|
||||
TRUNCATE TABLE agenda_eventos CASCADE;
|
||||
TRUNCATE TABLE agendador_solicitacoes CASCADE;
|
||||
```
|
||||
2. Fazer login com seu usuário real
|
||||
3. Selecionar a clínica/tenant correto
|
||||
|
||||
---
|
||||
|
||||
### 1. Evento Avulso
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Clicar em um horário livre na agenda | Dialog de criação abre |
|
||||
| Preencher paciente, horário, modalidade → Salvar | Evento aparece no calendário |
|
||||
| Clicar no evento → Editar horário → Salvar | Horário atualiza |
|
||||
| Clicar no evento → Marcar como "Faltou" | Cor muda para vermelho, ícone ✗ |
|
||||
| Clicar no evento → Marcar como "Realizado" | Cor muda para cinza, ícone ✓ |
|
||||
| Clicar no evento → Cancelar sessão | Cor muda para laranja, ícone ∅ |
|
||||
| Clicar no evento → Excluir | Evento some do calendário |
|
||||
|
||||
---
|
||||
|
||||
### 2. Recorrência Semanal
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar evento com frequência "Semanal" | Ocorrências aparecem em todas as semanas seguintes com ícone ↻ |
|
||||
| Navegar para a semana seguinte | Ocorrências continuam aparecendo |
|
||||
| Navegar para além do end_date | Não aparecem ocorrências após a data final |
|
||||
| Criar série com "4 sessões" (max_occurrences) | Exatamente 4 ocorrências visíveis no calendário |
|
||||
|
||||
---
|
||||
|
||||
### 3. Recorrência Quinzenal e Dias Específicos
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar série "Quinzenal" | Ocorrências aparecem a cada 2 semanas |
|
||||
| Criar série "Dias específicos" (ex: seg + qua) | Ambos os dias aparecem toda semana |
|
||||
| Navegar para semanas futuras | Padrão se mantém |
|
||||
|
||||
---
|
||||
|
||||
### 4. Edição de Série
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Clicar em ocorrência → Editar → "Somente este" → mudar horário | Só aquela data muda; as outras continuam iguais |
|
||||
| Clicar em ocorrência → Cancelar → "Somente este" | Só aquela data some (ou aparece cancelada) |
|
||||
| Clicar em ocorrência → Cancelar → "Este e os seguintes" | A partir daquela data, sem mais ocorrências |
|
||||
| Clicar em ocorrência → Cancelar → "Todos" | Série inteira some |
|
||||
|
||||
---
|
||||
|
||||
### 5. Remarcação Cross-Range ⭐
|
||||
|
||||
Este é o caso mais importante a testar.
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar série semanal (ex: toda segunda) | Ocorrências nas segundas |
|
||||
| Clicar na sessão da **semana 1** → Remarcar para **terça da semana 2** | — |
|
||||
| Navegar para a **semana 1** | Segunda da semana 1 aparece vazia ou como "remarcado" |
|
||||
| Navegar para a **semana 2** | Terça aparece com ícone ↺ e status "remarcado" |
|
||||
|
||||
---
|
||||
|
||||
### 6. Bloqueio de Agenda
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar bloqueio de horário | Aparece no calendário com visual diferente (ícone ⊘) |
|
||||
| Tentar agendar no horário bloqueado | Aviso de conflito |
|
||||
|
||||
---
|
||||
|
||||
### 7. Agendamento Online (Agendador Público)
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Acessar URL pública do agendador | Página pública abre sem login |
|
||||
| Selecionar data/horário disponível → Enviar solicitação | Confirmação exibida |
|
||||
| No painel do terapeuta → "Agendamentos Recebidos" | Solicitação aparece na lista |
|
||||
| Clicar em "Confirmar" | Evento criado na agenda |
|
||||
| Clicar em "Recusar" | Solicitação removida, sem evento na agenda |
|
||||
|
||||
---
|
||||
|
||||
### 8. Suporte Técnico SaaS
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Logar como `saas_admin` → Menu "Suporte Técnico" | Página de suporte abre |
|
||||
| Selecionar um tenant → "Criar Sessão de Suporte" | URL com token é gerada |
|
||||
| Copiar URL e abrir em outra aba | Agenda do terapeuta abre com banner de debug no rodapé |
|
||||
| No banner → filtrar logs por categoria | Logs filtram corretamente |
|
||||
| No banner → "Desativar suporte" | Banner some |
|
||||
| No painel SaaS → "Revogar" na sessão ativa | Token invalidado |
|
||||
|
||||
---
|
||||
|
||||
### 9. Multi-Tenancy (se você tem 2 clínicas cadastradas)
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar evento na clínica A | Evento aparece na agenda da clínica A |
|
||||
| Trocar para clínica B | Evento da clínica A **não aparece** |
|
||||
| Criar evento na clínica B | Aparece apenas na clínica B |
|
||||
|
||||
---
|
||||
|
||||
## Pedindo ao Claude para Executar os Testes
|
||||
|
||||
### Como usar o Claude Code para rodar e corrigir testes
|
||||
|
||||
O Claude Code (este agente) consegue rodar os testes, ler os erros e corrigir os problemas automaticamente. Basta iniciar a conversa com o contexto certo.
|
||||
|
||||
### Prompt de retomada recomendado
|
||||
|
||||
Cole isso no início de uma nova sessão com o Claude:
|
||||
|
||||
---
|
||||
|
||||
> Estou desenvolvendo o AgenciaPsi. Temos testes automatizados com Vitest.
|
||||
>
|
||||
> **Arquivos de teste:**
|
||||
> - `src/features/agenda/composables/__tests__/useRecurrence.spec.js` — testa `generateDates`, `expandRules`, `mergeWithStoredSessions`
|
||||
> - `src/features/agenda/services/__tests__/agendaMappers.spec.js` — testa mapeamento para FullCalendar
|
||||
>
|
||||
> **Rodar os testes:** `npm test`
|
||||
>
|
||||
> Por favor, rode os testes agora e me informe o resultado. Se houver falhas, analise a causa e corrija.
|
||||
|
||||
---
|
||||
|
||||
### O que o Claude consegue fazer automaticamente
|
||||
|
||||
| Pedido | O Claude faz |
|
||||
|---|---|
|
||||
| "Rode os testes" | Executa `npm test` e exibe o resultado |
|
||||
| "Tem algum teste falhando?" | Roda e diagnóstica a causa raiz |
|
||||
| "Corrija os testes que falham" | Analisa erro, ajusta o código ou o teste e re-roda |
|
||||
| "Adicionei a funcionalidade X, crie testes para ela" | Lê o código e escreve novos casos no spec |
|
||||
| "O teste Y está errado, o comportamento correto é Z" | Atualiza a asserção e confirma que passa |
|
||||
|
||||
### Boas práticas ao pedir testes ao Claude
|
||||
|
||||
- **Forneça o `AUDITORIA.md`** no início da sessão — dá contexto sobre a arquitetura e decisões já tomadas
|
||||
- **Descreva o comportamento esperado** em português, não o código — o Claude escreve o código do teste
|
||||
- **Se um teste falhar e você achar que o código está certo**, diga isso explicitamente: *"o teste está errado, não o código"* — o Claude vai ajustar a asserção
|
||||
- **Se um teste falhar e você achar que o código está errado**, diga: *"o comportamento esperado é X"* — o Claude vai corrigir a implementação
|
||||
|
||||
### Exemplo de sessão típica
|
||||
|
||||
```
|
||||
Você: Rodei npm test e 2 testes falharam. Analise e corrija.
|
||||
|
||||
Claude: [roda npm test, lê os erros, corrige o código ou as asserções, re-roda até 63/63 passarem]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adicionando Novos Testes
|
||||
|
||||
### Para `useRecurrence.spec.js`
|
||||
|
||||
```js
|
||||
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js'
|
||||
|
||||
it('meu novo caso', () => {
|
||||
const r = {
|
||||
id: 'rule-1', type: 'weekly', weekdays: [1], interval: 1,
|
||||
start_date: '2026-03-02', end_date: null, max_occurrences: null,
|
||||
status: 'ativo', start_time: '09:00', end_time: '10:00',
|
||||
// ... outros campos necessários
|
||||
}
|
||||
const dates = generateDates(r, new Date(2026, 2, 1), new Date(2026, 2, 31))
|
||||
expect(dates.length).toBe(/* esperado */)
|
||||
})
|
||||
```
|
||||
|
||||
### Para `agendaMappers.spec.js`
|
||||
|
||||
```js
|
||||
import { mapAgendaEventosToCalendarEvents } from '../agendaMappers.js'
|
||||
|
||||
it('meu novo caso de mapeamento', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([{
|
||||
id: 'ev-1', titulo: 'Teste', tipo: 'sessao', status: 'agendado',
|
||||
inicio_em: '2026-03-10T09:00:00', fim_em: '2026-03-10T10:00:00',
|
||||
owner_id: 'owner-1',
|
||||
}])
|
||||
expect(ev.extendedProps./* campo */).toBe(/* esperado */)
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,518 @@
|
||||
# WhatsApp Setup — CRM de Conversas + Créditos + Automações
|
||||
|
||||
Guia end-to-end do subsistema de WhatsApp do AgenciaPSI. Cobre **WhatsApp Pessoal** (Evolution, gratuito), **WhatsApp Oficial AgenciaPSI** (Twilio com créditos), **Asaas** (gateway de pagamento), e todas as automações (auto-reply, lembretes, opt-out, tags, notas).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Arquitetura
|
||||
|
||||
### Dois provedores, escolha exclusiva por tenant
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Tenant escolhe 1 canal em /configuracoes/whatsapp │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ WhatsApp Pessoal │ │ WhatsApp Oficial │
|
||||
│ (Evolution) │ │ AgenciaPSI (Twilio) │
|
||||
│ │ │ │
|
||||
│ • Gratuito │ │ • Consome créditos │
|
||||
│ • QR code │ │ • API oficial Meta │
|
||||
│ • Celular real │ │ • Zero ban risk │
|
||||
│ • Docker self-host │ │ • Cloud gerenciado │
|
||||
│ • Tier free do SaaS │ │ • Tier pago do SaaS │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└───────────┬────────────────┘
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Edge Functions │
|
||||
│ │
|
||||
│ • send-whatsapp-message │ ← rota por provider
|
||||
│ • send-session-reminders │ ← idem
|
||||
│ • evolution-whatsapp-inbound (auto-reply, opt-out)
|
||||
│ • twilio-whatsapp-inbound (⚠ sem auto-reply ainda)
|
||||
│ • create-whatsapp-credit-charge (Asaas PIX)
|
||||
│ • asaas-webhook (credita saldo)
|
||||
└─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ │
|
||||
│ conversation_messages │
|
||||
│ conversation_notes │
|
||||
│ conversation_tags │
|
||||
│ conversation_optouts │
|
||||
│ conversation_autoreply_* │
|
||||
│ session_reminder_* │
|
||||
│ whatsapp_credits_* │
|
||||
│ whatsapp_credit_packages │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### Dedução de créditos
|
||||
|
||||
```
|
||||
Usuário envia pelo drawer / lembrete dispara / auto-reply
|
||||
↓
|
||||
Edge function detecta provider do canal ativo
|
||||
↓
|
||||
Evolution? Twilio?
|
||||
↓ ↓
|
||||
Envia direto deduct_whatsapp_credits(1) ← atômico, lock, valida saldo
|
||||
↓ ↓
|
||||
Registra msg ┌──── OK ────┐ ┌── insufficient ──┐
|
||||
↓ ↓
|
||||
send Twilio return 402
|
||||
↓
|
||||
┌── ok ──┐ ┌── fail ──┐
|
||||
↓ ↓
|
||||
Registra add_whatsapp_credits(1, 'refund')
|
||||
msg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Setup completo (dev local)
|
||||
|
||||
### 1. Supabase local + edge functions
|
||||
|
||||
```bash
|
||||
# Subir stack Supabase local (Postgres + Auth + Storage + etc)
|
||||
npx supabase start
|
||||
|
||||
# Em outro terminal: rodar edge functions
|
||||
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
|
||||
```
|
||||
|
||||
**Funções carregadas:**
|
||||
|
||||
| Função | URL | Uso |
|
||||
|---|---|---|
|
||||
| `evolution-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Evolution: inbound msgs + auto-reply + opt-out |
|
||||
| `evolution-webhook-provision` | — | Configura webhook na Evolution |
|
||||
| `twilio-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Twilio (inbound only; sem auto-reply ainda) |
|
||||
| `send-whatsapp-message` | — | Envio unificado: detecta provider, deduz crédito se Twilio |
|
||||
| `send-session-reminders` | — | Cron/manual: dispara lembretes 24h e 2h antes |
|
||||
| `create-whatsapp-credit-charge` | — | Cria PIX Asaas pra compra de créditos |
|
||||
| `asaas-webhook` | — | Recebe eventos Asaas e credita saldo |
|
||||
| `deactivate-notification-channel` | — | Desativa canal (usado ao trocar provider) |
|
||||
|
||||
**Flag `--no-verify-jwt`:** necessária porque webhooks externos (Twilio, Evolution, Asaas) não mandam JWT.
|
||||
|
||||
### 2. Evolution API (WhatsApp Pessoal — tier gratuito)
|
||||
|
||||
```bash
|
||||
# Subir Evolution + Postgres + Redis
|
||||
docker compose -f evolution-api/docker-compose.yml up -d
|
||||
|
||||
# Verificar status
|
||||
docker ps --filter name=evolution_api
|
||||
```
|
||||
|
||||
Evolution roda em `http://localhost:8080` com API key `minha_chave_123` (ver `evolution-api/docker-compose.yml`).
|
||||
|
||||
### 3. Asaas (pagamentos — tier pago)
|
||||
|
||||
Ativa só em prod ou quando quiser testar compra de créditos end-to-end.
|
||||
|
||||
**Passo 1 — Criar conta sandbox:**
|
||||
1. https://sandbox.asaas.com (gratuito, CPF qualquer)
|
||||
2. Menu → Integrações → Integrações Avançadas → API → copia a API key (começa com `$aact_...`)
|
||||
|
||||
**Passo 2 — Configurar env:**
|
||||
|
||||
Edita `supabase/functions/.env`:
|
||||
```env
|
||||
ASAAS_API_KEY=$aact_sua_chave_aqui
|
||||
ASAAS_API_URL=https://sandbox.asaas.com/api/v3
|
||||
ASAAS_WEBHOOK_TOKEN= # opcional, pra autenticar webhook
|
||||
```
|
||||
|
||||
**Passo 3 — Reiniciar functions serve:**
|
||||
```bash
|
||||
# Ctrl+C no terminal do serve
|
||||
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
|
||||
```
|
||||
|
||||
**Passo 4 — (Opcional) Expor webhook via ngrok pro Asaas alcançar:**
|
||||
```bash
|
||||
# Outro terminal
|
||||
ngrok http 54321
|
||||
# Copia a URL (ex: https://abc123.ngrok.app)
|
||||
```
|
||||
|
||||
Configura no Asaas:
|
||||
- Dashboard → Integrações → Webhooks → **Adicionar**
|
||||
- URL: `https://abc123.ngrok.app/functions/v1/asaas-webhook`
|
||||
- Eventos: marca **Cobranças** (PAYMENT_RECEIVED, PAYMENT_CONFIRMED, PAYMENT_OVERDUE, PAYMENT_DELETED, PAYMENT_REFUNDED)
|
||||
- Token (opcional): cadastra o mesmo valor de `ASAAS_WEBHOOK_TOKEN`
|
||||
|
||||
**Em produção:**
|
||||
```bash
|
||||
supabase secrets set ASAAS_API_KEY="$aact_prod_key"
|
||||
supabase secrets set ASAAS_API_URL="https://api.asaas.com/v3"
|
||||
supabase secrets set ASAAS_WEBHOOK_TOKEN="token_seguro"
|
||||
```
|
||||
|
||||
Webhook da prod aponta pro URL real do Supabase cloud (sem ngrok).
|
||||
|
||||
---
|
||||
|
||||
## 📋 Features & como testar
|
||||
|
||||
### A. Envio manual via drawer
|
||||
|
||||
**Onde:** drawer de qualquer conversa (clica no card do Kanban em `/therapist/conversas`)
|
||||
|
||||
**Fluxo:**
|
||||
1. Compose no drawer → `store.sendMessage()` → chama `send-whatsapp-message` edge function
|
||||
2. Function detecta provider ativo em `notification_channels`
|
||||
3. Evolution: envia direto via `/message/sendText/{instance}`
|
||||
4. Twilio: `deduct_whatsapp_credits(1)` → se OK envia via Twilio API → se falhar, refunda
|
||||
5. Registra em `conversation_messages` (direction=outbound, delivery_status=sent/queued)
|
||||
|
||||
**Testar sem Twilio real** (valida dedução + rollback):
|
||||
- Topup 100 créditos via SQL (ver seção 🧪 mais abaixo)
|
||||
- Criar canal Twilio fake via SQL
|
||||
- Enviar msg → deduz, tenta enviar, falha com 401, refunda → saldo volta ao original
|
||||
|
||||
### B. Lembretes automáticos de sessão (2.4)
|
||||
|
||||
**Onde:** `/configuracoes/lembretes-sessao`
|
||||
|
||||
**Config:**
|
||||
- Toggle on/off
|
||||
- Ativa lembretes 24h e/ou 2h antes da sessão
|
||||
- Templates com variáveis: `{{nome_paciente}}`, `{{data_sessao}}`, `{{hora_sessao}}`, `{{modalidade}}`, `{{nome_clinica}}`
|
||||
- Quiet hours (default 22h-8h SP)
|
||||
- Respeitar opt-out (LGPD — recomendado ON)
|
||||
|
||||
**Como dispara:**
|
||||
- Cron hit `send-session-reminders` a cada 15min (pg_cron comentado na migration; em prod configure via Supabase Dashboard → Database → Cron Jobs)
|
||||
- Em dev: botão **"Testar agora"** na página dispara manualmente
|
||||
|
||||
**Query do worker:** busca `agenda_eventos` com `status='agendado'` dentro de:
|
||||
- Janela 24h: `inicio_em` entre `now+23h45min` e `now+24h15min`
|
||||
- Janela 2h: `inicio_em` entre `now+1h45min` e `now+2h15min`
|
||||
|
||||
**Anti-dup:** UNIQUE `(event_id, reminder_type)` no `session_reminder_logs`.
|
||||
|
||||
**Testar:**
|
||||
```sql
|
||||
-- Cria evento daqui a ~2h (no horário de SP)
|
||||
-- Pelo UI da agenda é mais fácil; via SQL:
|
||||
INSERT INTO agenda_eventos (tenant_id, owner_id, patient_id, inicio_em, fim_em, status, modalidade, tipo, titulo)
|
||||
VALUES (
|
||||
'<tenant>',
|
||||
'<user>',
|
||||
(SELECT id FROM patients WHERE tenant_id='<tenant>' LIMIT 1),
|
||||
now() + interval '2 hours',
|
||||
now() + interval '3 hours',
|
||||
'agendado',
|
||||
'presencial',
|
||||
'session',
|
||||
'Teste lembrete'
|
||||
);
|
||||
```
|
||||
Depois clica **"Testar agora"** em `/configuracoes/lembretes-sessao`.
|
||||
|
||||
### C. Auto-reply fora do horário (2.3)
|
||||
|
||||
**Onde:** `/configuracoes/conversas-autoreply`
|
||||
|
||||
**Config:**
|
||||
- Toggle on/off + mensagem + cooldown (minutos entre auto-replies pra mesma thread)
|
||||
- 3 modos:
|
||||
- **Seguir agenda** — usa `agenda_regras_semanais` dos membros ativos do tenant
|
||||
- **Horário de funcionamento** — janela semanal editável (armazena em JSONB `business_hours`)
|
||||
- **Custom** — janela específica pro auto-reply (`custom_window`)
|
||||
|
||||
**Como dispara:**
|
||||
- Webhook Evolution `evolution-whatsapp-inbound` recebe msg
|
||||
- Depois de inserir msg, chama `maybeSendAutoReply()`
|
||||
- Checa: enabled, não está em horário útil, não está em cooldown
|
||||
- Se OK → envia via Evolution (futuro: rotear pra Twilio se provider='twilio')
|
||||
|
||||
**⚠ Limitação:** atualmente **só funciona com Evolution**. Pra Twilio precisa implementar a mesma lógica em `twilio-whatsapp-inbound` (dívida técnica).
|
||||
|
||||
**Testar:**
|
||||
- Ativa feature + define janela custom (ex: seg-sex 9h-18h)
|
||||
- Fora dessa janela, paciente manda msg → chega no inbox + auto-reply é enviado de volta em ~1s
|
||||
|
||||
### D. Opt-out LGPD (5.2)
|
||||
|
||||
**Onde:** `/configuracoes/conversas-optouts`
|
||||
|
||||
**Como funciona:**
|
||||
- Paciente envia "PARAR", "SAIR", "CANCELAR", "STOP", etc (keyword match case-insensitive sem acentos)
|
||||
- Edge function `evolution-whatsapp-inbound` detecta → registra em `conversation_optouts` → envia msg de confirmação
|
||||
- Paciente envia "VOLTAR" / "RETORNAR" → reativa (opted_back_in_at preenchido)
|
||||
- Auto-reply e lembretes **respeitam opt-out** automaticamente (skip + log `opted_out`)
|
||||
- Envio manual do terapeuta NÃO é bloqueado (relação terapêutica existe)
|
||||
|
||||
**Keywords padrão:** 10 palavras (configuráveis na página — pode adicionar custom do tenant).
|
||||
|
||||
**Testar:**
|
||||
- Manda mensagem com "parar" pelo WhatsApp conectado
|
||||
- Volta em `/configuracoes/conversas-optouts` → número aparece na lista
|
||||
- Nova mensagem que dispararia auto-reply → não dispara mais
|
||||
|
||||
### E. Notas internas (3.3)
|
||||
|
||||
**Onde:** dentro do drawer de conversa, seção "Notas internas" collapsible
|
||||
|
||||
**Como funciona:**
|
||||
- CRUD simples por thread
|
||||
- Visível apenas pra membros ativos do tenant
|
||||
- Edição/remoção só pelo criador (ou SaaS admin)
|
||||
- Soft delete (`deleted_at`)
|
||||
- **NÃO vai pro paciente** — apenas anotação interna da equipe
|
||||
|
||||
### F. Tags na conversa (3.1)
|
||||
|
||||
**Onde:**
|
||||
- **Gestão:** `/configuracoes/conversas-tags` (CRUD de tags custom; system tags são read-only)
|
||||
- **Aplicação:** dentro do drawer de conversa + pills visíveis nos cards do Kanban
|
||||
|
||||
**Tags system (seedadas):** Urgente (🔴), Primeira consulta (🔵), Remarcação (🟡), Confirmada (🟢), Follow-up (🟣)
|
||||
|
||||
**Custom:** tenant cria suas próprias com nome + slug + cor + ícone (primeicons).
|
||||
|
||||
### G. Mídia (áudio / imagem / vídeo / documento)
|
||||
|
||||
**Arquitetura:**
|
||||
- Evolution manda URLs encriptadas do Meta CDN (não tocam direto)
|
||||
- Edge function `evolution-whatsapp-inbound` chama `/chat/getBase64FromMediaMessage/{instance}` do Evolution → decripta
|
||||
- Decoda base64 → faz upload no bucket **privado `whatsapp-media`**
|
||||
- Path: `<tenant_id>/<yyyy>/<mm>/<msg_id>_<timestamp>.<ext>`
|
||||
- Salva apenas o PATH em `media_url`, NÃO URL pública
|
||||
- Frontend (`ConversationDrawer`) gera **signed URL on-demand** (1h TTL) ao renderizar
|
||||
|
||||
**LGPD:** bucket privado, RLS só permite membros ativos do tenant; path tenant-scoped; signed URLs expiram.
|
||||
|
||||
**Player de áudio:** `<audio min-w-[260px] controls>` + `preload="metadata"` pra mostrar duração sem baixar tudo.
|
||||
|
||||
**Preview de imagem:** `<Image preview>` do PrimeVue (fullscreen com zoom/rotate) + **botão download injetado via MutationObserver** (fetch blob → download attr → force download com nome do arquivo).
|
||||
|
||||
### H. Créditos WhatsApp (Marco B)
|
||||
|
||||
**Onde:** `/configuracoes/creditos-whatsapp`
|
||||
|
||||
**Fluxo de compra:**
|
||||
1. User clica "Comprar" num pacote → `create-whatsapp-credit-charge` cria:
|
||||
- Customer Asaas (ou reutiliza via `externalReference=tenant_id`)
|
||||
- Pagamento PIX (`billingType=PIX`, value, dueDate)
|
||||
- Busca QR Code em `/payments/{id}/pixQrCode`
|
||||
2. Dialog mostra QR + copia-cola + link cartão
|
||||
3. User paga
|
||||
4. Asaas → webhook `asaas-webhook` recebe `PAYMENT_RECEIVED`/`CONFIRMED`
|
||||
5. Webhook chama `add_whatsapp_credits(tenant, credits, 'purchase')` → saldo atualiza
|
||||
|
||||
**Pacotes seedados:** Iniciante (100/R$49,90), Profissional (500/R$199,90, ⭐ featured), Clínica (1500/R$499,90), Enterprise (5000/R$1499,90) — editáveis via DB.
|
||||
|
||||
**RPCs atômicas (SECURITY DEFINER):**
|
||||
- `add_whatsapp_credits(tenant, amount, kind, purchase_id, admin_id, note)` → retorna novo saldo
|
||||
- `deduct_whatsapp_credits(tenant, amount, msg_id, note)` → atômico com `SELECT FOR UPDATE`; lança `insufficient_credits` se saldo < amount
|
||||
|
||||
**CPF:** fallback hardcoded `24971563792` em sandbox. Em produção, frontend deve coletar CPF real do user (TODO — adicionar input no dialog + salvar em profile/tenant).
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testar sem provedor real
|
||||
|
||||
### Pré-requisito: creditar saldo
|
||||
|
||||
No Supabase Studio (`http://localhost:54323`) → SQL Editor (**desativa LIMIT 100 no dropdown**):
|
||||
|
||||
```sql
|
||||
SELECT public.add_whatsapp_credits(
|
||||
tm.tenant_id,
|
||||
100,
|
||||
'topup_manual',
|
||||
NULL,
|
||||
auth.uid(),
|
||||
'Topup de teste'
|
||||
) AS novo_saldo
|
||||
FROM public.tenant_members tm
|
||||
JOIN auth.users u ON u.id = tm.user_id
|
||||
WHERE u.email = 'SEU_EMAIL'
|
||||
AND tm.status = 'active'
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### Simular canal Twilio fake (pra testar dedução)
|
||||
|
||||
```sql
|
||||
-- Desativa Evolution
|
||||
UPDATE public.notification_channels
|
||||
SET is_active = false, deleted_at = now()
|
||||
WHERE tenant_id = (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
JOIN auth.users u ON u.id = tm.user_id
|
||||
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active' LIMIT 1
|
||||
)
|
||||
AND channel = 'whatsapp' AND deleted_at IS NULL;
|
||||
|
||||
-- Cria Twilio fake
|
||||
INSERT INTO public.notification_channels (
|
||||
tenant_id, owner_id, channel, provider, is_active,
|
||||
twilio_subaccount_sid, twilio_phone_number, credentials
|
||||
)
|
||||
SELECT
|
||||
tm.tenant_id, u.id, 'whatsapp', 'twilio', true,
|
||||
'ACfake0000000000000000000000000000',
|
||||
'+15557775555',
|
||||
'{"subaccount_auth_token": "fake_token"}'::jsonb
|
||||
FROM public.tenant_members tm
|
||||
JOIN auth.users u ON u.id = tm.user_id
|
||||
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active'
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
Agora qualquer envio:
|
||||
- Deduz 1 crédito (`usage -1`)
|
||||
- Twilio API retorna 401 (creds fake)
|
||||
- Refunda automaticamente (`refund +1`)
|
||||
- Saldo final: igual ao inicial
|
||||
|
||||
Resultado esperado no extrato:
|
||||
```
|
||||
+1 Estorno — Refund envio falhou: Twilio 401: ...
|
||||
-1 Uso — Envio manual WhatsApp
|
||||
+100 Topup manual — Topup de teste
|
||||
```
|
||||
|
||||
### Simular mensagem inbound via curl
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:54321/functions/v1/evolution-whatsapp-inbound?tenant_id=<uuid>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "messages.upsert",
|
||||
"data": {
|
||||
"key": { "remoteJid": "5516912345678@s.whatsapp.net", "fromMe": false, "id": "MSG_TEST" },
|
||||
"message": { "conversation": "parar" },
|
||||
"messageTimestamp": '"$(date +%s)"'
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Isso vai testar detecção de opt-out ("parar" é keyword) → registra na lista + envia ACK.
|
||||
|
||||
### Testar Twilio sandbox real (opcional)
|
||||
|
||||
1. Cria conta trial em https://www.twilio.com/try-twilio (US$15 grátis)
|
||||
2. Dashboard → Messaging → Try it out → **Send a WhatsApp Message**
|
||||
3. Segue instruções pra join sandbox (`join <frase>` do seu celular pro número Twilio)
|
||||
4. Pega Account SID + Auth Token + From number do sandbox
|
||||
5. Atualiza o canal Twilio no SQL com valores reais
|
||||
6. Envia mensagem do drawer → chega no seu celular
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
| Sintoma | Causa provável | Fix |
|
||||
|---|---|---|
|
||||
| 404 no webhook | `supabase functions serve` não rodando OU function não carregada | Reinicia serve com `--env-file` |
|
||||
| 502 edge function | Crash antes do `console.error` — erro de sintaxe, env var faltando, ou API externa timeout | Olha logs do serve; adiciona `console.log` em cada etapa |
|
||||
| Asaas 400 "CPF obrigatório" | Customer existente sem CPF | Fix em `getOrCreateAsaasCustomer` faz PATCH com CPF quando falta |
|
||||
| QR code PIX não aparece | Asaas sandbox sem PIX habilitado OU endpoint `/pixQrCode` falhou | Ativa PIX em Asaas → Recebimentos → Chaves PIX |
|
||||
| Webhook Asaas não chega | URL não pública (localhost) | Usa ngrok OU deploy em prod |
|
||||
| "insufficient_credits" no envio | Saldo zerado | Topup via `/configuracoes/creditos-whatsapp` ou SQL manual |
|
||||
| Auto-reply não dispara | Tenant fora do modo horário configurado OU em cooldown OU opted-out | Checa banner de status em `/configuracoes/conversas-autoreply`; olha `conversation_autoreply_log` |
|
||||
| Lembrete duplicado | Não acontece — UNIQUE `(event_id, reminder_type)` previne | — |
|
||||
| Audio não toca (era o ponto inicial do Marco A-I) | URL encriptada do Meta salva direto | Fix: edge function agora decripta via `getBase64FromMediaMessage` + upload pro bucket |
|
||||
| Mime `audio/ogg; codecs=opus` rejeitado pelo bucket | `allowed_mime_types` faz match exato | Fix: strip `;codecs=...` antes do upload |
|
||||
| Dialog de confirmação aparece 2x | Dois `<ConfirmDialog>` montados (página + drawer global) | Fix: drawer usa `group="conversation-drawer"` pra isolar |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Segurança & Compliance
|
||||
|
||||
### RLS de `conversation_messages`
|
||||
- **SELECT:** tenant members ativos OU saas_admin
|
||||
- **INSERT direto:** bloqueado (só service_role via edge function)
|
||||
- **UPDATE:** tenant members podem mudar `kanban_status`, `read_at`
|
||||
- **DELETE:** bloqueado
|
||||
|
||||
### RLS de `whatsapp-media` bucket
|
||||
- Privado
|
||||
- Read: membros ativos do tenant cujo id é o primeiro segmento do path
|
||||
- Write: apenas service_role
|
||||
- Delete: apenas saas_admin
|
||||
|
||||
### RLS de `whatsapp_credits_*`
|
||||
- Balance/transactions: tenant members leem
|
||||
- Escrita: via RPCs `add_whatsapp_credits` / `deduct_whatsapp_credits` (SECURITY DEFINER)
|
||||
- Packages: tenant members leem os ativos; saas_admin gerencia
|
||||
|
||||
### LGPD
|
||||
- **Art. 18 §2 (direito de oposição):** opt-out implementado. Auto-reply + lembretes respeitam.
|
||||
- **Dados clínicos:** canal Oficial (Twilio) recomendado em prod. Pessoal (Evolution) é uso informal, sem SLA, tenant assume risco.
|
||||
- **Audit log:** toda transação de crédito fica em `whatsapp_credits_transactions` (append-only).
|
||||
|
||||
### Bot defense / Rate limiting
|
||||
- `public_submission_attempts`, `submission_rate_limits` e `math_challenges` são tabelas do sistema de bot defense pro cadastro externo. Não relacionado ao WhatsApp, mas protege fluxo paralelo.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Referência de arquivos
|
||||
|
||||
### Edge functions (`supabase/functions/`)
|
||||
- `evolution-whatsapp-inbound/` — webhook Evolution (msgs inbound + auto-reply + opt-out + media)
|
||||
- `evolution-webhook-provision/` — configura webhook na Evolution
|
||||
- `twilio-whatsapp-inbound/` — webhook Twilio (inbound only, sem automações ainda)
|
||||
- `twilio-whatsapp-provision/` — provisiona subconta Twilio (SaaS admin only)
|
||||
- `send-whatsapp-message/` — envio unificado (Evolution ou Twilio com dedução)
|
||||
- `send-session-reminders/` — worker de lembretes (chamado por cron)
|
||||
- `create-whatsapp-credit-charge/` — cria PIX Asaas
|
||||
- `asaas-webhook/` — recebe eventos Asaas e credita saldo
|
||||
- `deactivate-notification-channel/` — soft-delete de canal (via service_role)
|
||||
|
||||
### Composables (`src/composables/`)
|
||||
- `useConversations.js` — Kanban threads
|
||||
- `useConversationNotes.js` — notas internas
|
||||
- `useConversationTags.js` — tags CRUD + apply
|
||||
- `useConversationOptouts.js` — opt-outs + keywords
|
||||
- `useAutoReplySettings.js` — config auto-reply
|
||||
- `useSessionReminders.js` — config lembretes + logs
|
||||
- `useWhatsappCredits.js` — saldo + loja + extrato
|
||||
|
||||
### Páginas principais (`src/layout/configuracoes/`)
|
||||
- `ConfiguracoesWhatsappChooserPage.vue` — landing/chooser
|
||||
- `ConfiguracoesWhatsappPage.vue` — setup Evolution (Pessoal)
|
||||
- `ConfiguracoesTwilioWhatsappPage.vue` — setup Twilio (Oficial, rebrandeado)
|
||||
- `ConfiguracoesConversasTagsPage.vue` — CRUD tags
|
||||
- `ConfiguracoesConversasOptoutsPage.vue` — lista opt-outs + keywords
|
||||
- `ConfiguracoesConversasAutoreplyPage.vue` — auto-reply
|
||||
- `ConfiguracoesLembretesSessaoPage.vue` — lembretes
|
||||
- `ConfiguracoesCreditosWhatsappPage.vue` — saldo + loja + histórico
|
||||
|
||||
### Tabelas principais (database-novo/schema/)
|
||||
Ver dashboard interativo: `database-novo/agenciapsi-db-dashboard.html` (regenerado via `node db.cjs dashboard`).
|
||||
|
||||
Domínios no dashboard:
|
||||
- **CRM Conversas (WhatsApp)** — todas as tabelas de conversas, notas, tags, opt-outs, auto-reply, lembretes
|
||||
- **Addons / Créditos** — créditos WhatsApp (balance, transactions, packages, purchases)
|
||||
- **Comunicação / Notificações** — canais, templates, queue
|
||||
|
||||
---
|
||||
|
||||
## 📌 Dívidas técnicas conhecidas
|
||||
|
||||
1. **Auto-reply via Twilio** — hoje só funciona com Evolution. Precisa portar lógica pra `twilio-whatsapp-inbound`.
|
||||
2. **Opt-out via Twilio** — idem (keyword detection no inbound Twilio).
|
||||
3. **Admin SaaS UI de créditos** — topup manual + gestão de pacotes (hoje via SQL).
|
||||
4. **Input de CPF real** no dialog de compra (hoje fallback hardcoded em sandbox).
|
||||
5. **Alerta de saldo baixo** — estrutura DB pronta (`low_balance_threshold`, `low_balance_alerted_at`), falta trigger/notification.
|
||||
6. **Reconnect Evolution automático** — Grupo 6.3 do roadmap (heartbeat + reconnect).
|
||||
7. **pg_cron em prod** — migration tem bloco comentado; ativar via Supabase Dashboard → Database → Cron Jobs ou descomentar após setar `app.settings.service_role_key`.
|
||||
|
||||
---
|
||||
|
||||
**Última atualização:** 2026-04-21 (sessão de features CRM WhatsApp + Marco B Créditos)
|
||||
@@ -0,0 +1,159 @@
|
||||
# DialogConfirmation — Padrão de Componente
|
||||
|
||||
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## Regras gerais
|
||||
|
||||
| Propriedade | Valor obrigatório |
|
||||
|---|---|
|
||||
| `group` | sempre `"headless"` — desacopla o template do trigger |
|
||||
| `ConfirmDialog` | declarado **uma única vez**, no componente pai (página) |
|
||||
| Filhos | disparam via `useConfirm()` com `group: 'headless'` — sem declarar `ConfirmDialog` próprio |
|
||||
| `icon` | passado em `confirm.require({ icon })` — classe PrimeIcons sem o prefixo `pi` (ex: `'pi-trash'`) |
|
||||
| `color` | passado em `confirm.require({ color })` — hex; define o fundo do círculo e a cor do botão Confirmar |
|
||||
| `ConfirmationService` | obrigatório em `main.js` — `app.use(ConfirmationService)` |
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura pai / filho
|
||||
|
||||
```
|
||||
Pai (página)
|
||||
└── <ConfirmDialog group="headless" /> ← único, renderiza aqui
|
||||
├── Filho A (componente qualquer) → confirm.require({ group: 'headless', ... })
|
||||
└── Filho B (Dialog interno) → confirm.require({ group: 'headless', ... })
|
||||
```
|
||||
|
||||
> O `ConfirmDialog` **não** deve ser colocado dentro de um `<Dialog>` filho — isso causaria dois popups simultâneos. Sempre no pai.
|
||||
|
||||
---
|
||||
|
||||
## Setup obrigatório — `main.js`
|
||||
|
||||
```js
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
|
||||
app.use(ConfirmationService) // sem isso, useConfirm() não funciona
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template do `ConfirmDialog` (somente no pai)
|
||||
|
||||
```vue
|
||||
<!-- Declarado uma única vez, antes do conteúdo principal -->
|
||||
<ConfirmDialog group="headless">
|
||||
<template #container="{ message, acceptCallback, rejectCallback }">
|
||||
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
|
||||
|
||||
<!-- Círculo central: cor e ícone vindos de message -->
|
||||
<div
|
||||
class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20"
|
||||
:style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }"
|
||||
>
|
||||
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
|
||||
</div>
|
||||
|
||||
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
|
||||
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
|
||||
|
||||
<div class="flex items-center gap-2 mt-6">
|
||||
<!-- Confirmar: cor dinâmica via message.color -->
|
||||
<Button
|
||||
label="Confirmar"
|
||||
class="rounded-full"
|
||||
:style="{
|
||||
background: message.color || 'var(--p-primary-color)',
|
||||
borderColor: message.color || 'var(--p-primary-color)'
|
||||
}"
|
||||
@click="acceptCallback"
|
||||
/>
|
||||
<!-- Cancelar: sempre outlined, neutro -->
|
||||
<Button
|
||||
label="Cancelar"
|
||||
variant="outlined"
|
||||
class="rounded-full"
|
||||
@click="rejectCallback"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uso nos componentes (pai ou filhos)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
const confirm = useConfirm()
|
||||
|
||||
function confirmDelete(item) {
|
||||
confirm.require({
|
||||
group: 'headless',
|
||||
header: 'Excluir item?',
|
||||
message: `"${item.name}" será removido permanentemente. Essa ação não pode ser desfeita.`,
|
||||
icon: 'pi-trash',
|
||||
color: '#ef4444',
|
||||
accept: () => onDelete(item)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paleta de ícones e cores por ação
|
||||
|
||||
| Ação | `icon` | `color` | Observação |
|
||||
|---|---|---|---|
|
||||
| Excluir / Remover | `pi-trash` | `#ef4444` | Vermelho — ação destrutiva |
|
||||
| Salvar / Confirmar | `pi-save` | `var(--p-primary-color)` | Cor primária do tema |
|
||||
| Editar / Atualizar | `pi-pencil` | `#f97316` | Laranja — mudança de estado |
|
||||
| Aviso / Atenção | `pi-exclamation-triangle` | `#eab308` | Amarelo — ação reversível |
|
||||
| Info / Neutro | `pi-info-circle` | `#3b82f6` | Azul — informativo |
|
||||
|
||||
---
|
||||
|
||||
## Referência completa de `confirm.require`
|
||||
|
||||
```js
|
||||
confirm.require({
|
||||
group: 'headless', // obrigatório — aponta para o ConfirmDialog correto
|
||||
header: 'Título do popup', // linha em negrito
|
||||
message: 'Descrição clara.', // linha secundária
|
||||
icon: 'pi-trash', // sufixo PrimeIcons sem o "pi " inicial
|
||||
color: '#ef4444', // hex — fundo do círculo + cor do botão Confirmar
|
||||
accept: () => { /* ação confirmada */ },
|
||||
reject: () => { /* opcional — ação cancelada */ }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de usar
|
||||
|
||||
- [ ] `ConfirmationService` registrado no `main.js`
|
||||
- [ ] `<ConfirmDialog group="headless">` declarado **apenas no pai**, antes do conteúdo
|
||||
- [ ] Filhos usam `useConfirm()` com `group: 'headless'` — sem `ConfirmDialog` próprio
|
||||
- [ ] `icon` passado como sufixo PrimeIcons: `'pi-trash'`, não `'pi pi-trash'`
|
||||
- [ ] `color` em hex para ações com semântica de cor (delete = `#ef4444`)
|
||||
- [ ] `header` curto e direto | `message` com contexto suficiente para o usuário decidir
|
||||
- [ ] `accept` contém a ação real — `reject` é opcional
|
||||
|
||||
---
|
||||
|
||||
## Variações de confirmação
|
||||
|
||||
| Contexto | `header` | `icon` | `color` |
|
||||
|---|---|---|---|
|
||||
| Excluir registro | `'Excluir <entidade>?'` | `pi-trash` | `#ef4444` |
|
||||
| Remover item de lista | `'Remover campo?'` | `pi-trash` | `#ef4444` |
|
||||
| Salvar com impacto | `'Confirmar alterações?'` | `pi-save` | primária |
|
||||
| Atualizar com risco | `'Atualizar <entidade>?'` | `pi-pencil` | `#f97316` |
|
||||
| Ação irreversível genérica | `'Tem certeza?'` | `pi-exclamation-triangle` | `#eab308` |
|
||||
@@ -0,0 +1,514 @@
|
||||
# Composable Blueprint
|
||||
|
||||
> **Stack:** Vue 3 Composition API + Pinia (para state global) + Supabase via repository
|
||||
> **Canônicos:** `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
|
||||
> **Aplicável:** todo composable que orquestra estado reativo sobre uma repository
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio
|
||||
|
||||
Composable é **wrapper fino** sobre a repository. Responsabilidade:
|
||||
- Manter **estado reativo** (data + loading + error)
|
||||
- Chamar a repository (delegação 1:1)
|
||||
- (Opcional) Cachear com stale-while-revalidate
|
||||
- (Opcional) Compor outros composables
|
||||
|
||||
**Não faz:**
|
||||
- Lógica de banco direta (vai no repository)
|
||||
- Lógica de UI (vai no componente)
|
||||
- Manipulação de DOM
|
||||
- I/O direto fora do repository
|
||||
|
||||
> Regra de ouro: **se o composable tem `from('...')` do Supabase, ele virou repository disfarçado — refatorar.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Estrutura de arquivos
|
||||
|
||||
```
|
||||
src/features/<modulo>/composables/
|
||||
├── use<Entity>.js # CRUD básico (thin wrapper)
|
||||
├── use<Entity>Clinic.js # variant clinic-scoped (se aplicável)
|
||||
├── use<Entity>Settings.js # config/preferences (com cache opt-in)
|
||||
├── use<Entity>Lifecycle.js # orquestrador de estados (se domain complexo)
|
||||
└── <entity>Helpers.js # funções puras auxiliares (não-composable)
|
||||
```
|
||||
|
||||
**Convenção de nome:** sempre `use<Entity>...`. Funções helpers de domínio NÃO usam prefixo `use` — não são composables.
|
||||
|
||||
---
|
||||
|
||||
## 3. State shape canônico
|
||||
|
||||
Todo composable expõe **no mínimo** este shape:
|
||||
|
||||
```js
|
||||
const rows = ref([]); // ou single ref dependendo do domínio
|
||||
const loading = ref(false); // boolean
|
||||
const error = ref(''); // string vazia, não null — facilita v-if
|
||||
```
|
||||
|
||||
**Decisões importantes:**
|
||||
|
||||
| Refs | Tipo | Inicial | Por quê |
|
||||
|---|---|---|---|
|
||||
| `loading` | `boolean` | `false` | Padrão V3 — UI binda `:disabled="loading"` direto |
|
||||
| `error` | `string` | `''` (vazio) | `v-if="error"` é falsy-friendly; sem null check |
|
||||
| `rows`/data | `Array` ou objeto | `[]` ou `null` | Reset pra `[]` em erro de load — UI fica previsível |
|
||||
|
||||
**Anti-pattern:** misturar `error = ref(null)` num composable e `error = ref('')` em outro. Canonize `''` no projeto inteiro.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tipos de composable (3 patterns)
|
||||
|
||||
### Tipo A — Thin wrapper (default) · referência: `useAgendaClinicEvents.js`
|
||||
|
||||
CRUD direto, sem cache, com loading/error em TODA operação:
|
||||
|
||||
```js
|
||||
import { ref } from 'vue';
|
||||
import { listX, createX, updateX, deleteX } from '@/features/<modulo>/services/<feature>Repository';
|
||||
|
||||
export function useX() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadRange({ startISO, endISO, ...scope } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listX({ startISO, endISO, ...scope });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload, opts = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await createX(payload, opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar.';
|
||||
throw e; // ← re-throw: composable repassa o erro pro componente decidir
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch, opts = {}) { /* idem */ }
|
||||
async function remove(id, opts = {}) { /* idem */ }
|
||||
|
||||
return { rows, loading, error, loadRange, create, update, remove };
|
||||
}
|
||||
```
|
||||
|
||||
**Por que re-throw nas mutações?** Componente precisa saber se o `await` falhou pra:
|
||||
- Mostrar toast
|
||||
- Não fechar modal
|
||||
- Não navegar
|
||||
- Manter form com dados
|
||||
|
||||
`error.value` é só pra estado reativo persistente. Mutação síncrona precisa de throw também.
|
||||
|
||||
### Tipo B — Thin wrapper "extra-leve" · referência: `useAgendaEvents.js`
|
||||
|
||||
Variant aceitável quando mutações **não precisam de loading**:
|
||||
|
||||
```js
|
||||
async function create(payload) {
|
||||
return createX(payload); // ← repassa erro nativamente; componente try/catch
|
||||
}
|
||||
```
|
||||
|
||||
**Quando usar:** UIs onde criar/editar tem feedback próprio (skeleton no item criado, optimistic UI, etc.). Default é o Tipo A.
|
||||
|
||||
### Tipo C — Cache com stale-while-revalidate · referência: `useAgendaSettings.js`
|
||||
|
||||
Para dados raros/pesados (settings, preferences, listas estáveis):
|
||||
|
||||
```js
|
||||
import { ref } from 'vue';
|
||||
import { getX } from '../services/<feature>Repository';
|
||||
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||
|
||||
export function useX(opts = {}) {
|
||||
const useCache = !!opts.cache;
|
||||
const cache = useCache ? useMelissaCacheStore() : null;
|
||||
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function _doFetch() {
|
||||
const result = await getX();
|
||||
data.value = result;
|
||||
if (cache) {
|
||||
const key = result?.owner_id || 'anon';
|
||||
cache.set('xKey', result, key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (cache) {
|
||||
const cached = cache.get('xKey', undefined, MELISSA_CACHE_TTL.xKey);
|
||||
if (cached) {
|
||||
data.value = cached;
|
||||
_doFetch().catch((e) => console.warn('[useX] revalidate', e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await _doFetch();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar.';
|
||||
data.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { data, loading, error, load };
|
||||
}
|
||||
```
|
||||
|
||||
**Decisões do Tipo C:**
|
||||
|
||||
- **`opts.cache` default `false`** — páginas de configuração que editam settings esperam mudança imediata após salvar, então cache opt-in.
|
||||
- **Cache key inclui scope** (`owner_id`/`tenant_id`) — invalida automaticamente em troca de usuário/tenant.
|
||||
- **TTL constants no store** — `MELISSA_CACHE_TTL.<feature>` (não hardcoded no composable).
|
||||
- **Stale-while-revalidate:** retorna cached SE existe + dispara fetch em background (sem await).
|
||||
- **Revalidate fail é warn**, não error — UI já tem dados válidos do cache.
|
||||
|
||||
---
|
||||
|
||||
## 5. Convenções de nomenclatura
|
||||
|
||||
### Funções
|
||||
|
||||
| Operação | Nome canônico | Variantes aceitas |
|
||||
|---|---|---|
|
||||
| Listar com filtro | `loadRange` / `loadMy<X>` | `load<Scope><Range>` |
|
||||
| Criar | `create` | `create<Scope>` (se houver ambiguidade) |
|
||||
| Atualizar | `update` | `update<Scope>` |
|
||||
| Remover | `remove` | `remove<Scope>` (nunca `delete` — palavra reservada) |
|
||||
| Recarregar | `refresh` | `reload` |
|
||||
| Limpar estado | `reset` / `clear` | — |
|
||||
|
||||
**Scope sufixo** quando o composable serve múltiplos contextos: `loadMyRange` (terapeuta) vs `loadClinicRange` (admin).
|
||||
|
||||
### State refs
|
||||
|
||||
- `rows` — coleção principal (array)
|
||||
- `record` — single (quando faz sentido)
|
||||
- `data` — genérico (settings, config)
|
||||
- `loading` — boolean único; se há múltiplos `loading` (load vs save), nomear: `loadingList`, `saving`
|
||||
- `error` — string única; mesmo princípio: `loadError`, `saveError` se precisar
|
||||
|
||||
---
|
||||
|
||||
## 6. Anatomia padrão de uma operação `load*`
|
||||
|
||||
```js
|
||||
async function loadXxx(args) {
|
||||
// 1. Validação leve (early return, não throw)
|
||||
if (!args?.required) return;
|
||||
|
||||
// 2. State flag
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// 3. Delegate pra repository (UMA chamada — se múltiplas, Promise.all)
|
||||
const result = await listX(args);
|
||||
|
||||
// 4. Mutate state
|
||||
rows.value = result;
|
||||
} catch (e) {
|
||||
// 5. Erro humano + reset de data (UI fica previsível)
|
||||
error.value = e?.message || 'Mensagem PT-BR genérica.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
// 6. Sempre limpar loading
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Por que early-return em vez de throw na validação?** Composable é wrapper — chamadas inválidas (ex: `ownerId` ainda não chegou no mount) não devem quebrar UI. Throw fica pra repository.
|
||||
|
||||
---
|
||||
|
||||
## 7. Múltiplos fetches paralelos
|
||||
|
||||
Quando uma operação precisa de N queries:
|
||||
|
||||
```js
|
||||
async function _doFetch() {
|
||||
const [cfg, rules, profile] = await Promise.all([
|
||||
getMyAgendaSettings(),
|
||||
getMyWorkSchedule(),
|
||||
getMyProfile()
|
||||
]);
|
||||
settings.value = cfg;
|
||||
workRules.value = rules;
|
||||
profile.value = profile;
|
||||
}
|
||||
```
|
||||
|
||||
**Regras:**
|
||||
- `Promise.all` (não `Promise.allSettled`) — falha de qualquer query falha a operação inteira
|
||||
- Exception: quando uma query é opcional/best-effort → `Promise.allSettled` + processa por result
|
||||
- **Nunca** sequenciar fetches independentes (await + await + await)
|
||||
|
||||
---
|
||||
|
||||
## 8. Composição de composables
|
||||
|
||||
Composable pode usar outros composables, mas:
|
||||
|
||||
```js
|
||||
// ✅ certo — composição estrutural
|
||||
export function useAgendaEventLifecycle() {
|
||||
const events = useAgendaEvents();
|
||||
const billing = useAgendaFinanceiro();
|
||||
const settings = useAgendaSettings({ cache: true });
|
||||
|
||||
async function realizar(eventId) {
|
||||
// orquestra os 3
|
||||
}
|
||||
|
||||
return { ...events, realizar, ... };
|
||||
}
|
||||
|
||||
// ❌ errado — não compor pra economizar 1 linha
|
||||
export function useOnlyToWrapList() {
|
||||
const { rows, loadMyRange } = useAgendaEvents();
|
||||
return { rows, loadMyRange }; // ← isso é um re-export inútil
|
||||
}
|
||||
```
|
||||
|
||||
**Regra:** compõe quando há **orquestração**. Se é só forward, importa direto.
|
||||
|
||||
---
|
||||
|
||||
## 9. Anti-patterns (NÃO fazer)
|
||||
|
||||
### ❌ Composable que tem `supabase.from('...')` direto
|
||||
|
||||
```js
|
||||
// ❌ — violação de camadas
|
||||
export function useFoo() {
|
||||
async function load() {
|
||||
const { data } = await supabase.from('foo').select('*');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ Move pra repository, composable só delega.
|
||||
|
||||
### ❌ `error` ora `null`, ora `''`, ora `Error`
|
||||
|
||||
Canonize `string` (default `''`). Errors do JS dão `e?.message || 'fallback PT-BR'`.
|
||||
|
||||
### ❌ Não resetar `rows` em erro de load
|
||||
|
||||
```js
|
||||
// ❌
|
||||
async function loadRange() {
|
||||
try { rows.value = await listX(); } catch (e) { error.value = e.message; }
|
||||
// rows.value mantém dados antigos = UI mostra coisa stale + alerta de erro
|
||||
}
|
||||
```
|
||||
|
||||
✅ Reset `rows.value = []` no catch — UI fica determinística.
|
||||
|
||||
### ❌ Não re-throw mutações
|
||||
|
||||
```js
|
||||
// ❌
|
||||
async function create(payload) {
|
||||
try { return await createX(payload); }
|
||||
catch (e) { error.value = e.message; }
|
||||
// componente faz `await create()` e nunca sabe que falhou
|
||||
}
|
||||
```
|
||||
|
||||
✅ Re-throw após setar `error.value`.
|
||||
|
||||
### ❌ `Promise.all` quando uma falha é aceitável
|
||||
|
||||
Quando uma das queries pode falhar sem invalidar as outras, usar `Promise.allSettled`. Comum em listings que enriquece com lookups opcionais.
|
||||
|
||||
### ❌ State global em variável módulo
|
||||
|
||||
```js
|
||||
// ❌ — vaza entre componentes que compartilham o composable
|
||||
const rows = ref([]);
|
||||
export function useFoo() {
|
||||
return { rows };
|
||||
}
|
||||
```
|
||||
|
||||
✅ State sempre DENTRO da `function useFoo()`. Se precisar global, use Pinia store.
|
||||
|
||||
### ❌ Composable que faz `watch` no próprio state pra "side effect"
|
||||
|
||||
```js
|
||||
// ❌
|
||||
const rows = ref([]);
|
||||
watch(rows, () => { /* save algo */ });
|
||||
```
|
||||
|
||||
✅ Mover `watch` pro componente — composable não decide quando salvar.
|
||||
|
||||
**Exceção:** watch pra sincronizar com prop externa do composable (`watchEffect(() => loadRange(props.range))`) é OK.
|
||||
|
||||
### ❌ Composable retornando objeto enorme
|
||||
|
||||
Se o `return` tem 20+ chaves, o composable está fazendo coisa demais. Quebrar em N composables menores ou extrair Pinia store.
|
||||
|
||||
---
|
||||
|
||||
## 10. Cache store (Tipo C complementar)
|
||||
|
||||
Quando criar um composable Tipo C, garantir que existe entry em:
|
||||
|
||||
- `src/stores/melissaCacheStore.js` — `MELISSA_CACHE_TTL.<feature>` constante (TTL em ms)
|
||||
- `.get(key, scope, ttl)` retorna valor ou null
|
||||
- `.set(key, value, scope)` salva com timestamp
|
||||
- Invalidação manual: `.invalidate('<feature>')`
|
||||
|
||||
**TTL guidelines:**
|
||||
|
||||
| Tipo de dado | TTL sugerido |
|
||||
|---|---|
|
||||
| Settings/preferences | 5 min |
|
||||
| Listas estáveis (specialties, plans) | 30 min |
|
||||
| Catálogo (services, pricing) | 10 min |
|
||||
| Multi-tenant lookups | 5 min |
|
||||
| Anything user-edited | NÃO cachear (Tipo A) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist de auditoria por módulo
|
||||
|
||||
Quando rodar `/audit-module <nome>`, validar cada composable:
|
||||
|
||||
- [ ] Não tem `supabase.from(...)` direto — só importa da repository
|
||||
- [ ] State shape: `rows`/`data`, `loading: boolean`, `error: string`
|
||||
- [ ] `error` é string, default `''`
|
||||
- [ ] Reset de data em erro de load (`rows.value = []`)
|
||||
- [ ] Mutações re-throw após setar error.value
|
||||
- [ ] Nomenclatura: `loadRange`/`load<Scope>`, `create`, `update`, `remove`
|
||||
- [ ] `remove` não `delete` (palavra reservada)
|
||||
- [ ] Validação leve usa early-return (não throw)
|
||||
- [ ] Múltiplos fetches em `Promise.all` (não sequencial)
|
||||
- [ ] State DENTRO da `function use*()` (não em variável de módulo)
|
||||
- [ ] Sem `watch` em própria state pra side effect (mover pro componente)
|
||||
- [ ] Helpers de domínio em arquivo separado sem prefixo `use`
|
||||
- [ ] Se cacheia (Tipo C): `opts.cache` opt-in, default `false`; TTL em `MELISSA_CACHE_TTL`; cache key inclui scope
|
||||
- [ ] Return statement com chaves explícitas (não `return { ...state, ...actions }` opaco)
|
||||
- [ ] Return ≤ 15 chaves (>15 = composable fazendo coisa demais)
|
||||
|
||||
Divergências viram items em `dev_auditoria_items` com:
|
||||
- `categoria`: `padronizacao`
|
||||
- `tag`: `padronizacao:<modulo>`
|
||||
- `severidade`: alta se camada quebrada (composable com `from()`); média se viola convenção (error null vs ''); baixa se cosmético (nome de função)
|
||||
|
||||
---
|
||||
|
||||
## 12. Exemplo completo (template)
|
||||
|
||||
```js
|
||||
/*
|
||||
| Arquivo: src/features/patients/composables/usePatients.js
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
listPatients,
|
||||
createPatient,
|
||||
updatePatient,
|
||||
deletePatient
|
||||
} from '@/features/patients/services/patientsRepository';
|
||||
|
||||
export function usePatients() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadRange({ search, status, tenantId } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listPatients({ search, status, tenantId });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar pacientes.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await createPatient(payload);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await updatePatient(id, patch);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await deletePatient(id);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadRange, create, update, remove };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Referências
|
||||
|
||||
- Canônicos: `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
|
||||
- Repository pareado: `blueprints/repository-blueprint.md`
|
||||
- Cache store: `src/stores/melissaCacheStore.js`
|
||||
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
|
||||
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
|
||||
@@ -0,0 +1,247 @@
|
||||
# Dialog — Padrão de Componente
|
||||
|
||||
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
|
||||
> **Tema-aware**: header e footer respeitam dark/light automaticamente via CSS vars
|
||||
|
||||
---
|
||||
|
||||
## Regras gerais
|
||||
|
||||
| Propriedade | Valor obrigatório |
|
||||
|---|---|
|
||||
| `modal` | sempre `true` |
|
||||
| `maximizable` | sempre presente — botão nativo do PrimeVue, sem estado manual |
|
||||
| `:draggable` | sempre `false` |
|
||||
| `:closable` | `!saving` — desabilita o X durante operações assíncronas |
|
||||
| `:dismissableMask` | `!saving` — impede fechar clicando fora durante saving |
|
||||
| `pt:mask:class` | `backdrop-blur-xs` |
|
||||
| Largura | `w-[50rem]` (padrão); responsivo via `:breakpoints` |
|
||||
| Breakpoints | `{ '1199px': '90vw', '768px': '94vw' }` |
|
||||
|
||||
---
|
||||
|
||||
## Sistema de cores (tema-aware)
|
||||
|
||||
O dialog **nunca** deve usar `bg-gray-100` ou cores hardcoded — isso quebra no dark mode.
|
||||
Usar sempre as CSS vars do projeto:
|
||||
|
||||
| Var | Light | Dark | Uso |
|
||||
|---|---|---|---|
|
||||
| `--surface-card` | `--p-surface-0` (branco) | `--p-surface-900` (quase preto) | Fundo do **corpo** do dialog (default) |
|
||||
| `--surface-ground` | `--p-surface-100` (cinza claro) | `--p-surface-950` (preto) | Fundo do **header** e **footer** — um shade mais escuro que o card |
|
||||
| `--surface-border` | `--p-content-border-color` | idem | Borda separadora entre header/content/footer |
|
||||
| `--text-color` | preto | branco | Título principal |
|
||||
| `--text-color-secondary` | cinza médio | cinza claro | Subtítulo, hints |
|
||||
|
||||
> Resumo: `bg-[var(--surface-ground)]` no header/footer fica **sempre um pouco mais escuro que o corpo**, em ambos os temas. Definido em `_light.scss:19` e `_dark.scss:19`.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura obrigatória
|
||||
|
||||
```
|
||||
<Dialog>
|
||||
├── #header ← dot de cor (se aplicável), título/subtítulo, btn Excluir
|
||||
├── Banner ← preview visual (opcional — apenas quando há cor/identidade visual)
|
||||
├── Corpo ← campos do formulário
|
||||
└── #footer ← Cancelar (flat) | Salvar (primary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuração completa do `<Dialog>`
|
||||
|
||||
```vue
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
maximizable
|
||||
class="dc-dialog w-[50rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
```
|
||||
|
||||
### Detalhes do `pt`
|
||||
|
||||
| Chave | O que faz |
|
||||
|---|---|
|
||||
| `header` | `!p-3` padding uniforme; `!rounded-t-[12px]` borda top arredondada; `border-b` separador; `bg-[var(--surface-ground)]` fundo um shade mais escuro que o card (tema-aware) |
|
||||
| `content` | `!p-3` padding interno do corpo (herda `bg-[var(--surface-card)]` do PrimeVue) |
|
||||
| `footer` | `!p-0` remove padding nativo (controlado pelo wrapper interno); `!rounded-b-[12px]` borda bottom arredondada; `border-t` separador; `bg-[var(--surface-ground)]` mesmo fundo do header |
|
||||
| `pcCloseButton` | `!rounded-md` remove o círculo nativo; `hover:!text-red-500` feedback de danger no hover |
|
||||
| `pcMaximizeButton` | `!rounded-md` remove o círculo nativo; `hover:!text-primary` feedback de cor primária no hover |
|
||||
|
||||
> O `!` (important) é necessário porque o PrimeVue injeta estilos inline nos botões e no root do Dialog — sem ele o Tailwind perde a disputa de especificidade.
|
||||
|
||||
> **Migração de dialogs antigos**: trocar `bg-gray-100` por `bg-[var(--surface-ground)]`. O `shadow-[0_1px_0_0_rgba(255,255,255,0.06)]` antigo era um hack pro dark mode; pode ser removido (a borda já dá a separação).
|
||||
|
||||
---
|
||||
|
||||
## Header — slot `#header`
|
||||
|
||||
```
|
||||
[dot-cor] [título / subtítulo] [btn-excluir] ← Close e Maximize nativos vêm após
|
||||
```
|
||||
|
||||
- O PrimeVue injeta **Maximize** e **Close** automaticamente à direita do slot `#header`.
|
||||
- O botão **Excluir** fica **sempre no header**, nunca no footer.
|
||||
- Excluir desabilitado quando o registro é nativo/padrão: `:disabled="saving || isNativeRecord"`.
|
||||
|
||||
```vue
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Dot de cor (omitir se não houver cor associada) -->
|
||||
<span
|
||||
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30
|
||||
shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate text-[var(--text-color)]">
|
||||
{{ form.name || (mode === 'create' ? 'Novo item' : 'Editar item') }}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
{{ mode === 'create' ? 'Criando novo registro' : 'Editando registro' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<!-- Excluir — visível apenas em edit, desabilitado se nativo -->
|
||||
<Button
|
||||
v-if="mode === 'edit' && canDelete !== undefined"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="saving || isNativeRecord"
|
||||
v-tooltip.top="'Excluir'"
|
||||
@click="emitDelete"
|
||||
/>
|
||||
<!-- Maximize e Close nativos do PrimeVue são injetados aqui automaticamente -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> **Cores**: usar `text-[var(--text-color)]` no título e `text-[var(--text-color-secondary)]` no subtítulo. Não usar `opacity-50` — a cor secondary já tem contraste calibrado por tema.
|
||||
|
||||
---
|
||||
|
||||
## Footer — slot `#footer`
|
||||
|
||||
```vue
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-3">
|
||||
<!-- Cancelar: sempre flat, hover vermelho suave -->
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
text
|
||||
class="rounded-full hover:!text-red-500"
|
||||
:disabled="saving"
|
||||
@click="close"
|
||||
/>
|
||||
<!-- Salvar: sempre primary -->
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="saving"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> **Regra**: Cancelar = `severity="secondary" text` + `hover:!text-red-500`. Salvar = primary (sem severity, usa o padrão do tema). Padding controlado pelo `div` interno (`px-3 py-3`), não pelo `pt.footer`.
|
||||
|
||||
---
|
||||
|
||||
## Maximizar
|
||||
|
||||
Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automaticamente — sem `ref`, sem `isMaximized`, sem `<Button>` manual.
|
||||
|
||||
```vue
|
||||
<Dialog maximizable ...>
|
||||
```
|
||||
|
||||
Se você precisar customizar a largura/altura quando maximizado (ex: `100vw`), use `:style` reativo a um ref `maximized` E passe `:maximizable="false"` + um botão manual no `#header`. Padrão preferido: deixar o PrimeVue gerenciar.
|
||||
|
||||
---
|
||||
|
||||
## Dialogs aninhados (Dialog dentro de Dialog)
|
||||
|
||||
Quando um Dialog secundário (criar tag, criar grupo, criar convênio) é aberto a partir do form de um Dialog principal:
|
||||
|
||||
- Cada Dialog é independente — `v-model:visible` próprio
|
||||
- O Dialog secundário usa o **mesmo blueprint** (mesmo `pt`, mesmas cores)
|
||||
- Pode ser menor: `w-[36rem]` é o tamanho típico de "cadastro rápido"
|
||||
- Z-index: PrimeVue gerencia automaticamente (último aberto fica em cima)
|
||||
- Ao salvar no Dialog secundário, o item criado pode ser auto-selecionado no Dialog principal (UX comum em formulários grandes)
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de publicar um Dialog
|
||||
|
||||
- [ ] `modal`, `:draggable="false"`, `:closable="!saving"`, `:dismissableMask="!saving"` presentes
|
||||
- [ ] `maximizable` na prop (botão nativo, sem estado manual)
|
||||
- [ ] `class="dc-dialog w-[50rem]"` + `:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"`
|
||||
- [ ] `pt` completo: header, content, footer, pcCloseButton, pcMaximizeButton
|
||||
- [ ] Header com `bg-[var(--surface-ground)]`, `border-b`, e `!rounded-t-[12px]`
|
||||
- [ ] Footer com `bg-[var(--surface-ground)]`, `border-t`, e `!rounded-b-[12px]`
|
||||
- [ ] **Nenhum `bg-gray-100` ou cor hardcoded** — só CSS vars tema-aware
|
||||
- [ ] Botão **Excluir** no header (nunca no footer), desabilitado se nativo
|
||||
- [ ] Cancelar = `text` + `hover:!text-red-500` | Salvar = primary
|
||||
- [ ] Padding do footer via `px-3 py-3` no `div` interno
|
||||
- [ ] Texto usa `text-[var(--text-color)]` e `text-[var(--text-color-secondary)]`
|
||||
|
||||
---
|
||||
|
||||
## Variações de largura
|
||||
|
||||
| Uso | Classe |
|
||||
|---|---|
|
||||
| Cadastro rápido / formulário simples | `w-[36rem]` |
|
||||
| Formulário padrão | `w-[50rem]` ← **padrão** |
|
||||
| Formulário complexo (multi-coluna) | `w-[70rem]` |
|
||||
| Cadastro completo (paciente, agenda) | `w-[1100px]` |
|
||||
| Tela cheia | `maximizable` — usuário controla |
|
||||
|
||||
---
|
||||
|
||||
## Anti-pattern
|
||||
|
||||
```vue
|
||||
<!-- ❌ NÃO fazer: -->
|
||||
<Dialog :pt="{
|
||||
header: { class: 'bg-gray-100' }, // quebra no dark
|
||||
footer: { class: 'bg-gray-100' }, // quebra no dark
|
||||
}" />
|
||||
|
||||
<!-- ❌ NÃO fazer: -->
|
||||
<div class="text-base opacity-50">subtítulo</div> <!-- usar text-color-secondary -->
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ✅ Pattern correto: -->
|
||||
<Dialog :pt="{
|
||||
header: { class: 'bg-[var(--surface-ground)] border-b border-[var(--surface-border)]' },
|
||||
footer: { class: 'bg-[var(--surface-ground)] border-t border-[var(--surface-border)]' },
|
||||
}" />
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">subtítulo</div>
|
||||
```
|
||||
@@ -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`
|
||||
@@ -0,0 +1,176 @@
|
||||
# Sessões 6 (continuação) → 10 — hardening em 6 áreas + scan completo do SaaS
|
||||
|
||||
Continuação do commit `7c20b51` (Sessões 1-6 iniciais). Esta etapa fechou
|
||||
**toda revisão sênior do SaaS** + refator parcial de pacientes.
|
||||
|
||||
**Estado final do projeto:**
|
||||
- A# auditoria abertos: **1** (A#31 Deploy real)
|
||||
- V# verificações abertos: 14 (todos médios/baixos adiados com plano completo no DB)
|
||||
- 🔴 Críticos: **0** ✅
|
||||
- 🟠 Altos: **0** ✅
|
||||
- Vitest: **208/208** (era 192)
|
||||
- SQL integration: **33/33**
|
||||
- E2E (Playwright): **5/5**
|
||||
- Áreas auditadas: **15** (todas as principais do SaaS)
|
||||
|
||||
---
|
||||
|
||||
## Sessão 6 (continuação) — Documentos pendentes + Pacientes V#3
|
||||
|
||||
### Documentos: 100% fechado (V#50, V#51, V#52)
|
||||
- **V#50** — Policy `documents: portal patient read` adicional. Paciente lê documento via portal quando `compartilhado_portal=true` AND patient pertence a auth.uid AND não expirou.
|
||||
- **V#51** — `documents.content_sha256` (nullable, índice parcial). `Documents.service.uploadDocument` calcula SHA-256 hex client-side via `crypto.subtle.digest`. Helper novo `verifyDocumentIntegrity(docId)` baixa arquivo e re-hash.
|
||||
- **V#52** — Migration `...13` cron retention via pg_cron: 4 jobs (document_access_logs 1 ano, math_challenges 1h, public_submission_attempts 90 dias, submission_rate_limits 30 dias).
|
||||
|
||||
### Pacientes V#3 (parcial — fundação)
|
||||
- `src/features/patients/services/patientsRepository.js` — list/get/create/update/softDelete + groups + tags + getSessionCounts.
|
||||
- `src/features/patients/composables/usePatients.js` — wrapper reativo (rows/loading/error).
|
||||
- PatientsListPage.hydrateAssociationsSupabase migrada — substitui 4 queries diretas por chamadas ao repo (paralelismo preservado).
|
||||
- V#9 (PatientsCadastroPage 1991 linhas) → adiado pra Sessão 10.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 7 — Tenants + Calendário
|
||||
|
||||
### Tenants (8 V#)
|
||||
- 🔴 **V#1 P0** — `tenant_invites` com RLS off + 0 policies (mesmo padrão A#30 Sessão 5). Tabela tinha 0 rows. Migration: ENABLE RLS + 4 policies (SELECT tenant_admin/saas; INSERT WITH CHECK invited_by=auth.uid; UPDATE só revogação; DELETE tenant_admin/saas). Aceitar invite continua via RPC `tenant_accept_invite` SECURITY DEFINER.
|
||||
- 🟠 **V#2** profiles INSERT WITH CHECK (id = auth.uid)
|
||||
- 🟠 **V#3** support_sessions INSERT WITH CHECK (admin_id = auth.uid + saas_admin guard)
|
||||
- 🟡 **V#4 (signup público)** verificado: RPC `ensure_personal_tenant` SECURITY DEFINER já existia (Signup.vue:232) → **ok**
|
||||
- 🟡 **V#5 (accept_invite)** verificado: RPCs `tenant_accept_invite` + `tenant_invite_member_by_email` já existiam → **ok**
|
||||
- 🟡 **V#6** user_settings INSERT WITH CHECK
|
||||
- 🟢 V#7/V#8 baixos — adiados
|
||||
|
||||
### Calendário (2 V#) — 100% fechado
|
||||
- 🔴 **V#1** feriados_insert + feriados_saas_insert ganharam WITH CHECK. Spam de feriado global bloqueado.
|
||||
- 🟢 **V#2** feriados_delete agora permite tenant_admin (não só owner).
|
||||
|
||||
---
|
||||
|
||||
## Sessão 8 — Addons + Central SaaS
|
||||
|
||||
### Addons (4 V#)
|
||||
- 🔴 **V#1 P0 (dinheiro)** — `addon_transactions_admin_insert` ganhou WITH CHECK (EXISTS saas_admins). Edge functions com service_role bypassam RLS, pipeline preservado. **Authenticated comum não cria mais transação fake.**
|
||||
- 🟠 **V#2** — 3 CHECK constraints em `addon_credits`: balance >= 0, total_consumed >= 0, total_purchased >= 0. Saldo negativo silencioso eliminado.
|
||||
- 🟡 V#3 (UI extrato) — adiado.
|
||||
- 🟡 V#4 — verificado: `addon_products` não tem `tenant_id` (catálogo global por design) → **ok**.
|
||||
|
||||
### Central SaaS (3 V#)
|
||||
- 🟠 **V#1** — `faq_admin_write` substituído por `faq_saas_admin_write` em `saas_faq` E `saas_faq_itens` — só saas_admin escreve. Tenant_admin lê via `faq_auth_read` (permanece).
|
||||
- 🟢 V#2/V#3 médios/baixos — adiados.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 9 — Serviços/Prontuários (100% fechado)
|
||||
|
||||
5/5 V# corrigidos:
|
||||
- 🔴 **V#1** services + insurance_plans → 4 policies separadas (SELECT tenant_member; INSERT/UPDATE/DELETE owner+saas).
|
||||
- 🔴 **V#2** medicos → 4 policies separadas (catálogo de médicos referenciadores compartilhado entre profissionais do tenant).
|
||||
- 🟠 **V#3** commitment_services — cascade reescrito via JOIN com services (USING permite tenant_member; WITH CHECK só owner).
|
||||
- 🟠 **V#4** insurance_plan_services — cascade reescrito via JOIN com insurance_plans.
|
||||
- 🟡 **V#5** commitment_time_logs/determined_commitments/determined_commitment_fields ganharam WITH CHECK em INSERT.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 10 — Pacientes V#9 (script extraído)
|
||||
|
||||
PatientsCadastroPage.vue: 1991 → 1951 linhas (qualitativo > quantitativo).
|
||||
|
||||
### 2 composables novos
|
||||
- **`useCep.js`** — busca ViaCEP reutilizável. 6 testes (sem rede, mock fetch).
|
||||
- **`usePatientSupportContacts.js`** — CRUD de contatos de suporte encapsulado (load/save/add/remove/iniciaisFor). 10 testes com builder thenable.
|
||||
|
||||
### patientsRepository estendido
|
||||
- `getPatientRelations(patientId)` — retorna {groupIds, tagIds}
|
||||
- `replacePatientGroup(patientId, groupId, {tenantId})`
|
||||
- `replacePatientTags(patientId, tagIds, {tenantId, ownerId})`
|
||||
|
||||
### PatientsCadastroPage refatorado
|
||||
- 8 funções de query → delegação 1-linha ao patientsRepository
|
||||
- onCepBlur → usa composable useCep
|
||||
- Contatos de suporte → composable
|
||||
- Template **não** foi tocado (zero risco de regressão visual)
|
||||
- Quebra de template em sub-componentes Vue → adiado pra quando houver E2E cobrindo a página
|
||||
|
||||
---
|
||||
|
||||
## 📦 Migrations consolidadas neste commit (8)
|
||||
|
||||
```
|
||||
20260419000011_documents_portal_patient_policy.sql (V#50)
|
||||
20260419000012_documents_content_hash.sql (V#51)
|
||||
20260419000013_cron_retention_jobs.sql (V#52 + math_challenges + submissions + rate_limits)
|
||||
20260419000014_financial_security_hardening.sql (5 V# financeiro — fechados na Sessão 6)
|
||||
20260419000015_communication_security_hardening.sql (5 V# comunicação — fechados na Sessão 6)
|
||||
20260419000016_tenants_calendario_hardening.sql (Tenants V#1-V#3,V#6 + Calendário V#1-V#2)
|
||||
20260419000017_addons_central_saas_hardening.sql (Addons V#1-V#2 + Central SaaS V#1)
|
||||
20260419000018_servicos_prontuarios_hardening.sql (Serviços V#1-V#5)
|
||||
```
|
||||
|
||||
**Total acumulado de migrations no histórico: 18** (Sessões 1-10).
|
||||
|
||||
Várias dessas exigiram conexão direta como `supabase_admin` (ver memory `project_supabase_admin_gotcha.md` e `commit.md` anterior) por causa de tabelas owned por esse role.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Novos arquivos (código)
|
||||
|
||||
```
|
||||
src/features/patients/composables/useCep.js
|
||||
src/features/patients/composables/usePatientSupportContacts.js
|
||||
src/features/patients/composables/usePatients.js
|
||||
src/features/patients/composables/__tests__/useCep.spec.js (+6 testes)
|
||||
src/features/patients/composables/__tests__/usePatientSupportContacts.spec.js (+10 testes)
|
||||
src/features/patients/services/patientsRepository.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Modificações
|
||||
|
||||
- `src/features/patients/PatientsListPage.vue` — hydrateAssociationsSupabase usa repo
|
||||
- `src/features/patients/cadastro/PatientsCadastroPage.vue` — script extraído (queries → repo, CEP → composable, contatos → composable). Template intocado.
|
||||
- `src/services/Documents.service.js` — uploadDocument calcula content_sha256 + helper verifyDocumentIntegrity
|
||||
|
||||
---
|
||||
|
||||
## 📊 Áreas auditadas (estado final)
|
||||
|
||||
| Área | V# total | Estado |
|
||||
|---|---|---|
|
||||
| auth | 10 | 100% fechado/ok |
|
||||
| router | 9 | 100% |
|
||||
| stores | 1 | 100% |
|
||||
| agenda | 11 | 100% |
|
||||
| pacientes | 10 | **100% fechado** ✅ |
|
||||
| seguranca | 1 | 100% |
|
||||
| saas | 10 | 100% |
|
||||
| documentos | 10 | **100% fechado** ✅ |
|
||||
| financeiro | 11 | 5 fechados, 6 médios/baixos adiados |
|
||||
| comunicacao | 10 | 5 fechados, 5 médios/baixos adiados |
|
||||
| tenants | 8 | 6 fechados, 2 baixos adiados |
|
||||
| calendario | 2 | **100% fechado** ✅ |
|
||||
| addons | 4 | 3 resolvidos, 1 médio adiado |
|
||||
| central_saas | 3 | 1 alto fechado, 2 médios adiados |
|
||||
| servicos | 5 | **100% fechado** ✅ |
|
||||
|
||||
**Zero crítico/alto restante no sistema inteiro.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Pendências documentadas no DB (não esquecidas)
|
||||
|
||||
### A# (1 aberto)
|
||||
- **A#31 Deploy real** — alto. Reformulação pendente: como ainda não há cloud Supabase nem secrets reais, próxima sessão é "Preparação completa pra deploy" (DEPLOY.md, validar migrations num container limpo, audit de edge functions, listar env vars, script `db.cjs deploy-check`).
|
||||
|
||||
### V# adiados (14)
|
||||
Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`:
|
||||
- financeiro (6): parcelamento CHECK, payouts flow, recurrence DELETE policy, composables, máscara PIX, dashboard inadimplência
|
||||
- comunicacao (5): notifications/schedules silos, email_templates_global filtros, retention notification_logs, dashboard health, audit dismissals/preferences
|
||||
- tenants (2): owner_users policies, company_profiles + dev_user_credentials
|
||||
- central_saas (2): rate limit voto, valores tipo_acesso
|
||||
- addons (1): UI de extrato
|
||||
|
||||
### Outros
|
||||
- PatientsCadastroPage template breakdown — quando houver E2E
|
||||
- Pacientes V#9 segue 100% no banco (script foi extraído; template é refator visual separado)
|
||||
@@ -0,0 +1,96 @@
|
||||
# README — generate-dashboard.js
|
||||
|
||||
Script Node.js que lê o `schema.sql` do backup mais recente e gera um `dashboard.html` interativo com a visão completa do banco de dados do projeto.
|
||||
|
||||
---
|
||||
|
||||
## Como usar
|
||||
|
||||
Coloque o `generate-dashboard.js` na **raiz do projeto** (mesma pasta do `db.cjs`) e rode:
|
||||
|
||||
```bash
|
||||
# Usa o backup mais recente automaticamente
|
||||
node generate-dashboard.js
|
||||
|
||||
# Ou especifica uma data
|
||||
node generate-dashboard.js 2026-03-27
|
||||
```
|
||||
|
||||
O arquivo `dashboard.html` será gerado na raiz do projeto. Basta abrir no browser.
|
||||
|
||||
---
|
||||
|
||||
## Fluxo recomendado
|
||||
|
||||
Sempre que fizer alterações no banco, rode os dois comandos em sequência:
|
||||
|
||||
```bash
|
||||
node db.cjs backup # gera o backup em database-novo/backups/YYYY-MM-DD/
|
||||
node generate-dashboard.js # lê o backup mais recente e gera o dashboard.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O que o dashboard mostra
|
||||
|
||||
- **Visão geral** — cards com os 9 domínios do projeto, quantidade de tabelas e FKs por domínio
|
||||
- **Tabelas** — todas as 86 tabelas com colunas, tipos, badges PK/FK
|
||||
- **Foreign Keys** — cada FK aparece como link clicável que pula direto para a tabela destino
|
||||
- **Views** — lista das 24 views do schema público
|
||||
- **Busca** — busca em tempo real por nome de tabela ou nome de coluna
|
||||
- **Sidebar** — navegação por domínio
|
||||
|
||||
---
|
||||
|
||||
## Estrutura de pastas esperada
|
||||
|
||||
O script espera essa estrutura para funcionar:
|
||||
|
||||
```
|
||||
raiz-do-projeto/
|
||||
├── db.cjs
|
||||
├── db.config.json
|
||||
├── generate-dashboard.js ← script
|
||||
├── dashboard.html ← gerado aqui
|
||||
└── database-novo/
|
||||
└── backups/
|
||||
└── 2026-03-27/
|
||||
├── schema.sql ← lido pelo script
|
||||
├── data.sql
|
||||
└── full_dump.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tabelas novas não aparecem no domínio certo?
|
||||
|
||||
Quando você criar uma migration nova com uma tabela nova, ela aparecerá no dashboard na seção **"Outros"** e o script vai avisar no terminal:
|
||||
|
||||
```
|
||||
⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):
|
||||
- minha_tabela_nova
|
||||
→ Edite DOMAIN_TABLES no script para mapeá-las.
|
||||
```
|
||||
|
||||
Para corrigir, abra o `generate-dashboard.js` e adicione a tabela no domínio correto dentro do objeto `DOMAIN_TABLES` no topo do arquivo:
|
||||
|
||||
```js
|
||||
const DOMAIN_TABLES = {
|
||||
'Agenda': [
|
||||
'agenda_eventos',
|
||||
'agenda_configuracoes',
|
||||
// ...
|
||||
'minha_tabela_nova', // ← adiciona aqui
|
||||
],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
Depois rode `node generate-dashboard.js` novamente.
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node.js instalado (qualquer versão >= 14)
|
||||
- Sem dependências externas — usa apenas módulos nativos (`fs`, `path`)
|
||||
@@ -0,0 +1,119 @@
|
||||
# database-novo
|
||||
|
||||
Banco de dados do AgenciaPsi — organizado, documentado e com CLI para gerenciamento.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd database-novo
|
||||
|
||||
# Instalação do zero (schema + fixes + seeds + backup)
|
||||
node db.cjs setup
|
||||
|
||||
# Ver estado do banco
|
||||
node db.cjs status
|
||||
|
||||
# Backup
|
||||
node db.cjs backup
|
||||
|
||||
# Restaurar (perdi o banco!)
|
||||
node db.cjs restore
|
||||
```
|
||||
|
||||
Para o guia completo, veja **`docs/setup_guide.md`**.
|
||||
|
||||
## Comandos do CLI
|
||||
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `node db.cjs setup` | Instala do zero (schema + fixes + seeds) |
|
||||
| `node db.cjs backup` | Exporta backup com data para `backups/` |
|
||||
| `node db.cjs restore [data]` | Restaura de um backup |
|
||||
| `node db.cjs migrate` | Aplica migrations pendentes |
|
||||
| `node db.cjs seed [grupo]` | Roda seeds (all, users, system, test_data) |
|
||||
| `node db.cjs status` | Estado do banco, backups, migrations |
|
||||
| `node db.cjs diff` | Compara schema atual vs último backup |
|
||||
| `node db.cjs reset` | Reseta e reinstala tudo |
|
||||
| `node db.cjs verify` | Verifica integridade dos dados |
|
||||
|
||||
## Estrutura
|
||||
|
||||
```
|
||||
database-novo/
|
||||
│
|
||||
├── db.cjs # CLI de gerenciamento do banco
|
||||
├── db.config.json # Configuração (container, seeds, fixes)
|
||||
│
|
||||
├── schema/ # Schema SQL separado por seção
|
||||
│ ├── 00_full/schema.sql # Schema completo (referência)
|
||||
│ ├── 01_extensions/ # Schemas + extensões PostgreSQL
|
||||
│ ├── 02_types/ # Enums (auth, public, infra)
|
||||
│ ├── 03_functions/ # 11 arquivos por domínio
|
||||
│ ├── 04_tables/ # 10 arquivos por domínio
|
||||
│ ├── 05_views/ # 24 views
|
||||
│ ├── 06_indexes/ # Índices
|
||||
│ ├── 07_foreign_keys/ # PKs, FKs, constraints
|
||||
│ ├── 08_triggers/ # Triggers
|
||||
│ ├── 09_policies/ # 217 RLS policies
|
||||
│ └── 10_grants/ # Grants
|
||||
│
|
||||
├── seeds/ # Seeds de dados
|
||||
│ ├── seed_001_fixed.sql # 6 usuários base + tenants
|
||||
│ ├── seed_002.sql # Supervisor + Editor
|
||||
│ ├── seed_003.sql # Therapist2, Therapist3, Secretary
|
||||
│ ├── seed_010_plans.sql # 7 planos + 4 preços
|
||||
│ ├── seed_011_features.sql # 26 features
|
||||
│ ├── seed_012_plan_features.sql # 85 vínculos plano↔feature
|
||||
│ ├── seed_013_subscriptions.sql # 9 subscriptions + compromissos
|
||||
│ ├── seed_014_global_data.sql # 11 email + 16 notif templates + 3 slides
|
||||
│ ├── seed_020_test_data.sql # Dados de teste (50 pacientes, eventos, etc.)
|
||||
│ ├── seed_020_test_data_cleanup.sql # Limpeza dos dados de teste
|
||||
│ └── run_all_seeds.sh # Script bash alternativo
|
||||
│
|
||||
├── migrations/ # Migrations incrementais
|
||||
│
|
||||
├── fixes/ # 7 correções aplicadas
|
||||
│
|
||||
├── backups/ # Backups com data (auto-gerenciados)
|
||||
│ └── 2026-03-23/ # schema.sql + data.sql + full_dump.sql
|
||||
│
|
||||
└── docs/ # Documentação
|
||||
├── setup_guide.md # Guia completo de instalação e uso
|
||||
├── schema_map.md # Mapa das 84 tabelas
|
||||
├── business_rules.md # Regras de negócio
|
||||
└── users_test.md # 11 usuários de teste (UUIDs + vínculos)
|
||||
```
|
||||
|
||||
## Planos
|
||||
|
||||
| Key | Target | Preço | Limites |
|
||||
|-----|--------|-------|---------|
|
||||
| `patient_free` | patient | R$0 | — |
|
||||
| `therapist_free` | therapist | R$0 | 40 agendamentos/mês, 50 lembretes/mês |
|
||||
| `therapist_pro` | therapist | R$49/mês · R$490/ano | Ilimitado |
|
||||
| `clinic_free` | clinic | R$0 | 30 pacientes, 5 terapeutas, 40 agend/mês |
|
||||
| `clinic_pro` | clinic | R$149/mês · R$1490/ano | Ilimitado |
|
||||
| `supervisor_free` | supervisor | R$0 | Até 3 supervisionados |
|
||||
| `supervisor_pro` | supervisor | R$0 | Até 20 supervisionados |
|
||||
|
||||
## Usuários de Teste
|
||||
|
||||
Senha de todos: `Teste@123`
|
||||
|
||||
| Email | Plano | Tipo |
|
||||
|-------|-------|------|
|
||||
| paciente@agenciapsi.com.br | patient_free | Paciente |
|
||||
| terapeuta@agenciapsi.com.br | therapist_free | Terapeuta solo + Clínica 3 |
|
||||
| clinica1@agenciapsi.com.br | clinic_free | Clínica coworking |
|
||||
| clinica2@agenciapsi.com.br | clinic_free | Clínica recepção |
|
||||
| clinica3@agenciapsi.com.br | clinic_free | Clínica full |
|
||||
| saas@agenciapsi.com.br | — | Admin plataforma |
|
||||
| supervisor@agenciapsi.com.br | supervisor_free | Supervisor |
|
||||
| editor@agenciapsi.com.br | therapist_free | Editor |
|
||||
| therapist2@agenciapsi.com.br | therapist_free | Terapeuta |
|
||||
| therapist3@agenciapsi.com.br | therapist_free | Terapeuta |
|
||||
| secretary@agenciapsi.com.br | — | Secretária (Clínica 2) |
|
||||
|
||||
## Idempotência
|
||||
|
||||
Todos os seeds são idempotentes (ON CONFLICT DO UPDATE ou DELETE + INSERT). Podem ser re-executados quantas vezes necessário.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"container": "supabase_db_agenciapsi-primesakai",
|
||||
"database": "postgres",
|
||||
"user": "postgres",
|
||||
"backupRetentionDays": 30,
|
||||
"schema": "schema/00_full/schema.sql",
|
||||
"migrationsDir": "migrations",
|
||||
"seedsDir": "seeds",
|
||||
"fixesDir": "fixes",
|
||||
"seeds": {
|
||||
"users": [
|
||||
"seed_001_fixed.sql",
|
||||
"seed_002.sql",
|
||||
"seed_003.sql"
|
||||
],
|
||||
"system": [
|
||||
"seed_010_plans.sql",
|
||||
"seed_011_features.sql",
|
||||
"seed_012_plan_features.sql",
|
||||
"seed_013_subscriptions.sql",
|
||||
"seed_014_global_data.sql",
|
||||
"seed_015_document_templates.sql",
|
||||
"seed_030_dev_phases_items.sql",
|
||||
"seed_031_dev_auditoria.sql",
|
||||
"seed_032_dev_competitors.sql"
|
||||
],
|
||||
"test_data": [
|
||||
"seed_020_test_data.sql"
|
||||
]
|
||||
},
|
||||
"fixes": [
|
||||
"fix_addon_credits_fk.sql",
|
||||
"fix_addon_rls_saas_admin.sql",
|
||||
"fix_missing_subscriptions.sql",
|
||||
"fix_notification_templates_rls_admin.sql",
|
||||
"fix_seed_patient_groups.sql",
|
||||
"fix_subscriptions_validate_scope.sql",
|
||||
"fix_template_keys_match_populate.sql",
|
||||
"fix_encoding_accents.sql"
|
||||
],
|
||||
"verify": {
|
||||
"tables": [
|
||||
{ "name": "auth.users", "min": 1 },
|
||||
{ "name": "profiles", "min": 1 },
|
||||
{ "name": "tenants", "min": 1 },
|
||||
{ "name": "plans", "min": 7 },
|
||||
{ "name": "features", "min": 20 },
|
||||
{ "name": "plan_features", "min": 50 },
|
||||
{ "name": "subscriptions", "min": 1 },
|
||||
{ "name": "email_templates_global", "min": 10 },
|
||||
{ "name": "notification_templates", "min": 5 },
|
||||
{ "name": "document_templates", "min": 1 }
|
||||
],
|
||||
"views": [
|
||||
"v_tenant_entitlements",
|
||||
"v_tenant_active_subscription"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"tables": [
|
||||
"auth.users",
|
||||
"profiles",
|
||||
"tenants",
|
||||
"tenant_members",
|
||||
"plans",
|
||||
"features",
|
||||
"plan_features",
|
||||
"subscriptions",
|
||||
"patients",
|
||||
"agenda_eventos",
|
||||
"services",
|
||||
"financial_records",
|
||||
"document_templates",
|
||||
"documents",
|
||||
"email_templates_global",
|
||||
"notification_templates"
|
||||
]
|
||||
},
|
||||
"domains": {
|
||||
"SaaS / Planos": [
|
||||
"plans", "plan_features", "plan_prices", "plan_public", "plan_public_bullets",
|
||||
"features", "modules", "module_features",
|
||||
"subscriptions", "subscription_events",
|
||||
"subscription_intents_legacy", "subscription_intents_personal", "subscription_intents_tenant",
|
||||
"tenant_modules", "tenant_features", "tenant_feature_exceptions_log",
|
||||
"billing_contracts", "entitlements_invalidation"
|
||||
],
|
||||
"Addons / Créditos": [
|
||||
"addon_products", "addon_credits", "addon_transactions",
|
||||
"whatsapp_credits_balance", "whatsapp_credits_transactions",
|
||||
"whatsapp_credit_packages", "whatsapp_credit_purchases"
|
||||
],
|
||||
"Tenants / Multi-tenant": [
|
||||
"tenants", "profiles", "user_settings",
|
||||
"tenant_invites", "tenant_members",
|
||||
"company_profiles", "support_sessions",
|
||||
"saas_admins", "owner_users", "dev_user_credentials"
|
||||
],
|
||||
"Pacientes": [
|
||||
"patients", "patient_contacts", "patient_support_contacts",
|
||||
"patient_groups", "patient_group_patient",
|
||||
"patient_tags", "patient_patient_tag",
|
||||
"patient_discounts", "patient_intake_requests", "patient_invites",
|
||||
"patient_status_history", "patient_timeline",
|
||||
"contact_types", "contact_phones",
|
||||
"contact_email_types", "contact_emails"
|
||||
],
|
||||
"Agenda / Agendamento": [
|
||||
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes",
|
||||
"agenda_online_slots", "agenda_regras_semanais",
|
||||
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
|
||||
"agendador_configuracoes", "agendador_solicitacoes"
|
||||
],
|
||||
"Financeiro": [
|
||||
"financial_categories", "financial_exceptions", "financial_records",
|
||||
"payment_settings", "professional_pricing",
|
||||
"therapist_payouts", "therapist_payout_records",
|
||||
"recurrence_rules", "recurrence_exceptions", "recurrence_rule_services"
|
||||
],
|
||||
"Serviços / Prontuários": [
|
||||
"services", "commitment_services", "commitment_time_logs",
|
||||
"determined_commitments", "determined_commitment_fields",
|
||||
"insurance_plans", "insurance_plan_services",
|
||||
"medicos"
|
||||
],
|
||||
"Documentos": [
|
||||
"documents", "document_templates", "document_generated",
|
||||
"document_access_logs", "document_share_links", "document_signatures"
|
||||
],
|
||||
"Comunicação / Notificações": [
|
||||
"email_templates_global", "email_templates_tenant", "email_layout_config",
|
||||
"notification_templates", "notification_channels", "notification_preferences",
|
||||
"notification_logs", "notification_schedules", "notification_queue",
|
||||
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
|
||||
"twilio_subaccount_usage", "saas_twilio_config"
|
||||
],
|
||||
"CRM Conversas (WhatsApp)": [
|
||||
"conversation_messages", "conversation_threads",
|
||||
"conversation_notes",
|
||||
"conversation_tags", "conversation_thread_tags",
|
||||
"conversation_optouts", "conversation_optout_keywords",
|
||||
"conversation_autoreply_settings", "conversation_autoreply_log",
|
||||
"session_reminder_settings", "session_reminder_logs",
|
||||
"conversation_assignments",
|
||||
"conversation_bots", "conversation_bot_sessions",
|
||||
"conversation_sla_rules", "conversation_sla_breaches",
|
||||
"whatsapp_connection_incidents"
|
||||
],
|
||||
"Segurança / Auditoria": [
|
||||
"submission_rate_limits",
|
||||
"audit_logs",
|
||||
"saas_security_config",
|
||||
"math_challenges",
|
||||
"patient_invite_attempts",
|
||||
"public_submission_attempts"
|
||||
],
|
||||
"Central SaaS (docs/FAQ)": [
|
||||
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
|
||||
],
|
||||
"Dev / Tracking": [
|
||||
"dev_auditoria_items", "dev_verificacoes_items", "dev_test_items",
|
||||
"dev_roadmap_phases", "dev_roadmap_items",
|
||||
"dev_competitors", "dev_competitor_features",
|
||||
"dev_comparison_matrix", "dev_comparison_competitor_status",
|
||||
"dev_generation_log"
|
||||
],
|
||||
"Estrutura / Calendário": [
|
||||
"feriados"
|
||||
]
|
||||
},
|
||||
"domainColors": {
|
||||
"SaaS / Planos": "#4f8cff",
|
||||
"Addons / Créditos": "#a78bfa",
|
||||
"Tenants / Multi-tenant": "#6ee7b7",
|
||||
"Pacientes": "#f472b6",
|
||||
"Agenda / Agendamento": "#38bdf8",
|
||||
"Financeiro": "#f87171",
|
||||
"Serviços / Prontuários": "#34d399",
|
||||
"Documentos": "#0ea5e9",
|
||||
"Comunicação / Notificações": "#fbbf24",
|
||||
"CRM Conversas (WhatsApp)": "#25d366",
|
||||
"Segurança / Auditoria": "#ef4444",
|
||||
"Central SaaS (docs/FAQ)": "#c084fc",
|
||||
"Dev / Tracking": "#94a3b8",
|
||||
"Estrutura / Calendário": "#fb923c"
|
||||
},
|
||||
"infrastructure": {
|
||||
"Banco & Backend": {
|
||||
"color": "#4f8cff",
|
||||
"items": [
|
||||
{
|
||||
"name": "Supabase",
|
||||
"role": "Postgres + Auth + Storage + Realtime + Edge Functions",
|
||||
"env": "Local (Docker) + Cloud",
|
||||
"status": "ativo",
|
||||
"notes": "Stack principal. Migrations em database-novo/migrations/. Functions em supabase/functions/. CLI via npx supabase."
|
||||
},
|
||||
{
|
||||
"name": "PostgreSQL 15",
|
||||
"role": "Banco de dados relacional (via container supabase_db_agenciapsi-primesakai)",
|
||||
"env": "Local (Docker)",
|
||||
"status": "ativo",
|
||||
"notes": "RLS habilitada em todas as tabelas públicas. Multi-tenant via tenant_id. SECURITY DEFINER em RPCs sensíveis."
|
||||
},
|
||||
{
|
||||
"name": "Docker + Docker Compose",
|
||||
"role": "Orquestração dos containers do stack Supabase local + Evolution API",
|
||||
"env": "Local",
|
||||
"status": "ativo",
|
||||
"notes": "docker-compose.yml na raiz. Iniciado via npx supabase start."
|
||||
}
|
||||
]
|
||||
},
|
||||
"Email": {
|
||||
"color": "#fbbf24",
|
||||
"items": [
|
||||
{
|
||||
"name": "Mailpit (Supabase inbucket)",
|
||||
"role": "Inbox SMTP local para capturar emails de teste",
|
||||
"env": "Local (Docker)",
|
||||
"status": "ativo",
|
||||
"notes": "Container supabase_inbucket. Usado em dev para validar templates sem enviar email real."
|
||||
},
|
||||
{
|
||||
"name": "SMTP produção",
|
||||
"role": "Envio real de emails transacionais (faturas, convites, notificações)",
|
||||
"env": "Cloud (pendente)",
|
||||
"status": "pendente",
|
||||
"notes": "Requer SMTP_HOST/PORT/USER/PASS/FROM nos secrets das edge functions."
|
||||
}
|
||||
]
|
||||
},
|
||||
"WhatsApp / SMS": {
|
||||
"color": "#34d399",
|
||||
"items": [
|
||||
{
|
||||
"name": "Evolution API",
|
||||
"role": "WhatsApp self-hosted via Baileys (tier gratuito do SaaS — 'WhatsApp Pessoal')",
|
||||
"env": "Local (Docker)",
|
||||
"status": "ativo",
|
||||
"notes": "Container via evolution-api/docker-compose.yml. Uso do usuário conecta via QR code no celular real. Sem SLA, Meta pode banir número. Envio sem custo. Edge functions: evolution-whatsapp-inbound, evolution-webhook-provision, send-whatsapp-message."
|
||||
},
|
||||
{
|
||||
"name": "Twilio WhatsApp Business API",
|
||||
"role": "WhatsApp oficial (tier pago rebrandeado como 'WhatsApp Oficial AgenciaPSI')",
|
||||
"env": "Cloud",
|
||||
"status": "ativo",
|
||||
"notes": "API oficial Meta, zero risco de ban. Credenciais em notification_channels (twilio_subaccount_sid + credentials.subaccount_auth_token). Envio consome 1 crédito via RPC deduct_whatsapp_credits (atômico + rollback em falha). Provisionamento: supabase/functions/twilio-whatsapp-provision/."
|
||||
}
|
||||
]
|
||||
},
|
||||
"Pagamentos / Billing": {
|
||||
"color": "#fb923c",
|
||||
"items": [
|
||||
{
|
||||
"name": "Asaas (gateway PIX/cartão/boleto)",
|
||||
"role": "Processamento de pagamentos pra compra de créditos WhatsApp (Marco B)",
|
||||
"env": "Cloud — sandbox.asaas.com em dev, api.asaas.com em prod",
|
||||
"status": "ativo",
|
||||
"notes": "API key em ASAAS_API_KEY (env secret). URL em ASAAS_API_URL. Webhook token opcional em ASAAS_WEBHOOK_TOKEN. Edge functions: create-whatsapp-credit-charge (cria customer + PIX), asaas-webhook (recebe PAYMENT_RECEIVED/CONFIRMED e credita saldo via add_whatsapp_credits)."
|
||||
},
|
||||
{
|
||||
"name": "ngrok (dev only — tunnel pro webhook)",
|
||||
"role": "Expõe edge functions locais pra Asaas alcançar via internet",
|
||||
"env": "Local (dev)",
|
||||
"status": "opcional",
|
||||
"notes": "Uso: ngrok http 54321 → copia URL e cadastra em Asaas → Integrações → Webhooks → /functions/v1/asaas-webhook. Necessário só pra testar fluxo completo local incluindo confirmação de pagamento."
|
||||
}
|
||||
]
|
||||
},
|
||||
"Geração de documentos": {
|
||||
"color": "#38bdf8",
|
||||
"items": [
|
||||
{
|
||||
"name": "pdfmake 0.3.7",
|
||||
"role": "Geração de PDF client-side (atestados, laudos, recibos)",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "UMD/webpack. Requer optimizeDeps.include explícito no vite.config.mjs."
|
||||
},
|
||||
{
|
||||
"name": "html-to-pdfmake / html2pdf.js / jsPDF",
|
||||
"role": "Conversão HTML→PDF para documentos ricos",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "Usado em document_templates e documents gerados para pacientes."
|
||||
},
|
||||
{
|
||||
"name": "Jodit + Quill",
|
||||
"role": "Editores de texto rico para templates de documentos",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "Jodit em DocumentTemplateEditor; Quill em páginas legadas. Migração em andamento."
|
||||
},
|
||||
{
|
||||
"name": "html2canvas-pro",
|
||||
"role": "Captura de screenshots de DOM (preview/export)",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "Usado para thumbnails de templates e previews."
|
||||
}
|
||||
]
|
||||
},
|
||||
"Frontend": {
|
||||
"color": "#a78bfa",
|
||||
"items": [
|
||||
{
|
||||
"name": "Vue 3 + Composition API",
|
||||
"role": "Framework principal (script setup)",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "~487 componentes Vue. Pinia para state management."
|
||||
},
|
||||
{
|
||||
"name": "Vite 5",
|
||||
"role": "Build tool e dev server",
|
||||
"env": "Node.js",
|
||||
"status": "ativo",
|
||||
"notes": "vite-plugin-compression (Brotli/Gzip), unplugin-auto-import para PrimeVue e Vue. rollup-plugin-visualizer para análise de bundle."
|
||||
},
|
||||
{
|
||||
"name": "PrimeVue 4 (tema Sakai)",
|
||||
"role": "Biblioteca de componentes UI",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "@primeuix/themes. auto-import-resolver. DataTable, Dialog, DatePicker, Popover, Toast, ConfirmDialog headless."
|
||||
},
|
||||
{
|
||||
"name": "Tailwind CSS v4",
|
||||
"role": "Utility-first CSS",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "@tailwindcss/vite + tailwindcss-primeui. Surface tokens do PrimeVue (var(--surface-card), var(--text-color-secondary))."
|
||||
},
|
||||
{
|
||||
"name": "Vue Router",
|
||||
"role": "Roteamento SPA com guards por role/tenant",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "Grupos de rota: therapist, admin, supervisor, saas, billing, account, configuracoes, features."
|
||||
},
|
||||
{
|
||||
"name": "FullCalendar 6",
|
||||
"role": "Calendário para agenda de terapeutas",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "Plugins: daygrid, timegrid, interaction, list, resource, resource-timegrid."
|
||||
},
|
||||
{
|
||||
"name": "Chart.js 3",
|
||||
"role": "Gráficos para dashboards (financeiro, KPIs)",
|
||||
"env": "Browser",
|
||||
"status": "ativo",
|
||||
"notes": "Usado em dashboards do therapist e clinic."
|
||||
}
|
||||
]
|
||||
},
|
||||
"Dev / Tooling": {
|
||||
"color": "#94a3b8",
|
||||
"items": [
|
||||
{
|
||||
"name": "Supabase CLI",
|
||||
"role": "Gerencia ambiente local, migrations, edge functions",
|
||||
"env": "Node.js",
|
||||
"status": "ativo",
|
||||
"notes": "Via npx supabase. Start/stop/status/db-push/functions-deploy."
|
||||
},
|
||||
{
|
||||
"name": "db.cjs (este projeto)",
|
||||
"role": "CLI auxiliar pra setup/backup/restore/migrate/verify via docker exec",
|
||||
"env": "Node.js",
|
||||
"status": "ativo",
|
||||
"notes": "Complementa o supabase CLI com fluxo schema + fixes + seeds + migrations. Encoding UTF-8 preservado."
|
||||
},
|
||||
{
|
||||
"name": "generate-dashboard.cjs",
|
||||
"role": "Gera dashboard HTML estático do schema (tabelas, FKs, infra)",
|
||||
"env": "Node.js",
|
||||
"status": "ativo",
|
||||
"notes": "Standalone, sem dependências externas. Lê config de db.config.json e schema do backup mais recente."
|
||||
},
|
||||
{
|
||||
"name": "Vitest 4",
|
||||
"role": "Runner de testes unitários",
|
||||
"env": "Node.js",
|
||||
"status": "ativo",
|
||||
"notes": "npm test / test:watch / test:ui. Bateria inicial em src/**/__tests__."
|
||||
},
|
||||
{
|
||||
"name": "ESLint + Prettier",
|
||||
"role": "Lint + formatação automática",
|
||||
"env": "Node.js",
|
||||
"status": "ativo",
|
||||
"notes": "@vue/eslint-config-prettier. Rodado via npm run lint."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# Regras de Negócio — Banco de Dados AgenciaPsi
|
||||
|
||||
## 1. Planos e Targets
|
||||
|
||||
| Target | Planos | Escopo da Subscription |
|
||||
|--------|--------|----------------------|
|
||||
| `patient` | patient_free | `user_id` (sem tenant_id) |
|
||||
| `therapist` | therapist_free, therapist_pro | `user_id` (sem tenant_id) |
|
||||
| `clinic` | clinic_free, clinic_pro | `tenant_id` (sem user_id) |
|
||||
| `supervisor` | supervisor_free, supervisor_pro | `user_id` (sem tenant_id) |
|
||||
|
||||
**Constraint `subscriptions_owner_xor`**: Uma subscription DEVE ter `tenant_id` XOR `user_id`, nunca ambos.
|
||||
|
||||
**Trigger `subscriptions_validate_scope`**: Valida que o target do plano casa com o escopo:
|
||||
- `clinic` → exige `tenant_id`, rejeita `user_id`
|
||||
- `therapist`, `supervisor`, `patient` → exige `user_id`, rejeita `tenant_id`
|
||||
|
||||
## 2. Planos Core (protegidos)
|
||||
|
||||
Os planos `clinic_free`, `clinic_pro`, `therapist_free`, `therapist_pro` são **core**:
|
||||
- **Não podem ter `key` alterada** (trigger `trg_no_change_core_plan_key`)
|
||||
- **Não podem ter `target` alterado** (trigger `trg_no_change_plan_target`)
|
||||
- **Não podem ser deletados** (trigger `trg_no_delete_core_plans`)
|
||||
|
||||
Para bypass (migração): `SET LOCAL app.plan_migration_bypass = '1'`
|
||||
|
||||
## 3. Entitlements (Features)
|
||||
|
||||
### Resolução de features para TENANTS (clínicas)
|
||||
```
|
||||
tenant_has_feature(tenant_id, feature_key) =
|
||||
EXISTS em v_tenant_entitlements (via plano)
|
||||
OR
|
||||
EXISTS em tenant_features (override direto)
|
||||
```
|
||||
|
||||
### Resolução de features para USERS (terapeutas, supervisores)
|
||||
```
|
||||
user_has_feature(user_id, feature_key) =
|
||||
EXISTS em v_user_entitlements (via plano pessoal)
|
||||
```
|
||||
|
||||
### Cadeia de resolução
|
||||
```
|
||||
subscription → plan → plan_features → features
|
||||
↓
|
||||
plan_features.limits (jsonb) → limites quantitativos
|
||||
```
|
||||
|
||||
### Views de entitlements
|
||||
- `v_tenant_active_subscription` → subscription ativa do tenant
|
||||
- `v_user_active_subscription` → subscription ativa do user
|
||||
- `v_tenant_entitlements` → feature_key + allowed
|
||||
- `v_tenant_entitlements_full` → + limits + plan_id + plan_key
|
||||
- `v_user_entitlements` → feature_key + allowed (para planos pessoais)
|
||||
|
||||
## 4. Tipos de Tenant
|
||||
|
||||
| kind | Descrição | Criação |
|
||||
|------|-----------|---------|
|
||||
| `therapist` | Terapeuta solo | Automático ao criar conta de terapeuta |
|
||||
| `clinic_coworking` | Clínica coworking | Manual |
|
||||
| `clinic_reception` | Clínica com recepção | Manual |
|
||||
| `clinic_full` | Clínica completa | Manual |
|
||||
| `supervisor` | Supervisor | Automático |
|
||||
| `saas` | Sistema (legado) | — |
|
||||
| `clinic` | Legado | — |
|
||||
|
||||
**O `kind` é imutável após criação** (trigger `trg_tenant_kind_immutable`).
|
||||
|
||||
## 5. Roles e Permissões
|
||||
|
||||
### Profile roles
|
||||
| Role | Descrição |
|
||||
|------|-----------|
|
||||
| `saas_admin` | Administrador da plataforma |
|
||||
| `tenant_member` | Membro de um ou mais tenants |
|
||||
| `portal_user` | Paciente (acesso ao portal) |
|
||||
| `patient` | Paciente (legado) |
|
||||
|
||||
### Tenant member roles
|
||||
| Role | Descrição |
|
||||
|------|-----------|
|
||||
| `tenant_admin` | Admin do tenant (dono) |
|
||||
| `therapist` | Terapeuta membro |
|
||||
| `clinic_admin` | Admin da clínica (secretária com poderes) |
|
||||
| `secretary` | Secretária |
|
||||
| `supervisor` | Supervisor |
|
||||
| `patient` | Paciente do tenant |
|
||||
|
||||
### Platform roles (array em profiles)
|
||||
| Role | Descrição |
|
||||
|------|-----------|
|
||||
| `editor` | Editor de conteúdo da plataforma |
|
||||
|
||||
## 6. Compromissos Determinados
|
||||
|
||||
A função `seed_determined_commitments(tenant_id)` cria 5 tipos nativos:
|
||||
|
||||
| native_key | Nome | locked | active |
|
||||
|------------|------|--------|--------|
|
||||
| `session` | Sessão | true | true |
|
||||
| `reading` | Leitura | false | true |
|
||||
| `supervision` | Supervisão | false | true |
|
||||
| `class` | Aula | false | **false** |
|
||||
| `analysis` | Análise Pessoal | false | true |
|
||||
|
||||
- `session` é **locked** (não pode ser editado/deletado)
|
||||
- O `native_key = 'session'` é usado pelo agendador online para identificar o compromisso padrão
|
||||
|
||||
## 7. Grupos de Pacientes Padrão
|
||||
|
||||
A função `seed_default_patient_groups(tenant_id)` cria 3 grupos sistema:
|
||||
|
||||
| Nome | Cor | is_system |
|
||||
|------|-----|-----------|
|
||||
| Crianças | #60a5fa | true |
|
||||
| Adolescentes | #a78bfa | true |
|
||||
| Idosos | #34d399 | true |
|
||||
|
||||
Grupos sistema não podem ser editados/deletados (trigger `prevent_system_group_changes`).
|
||||
|
||||
## 8. Subscriptions — Status
|
||||
|
||||
| Status | Descrição |
|
||||
|--------|-----------|
|
||||
| `pending` | Aguardando ativação |
|
||||
| `active` | Ativa |
|
||||
| `past_due` | Pagamento atrasado |
|
||||
| `suspended` | Suspensa |
|
||||
| `cancelled` | Cancelada |
|
||||
| `expired` | Expirada |
|
||||
|
||||
## 9. Templates de Email
|
||||
|
||||
**Globais** (`email_templates_global`): templates padrão da plataforma, gerenciados pelo saas_admin.
|
||||
|
||||
**Tenant** (`email_templates_tenant`): overrides por tenant. Se existir, usa o do tenant; se não, usa o global.
|
||||
|
||||
### Keys de template
|
||||
| Domínio | Templates |
|
||||
|---------|-----------|
|
||||
| session | reminder, confirmation, cancellation, rescheduled |
|
||||
| intake | received, approved, rejected |
|
||||
| scheduler | request_accepted, request_rejected |
|
||||
| system | welcome, password_reset |
|
||||
|
||||
Canais: `email`, `whatsapp`, `sms`
|
||||
|
||||
## 10. Notificações — Sistema
|
||||
|
||||
| Canal | Tipos |
|
||||
|-------|-------|
|
||||
| WhatsApp | lembrete_sessao, confirmacao_sessao, cancelamento_sessao |
|
||||
| SMS | lembrete_sessao |
|
||||
|
||||
### Schedule keys
|
||||
| Key | Descrição |
|
||||
|-----|-----------|
|
||||
| `lembrete_24h` | 24 horas antes |
|
||||
| `lembrete_2h` | 2 horas antes |
|
||||
| `lembrete_30min` | 30 minutos antes |
|
||||
| `confirmacao_imediata` | Imediato após confirmar |
|
||||
| `cancelamento_imediato` | Imediato após cancelar |
|
||||
|
||||
## 11. RLS (Row Level Security)
|
||||
|
||||
Todas as tabelas do schema `public` têm RLS habilitado. As policies usam:
|
||||
- `auth.uid()` — ID do usuário autenticado
|
||||
- `is_saas_admin()` — verifica se é admin da plataforma
|
||||
- `is_tenant_member(tenant_id)` — verifica se pertence ao tenant
|
||||
- `is_tenant_admin(tenant_id)` — verifica se é admin do tenant
|
||||
- `current_member_role(tenant_id)` — role do membro no tenant
|
||||
- `tenant_has_feature(tenant_id, feature_key)` — verifica feature
|
||||
|
||||
**Se as features/plan_features não existirem no banco, as policies de RLS bloqueiam o acesso.**
|
||||
@@ -0,0 +1,190 @@
|
||||
# Schema Map — AgenciaPsi
|
||||
|
||||
Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-03-23).
|
||||
**84 tabelas** no schema `public` + tabelas de infraestrutura (auth, storage, realtime).
|
||||
|
||||
## Domínios
|
||||
|
||||
### Core (11 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `profiles` | Perfil do usuário (role, account_type, full_name, platform_roles) |
|
||||
| `tenants` | Organizações (clínicas, terapeutas solo, supervisores) |
|
||||
| `tenant_members` | Vínculo usuário↔tenant com role (tenant_admin, therapist, secretary, etc.) |
|
||||
| `tenant_invites` | Convites pendentes para ingressar em um tenant |
|
||||
| `tenant_features` | Overrides de features por tenant (exceções comerciais) |
|
||||
| `tenant_feature_exceptions_log` | Log de alterações em tenant_features |
|
||||
| `saas_admins` | Administradores da plataforma |
|
||||
| `owner_users` | Mapeamento owner_id→user_id para RLS |
|
||||
| `user_settings` | Configurações pessoais do usuário |
|
||||
| `company_profiles` | Perfil da empresa/clínica (logo, endereço, etc.) |
|
||||
| `dev_user_credentials` | Credenciais de teste (apenas dev) |
|
||||
|
||||
### Plans & Billing (20 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `plans` | Planos disponíveis (key, target, price_cents, max_supervisees) |
|
||||
| `plan_prices` | Preços por intervalo (month/year) com versionamento |
|
||||
| `plan_features` | Vínculo plano↔feature com limites (limits jsonb) |
|
||||
| `plan_public` | Info pública dos planos (para página de preços) |
|
||||
| `plan_public_bullets` | Bullets de marketing dos planos |
|
||||
| `features` | Features do sistema (key, name, descricao) |
|
||||
| `entitlements_invalidation` | Cache invalidation de entitlements |
|
||||
| `subscriptions` | Assinaturas ativas (user_id XOR tenant_id) |
|
||||
| `subscription_events` | Histórico de eventos de assinatura |
|
||||
| `subscription_intents_personal` | Intenções de assinatura pessoal |
|
||||
| `subscription_intents_tenant` | Intenções de assinatura de tenant |
|
||||
| `subscription_intents_legacy` | Intenções legadas |
|
||||
| `billing_contracts` | Contratos de cobrança |
|
||||
| `addon_credits` | Créditos de add-ons por tenant |
|
||||
| `addon_products` | Produtos add-on disponíveis |
|
||||
| `addon_transactions` | Transações de add-ons |
|
||||
| `modules` | Módulos do sistema |
|
||||
| `module_features` | Features por módulo |
|
||||
| `tenant_modules` | Módulos ativos por tenant |
|
||||
|
||||
### Agenda (10 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `agenda_bloqueios` | Bloqueios de horário |
|
||||
| `agenda_configuracoes` | Configurações da agenda por tenant_member |
|
||||
| `agenda_eventos` | Eventos da agenda (sessões, bloqueios) |
|
||||
| `agenda_online_slots` | Slots de agendamento online |
|
||||
| `agenda_regras_semanais` | Regras semanais de disponibilidade |
|
||||
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
|
||||
| `agenda_slots_regras` | Regras de slots |
|
||||
| `recurrence_rules` | Regras de recorrência de sessões |
|
||||
| `recurrence_exceptions` | Exceções a recorrências |
|
||||
| `recurrence_rule_services` | Serviços vinculados a recorrências |
|
||||
|
||||
### Agendador Online (2 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `agendador_configuracoes` | Configurações do agendador online público |
|
||||
| `agendador_solicitacoes` | Solicitações de agendamento recebidas |
|
||||
|
||||
### Pacientes (8 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `patients` | Pacientes vinculados a um tenant |
|
||||
| `patient_groups` | Grupos de pacientes (sistema + customizados) |
|
||||
| `patient_group_patient` | Vínculo paciente↔grupo |
|
||||
| `patient_tags` | Tags personalizadas |
|
||||
| `patient_patient_tag` | Vínculo paciente↔tag |
|
||||
| `patient_intake_requests` | Solicitações de cadastro (triagem) |
|
||||
| `patient_invites` | Convites para portal do paciente |
|
||||
| `patient_discounts` | Descontos por paciente |
|
||||
|
||||
### Compromissos Determinados (4 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `determined_commitments` | Tipos de compromisso (sessão, leitura, supervisão, etc.) |
|
||||
| `determined_commitment_fields` | Campos customizados por tipo de compromisso |
|
||||
| `commitment_services` | Serviços vinculados a compromissos |
|
||||
| `commitment_time_logs` | Logs de tempo por compromisso |
|
||||
|
||||
### Financeiro (9 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `financial_records` | Lançamentos financeiros (receita/despesa) |
|
||||
| `financial_categories` | Categorias de lançamento |
|
||||
| `financial_exceptions` | Exceções financeiras |
|
||||
| `payment_settings` | Configurações de pagamento por tenant |
|
||||
| `professional_pricing` | Precificação por profissional |
|
||||
| `therapist_payouts` | Repasses a terapeutas |
|
||||
| `therapist_payout_records` | Registros de repasse |
|
||||
| `services` | Serviços oferecidos |
|
||||
| `insurance_plans` + `insurance_plan_services` | Convênios e serviços por convênio |
|
||||
|
||||
### Notificações (10 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `notification_channels` | Canais de notificação por tenant |
|
||||
| `notification_logs` | Logs de envio |
|
||||
| `notification_preferences` | Preferências do paciente (opt-in/out) |
|
||||
| `notification_queue` | Fila de envio |
|
||||
| `notification_schedules` | Agendamentos de notificação |
|
||||
| `notification_templates` | Templates WhatsApp/SMS (default + tenant) |
|
||||
| `notifications` | Notificações in-app |
|
||||
| `email_templates_global` | Templates de email globais (plataforma) |
|
||||
| `email_templates_tenant` | Overrides de templates por tenant |
|
||||
| `email_layout_config` | Configuração de layout de email |
|
||||
|
||||
### SaaS Admin / UI (8 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `saas_docs` | Documentação da plataforma |
|
||||
| `saas_doc_votos` | Votos em docs |
|
||||
| `saas_faq` | Categorias de FAQ |
|
||||
| `saas_faq_itens` | Itens de FAQ |
|
||||
| `feriados` | Feriados nacionais/regionais |
|
||||
| `global_notices` | Avisos globais da plataforma |
|
||||
| `login_carousel_slides` | Slides do carrossel de login |
|
||||
| `notice_dismissals` | Dismissals de avisos por usuário |
|
||||
|
||||
### Suporte (1 tabela)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `support_sessions` | Sessões de suporte técnico |
|
||||
|
||||
---
|
||||
|
||||
## Views Principais
|
||||
|
||||
| View | Descrição |
|
||||
|------|-----------|
|
||||
| `v_tenant_active_subscription` | Subscription ativa por tenant |
|
||||
| `v_user_active_subscription` | Subscription ativa por user |
|
||||
| `v_tenant_entitlements` | Features habilitadas por tenant (via plano) |
|
||||
| `v_tenant_entitlements_full` | Entitlements + limits + plan info |
|
||||
| `v_tenant_entitlements_json` | Entitlements agregados como JSON |
|
||||
| `v_user_entitlements` | Features habilitadas por user (via plano) |
|
||||
| `v_tenant_members_with_profiles` | Membros do tenant com dados do perfil |
|
||||
| `v_tenant_staff` | Staff do tenant (membros + convites) |
|
||||
| `v_tenant_people` | Todas as pessoas do tenant |
|
||||
| `v_plan_active_prices` | Preços ativos dos planos |
|
||||
| `v_public_pricing` | Preços públicos para página de marketing |
|
||||
| `v_subscription_health` | Saúde das subscriptions |
|
||||
| `v_cashflow_projection` | Projeção de fluxo de caixa |
|
||||
| `v_commitment_totals` | Totais de compromissos |
|
||||
| `v_patient_groups_with_counts` | Grupos com contagem de pacientes |
|
||||
| `v_tag_patient_counts` | Tags com contagem de pacientes |
|
||||
| `subscription_intents` | View unificada de intenções (com INSTEAD OF trigger) |
|
||||
| `owner_feature_entitlements` | Entitlements por owner |
|
||||
| `current_tenant_id` | Tenant ativo do usuário corrente |
|
||||
|
||||
---
|
||||
|
||||
## Funções Críticas
|
||||
|
||||
| Função | Tipo | Descrição |
|
||||
|--------|------|-----------|
|
||||
| `tenant_has_feature(uuid, text)` | Query | Verifica se tenant tem feature (plano + override) |
|
||||
| `user_has_feature(uuid, text)` | Query | Verifica se user tem feature via plano pessoal |
|
||||
| `has_feature(uuid, text)` | Query | Alias genérico |
|
||||
| `seed_determined_commitments(uuid)` | Seed | Cria 5 tipos de compromisso nativos por tenant |
|
||||
| `seed_default_patient_groups(uuid)` | Seed | Cria 3 grupos de pacientes padrão |
|
||||
| `seed_default_financial_categories(uuid)` | Seed | Cria categorias financeiras padrão |
|
||||
| `subscriptions_validate_scope()` | Trigger | Valida XOR (user_id vs tenant_id) por target |
|
||||
| `activate_subscription_from_intent(uuid)` | RPC | Ativa subscription a partir de intent |
|
||||
| `handle_new_user()` | Trigger | Cria profile + tenant pessoal ao cadastrar |
|
||||
| `ensure_personal_tenant()` | RPC | Garante que o user tem um tenant pessoal |
|
||||
| `populate_notification_queue()` | Cron | Popula fila de notificações |
|
||||
| `agendador_slots_disponiveis(text, date)` | RPC | Retorna slots disponíveis para agendamento |
|
||||
|
||||
---
|
||||
|
||||
## Enums (public schema)
|
||||
|
||||
| Tipo | Valores |
|
||||
|------|---------|
|
||||
| `commitment_log_source` | manual, auto |
|
||||
| `determined_field_type` | text, textarea, number, date, select, boolean |
|
||||
| `financial_record_type` | receita, despesa |
|
||||
| `recurrence_exception_type` | cancel_session, reschedule_session, patient_missed, therapist_canceled, holiday_block |
|
||||
| `recurrence_type` | weekly, biweekly, monthly, yearly, custom_weekdays |
|
||||
| `status_agenda_serie` | ativo, pausado, cancelado |
|
||||
| `status_evento_agenda` | agendado, realizado, faltou, cancelado, remarcar |
|
||||
| `status_excecao_agenda` | pendente, ativo, arquivado |
|
||||
| `tipo_evento_agenda` | sessao, bloqueio |
|
||||
| `tipo_excecao_agenda` | bloqueio, horario_extra |
|
||||
@@ -0,0 +1,297 @@
|
||||
# Guia de Instalação e Uso — AgenciaPsi Database
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
1. **Docker Desktop** instalado e rodando
|
||||
2. **Node.js** 18+ instalado
|
||||
3. **Supabase CLI** instalado (`npm install -g supabase`)
|
||||
|
||||
## Instalação do Zero (banco vazio)
|
||||
|
||||
### 1. Iniciar o Supabase
|
||||
|
||||
```bash
|
||||
# Na raiz do projeto (agenciapsi-primesakai/)
|
||||
npx supabase start
|
||||
```
|
||||
|
||||
Aguarde até o container `supabase_db_agenciapsi-primesakai` estar rodando.
|
||||
|
||||
### 2. Verificar se o container está ok
|
||||
|
||||
```bash
|
||||
docker ps | grep supabase_db
|
||||
```
|
||||
|
||||
Deve mostrar o container com status `Up`.
|
||||
|
||||
### 3. Instalar o banco completo
|
||||
|
||||
```bash
|
||||
cd database-novo
|
||||
node db.cjs setup
|
||||
```
|
||||
|
||||
Isso faz tudo automaticamente:
|
||||
- Aplica o schema completo (84 tabelas, funções, triggers, policies)
|
||||
- Aplica os 7 fixes conhecidos
|
||||
- Cria os 11 usuários de teste
|
||||
- Cria os 7 planos + 4 preços
|
||||
- Cria as 26 features + 85 vínculos plano↔feature
|
||||
- Cria as 9 subscriptions + compromissos determinados
|
||||
- Cria os templates de email, notificação e carousel
|
||||
- Cria backup automático pós-instalação
|
||||
- Verifica integridade no final
|
||||
|
||||
### 4. Verificar
|
||||
|
||||
```bash
|
||||
node db.cjs status
|
||||
```
|
||||
|
||||
Deve mostrar todos os counts verdes.
|
||||
|
||||
## Backup
|
||||
|
||||
### Criar backup manual
|
||||
|
||||
```bash
|
||||
node db.cjs backup
|
||||
```
|
||||
|
||||
Salva em `backups/YYYY-MM-DD/` com 3 arquivos:
|
||||
- `schema.sql` — estrutura do banco
|
||||
- `data.sql` — dados (sem schemas de infra)
|
||||
- `full_dump.sql` — tudo junto
|
||||
|
||||
### Backup automático
|
||||
|
||||
O backup é feito automaticamente:
|
||||
- Após o `setup`
|
||||
- Antes de cada `migrate`
|
||||
- Antes de cada `restore`
|
||||
- Antes de cada `reset`
|
||||
|
||||
### Retenção
|
||||
|
||||
Backups com mais de 30 dias são removidos automaticamente. Para alterar, edite `backupRetentionDays` no `db.config.json`.
|
||||
|
||||
## Restaurar o Banco
|
||||
|
||||
### Restaurar do último backup
|
||||
|
||||
```bash
|
||||
node db.cjs restore
|
||||
```
|
||||
|
||||
### Restaurar de uma data específica
|
||||
|
||||
```bash
|
||||
node db.cjs restore 2026-03-23
|
||||
```
|
||||
|
||||
O restore:
|
||||
1. Cria backup de segurança do estado atual
|
||||
2. Limpa o schema public
|
||||
3. Aplica o full_dump.sql do backup
|
||||
4. Verifica integridade
|
||||
|
||||
## Migrations (alterações no banco)
|
||||
|
||||
### Criar uma migration
|
||||
|
||||
Crie um arquivo SQL na pasta `migrations/` com nome sequencial:
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── 001_add_column_x.sql
|
||||
├── 002_create_table_y.sql
|
||||
└── 003_fix_something.sql
|
||||
```
|
||||
|
||||
O nome deve começar com número para garantir a ordem.
|
||||
|
||||
### Aplicar migrations pendentes
|
||||
|
||||
```bash
|
||||
node db.cjs migrate
|
||||
```
|
||||
|
||||
O CLI:
|
||||
1. Cria backup automático
|
||||
2. Compara com a tabela `_db_migrations` no banco
|
||||
3. Aplica apenas as que ainda não foram executadas
|
||||
4. Registra cada migration aplicada
|
||||
5. Se uma falhar, para imediatamente (use `restore` para voltar)
|
||||
|
||||
### Ver migrations aplicadas
|
||||
|
||||
```bash
|
||||
node db.cjs status
|
||||
```
|
||||
|
||||
## Seeds (dados de teste)
|
||||
|
||||
### Rodar todos os seeds
|
||||
|
||||
```bash
|
||||
node db.cjs seed all # ou simplesmente: node db.cjs seed
|
||||
```
|
||||
|
||||
### Rodar grupo específico
|
||||
|
||||
```bash
|
||||
node db.cjs seed users # Apenas usuários (seed_001 a 003)
|
||||
node db.cjs seed system # Apenas sistema (seed_010 a 014)
|
||||
node db.cjs seed test_data # Dados de teste (seed_020)
|
||||
```
|
||||
|
||||
### Ordem dos seeds
|
||||
|
||||
| # | Arquivo | O que faz |
|
||||
|---|---------|-----------|
|
||||
| 1 | `seed_001_fixed.sql` | 6 usuários base + tenants |
|
||||
| 2 | `seed_002.sql` | Supervisor + Editor |
|
||||
| 3 | `seed_003.sql` | Therapist2, Therapist3, Secretary |
|
||||
| 4 | `seed_010_plans.sql` | 7 planos + 4 preços |
|
||||
| 5 | `seed_011_features.sql` | 26 features |
|
||||
| 6 | `seed_012_plan_features.sql` | 85 vínculos plano↔feature |
|
||||
| 7 | `seed_013_subscriptions.sql` | 9 subscriptions + compromissos |
|
||||
| 8 | `seed_014_global_data.sql` | Templates + carousel |
|
||||
|
||||
## Outros Comandos
|
||||
|
||||
### Ver status
|
||||
|
||||
```bash
|
||||
node db.cjs status
|
||||
```
|
||||
|
||||
Mostra: container, backups, migrations aplicadas/pendentes, counts de todas as tabelas.
|
||||
|
||||
### Comparar mudanças
|
||||
|
||||
```bash
|
||||
node db.cjs diff
|
||||
```
|
||||
|
||||
Compara o schema atual no banco com o último backup. Mostra tabelas adicionadas, removidas ou alteradas.
|
||||
|
||||
### Verificar integridade
|
||||
|
||||
```bash
|
||||
node db.cjs verify
|
||||
```
|
||||
|
||||
Checa se os dados essenciais existem (plans, features, subscriptions, etc).
|
||||
|
||||
### Reset completo
|
||||
|
||||
```bash
|
||||
node db.cjs reset
|
||||
```
|
||||
|
||||
**⚠ CUIDADO**: Apaga tudo e reinstala do zero. Cria backup antes.
|
||||
|
||||
## Estrutura de Pastas
|
||||
|
||||
```
|
||||
database-novo/
|
||||
├── db.js ← CLI principal
|
||||
├── db.config.json ← Configuração (container, seeds, fixes)
|
||||
│
|
||||
├── schema/ ← Schema SQL separado por seção
|
||||
│ ├── 00_full/ ← Schema completo (referência)
|
||||
│ ├── 01_extensions/ ← Extensões PostgreSQL
|
||||
│ ├── 02_types/ ← Enums e tipos
|
||||
│ ├── 03_functions/ ← Funções (11 arquivos por domínio)
|
||||
│ ├── 04_tables/ ← Tabelas (10 arquivos por domínio)
|
||||
│ ├── 05_views/ ← 24 views
|
||||
│ ├── 06_indexes/ ← Índices
|
||||
│ ├── 07_foreign_keys/ ← PKs, FKs, constraints
|
||||
│ ├── 08_triggers/ ← Triggers
|
||||
│ ├── 09_policies/ ← 217 RLS policies
|
||||
│ └── 10_grants/ ← Grants
|
||||
│
|
||||
├── seeds/ ← Seeds de dados
|
||||
│ ├── seed_001_fixed.sql
|
||||
│ ├── ...
|
||||
│ └── run_all_seeds.sh
|
||||
│
|
||||
├── migrations/ ← Migrations (alterações incrementais)
|
||||
│
|
||||
├── fixes/ ← Correções aplicadas
|
||||
│
|
||||
├── backups/ ← Backups com data
|
||||
│ ├── 2026-03-23/
|
||||
│ └── ...
|
||||
│
|
||||
└── docs/ ← Documentação
|
||||
├── setup_guide.md ← Este arquivo
|
||||
├── schema_map.md ← Mapa de 84 tabelas
|
||||
├── business_rules.md ← Regras de negócio
|
||||
└── users_test.md ← Usuários de teste
|
||||
```
|
||||
|
||||
## Credenciais de Teste
|
||||
|
||||
| Email | Senha | Tipo |
|
||||
|-------|-------|------|
|
||||
| paciente@agenciapsi.com.br | Teste@123 | Paciente |
|
||||
| terapeuta@agenciapsi.com.br | Teste@123 | Terapeuta solo |
|
||||
| clinica1@agenciapsi.com.br | Teste@123 | Clínica coworking |
|
||||
| clinica2@agenciapsi.com.br | Teste@123 | Clínica recepção |
|
||||
| clinica3@agenciapsi.com.br | Teste@123 | Clínica full |
|
||||
| saas@agenciapsi.com.br | Teste@123 | Admin plataforma |
|
||||
| supervisor@agenciapsi.com.br | Teste@123 | Supervisor |
|
||||
| editor@agenciapsi.com.br | Teste@123 | Editor |
|
||||
| therapist2@agenciapsi.com.br | Teste@123 | Terapeuta |
|
||||
| therapist3@agenciapsi.com.br | Teste@123 | Terapeuta |
|
||||
| secretary@agenciapsi.com.br | Teste@123 | Secretária |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Container não está rodando"
|
||||
|
||||
```bash
|
||||
# Verificar
|
||||
docker ps | grep supabase
|
||||
|
||||
# Reiniciar
|
||||
npx supabase stop
|
||||
npx supabase start
|
||||
```
|
||||
|
||||
### "Tabela não existe" após setup
|
||||
|
||||
O schema pode não ter sido aplicado corretamente. Rode:
|
||||
|
||||
```bash
|
||||
node db.cjs reset
|
||||
```
|
||||
|
||||
### "Permission denied" / RLS bloqueando
|
||||
|
||||
Se features/plan_features estiverem vazios, o RLS bloqueia tudo. Rode:
|
||||
|
||||
```bash
|
||||
node db.cjs seed system
|
||||
```
|
||||
|
||||
### Migration falhou no meio
|
||||
|
||||
```bash
|
||||
# Voltar ao estado anterior
|
||||
node db.cjs restore
|
||||
|
||||
# Corrigir o SQL da migration, depois tentar de novo
|
||||
node db.cjs migrate
|
||||
```
|
||||
|
||||
### Quero começar do zero
|
||||
|
||||
```bash
|
||||
node db.cjs reset
|
||||
```
|
||||
|
||||
Isso apaga tudo, reaplica schema, fixes, seeds, e verifica.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Usuários de Teste — AgenciaPsi
|
||||
|
||||
Senha de todos: `Teste@123`
|
||||
|
||||
## Mapa de UUIDs
|
||||
|
||||
### Users (auth.users.id = profiles.id)
|
||||
| Email | UUID | Nome |
|
||||
|-------|------|------|
|
||||
| paciente@agenciapsi.com.br | `aaaaaaaa-0001-0001-0001-000000000001` | Ana Paciente |
|
||||
| terapeuta@agenciapsi.com.br | `aaaaaaaa-0002-0002-0002-000000000002` | Bruno Terapeuta |
|
||||
| clinica1@agenciapsi.com.br | `aaaaaaaa-0003-0003-0003-000000000003` | Clínica Espaço Psi |
|
||||
| clinica2@agenciapsi.com.br | `aaaaaaaa-0004-0004-0004-000000000004` | Clínica Mente sã |
|
||||
| clinica3@agenciapsi.com.br | `aaaaaaaa-0005-0005-0005-000000000005` | Clínica Bem Estar |
|
||||
| saas@agenciapsi.com.br | `aaaaaaaa-0006-0006-0006-000000000006` | Admin Plataforma |
|
||||
| supervisor@agenciapsi.com.br | `aaaaaaaa-0007-0007-0007-000000000007` | Carlos Supervisor |
|
||||
| editor@agenciapsi.com.br | `aaaaaaaa-0008-0008-0008-000000000008` | Diana Editora |
|
||||
| therapist2@agenciapsi.com.br | `aaaaaaaa-0009-0009-0009-000000000009` | Eva Terapeuta |
|
||||
| therapist3@agenciapsi.com.br | `aaaaaaaa-0010-0010-0010-000000000010` | Felipe Terapeuta |
|
||||
| secretary@agenciapsi.com.br | `aaaaaaaa-0011-0011-0011-000000000011` | Gabriela Secretária |
|
||||
|
||||
### Tenants
|
||||
| Nome | UUID | Kind |
|
||||
|------|------|------|
|
||||
| Bruno Terapeuta | `bbbbbbbb-0002-0002-0002-000000000002` | therapist |
|
||||
| Clínica Espaço Psi | `bbbbbbbb-0003-0003-0003-000000000003` | clinic_coworking |
|
||||
| Clínica Mente sã | `bbbbbbbb-0004-0004-0004-000000000004` | clinic_reception |
|
||||
| Clínica Bem Estar | `bbbbbbbb-0005-0005-0005-000000000005` | clinic_full |
|
||||
| Eva Terapeuta | `bbbbbbbb-0009-0009-0009-000000000009` | therapist |
|
||||
| Felipe Terapeuta | `bbbbbbbb-0010-0010-0010-000000000010` | therapist |
|
||||
|
||||
## Mapa de Vínculos
|
||||
|
||||
```
|
||||
paciente@ → portal_user / patient_free (user_id)
|
||||
Sem tenant próprio
|
||||
|
||||
terapeuta@ → tenant_member / therapist
|
||||
Tenant: bbbbbbbb-0002 (therapist) → tenant_admin
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
clinica1@ → tenant_member / clinic
|
||||
Tenant: bbbbbbbb-0003 (clinic_coworking) → tenant_admin
|
||||
Subscription: clinic_free (tenant_id)
|
||||
|
||||
clinica2@ → tenant_member / clinic
|
||||
Tenant: bbbbbbbb-0004 (clinic_reception) → tenant_admin
|
||||
Subscription: clinic_free (tenant_id)
|
||||
|
||||
clinica3@ → tenant_member / clinic
|
||||
Tenant: bbbbbbbb-0005 (clinic_full) → tenant_admin
|
||||
Subscription: clinic_free (tenant_id)
|
||||
|
||||
saas@ → saas_admin
|
||||
Sem tenant, sem subscription
|
||||
|
||||
supervisor@ → tenant_member / therapist
|
||||
Clínica 3: bbbbbbbb-0005 → supervisor
|
||||
Subscription: supervisor_free (user_id)
|
||||
|
||||
editor@ → tenant_member / therapist + platform_roles: {editor}
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
therapist2@ → tenant_member / therapist
|
||||
Tenant: bbbbbbbb-0009 (therapist) → tenant_admin
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
therapist3@ → tenant_member / therapist
|
||||
Tenant: bbbbbbbb-0010 (therapist) → tenant_admin
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
secretary@ → tenant_member / therapist (profile)
|
||||
Clínica 2: bbbbbbbb-0004 → clinic_admin
|
||||
Sem subscription própria (usa plano da Clínica 2)
|
||||
```
|
||||
|
||||
## Clínica 3 — Bem Estar (Full) — Membros
|
||||
|
||||
| Membro | Role |
|
||||
|--------|------|
|
||||
| clinica3@ | tenant_admin |
|
||||
| terapeuta@ | therapist |
|
||||
| supervisor@ | supervisor |
|
||||
| editor@ | therapist |
|
||||
| therapist2@ | therapist |
|
||||
| therapist3@ | therapist |
|
||||
@@ -0,0 +1,11 @@
|
||||
-- ============================================================
|
||||
-- Fix: addon_credits e addon_transactions tenant_id FK
|
||||
-- Corrige FK que apontava para auth.users → agora aponta para public.tenants
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE public.addon_credits DROP CONSTRAINT IF EXISTS addon_credits_tenant_id_fkey;
|
||||
ALTER TABLE public.addon_credits ADD CONSTRAINT addon_credits_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
|
||||
|
||||
ALTER TABLE public.addon_transactions DROP CONSTRAINT IF EXISTS addon_transactions_tenant_id_fkey;
|
||||
ALTER TABLE public.addon_transactions ADD CONSTRAINT addon_transactions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
|
||||
@@ -0,0 +1,83 @@
|
||||
-- ============================================================
|
||||
-- Fix: RLS addon_credits e addon_transactions
|
||||
-- 1. SaaS Admin: acesso total
|
||||
-- 2. Tenant members: SELECT nos seus créditos/transações
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
|
||||
-- ── addon_products: admin pode tudo (CRUD) ────────────────────
|
||||
DROP POLICY IF EXISTS "addon_products_admin_all" ON public.addon_products;
|
||||
CREATE POLICY "addon_products_admin_all"
|
||||
ON public.addon_products FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_credits: admin pode ver todos ───────────────────────
|
||||
DROP POLICY IF EXISTS "addon_credits_admin_select" ON public.addon_credits;
|
||||
CREATE POLICY "addon_credits_admin_select"
|
||||
ON public.addon_credits FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_credits: admin pode inserir/atualizar ───────────────
|
||||
DROP POLICY IF EXISTS "addon_credits_admin_write" ON public.addon_credits;
|
||||
CREATE POLICY "addon_credits_admin_write"
|
||||
ON public.addon_credits FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_transactions: admin pode ver todas ──────────────────
|
||||
DROP POLICY IF EXISTS "addon_transactions_admin_select" ON public.addon_transactions;
|
||||
CREATE POLICY "addon_transactions_admin_select"
|
||||
ON public.addon_transactions FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_transactions: admin pode inserir ────────────────────
|
||||
DROP POLICY IF EXISTS "addon_transactions_admin_insert" ON public.addon_transactions;
|
||||
CREATE POLICY "addon_transactions_admin_insert"
|
||||
ON public.addon_transactions FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- Corrige policies de tenant members (SELECT)
|
||||
-- A policy original usava tenant_id = auth.uid(), mas o auth.uid()
|
||||
-- é o user_id, não o tenant_id. Usa is_tenant_member() em vez disso.
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
|
||||
-- addon_credits: membro do tenant vê os créditos do seu tenant
|
||||
DROP POLICY IF EXISTS "addon_credits_select_own" ON public.addon_credits;
|
||||
CREATE POLICY "addon_credits_select_own"
|
||||
ON public.addon_credits FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.is_tenant_member(tenant_id)
|
||||
OR owner_id = auth.uid()
|
||||
);
|
||||
|
||||
-- addon_transactions: membro do tenant vê as transações do seu tenant
|
||||
DROP POLICY IF EXISTS "addon_transactions_select_own" ON public.addon_transactions;
|
||||
CREATE POLICY "addon_transactions_select_own"
|
||||
ON public.addon_transactions FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.is_tenant_member(tenant_id)
|
||||
OR owner_id = auth.uid()
|
||||
);
|
||||
@@ -0,0 +1,179 @@
|
||||
-- =============================================================================
|
||||
-- FIX: Corrige acentuação perdida (caracteres ?? no banco)
|
||||
-- =============================================================================
|
||||
-- Causa: Seeds aplicados originalmente sem encoding UTF-8 correto.
|
||||
-- Os ?? são bytes literais 0x3F (ASCII ?) onde deveria haver UTF-8.
|
||||
-- Este fix faz UPDATE direto nos valores conhecidos.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET client_encoding TO 'UTF8';
|
||||
|
||||
-- ============================================================
|
||||
-- 1. PROFILES — full_name
|
||||
-- ============================================================
|
||||
UPDATE profiles SET full_name = 'Clínica Espaço Psi' WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003' AND full_name != 'Clínica Espaço Psi';
|
||||
UPDATE profiles SET full_name = 'Clínica Mente Sã' WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004' AND full_name != 'Clínica Mente Sã';
|
||||
UPDATE profiles SET full_name = 'Clínica Bem Estar' WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005' AND full_name != 'Clínica Bem Estar';
|
||||
UPDATE profiles SET full_name = 'Gabriela Secretária' WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011' AND full_name != 'Gabriela Secretária';
|
||||
|
||||
-- ============================================================
|
||||
-- 2. TENANTS — name
|
||||
-- ============================================================
|
||||
UPDATE tenants SET name = 'Clínica Espaço Psi' WHERE id = 'bbbbbbbb-0003-0003-0003-000000000003';
|
||||
UPDATE tenants SET name = 'Clínica Mente Sã' WHERE id = 'bbbbbbbb-0004-0004-0004-000000000004';
|
||||
UPDATE tenants SET name = 'Clínica Bem Estar' WHERE id = 'bbbbbbbb-0005-0005-0005-000000000005';
|
||||
|
||||
-- ============================================================
|
||||
-- 3. DETERMINED_COMMITMENTS — name
|
||||
-- ============================================================
|
||||
UPDATE determined_commitments SET name = 'Sessão' WHERE native_key = 'session';
|
||||
UPDATE determined_commitments SET name = 'Supervisão' WHERE native_key = 'supervision';
|
||||
UPDATE determined_commitments SET name = 'Análise Pessoal' WHERE native_key = 'analysis';
|
||||
|
||||
-- ============================================================
|
||||
-- 4. PLANS — name, description
|
||||
-- ============================================================
|
||||
UPDATE plans SET name = 'THERAPIST PRO', description = 'Plano profissional para terapeutas' WHERE key = 'therapist_pro' AND description LIKE '%??%';
|
||||
UPDATE plans SET name = 'CLINIC PRO', description = 'Plano profissional para clínicas' WHERE key = 'clinic_pro' AND description LIKE '%??%';
|
||||
UPDATE plans SET name = 'THERAPIST FREE', description = 'Plano gratuito para terapeutas' WHERE key = 'therapist_free' AND description LIKE '%??%';
|
||||
UPDATE plans SET name = 'CLINIC FREE', description = 'Plano gratuito para clínicas' WHERE key = 'clinic_free' AND description LIKE '%??%';
|
||||
|
||||
-- ============================================================
|
||||
-- 5. FEATURES — name, description
|
||||
-- ============================================================
|
||||
UPDATE features SET name = 'Agenda - Visualizar', description = 'Visualização da agenda' WHERE key = 'agenda.view';
|
||||
UPDATE features SET name = 'Agenda - Gerenciar', description = 'Gerenciamento completo da agenda' WHERE key = 'agenda.manage';
|
||||
UPDATE features SET name = 'Pacientes', description = 'Módulo de pacientes' WHERE key = 'patients';
|
||||
UPDATE features SET name = 'Pacientes - Visualizar', description = 'Visualização de pacientes' WHERE key = 'patients.view';
|
||||
UPDATE features SET name = 'Pacientes - Gerenciar', description = 'Gerenciamento completo de pacientes' WHERE key = 'patients.manage';
|
||||
UPDATE features SET name = 'Agendamento Online', description = 'Sistema de agendamento online' WHERE key = 'online_scheduling';
|
||||
UPDATE features SET name = 'Agendamento Online - Gerenciar', description = 'Gerenciamento do agendamento online' WHERE key = 'online_scheduling.manage';
|
||||
UPDATE features SET name = 'Agendamento Online - Público', description = 'Página pública do agendador' WHERE key = 'online_scheduling.public';
|
||||
UPDATE features SET name = 'Lembretes', description = 'Sistema de lembretes automáticos' WHERE key = 'reminders';
|
||||
UPDATE features SET name = 'Relatórios Básicos', description = 'Relatórios básicos' WHERE key = 'reports_basic';
|
||||
UPDATE features SET name = 'Relatórios Avançados', description = 'Relatórios avançados com exportação' WHERE key = 'reports_advanced';
|
||||
UPDATE features SET name = 'Secretária', description = 'Funcionalidade de secretária' WHERE key = 'secretary';
|
||||
UPDATE features SET name = 'Recepção Compartilhada', description = 'Recepção compartilhada entre terapeutas' WHERE key = 'shared_reception';
|
||||
UPDATE features SET name = 'Salas', description = 'Gerenciamento de salas' WHERE key = 'rooms';
|
||||
UPDATE features SET name = 'Intake Público', description = 'Formulário de intake público' WHERE key = 'intake_public';
|
||||
UPDATE features SET name = 'Intakes PRO', description = 'Funcionalidades avançadas de intake' WHERE key = 'intakes_pro';
|
||||
UPDATE features SET name = 'Branding Personalizado', description = 'Personalização de marca' WHERE key = 'custom_branding';
|
||||
UPDATE features SET name = 'Acesso API', description = 'Acesso via API' WHERE key = 'api_access';
|
||||
UPDATE features SET name = 'Log de Auditoria', description = 'Log de auditoria completo' WHERE key = 'audit_log';
|
||||
UPDATE features SET name = 'Lembrete SMS', description = 'Lembretes via SMS' WHERE key = 'sms_reminder';
|
||||
UPDATE features SET name = 'Calendário da Clínica', description = 'Visão consolidada do calendário' WHERE key = 'clinic_calendar';
|
||||
UPDATE features SET name = 'Relatórios Avançados (Clínica)', description = 'Relatórios avançados da clínica' WHERE key = 'advanced_reports';
|
||||
UPDATE features SET name = 'Supervisor - Acesso', description = 'Acesso ao módulo de supervisão' WHERE key = 'supervisor.access';
|
||||
UPDATE features SET name = 'Supervisor - Convidar', description = 'Convidar supervisionados' WHERE key = 'supervisor.invite';
|
||||
UPDATE features SET name = 'Supervisor - Sessões', description = 'Gerenciar sessões de supervisão' WHERE key = 'supervisor.sessions';
|
||||
UPDATE features SET name = 'Supervisor - Relatórios', description = 'Relatórios de supervisão' WHERE key = 'supervisor.reports';
|
||||
|
||||
-- ============================================================
|
||||
-- 6. EMAIL_TEMPLATES_GLOBAL — subject, body_html, body_text
|
||||
-- ============================================================
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Lembrete: sua sessão amanhã às {{session_time}}',
|
||||
body_text = 'Olá {{patient_name}}, lembrete da sua sessão amanhã às {{session_time}} com {{therapist_name}}.'
|
||||
WHERE key = 'session.reminder';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Sessão confirmada — {{session_date}} às {{session_time}}',
|
||||
body_text = 'Sua sessão com {{therapist_name}} em {{session_date}} às {{session_time}} foi confirmada.'
|
||||
WHERE key = 'session.confirmation';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Sessão cancelada — {{session_date}}',
|
||||
body_text = 'A sessão de {{session_date}} às {{session_time}} com {{therapist_name}} foi cancelada.'
|
||||
WHERE key = 'session.cancellation';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Sessão reagendada — novo horário: {{session_date}} às {{session_time}}',
|
||||
body_text = 'Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.'
|
||||
WHERE key = 'session.rescheduled';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Recebemos seu cadastro — {{patient_name}}',
|
||||
body_text = 'Olá {{patient_name}}, recebemos seu formulário de cadastro. Entraremos em contato em breve.'
|
||||
WHERE key = 'intake.received';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Cadastro aprovado — Bem-vindo(a)!',
|
||||
body_text = 'Olá {{patient_name}}, seu cadastro foi aprovado. Você já pode acessar a plataforma.'
|
||||
WHERE key = 'intake.approved';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Cadastro não aprovado',
|
||||
body_text = 'Olá {{patient_name}}, infelizmente seu cadastro não foi aprovado no momento.'
|
||||
WHERE key = 'intake.rejected';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Solicitação aceita — {{session_date}} às {{session_time}}',
|
||||
body_text = 'Sua solicitação de agendamento para {{session_date}} às {{session_time}} foi aceita.'
|
||||
WHERE key = 'scheduler.request_accepted';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Solicitação não disponível',
|
||||
body_text = 'Infelizmente o horário solicitado não está disponível. Por favor, escolha outro horário.'
|
||||
WHERE key = 'scheduler.request_rejected';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Bem-vindo(a) à AgenciaPsi!',
|
||||
body_text = 'Olá {{user_name}}, sua conta foi criada com sucesso. Acesse a plataforma para começar.'
|
||||
WHERE key = 'system.welcome';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Redefinição de senha — AgenciaPsi',
|
||||
body_text = 'Clique no link abaixo para redefinir sua senha: {{reset_link}}'
|
||||
WHERE key = 'system.password_reset';
|
||||
|
||||
-- ============================================================
|
||||
-- 7. LOGIN_CAROUSEL_SLIDES — title, description
|
||||
-- ============================================================
|
||||
UPDATE login_carousel_slides SET
|
||||
title = '<strong>Gestão clínica simplificada</strong>',
|
||||
body = 'Gerencie agenda, pacientes e financeiro em um só lugar. Simples, rápido e seguro.'
|
||||
WHERE ordem = 1;
|
||||
|
||||
UPDATE login_carousel_slides SET
|
||||
title = '<strong>Múltiplos profissionais, uma só plataforma</strong>',
|
||||
body = 'Ideal para clínicas com vários terapeutas. Cada profissional com sua agenda e seus pacientes.'
|
||||
WHERE ordem = 2;
|
||||
|
||||
UPDATE login_carousel_slides SET
|
||||
title = '<strong>Seguro, privado e sempre disponível</strong>',
|
||||
body = 'Seus dados protegidos com criptografia. Acesse de qualquer lugar, a qualquer hora.'
|
||||
WHERE ordem = 3;
|
||||
|
||||
-- ============================================================
|
||||
-- 8. PATIENT_GROUPS (default groups) — name
|
||||
-- ============================================================
|
||||
UPDATE patient_groups SET nome = 'Crianças' WHERE nome LIKE 'Crian%' AND is_system = true;
|
||||
UPDATE patient_groups SET nome = 'Adolescentes' WHERE nome LIKE 'Adolescen%' AND is_system = true;
|
||||
UPDATE patient_groups SET nome = 'Idosos' WHERE nome LIKE 'Idoso%' AND is_system = true;
|
||||
|
||||
-- ============================================================
|
||||
-- 9. AUTH.USERS — raw_user_meta_data (name field)
|
||||
-- ============================================================
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Espaço Psi"') WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003';
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Mente Sã"') WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004';
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Bem Estar"') WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005';
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Gabriela Secretária"') WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
broken_count int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO broken_count
|
||||
FROM profiles WHERE full_name LIKE '%??%';
|
||||
|
||||
IF broken_count = 0 THEN
|
||||
RAISE NOTICE 'fix_encoding_accents: Todos os acentos corrigidos com sucesso.';
|
||||
ELSE
|
||||
RAISE WARNING 'fix_encoding_accents: Ainda restam % registros com ?? em profiles.full_name', broken_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,220 @@
|
||||
-- =============================================================================
|
||||
-- FIX: Atribuir plano free a usuários/tenants sem assinatura ativa
|
||||
-- =============================================================================
|
||||
-- Execute no SQL Editor do Supabase (service_role)
|
||||
-- Idempotente: só insere onde não existe assinatura ativa.
|
||||
--
|
||||
-- Regras:
|
||||
-- • tenant kind = 'therapist' → therapist_free (por user_id do admin)
|
||||
-- • tenant kind IN (clinic_*) → clinic_free (por tenant_id)
|
||||
-- • profiles.account_type = 'patient' / portal_user → patient_free (por user_id)
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- DIAGNÓSTICO — mostra o estado atual antes de corrigir
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
RAISE NOTICE '=== DIAGNÓSTICO DE ASSINATURAS ===';
|
||||
RAISE NOTICE '';
|
||||
|
||||
-- Terapeutas sem plano
|
||||
RAISE NOTICE '--- Terapeutas SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT
|
||||
tm.user_id,
|
||||
p.full_name,
|
||||
t.id AS tenant_id,
|
||||
t.name AS tenant_name
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
JOIN public.profiles p ON p.id = tm.user_id
|
||||
WHERE t.kind = 'therapist'
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = tm.user_id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%) — tenant %', r.full_name, r.user_id, r.tenant_id;
|
||||
END LOOP;
|
||||
|
||||
-- Clínicas sem plano
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '--- Clínicas SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT t.id, t.name, t.kind
|
||||
FROM public.tenants t
|
||||
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%) — kind %', r.name, r.id, r.kind;
|
||||
END LOOP;
|
||||
|
||||
-- Pacientes sem plano
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '--- Pacientes SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT p.id, p.full_name
|
||||
FROM public.profiles p
|
||||
WHERE p.account_type = 'patient'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = p.id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%)', r.full_name, r.id;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '=== FIM DO DIAGNÓSTICO — aplicando correções... ===';
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 1: Terapeutas sem assinatura → therapist_free
|
||||
-- Escopo: user_id do tenant_admin do tenant kind='therapist'
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
tm.user_id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
JOIN public.plans p ON p.key = 'therapist_free'
|
||||
WHERE t.kind = 'therapist'
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = tm.user_id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 2: Clínicas sem assinatura → clinic_free
|
||||
-- Escopo: tenant_id
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.tenants t
|
||||
JOIN public.plans p ON p.key = 'clinic_free'
|
||||
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 3: Pacientes sem assinatura → patient_free
|
||||
-- Escopo: user_id
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
pr.id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.profiles pr
|
||||
JOIN public.plans p ON p.key = 'patient_free'
|
||||
WHERE pr.account_type = 'patient'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = pr.id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CONFIRMAÇÃO — mostra o que foi inserido (source = 'fix_seed')
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
total INT := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '=== ASSINATURAS CRIADAS NESTA EXECUÇÃO ===';
|
||||
|
||||
FOR r IN
|
||||
SELECT
|
||||
s.plan_key,
|
||||
COALESCE(pr.full_name, t.name) AS nome,
|
||||
COALESCE(s.user_id::text, s.tenant_id::text) AS owner_id
|
||||
FROM public.subscriptions s
|
||||
LEFT JOIN public.profiles pr ON pr.id = s.user_id
|
||||
LEFT JOIN public.tenants t ON t.id = s.tenant_id
|
||||
WHERE s.source = 'fix_seed'
|
||||
AND s.started_at >= now() - interval '5 seconds'
|
||||
ORDER BY s.plan_key, nome
|
||||
LOOP
|
||||
RAISE NOTICE ' ✅ % → % (%)', r.plan_key, r.nome, r.owner_id;
|
||||
total := total + 1;
|
||||
END LOOP;
|
||||
|
||||
IF total = 0 THEN
|
||||
RAISE NOTICE ' (nenhuma nova assinatura criada — todos já tinham plano ativo)';
|
||||
ELSE
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE ' Total: % assinatura(s) criada(s).', total;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- ============================================================
|
||||
-- Fix: RLS notification_templates — acesso SaaS Admin
|
||||
-- Admin precisa criar/editar/excluir templates globais (tenant_id IS NULL)
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
|
||||
-- SaaS Admin: acesso total (SELECT + INSERT + UPDATE + DELETE)
|
||||
DROP POLICY IF EXISTS "notif_templates_admin_all" ON public.notification_templates;
|
||||
CREATE POLICY "notif_templates_admin_all"
|
||||
ON public.notification_templates FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- Tenant member: pode ler os globais + os do seu tenant
|
||||
DROP POLICY IF EXISTS "notif_templates_read_global" ON public.notification_templates;
|
||||
CREATE POLICY "notif_templates_read_global"
|
||||
ON public.notification_templates FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
(tenant_id IS NULL AND is_default = true)
|
||||
OR owner_id = auth.uid()
|
||||
OR public.is_tenant_member(tenant_id)
|
||||
)
|
||||
);
|
||||
|
||||
-- Tenant member: pode inserir/atualizar templates do seu tenant
|
||||
DROP POLICY IF EXISTS "notif_templates_write_owner" ON public.notification_templates;
|
||||
CREATE POLICY "notif_templates_write_owner"
|
||||
ON public.notification_templates FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_tenant_member(tenant_id)
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_tenant_member(tenant_id)
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ============================================================
|
||||
-- Fix: cria função seed_default_patient_groups
|
||||
-- Colunas reais: nome, cor, descricao, tenant_id (NOT NULL)
|
||||
-- ============================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
BEGIN
|
||||
-- busca o owner (tenant_admin) do tenant
|
||||
SELECT user_id INTO v_owner_id
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND role = 'tenant_admin'
|
||||
AND status = 'active'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.patient_groups (owner_id, nome, cor, is_system, tenant_id)
|
||||
VALUES
|
||||
(v_owner_id, 'Crianças', '#60a5fa', true, p_tenant_id),
|
||||
(v_owner_id, 'Adolescentes', '#a78bfa', true, p_tenant_id),
|
||||
(v_owner_id, 'Idosos', '#34d399', true, p_tenant_id)
|
||||
ON CONFLICT (owner_id, nome) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.seed_default_patient_groups(uuid)
|
||||
TO postgres, anon, authenticated, service_role;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Fix: subscriptions_validate_scope — adiciona suporte a target='patient'
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target text;
|
||||
BEGIN
|
||||
SELECT lower(p.target) INTO v_target
|
||||
FROM public.plans p
|
||||
WHERE p.id = NEW.plan_id;
|
||||
|
||||
IF v_target IS NULL THEN
|
||||
RAISE EXCEPTION 'Plano inválido (target nulo).';
|
||||
END IF;
|
||||
|
||||
IF v_target = 'clinic' THEN
|
||||
IF NEW.tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'therapist' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura therapist não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura therapist exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'patient' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.subscriptions_validate_scope() OWNER TO supabase_admin;
|
||||
@@ -0,0 +1,78 @@
|
||||
-- ============================================================
|
||||
-- Fix: Template keys devem casar com o que populate_notification_queue gera
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
-- O populate gera: 'session.' || REPLACE(event_type, '_sessao', '') || '.' || channel
|
||||
-- Ex: event_type='lembrete_sessao' → 'session.lembrete.whatsapp'
|
||||
--
|
||||
-- Os seeds usavam nomes em inglês (session.reminder.whatsapp).
|
||||
-- Este fix renomeia para casar com o populate.
|
||||
-- ============================================================
|
||||
|
||||
-- ── 1. Renomeia templates existentes ──────────────────────────
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete.whatsapp'
|
||||
WHERE key = 'session.reminder.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete_2h.whatsapp'
|
||||
WHERE key = 'session.reminder_2h.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.confirmacao.whatsapp'
|
||||
WHERE key = 'session.confirmation.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.cancelamento.whatsapp'
|
||||
WHERE key = 'session.cancellation.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.reagendamento.whatsapp'
|
||||
WHERE key = 'session.reschedule.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'cobranca.pendente.whatsapp'
|
||||
WHERE key = 'billing.pending.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'sistema.boas_vindas.whatsapp'
|
||||
WHERE key = 'system.welcome.whatsapp';
|
||||
|
||||
-- ── SMS templates (mesmo padrão) ──────────────────────────────
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete.sms'
|
||||
WHERE key = 'session.reminder.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete_2h.sms'
|
||||
WHERE key = 'session.reminder_2h.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.confirmacao.sms'
|
||||
WHERE key = 'session.confirmation.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.cancelamento.sms'
|
||||
WHERE key = 'session.cancellation.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.reagendamento.sms'
|
||||
WHERE key = 'session.reschedule.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'cobranca.pendente.sms'
|
||||
WHERE key = 'billing.pending.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'sistema.boas_vindas.sms'
|
||||
WHERE key = 'system.welcome.sms';
|
||||
|
||||
|
||||
-- ── 2. Verifica resultado ─────────────────────────────────────
|
||||
|
||||
SELECT key, channel, domain, event_type, is_default
|
||||
FROM notification_templates
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY channel, key;
|
||||
@@ -0,0 +1,163 @@
|
||||
-- ============================================================
|
||||
-- Fix: Remove templates com keys em inglês (WhatsApp/SMS)
|
||||
-- Agência PSI — 2026-04-22
|
||||
-- ============================================================
|
||||
-- Ambiente de desenvolvimento sem dados reais: DELETE físico.
|
||||
-- Mantém apenas as keys canônicas em português definidas
|
||||
-- pelo seed_014_global_data.sql. Se alguma key PT estiver
|
||||
-- faltando após rodar esta migration, rode o Step 3 de reseed.
|
||||
--
|
||||
-- Idempotente: rodar de novo não causa erro (DELETE simples
|
||||
-- encontra 0 linhas).
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── Step 1: Snapshot ANTES ────────────────────────────────────────────
|
||||
-- Útil pra conferir o que vai ser apagado
|
||||
SELECT
|
||||
'BEFORE' AS stage,
|
||||
key,
|
||||
channel,
|
||||
event_type,
|
||||
tenant_id IS NULL AS is_global,
|
||||
is_default,
|
||||
is_active,
|
||||
deleted_at
|
||||
FROM public.notification_templates
|
||||
WHERE channel IN ('whatsapp', 'sms')
|
||||
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
|
||||
|
||||
|
||||
-- ── Step 2: DELETE físico de todas as keys em inglês ─────────────────
|
||||
DELETE FROM public.notification_templates
|
||||
WHERE key IN (
|
||||
-- WhatsApp — variantes em inglês
|
||||
'session.reminder.whatsapp',
|
||||
'session.reminder_2h.whatsapp',
|
||||
'session.confirmation.whatsapp',
|
||||
'session.cancellation.whatsapp',
|
||||
'session.reschedule.whatsapp',
|
||||
'session.rescheduled.whatsapp',
|
||||
'billing.pending.whatsapp',
|
||||
'system.welcome.whatsapp',
|
||||
-- SMS — variantes em inglês
|
||||
'session.reminder.sms',
|
||||
'session.reminder_2h.sms',
|
||||
'session.confirmation.sms',
|
||||
'session.cancellation.sms',
|
||||
'session.reschedule.sms',
|
||||
'session.rescheduled.sms',
|
||||
'billing.pending.sms',
|
||||
'system.welcome.sms'
|
||||
);
|
||||
|
||||
|
||||
-- ── Step 3: Re-seed (inserção idempotente) das keys PT canônicas ─────
|
||||
-- Garante que todas as keys esperadas existem como globais ativas.
|
||||
-- Usa INSERT … ON CONFLICT DO UPDATE para ser idempotente.
|
||||
-- Os body_text são placeholders padrão; se quiser textos diferentes,
|
||||
-- edite depois via /configuracoes/whatsapp-templates ou /saas/notification-templates.
|
||||
|
||||
INSERT INTO public.notification_templates
|
||||
(tenant_id, owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
|
||||
VALUES
|
||||
-- ── WhatsApp ─────────────────────────────────────────────────────────
|
||||
(NULL, NULL, 'session.lembrete.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
|
||||
'Olá {{patient_name}}! Lembrete: sua sessão com {{therapist_name}} é amanhã às {{session_time}}. Até lá!',
|
||||
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.lembrete_2h.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
|
||||
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} começa em 2 horas ({{session_time}}). Até já!',
|
||||
'["patient_name","therapist_name","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.confirmacao.whatsapp', 'session', 'whatsapp', 'confirmacao_sessao',
|
||||
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} foi confirmada para {{session_date}} às {{session_time}}.',
|
||||
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.cancelamento.whatsapp', 'session', 'whatsapp', 'cancelamento_sessao',
|
||||
'Olá {{patient_name}}. Sua sessão de {{session_date}} às {{session_time}} foi cancelada. Entre em contato para remarcar.',
|
||||
'["patient_name","session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.reagendamento.whatsapp', 'session', 'whatsapp', 'reagendamento',
|
||||
'Olá {{patient_name}}! Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.',
|
||||
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'cobranca.pendente.whatsapp', 'billing', 'whatsapp', 'cobranca_pendente',
|
||||
'Olá {{patient_name}}! Identificamos um pagamento pendente de {{valor}} com vencimento em {{vencimento}}. Qualquer dúvida, estou à disposição.',
|
||||
'["patient_name","valor","vencimento"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'sistema.boas_vindas.whatsapp', 'system', 'whatsapp', 'boas_vindas_paciente',
|
||||
'Olá {{patient_name}}! Bem-vindo(a) à {{clinic_name}}. Seu terapeuta {{therapist_name}} está à disposição.',
|
||||
'["patient_name","clinic_name","therapist_name"]'::jsonb, true, true),
|
||||
|
||||
-- ── SMS ──────────────────────────────────────────────────────────────
|
||||
(NULL, NULL, 'session.lembrete.sms', 'session', 'sms', 'lembrete_sessao',
|
||||
'Lembrete: sua sessao com {{therapist_name}} e amanha as {{session_time}}.',
|
||||
'["therapist_name","session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.lembrete_2h.sms', 'session', 'sms', 'lembrete_sessao',
|
||||
'Sua sessao com {{therapist_name}} comeca em 2h ({{session_time}}).',
|
||||
'["therapist_name","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.confirmacao.sms', 'session', 'sms', 'confirmacao_sessao',
|
||||
'Sua sessao foi confirmada para {{session_date}} as {{session_time}}.',
|
||||
'["session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.cancelamento.sms', 'session', 'sms', 'cancelamento_sessao',
|
||||
'Sua sessao de {{session_date}} as {{session_time}} foi cancelada.',
|
||||
'["session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'session.reagendamento.sms', 'session', 'sms', 'reagendamento',
|
||||
'Sua sessao foi reagendada para {{session_date}} as {{session_time}}.',
|
||||
'["session_date","session_time"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'cobranca.pendente.sms', 'billing', 'sms', 'cobranca_pendente',
|
||||
'Pagamento pendente: {{valor}}, venc. {{vencimento}}.',
|
||||
'["valor","vencimento"]'::jsonb, true, true),
|
||||
|
||||
(NULL, NULL, 'sistema.boas_vindas.sms', 'system', 'sms', 'boas_vindas_paciente',
|
||||
'Bem-vindo a {{clinic_name}}! Seu terapeuta e {{therapist_name}}.',
|
||||
'["clinic_name","therapist_name"]'::jsonb, true, true)
|
||||
|
||||
ON CONFLICT (tenant_id, owner_id, key, deleted_at)
|
||||
DO UPDATE SET
|
||||
body_text = EXCLUDED.body_text,
|
||||
variables = EXCLUDED.variables,
|
||||
is_default = EXCLUDED.is_default,
|
||||
is_active = EXCLUDED.is_active,
|
||||
domain = EXCLUDED.domain,
|
||||
event_type = EXCLUDED.event_type,
|
||||
updated_at = now();
|
||||
|
||||
|
||||
-- ── Step 4: Snapshot DEPOIS ──────────────────────────────────────────
|
||||
SELECT
|
||||
'AFTER' AS stage,
|
||||
key,
|
||||
channel,
|
||||
event_type,
|
||||
tenant_id IS NULL AS is_global,
|
||||
is_default,
|
||||
is_active
|
||||
FROM public.notification_templates
|
||||
WHERE channel IN ('whatsapp', 'sms')
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
|
||||
|
||||
|
||||
-- ── Step 5: Verificação — esperado 0 linhas ativas em EN ─────────────
|
||||
SELECT
|
||||
count(*) AS remaining_english_keys
|
||||
FROM public.notification_templates
|
||||
WHERE deleted_at IS NULL
|
||||
AND key IN (
|
||||
'session.reminder.whatsapp', 'session.reminder_2h.whatsapp', 'session.confirmation.whatsapp',
|
||||
'session.cancellation.whatsapp', 'session.reschedule.whatsapp', 'session.rescheduled.whatsapp',
|
||||
'billing.pending.whatsapp', 'system.welcome.whatsapp',
|
||||
'session.reminder.sms', 'session.reminder_2h.sms', 'session.confirmation.sms',
|
||||
'session.cancellation.sms', 'session.reschedule.sms', 'session.rescheduled.sms',
|
||||
'billing.pending.sms', 'system.welcome.sms'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,516 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// AgenciaPsi — Dashboard Generator
|
||||
// =============================================================================
|
||||
// Uso:
|
||||
// node generate-dashboard.cjs → usa backup mais recente
|
||||
// node generate-dashboard.cjs 2026-04-17 → usa backup de data específica
|
||||
//
|
||||
// Lê de: ./backups/YYYY-MM-DD/schema.sql
|
||||
// Lê de: ./db.config.json (domínios, cores e infraestrutura)
|
||||
// Gera: ./agenciapsi-db-dashboard.html (na mesma pasta do script)
|
||||
// =============================================================================
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = __dirname;
|
||||
const BACKUPS_DIR = path.join(ROOT, 'backups');
|
||||
const OUTPUT_FILE = path.join(ROOT, 'agenciapsi-db-dashboard.html');
|
||||
const CONFIG_FILE = path.join(ROOT, 'db.config.json');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carrega config (domínios, cores e infraestrutura)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
console.error(`✖ Config não encontrada: ${CONFIG_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const CONFIG = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
const DOMAIN_TABLES = CONFIG.domains || {};
|
||||
const DOMAIN_COLORS = CONFIG.domainColors || {};
|
||||
const INFRASTRUCTURE = CONFIG.infrastructure || {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Resolve qual schema.sql usar
|
||||
// ---------------------------------------------------------------------------
|
||||
function resolveSchema() {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (!fs.existsSync(BACKUPS_DIR)) {
|
||||
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
|
||||
console.error(` Rode primeiro: node db.cjs backup`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const available = fs
|
||||
.readdirSync(BACKUPS_DIR)
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (available.length === 0) {
|
||||
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
|
||||
console.error(' Rode primeiro: node db.cjs backup');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const date = arg && /^\d{4}-\d{2}-\d{2}$/.test(arg) ? arg : available[0];
|
||||
|
||||
if (!available.includes(date)) {
|
||||
console.error(`✖ Backup não encontrado para: ${date}`);
|
||||
console.error(` Disponíveis: ${available.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
console.error(`✖ schema.sql não encontrado em backups/${date}/`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { schemaPath, date, available };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseSchema(content) {
|
||||
const tables = {};
|
||||
|
||||
// Tabelas public.*
|
||||
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
|
||||
let m;
|
||||
while ((m = tableRe.exec(content)) !== null) {
|
||||
const name = m[1].replace('public.', '');
|
||||
const body = m[2];
|
||||
const columns = [];
|
||||
|
||||
for (let line of body.split('\n')) {
|
||||
line = line.trim().replace(/,$/, '');
|
||||
if (!line || line.startsWith('--')) continue;
|
||||
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
|
||||
|
||||
const col = line.match(
|
||||
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
|
||||
);
|
||||
if (col) {
|
||||
columns.push({
|
||||
name: col[1],
|
||||
type: col[2].trim().split('(')[0].trim(),
|
||||
pk: col[1] === 'id'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tables[name] = { columns, fks: [] };
|
||||
}
|
||||
|
||||
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
|
||||
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
|
||||
while ((m = fkRe.exec(content)) !== null) {
|
||||
const [, fromTable, fromCol, toTable, toCol] = m;
|
||||
if (tables[fromTable]) {
|
||||
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
|
||||
}
|
||||
}
|
||||
|
||||
// Views
|
||||
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
|
||||
const views = [];
|
||||
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
|
||||
|
||||
return { tables, views };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Monta os domínios
|
||||
// Tabelas novas que ainda não estão mapeadas vão para "Outros"
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildDomains(tables) {
|
||||
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
|
||||
const others = Object.keys(tables).filter((t) => !mapped.has(t) && t !== '_db_migrations');
|
||||
|
||||
const domains = {};
|
||||
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
|
||||
const present = list.filter((t) => tables[t]);
|
||||
if (present.length > 0) domains[domain] = present;
|
||||
}
|
||||
if (others.length > 0) {
|
||||
domains['Outros'] = others;
|
||||
DOMAIN_COLORS['Outros'] = '#6b7280';
|
||||
}
|
||||
|
||||
return domains;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Gera o HTML final (standalone, sem dependências externas de JS)
|
||||
// ---------------------------------------------------------------------------
|
||||
function generateHTML(tables, views, domains, date, available) {
|
||||
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
|
||||
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
|
||||
const infraGroups = Object.keys(INFRASTRUCTURE).length;
|
||||
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
|
||||
const generated = new Date().toLocaleString('pt-BR');
|
||||
|
||||
// Slug por domínio — usado como id para scroll (ex: "SaaS / Planos" → "saas-planos")
|
||||
const slugify = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const domainSlugs = {};
|
||||
for (const d of Object.keys(domains)) domainSlugs[d] = slugify(d);
|
||||
|
||||
// Serializa dados para embutir no HTML
|
||||
const jsonData = JSON.stringify({ tables, views, domains, slugs: domainSlugs });
|
||||
const jsonColors = JSON.stringify(DOMAIN_COLORS);
|
||||
const jsonInfra = JSON.stringify(INFRASTRUCTURE);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AgenciaPsi DB · ${date}</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#6366f1;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6;--ok:#34d399;--warn:#fbbf24;--pend:#f87171;--leg:#94a3b8}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;min-height:100vh;overflow-x:hidden}
|
||||
|
||||
.topbar{position:sticky;top:0;z-index:100;background:rgba(11,13,18,.94);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 28px;height:56px;display:flex;align-items:center;gap:20px}
|
||||
.brand{font-weight:700;font-size:15px;letter-spacing:-.3px}.brand span{color:var(--accent)}
|
||||
.gen{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
|
||||
.pills{display:flex;gap:10px;margin-left:auto}
|
||||
.pill{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 12px}
|
||||
.pill strong{color:var(--text);font-size:13px}
|
||||
.search{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;color:var(--text);font-family:'Space Grotesk',sans-serif;font-size:13px;outline:none;width:200px;transition:border-color .2s,width .2s}
|
||||
.search:focus{border-color:var(--accent);width:280px}
|
||||
.search::placeholder{color:var(--text3)}
|
||||
|
||||
.layout{display:flex;height:calc(100vh - 56px)}
|
||||
|
||||
.sidebar{width:260px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
|
||||
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
|
||||
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
|
||||
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
|
||||
.sb-i:hover{color:var(--text);background:var(--bg3)}
|
||||
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(99,102,241,.08)}
|
||||
.sb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
.sb-c{margin-left:auto;font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
|
||||
|
||||
.main{flex:1;overflow-y:auto}
|
||||
.main::-webkit-scrollbar{width:5px}.main::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
|
||||
|
||||
.overview{padding:32px 36px;border-bottom:1px solid var(--border)}
|
||||
.ov-t{font-size:22px;font-weight:700;margin-bottom:6px}
|
||||
.ov-s{font-size:14px;color:var(--text2);margin-bottom:28px}
|
||||
.dgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
|
||||
.dc{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:16px 18px;cursor:pointer;transition:all .2s;position:relative;overflow:hidden}
|
||||
.dc::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--c)}
|
||||
.dc:hover{border-color:var(--border2);transform:translateY(-1px)}
|
||||
.dc-n{font-size:14px;font-weight:600;margin-bottom:6px}
|
||||
.dc-m{font-size:12px;color:var(--text2);font-family:'IBM Plex Mono',monospace}
|
||||
.dc-m span{font-weight:600}
|
||||
|
||||
.section{padding:28px 36px}
|
||||
.sec-h{display:flex;align-items:center;gap:14px;margin-bottom:20px}
|
||||
.sec-t{font-size:18px;font-weight:700}
|
||||
.sec-b{font-size:11px;font-family:'IBM Plex Mono',monospace;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:3px 10px;color:var(--text2)}
|
||||
|
||||
.tgrid{display:flex;flex-direction:column;gap:10px}
|
||||
.tc{background:var(--bg3);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .15s}
|
||||
.tc:hover{border-color:var(--border2)}.tc.hl{border-color:var(--accent)}
|
||||
.tc-h{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;user-select:none}
|
||||
.tc-n{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600}
|
||||
.tc-m{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
|
||||
.tc-f{font-size:11px;color:var(--fk);font-family:'IBM Plex Mono',monospace;margin-left:4px}
|
||||
.tc-tg{margin-left:auto;color:var(--text3);font-size:11px;transition:transform .2s}
|
||||
.tc-tg.open{transform:rotate(180deg)}
|
||||
.tc-b{display:none;border-top:1px solid var(--border)}.tc-b.open{display:block}
|
||||
.cols{padding:6px 0}
|
||||
.cr{display:flex;align-items:center;gap:10px;padding:5px 16px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2)}
|
||||
.cr:hover{background:rgba(255,255,255,.02)}
|
||||
.bdg{font-size:9px;font-weight:700;letter-spacing:.5px;padding:1px 5px;border-radius:3px;width:26px;text-align:center;flex-shrink:0}
|
||||
.bdg.pk{background:rgba(251,191,36,.15);color:var(--pk)}.bdg.fk{background:rgba(244,114,182,.15);color:var(--fk)}.bdg.x{background:transparent}
|
||||
.cn{color:var(--text)}.ct{color:var(--text3);margin-left:auto;font-size:11px}
|
||||
.fksec{border-top:1px solid var(--border);padding:10px 16px}
|
||||
.fkt{font-size:10px;font-weight:600;letter-spacing:1px;color:var(--text3);text-transform:uppercase;margin-bottom:8px}
|
||||
.fkr{display:flex;align-items:center;gap:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2);padding:3px 0}
|
||||
.fka{color:var(--fk)}.fkl{color:var(--accent);cursor:pointer}.fkl:hover{text-decoration:underline}
|
||||
|
||||
.vsec{padding:0 36px 32px}
|
||||
.vgrid{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
||||
.vc{background:rgba(110,231,183,.08);border:1px solid rgba(110,231,183,.2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--accent2)}
|
||||
.empty{padding:40px;text-align:center;color:var(--text3);font-size:14px}
|
||||
mark{background:rgba(99,102,241,.3);color:#fff;border-radius:2px}
|
||||
|
||||
/* Infraestrutura */
|
||||
.igroup{margin-bottom:28px}
|
||||
.igroup-h{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
||||
.igroup-t{font-size:15px;font-weight:600;letter-spacing:-.2px}
|
||||
.igroup-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
||||
.igrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
|
||||
.ic{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:16px 18px;transition:border-color .15s;position:relative;overflow:hidden}
|
||||
.ic::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;background:var(--c)}
|
||||
.ic:hover{border-color:var(--border2)}
|
||||
.ic-h{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||
.ic-n{font-size:14px;font-weight:600;flex:1;min-width:0}
|
||||
.ic-st{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;padding:2px 7px;border-radius:10px;flex-shrink:0;white-space:nowrap}
|
||||
.ic-st.ativo{background:rgba(52,211,153,.15);color:var(--ok)}
|
||||
.ic-st.pendente{background:rgba(248,113,113,.15);color:var(--pend)}
|
||||
.ic-st.planejado{background:rgba(251,191,36,.15);color:var(--warn)}
|
||||
.ic-st.legado{background:rgba(148,163,184,.2);color:var(--leg)}
|
||||
.ic-r{font-size:12px;color:var(--text2);margin-bottom:8px;line-height:1.5}
|
||||
.ic-e{font-size:10px;color:var(--text3);font-family:'IBM Plex Mono',monospace;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.ic-nt{font-size:11px;color:var(--text3);line-height:1.55;border-top:1px solid var(--border);padding-top:8px;margin-top:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="brand">Agência<span>Psi</span> DB</div>
|
||||
<span class="gen">${date} · ${generated}</span>
|
||||
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
|
||||
<div class="pills">
|
||||
<div class="pill"><strong>${Object.keys(tables).length}</strong> tabelas</div>
|
||||
<div class="pill"><strong>${totalFKs}</strong> FKs</div>
|
||||
<div class="pill"><strong>${views.length}</strong> views</div>
|
||||
<div class="pill"><strong>${totalCols}</strong> colunas</div>
|
||||
<div class="pill"><strong>${infraItems}</strong> infra</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<nav class="sidebar" id="sb"></nav>
|
||||
<main class="main" id="mn"></main>
|
||||
</div>
|
||||
<script>
|
||||
const D=${jsonData};
|
||||
const C=${jsonColors};
|
||||
const INFRA=${jsonInfra};
|
||||
const INFRA_GROUPS=${infraGroups};
|
||||
const INFRA_ITEMS=${infraItems};
|
||||
const T2D={};
|
||||
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
|
||||
let dom=null,view='overview',q='';
|
||||
function gc(d){return C[d]||'#6b7280';}
|
||||
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
||||
|
||||
function buildSB(){
|
||||
let h=\`<div class="sb-h">Visão Geral</div>
|
||||
<div class="sb-i \${view==='overview'&&!dom?'active':''}" onclick="selOverview()">
|
||||
<div class="sb-dot" style="background:#6366f1"></div>Todos (tabelas)
|
||||
<span class="sb-c">\${Object.keys(D.tables).length}</span>
|
||||
</div>
|
||||
<div class="sb-i \${view==='infra'?'active':''}" onclick="selInfra()">
|
||||
<div class="sb-dot" style="background:#fbbf24"></div>Infraestrutura
|
||||
<span class="sb-c">\${INFRA_ITEMS}</span>
|
||||
</div>
|
||||
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
|
||||
for(const[d,ts]of Object.entries(D.domains)){
|
||||
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain('\${D.slugs[d]}')">
|
||||
<div class="sb-dot" style="background:\${gc(d)}"></div>\${escapeHtml(d)}
|
||||
<span class="sb-c">\${ts.length}</span>
|
||||
</div>\`;
|
||||
}
|
||||
h+=\`<div class="sb-i" onclick="scrollToViews()">
|
||||
<div class="sb-dot" style="background:#6ee7b7"></div>Views
|
||||
<span class="sb-c">\${D.views.length}</span>
|
||||
</div>\`;
|
||||
document.getElementById('sb').innerHTML=h;
|
||||
}
|
||||
|
||||
function buildMN(){
|
||||
const mn=document.getElementById('mn');
|
||||
let h='';
|
||||
if(q){
|
||||
const matches=Object.entries(D.tables).filter(([n,t])=>n.includes(q)||t.columns.some(c=>c.name.includes(q)));
|
||||
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${escapeHtml(q)}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
|
||||
h+=matches.length?matches.map(([n,t])=>card(n,t,q)).join(''):'<div class="empty">Nenhum resultado.</div>';
|
||||
h+='</div></div>';
|
||||
} else if(view==='infra'){
|
||||
h+=\`<div class="overview"><div class="ov-t">Infraestrutura</div>
|
||||
<div class="ov-s">Serviços, bibliotecas e ferramentas que o sistema usa · \${INFRA_GROUPS} grupos · \${INFRA_ITEMS} itens</div></div>
|
||||
<div class="section">\`;
|
||||
for(const[grupo,info]of Object.entries(INFRA)){
|
||||
const color=info.color||'#6b7280';
|
||||
h+=\`<div class="igroup">
|
||||
<div class="igroup-h">
|
||||
<div class="igroup-c" style="background:\${color}"></div>
|
||||
<div class="igroup-t" style="color:\${color}">\${escapeHtml(grupo)}</div>
|
||||
<div class="sec-b">\${info.items.length} itens</div>
|
||||
</div>
|
||||
<div class="igrid">\${info.items.map(item=>infraCard(item,color)).join('')}</div>
|
||||
</div>\`;
|
||||
}
|
||||
h+='</div>';
|
||||
} else {
|
||||
const ds=dom?{[dom]:D.domains[dom]}:D.domains;
|
||||
if(!dom){
|
||||
h+=\`<div class="overview"><div class="ov-t">AgenciaPsi — Banco de Dados</div>
|
||||
<div class="ov-s">Schema público · \${Object.keys(D.tables).length} tabelas · \${Object.values(D.tables).reduce((a,t)=>a+t.fks.length,0)} FKs · \${D.views.length} views</div>
|
||||
<div class="dgrid">\`;
|
||||
for(const[d,ts]of Object.entries(D.domains)){
|
||||
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
|
||||
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain('\${D.slugs[d]}')">
|
||||
<div class="dc-n">\${escapeHtml(d)}</div>
|
||||
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
|
||||
</div>\`;
|
||||
}
|
||||
h+='</div></div>';
|
||||
}
|
||||
for(const[d,ts]of Object.entries(ds)){
|
||||
h+=\`<div class="section" id="dom-\${D.slugs[d]||''}"><div class="sec-h">
|
||||
<div class="sec-t" style="color:\${gc(d)}">\${escapeHtml(d)}</div>
|
||||
<div class="sec-b">\${ts.length} tabelas</div>
|
||||
</div><div class="tgrid">\`;
|
||||
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
|
||||
h+='</div></div>';
|
||||
}
|
||||
if(!dom){
|
||||
h+=\`<div class="vsec" id="dom-views"><div class="sec-h">
|
||||
<div class="sec-t" style="color:#6ee7b7">Views</div>
|
||||
<div class="sec-b">\${D.views.length}</div>
|
||||
</div><div class="vgrid">\${D.views.map(v=>\`<div class="vc">\${v}</div>\`).join('')}</div></div>\`;
|
||||
}
|
||||
}
|
||||
mn.innerHTML=h;
|
||||
}
|
||||
|
||||
function infraCard(item,color){
|
||||
const status=(item.status||'ativo').toLowerCase();
|
||||
return \`<div class="ic" style="--c:\${color}">
|
||||
<div class="ic-h">
|
||||
<div class="ic-n">\${escapeHtml(item.name)}</div>
|
||||
<div class="ic-st \${status}">\${escapeHtml(item.status||'ativo')}</div>
|
||||
</div>
|
||||
<div class="ic-r">\${escapeHtml(item.role||'')}</div>
|
||||
\${item.env?\`<div class="ic-e">\${escapeHtml(item.env)}</div>\`:''}
|
||||
\${item.notes?\`<div class="ic-nt">\${escapeHtml(item.notes)}</div>\`:''}
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
function card(name,t,hl){
|
||||
const fkCols=new Set(t.fks.map(f=>f.from_col));
|
||||
const c=gc(T2D[name]);
|
||||
const cols=t.columns.map(col=>{
|
||||
let n=col.name;
|
||||
if(hl&&n.includes(hl))n=n.replace(new RegExp(\`(\${hl})\`,'gi'),'<mark>$1</mark>');
|
||||
const b=col.pk?'pk':fkCols.has(col.name)?'fk':'x';
|
||||
const l=col.pk?'PK':fkCols.has(col.name)?'FK':'';
|
||||
return \`<div class="cr"><span class="bdg \${b}">\${l}</span><span class="cn">\${n}</span><span class="ct">\${col.type}</span></div>\`;
|
||||
}).join('');
|
||||
const fks=t.fks.length?\`<div class="fksec"><div class="fkt">Foreign Keys</div>\${
|
||||
t.fks.map(f=>\`<div class="fkr"><span>\${f.from_col}</span><span class="fka">→</span><span class="fkl" onclick="jump('\${f.to_table}')">\${f.to_table}.\${f.to_col}</span></div>\`).join('')
|
||||
}</div>\`:'';
|
||||
return \`<div class="tc \${hl&&name.includes(hl)?'hl':''}" id="tc-\${name}">
|
||||
<div class="tc-h" onclick="tog('\${name}')">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:\${c};flex-shrink:0"></div>
|
||||
<div class="tc-n">\${name}</div>
|
||||
<span class="tc-m">\${t.columns.length} cols</span>
|
||||
\${t.fks.length?\`<span class="tc-f">\${t.fks.length} FK</span>\`:''}
|
||||
<span class="tc-tg" id="tg-\${name}">▼</span>
|
||||
</div>
|
||||
<div class="tc-b" id="bd-\${name}"><div class="cols">\${cols}</div>\${fks}</div>
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
function tog(n){
|
||||
document.getElementById('bd-'+n)?.classList.toggle('open');
|
||||
document.getElementById('tg-'+n)?.classList.toggle('open');
|
||||
}
|
||||
function sel(d){
|
||||
dom=d;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function scrollToDomain(slug){
|
||||
// Sempre ir pra overview (com todos os domínios visíveis) antes de scrollar
|
||||
const needRebuild=view!=='overview'||dom!==null||q;
|
||||
if(needRebuild){
|
||||
dom=null;view='overview';q='';
|
||||
document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
}
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('dom-'+slug);
|
||||
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}, needRebuild?80:0);
|
||||
}
|
||||
function scrollToViews(){
|
||||
const needRebuild=view!=='overview'||dom!==null||q;
|
||||
if(needRebuild){
|
||||
dom=null;view='overview';q='';
|
||||
document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
}
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('dom-views');
|
||||
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}, needRebuild?80:0);
|
||||
}
|
||||
function selOverview(){
|
||||
dom=null;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function selInfra(){
|
||||
dom=null;view='infra';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function jump(name){
|
||||
dom=T2D[name]||null;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('tc-'+name);
|
||||
if(!el)return;
|
||||
el.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
const bd=document.getElementById('bd-'+name);
|
||||
const tg=document.getElementById('tg-'+name);
|
||||
if(bd&&!bd.classList.contains('open')){bd.classList.add('open');tg?.classList.add('open');}
|
||||
el.style.borderColor='#6366f1';
|
||||
setTimeout(()=>el.style.borderColor='',2000);
|
||||
},80);
|
||||
}
|
||||
let st;
|
||||
function search(v){
|
||||
clearTimeout(st);q=v.trim();
|
||||
st=setTimeout(()=>{dom=null;view='overview';buildSB();buildMN();},200);
|
||||
}
|
||||
buildSB();buildMN();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Execução
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('\n═══ AgenciaPsi — Dashboard Generator ═══\n');
|
||||
|
||||
const { schemaPath, date, available } = resolveSchema();
|
||||
console.log(` → Schema: ${schemaPath}`);
|
||||
if (available.length > 1) console.log(` → Outros backups: ${available.slice(1).join(', ')}`);
|
||||
|
||||
const content = fs.readFileSync(schemaPath, 'utf8');
|
||||
console.log(` → Lendo schema... (${(content.length / 1024).toFixed(0)} KB)`);
|
||||
|
||||
const { tables, views } = parseSchema(content);
|
||||
const domains = buildDomains(tables);
|
||||
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
|
||||
|
||||
console.log(` → ${Object.keys(tables).length} tabelas · ${totalFKs} FKs · ${views.length} views`);
|
||||
|
||||
// Avisa sobre tabelas novas não mapeadas
|
||||
if (domains['Outros']) {
|
||||
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
|
||||
domains['Outros'].forEach((t) => console.log(` - ${t}`));
|
||||
console.log(` → Edite "domains" em db.config.json para mapeá-las.\n`);
|
||||
}
|
||||
|
||||
// Infra stats
|
||||
const infraGroups = Object.keys(INFRASTRUCTURE).length;
|
||||
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
|
||||
console.log(` → Infraestrutura: ${infraGroups} grupos, ${infraItems} itens`);
|
||||
|
||||
const html = generateHTML(tables, views, domains, date, available);
|
||||
fs.writeFileSync(OUTPUT_FILE, html, 'utf8');
|
||||
|
||||
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
|
||||
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
|
||||
console.log(` Abra no browser: file://${OUTPUT_FILE.replace(/\\/g, '/')}\n`);
|
||||
@@ -0,0 +1,132 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Migration 001: Twilio WhatsApp Subaccounts
|
||||
-- =============================================================================
|
||||
-- Adiciona suporte a subcontas Twilio com número WhatsApp dedicado por tenant.
|
||||
-- Cada clínica/terapeuta recebe sua própria subconta Twilio com número exclusivo.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── 1. Campos de subconta Twilio em notification_channels ──────────────────
|
||||
|
||||
ALTER TABLE public.notification_channels
|
||||
ADD COLUMN IF NOT EXISTS twilio_subaccount_sid text,
|
||||
ADD COLUMN IF NOT EXISTS twilio_phone_number text,
|
||||
ADD COLUMN IF NOT EXISTS twilio_phone_sid text,
|
||||
ADD COLUMN IF NOT EXISTS webhook_url text,
|
||||
ADD COLUMN IF NOT EXISTS cost_per_message_usd numeric(8,6) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS price_per_message_brl numeric(8,4) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS provisioned_at timestamp with time zone;
|
||||
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_subaccount_sid IS 'SID da subconta Twilio criada para este tenant';
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_phone_number IS 'Número WhatsApp provisionado (E.164, ex: +5511999990000)';
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_phone_sid IS 'SID do número de telefone na subconta Twilio';
|
||||
COMMENT ON COLUMN public.notification_channels.webhook_url IS 'URL do webhook configurada na Twilio para receber callbacks de status';
|
||||
COMMENT ON COLUMN public.notification_channels.cost_per_message_usd IS 'Custo real Twilio por mensagem WhatsApp (USD)';
|
||||
COMMENT ON COLUMN public.notification_channels.price_per_message_brl IS 'Valor cobrado do tenant por mensagem (BRL, inclui margem SaaS)';
|
||||
COMMENT ON COLUMN public.notification_channels.provisioned_at IS 'Timestamp do provisionamento da subconta';
|
||||
|
||||
-- Índice para busca rápida por subconta
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channels_twilio_subaccount_sid
|
||||
ON public.notification_channels (twilio_subaccount_sid)
|
||||
WHERE twilio_subaccount_sid IS NOT NULL;
|
||||
|
||||
-- ── 2. Tabela de consumo por subconta ─────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.twilio_subaccount_usage (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
channel_id uuid NOT NULL,
|
||||
twilio_subaccount_sid text NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
period_end date NOT NULL,
|
||||
messages_sent integer DEFAULT 0 NOT NULL,
|
||||
messages_delivered integer DEFAULT 0 NOT NULL,
|
||||
messages_failed integer DEFAULT 0 NOT NULL,
|
||||
cost_usd numeric(12,6) DEFAULT 0 NOT NULL,
|
||||
cost_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
revenue_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
margin_brl numeric(12,4) GENERATED ALWAYS AS (revenue_brl - cost_brl) STORED,
|
||||
usd_brl_rate numeric(8,4) DEFAULT 0,
|
||||
synced_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT twilio_subaccount_usage_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT twilio_subaccount_usage_channel_fk
|
||||
FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE,
|
||||
CONSTRAINT twilio_subaccount_usage_period_check
|
||||
CHECK (period_end >= period_start)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.twilio_subaccount_usage IS
|
||||
'Consumo mensal de mensagens WhatsApp por subconta Twilio. Sincronizado via Edge Function.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_twilio_usage_tenant_period
|
||||
ON public.twilio_subaccount_usage (tenant_id, period_start DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_twilio_usage_channel
|
||||
ON public.twilio_subaccount_usage (channel_id, period_start DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_twilio_usage_unique_period
|
||||
ON public.twilio_subaccount_usage (channel_id, period_start, period_end);
|
||||
|
||||
ALTER TABLE public.twilio_subaccount_usage OWNER TO supabase_admin;
|
||||
|
||||
-- ── 3. RLS: twilio_subaccount_usage ───────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Tenant vê apenas seu próprio consumo
|
||||
CREATE POLICY "tenant_select_own_usage"
|
||||
ON public.twilio_subaccount_usage
|
||||
FOR SELECT
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT tenant_id FROM public.tenant_members
|
||||
WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Apenas service_role pode inserir/atualizar (via Edge Function)
|
||||
CREATE POLICY "service_role_manage_usage"
|
||||
ON public.twilio_subaccount_usage
|
||||
FOR ALL
|
||||
USING (auth.role() = 'service_role');
|
||||
|
||||
-- ── 4. RLS: notification_channels — acesso ao twilio_subaccount_sid ───────
|
||||
-- As políticas existentes já cobrem SELECT/UPDATE. Nenhuma alteração necessária.
|
||||
|
||||
-- ── 5. View: resumo de subcontas para o painel SaaS admin ─────────────────
|
||||
|
||||
CREATE OR REPLACE VIEW public.v_twilio_whatsapp_overview AS
|
||||
SELECT
|
||||
nc.id AS channel_id,
|
||||
nc.tenant_id,
|
||||
nc.owner_id,
|
||||
nc.is_active,
|
||||
nc.connection_status,
|
||||
nc.display_name,
|
||||
nc.twilio_subaccount_sid,
|
||||
nc.twilio_phone_number,
|
||||
nc.twilio_phone_sid,
|
||||
nc.cost_per_message_usd,
|
||||
nc.price_per_message_brl,
|
||||
nc.provisioned_at,
|
||||
nc.created_at,
|
||||
nc.updated_at,
|
||||
-- Uso do mês atual
|
||||
COALESCE(u.messages_sent, 0) AS current_month_sent,
|
||||
COALESCE(u.messages_delivered, 0) AS current_month_delivered,
|
||||
COALESCE(u.messages_failed, 0) AS current_month_failed,
|
||||
COALESCE(u.cost_usd, 0) AS current_month_cost_usd,
|
||||
COALESCE(u.cost_brl, 0) AS current_month_cost_brl,
|
||||
COALESCE(u.revenue_brl, 0) AS current_month_revenue_brl,
|
||||
COALESCE(u.margin_brl, 0) AS current_month_margin_brl
|
||||
FROM public.notification_channels nc
|
||||
LEFT JOIN public.twilio_subaccount_usage u
|
||||
ON u.channel_id = nc.id
|
||||
AND u.period_start = date_trunc('month', CURRENT_DATE)::date
|
||||
WHERE nc.channel = 'whatsapp'
|
||||
AND nc.provider = 'twilio'
|
||||
AND nc.deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW public.v_twilio_whatsapp_overview IS
|
||||
'Visão consolidada de subcontas Twilio WhatsApp com uso do mês corrente.';
|
||||
@@ -0,0 +1,57 @@
|
||||
-- ============================================================
|
||||
-- Migration 002 — SetupWizard1: campos Negócio e Atendimento
|
||||
-- ============================================================
|
||||
-- Tabela: tenants (Step 2 — Negócio)
|
||||
-- Tabela: agenda_configuracoes (Step 3 — Atendimento)
|
||||
-- ============================================================
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- tenants: dados do negócio
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.tenants
|
||||
ADD COLUMN IF NOT EXISTS business_type text,
|
||||
ADD COLUMN IF NOT EXISTS logo_url text,
|
||||
ADD COLUMN IF NOT EXISTS address text,
|
||||
ADD COLUMN IF NOT EXISTS phone text,
|
||||
ADD COLUMN IF NOT EXISTS contact_email text,
|
||||
ADD COLUMN IF NOT EXISTS site_url text,
|
||||
ADD COLUMN IF NOT EXISTS social_instagram text;
|
||||
|
||||
-- Valores aceitos: consultorio | clinica | instituto | grupo
|
||||
ALTER TABLE public.tenants
|
||||
ADD CONSTRAINT tenants_business_type_check
|
||||
CHECK (business_type IS NULL OR business_type = ANY (ARRAY[
|
||||
'consultorio'::text,
|
||||
'clinica'::text,
|
||||
'instituto'::text,
|
||||
'grupo'::text
|
||||
]));
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- agenda_configuracoes: modo de atendimento
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes
|
||||
ADD COLUMN IF NOT EXISTS atendimento_mode text DEFAULT 'particular'::text;
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes
|
||||
ADD CONSTRAINT agenda_configuracoes_atendimento_mode_check
|
||||
CHECK (atendimento_mode IS NULL OR atendimento_mode = ANY (ARRAY[
|
||||
'particular'::text,
|
||||
'convenio'::text,
|
||||
'ambos'::text
|
||||
]));
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- Comments
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
COMMENT ON COLUMN public.tenants.business_type IS 'Tipo de negócio: consultorio, clinica, instituto, grupo';
|
||||
COMMENT ON COLUMN public.tenants.logo_url IS 'URL da logo do negócio (Storage bucket)';
|
||||
COMMENT ON COLUMN public.tenants.address IS 'Endereço do negócio (texto livre)';
|
||||
COMMENT ON COLUMN public.tenants.phone IS 'Telefone/WhatsApp do negócio';
|
||||
COMMENT ON COLUMN public.tenants.contact_email IS 'E-mail público de contato do negócio';
|
||||
COMMENT ON COLUMN public.tenants.site_url IS 'Site do negócio';
|
||||
COMMENT ON COLUMN public.tenants.social_instagram IS 'Instagram do negócio (sem @)';
|
||||
COMMENT ON COLUMN public.agenda_configuracoes.atendimento_mode IS 'Modo de atendimento: particular | convenio | ambos';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- ============================================================
|
||||
-- Migration 003 — Tenants: campos de endereço detalhado
|
||||
-- ============================================================
|
||||
-- Substitui o campo address (texto livre) por campos estruturados
|
||||
-- preenchidos via consulta de CEP (ViaCEP)
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE public.tenants
|
||||
ADD COLUMN IF NOT EXISTS cep text,
|
||||
ADD COLUMN IF NOT EXISTS logradouro text,
|
||||
ADD COLUMN IF NOT EXISTS numero text,
|
||||
ADD COLUMN IF NOT EXISTS complemento text,
|
||||
ADD COLUMN IF NOT EXISTS bairro text,
|
||||
ADD COLUMN IF NOT EXISTS cidade text,
|
||||
ADD COLUMN IF NOT EXISTS estado text;
|
||||
|
||||
-- Migra dados existentes do campo address para logradouro
|
||||
UPDATE public.tenants
|
||||
SET logradouro = address
|
||||
WHERE address IS NOT NULL
|
||||
AND logradouro IS NULL;
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- Comments
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
COMMENT ON COLUMN public.tenants.cep IS 'CEP do endereço do negócio';
|
||||
COMMENT ON COLUMN public.tenants.logradouro IS 'Logradouro (rua, avenida, etc.)';
|
||||
COMMENT ON COLUMN public.tenants.numero IS 'Número do endereço';
|
||||
COMMENT ON COLUMN public.tenants.complemento IS 'Complemento (sala, andar, etc.)';
|
||||
COMMENT ON COLUMN public.tenants.bairro IS 'Bairro';
|
||||
COMMENT ON COLUMN public.tenants.cidade IS 'Cidade';
|
||||
COMMENT ON COLUMN public.tenants.estado IS 'UF (2 letras)';
|
||||
@@ -0,0 +1,147 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: tabela `medicos`
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026 · São Carlos/SP — Brasil
|
||||
--
|
||||
-- Propósito:
|
||||
-- Armazena médicos e profissionais de referência (psiquiatras, neurologistas,
|
||||
-- clínicos gerais, etc.) que encaminham pacientes ou fazem parte da rede de
|
||||
-- suporte clínico do terapeuta.
|
||||
--
|
||||
-- Usado em:
|
||||
-- - PatientsCadastroPage: campo "Encaminhado por" (FK medico_id)
|
||||
-- - CadastroRapidoMedico.vue: cadastro rápido dentro do formulário
|
||||
-- - MedicosCadastroPage.vue: página completa de gestão de médicos
|
||||
--
|
||||
-- Relacionamentos:
|
||||
-- medicos.owner_id → auth.users(id)
|
||||
-- medicos.tenant_id → tenants(id)
|
||||
-- patients.medico_encaminhador_id → medicos(id) (opcional, ver abaixo)
|
||||
--
|
||||
-- RLS: owner_id = auth.uid() — cada profissional vê apenas seus médicos.
|
||||
-- ==========================================================================
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Tabela principal
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.medicos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Contexto de acesso
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Identidade profissional
|
||||
nome text NOT NULL,
|
||||
crm text, -- Ex: "123456/SP"
|
||||
especialidade text, -- Ex: "Psiquiatria"
|
||||
|
||||
-- Contatos — telefone_pessoal é sensível (exibido com ícone de olho)
|
||||
telefone_profissional text, -- Consultório / clínica
|
||||
telefone_pessoal text, -- WhatsApp / pessoal
|
||||
email text,
|
||||
|
||||
-- Local de atuação
|
||||
clinica text, -- Nome da clínica/hospital
|
||||
cidade text,
|
||||
estado text DEFAULT 'SP',
|
||||
|
||||
-- Notas internas do terapeuta
|
||||
observacoes text,
|
||||
|
||||
-- Controle
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT medicos_pkey PRIMARY KEY (id),
|
||||
|
||||
-- CRM único por owner (mesmo terapeuta não cadastra o mesmo CRM duas vezes)
|
||||
CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm)
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Índices de performance
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS medicos_owner_idx
|
||||
ON public.medicos USING btree (owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS medicos_tenant_idx
|
||||
ON public.medicos USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS medicos_nome_idx
|
||||
ON public.medicos USING btree (nome);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS medicos_especialidade_idx
|
||||
ON public.medicos USING btree (especialidade);
|
||||
|
||||
-- Busca textual por nome e especialidade
|
||||
CREATE INDEX IF NOT EXISTS medicos_nome_trgm_idx
|
||||
ON public.medicos USING gin (nome gin_trgm_ops);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Trigger de updated_at
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.set_medicos_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_medicos_updated_at
|
||||
BEFORE UPDATE ON public.medicos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_medicos_updated_at();
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. Row Level Security
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner tem acesso total aos seus próprios médicos
|
||||
CREATE POLICY "medicos: owner full access"
|
||||
ON public.medicos
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. Comentários de documentação
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.medicos IS 'Médicos e profissionais de referência cadastrados pelo terapeuta.';
|
||||
COMMENT ON COLUMN public.medicos.owner_id IS 'Terapeuta dono do cadastro (auth.uid()).';
|
||||
COMMENT ON COLUMN public.medicos.tenant_id IS 'Tenant do terapeuta.';
|
||||
COMMENT ON COLUMN public.medicos.nome IS 'Nome completo do médico/profissional.';
|
||||
COMMENT ON COLUMN public.medicos.crm IS 'CRM com UF. Ex: 123456/SP. Único por owner_id.';
|
||||
COMMENT ON COLUMN public.medicos.especialidade IS 'Especialidade médica. Ex: Psiquiatria, Neurologia.';
|
||||
COMMENT ON COLUMN public.medicos.telefone_profissional IS 'Telefone do consultório ou clínica.';
|
||||
COMMENT ON COLUMN public.medicos.telefone_pessoal IS 'Telefone pessoal / WhatsApp. Campo sensível.';
|
||||
COMMENT ON COLUMN public.medicos.email IS 'E-mail profissional.';
|
||||
COMMENT ON COLUMN public.medicos.clinica IS 'Nome da clínica ou hospital onde atua.';
|
||||
COMMENT ON COLUMN public.medicos.cidade IS 'Cidade de atuação.';
|
||||
COMMENT ON COLUMN public.medicos.estado IS 'UF de atuação. Default SP.';
|
||||
COMMENT ON COLUMN public.medicos.observacoes IS 'Notas internas do terapeuta sobre o médico.';
|
||||
COMMENT ON COLUMN public.medicos.ativo IS 'Soft delete: false oculta da listagem.';
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 6. Coluna FK opcional em patients
|
||||
-- (Conecta "Encaminhado por" ao cadastro de médico)
|
||||
-- Execute apenas se quiser a FK estruturada; caso contrário,
|
||||
-- o campo encaminhado_por (text) no PatientsCadastroPage já funciona.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
-- ALTER TABLE public.patients
|
||||
-- ADD COLUMN IF NOT EXISTS medico_encaminhador_id uuid
|
||||
-- REFERENCES public.medicos(id) ON DELETE SET NULL;
|
||||
|
||||
-- CREATE INDEX IF NOT EXISTS patients_medico_encaminhador_idx
|
||||
-- ON public.patients USING btree (medico_encaminhador_id);
|
||||
|
||||
-- COMMENT ON COLUMN public.patients.medico_encaminhador_id
|
||||
-- IS 'FK para medicos.id — quem encaminhou o paciente.';
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,119 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: novos campos em `patients`
|
||||
-- ==========================================================================
|
||||
-- Arquivo: supabase/migrations/20260328000002_patients_new_columns.sql
|
||||
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
|
||||
--
|
||||
-- Adiciona as colunas identificadas na engenharia reversa da tela de detalhe
|
||||
-- (PatientsDetailPage) que ainda não existiam na tabela `patients`.
|
||||
--
|
||||
-- Também ajusta os CHECK constraints de `status` e `patient_scope` para
|
||||
-- aceitar os valores usados no novo formulário de cadastro.
|
||||
-- ==========================================================================
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Colunas novas
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
-- Identidade
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS pronomes text,
|
||||
ADD COLUMN IF NOT EXISTS nome_social text,
|
||||
ADD COLUMN IF NOT EXISTS etnia text;
|
||||
|
||||
-- Contato
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS canal_preferido text,
|
||||
ADD COLUMN IF NOT EXISTS horario_contato text;
|
||||
|
||||
-- Clínico / convênio
|
||||
-- convenio: nome de exibição (badge azul no header)
|
||||
-- convenio_id: FK para insurance_plans (opcional — permite vincular ao cadastro)
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS convenio text,
|
||||
ADD COLUMN IF NOT EXISTS convenio_id uuid REFERENCES public.insurance_plans(id) ON DELETE SET NULL;
|
||||
|
||||
-- Origem
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
|
||||
ADD COLUMN IF NOT EXISTS motivo_saida text;
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Ajuste do CHECK constraint de `status`
|
||||
-- Valores originais: Ativo | Inativo | Alta | Encaminhado | Arquivado
|
||||
-- Valores novos: + Em espera
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_status_check;
|
||||
|
||||
ALTER TABLE public.patients
|
||||
ADD CONSTRAINT patients_status_check CHECK (
|
||||
status = ANY (ARRAY[
|
||||
'Ativo'::text,
|
||||
'Em espera'::text,
|
||||
'Inativo'::text,
|
||||
'Alta'::text,
|
||||
'Encaminhado'::text,
|
||||
'Arquivado'::text
|
||||
])
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Ajuste do CHECK constraint de `patient_scope`
|
||||
-- Valores originais: clinic | therapist (valores técnicos internos)
|
||||
-- Valores novos: + Clínica | Particular | Online | Híbrido
|
||||
-- Estratégia: remover o constraint restritivo e deixar livre (text),
|
||||
-- pois o controle já é feito no frontend via Select com opções fixas.
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
|
||||
|
||||
-- Também remove a constraint de consistência que dependia do scope antigo
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. Índices de performance
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS patients_convenio_id_idx
|
||||
ON public.patients USING btree (convenio_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS patients_pronomes_idx
|
||||
ON public.patients USING btree (pronomes);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS patients_etnia_idx
|
||||
ON public.patients USING btree (etnia);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. Comentários
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON COLUMN public.patients.pronomes
|
||||
IS 'Pronomes de tratamento. Ex: ela/dela, ele/dele. Exibido no header do perfil.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.nome_social
|
||||
IS 'Nome social / como prefere ser chamado(a) no atendimento.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.etnia
|
||||
IS 'Etnia / raça autodeclarada. Exibida no card "Dados pessoais".';
|
||||
|
||||
COMMENT ON COLUMN public.patients.canal_preferido
|
||||
IS 'Canal preferido de contato. Ex: WhatsApp, Telefone, E-mail.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.horario_contato
|
||||
IS 'Horário preferido para contato. Ex: 08h–18h.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.convenio
|
||||
IS 'Nome do convênio para exibição (badge azul no header). Derivado de convenio_id.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.convenio_id
|
||||
IS 'FK para insurance_plans.id. Vincula o paciente ao convênio cadastrado.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido
|
||||
IS 'Método de pagamento preferido. Ex: PIX, Cartão crédito. Exibido no card Origem.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.motivo_saida
|
||||
IS 'Motivo de encerramento do acompanhamento. Exibido no card Origem quando preenchido.';
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,70 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: remove check constraints dos novos campos
|
||||
-- ==========================================================================
|
||||
-- Arquivo: supabase/migrations/20260328000003_patients_drop_check_constraints.sql
|
||||
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
|
||||
--
|
||||
-- O banco tinha CHECK constraints nos novos campos que foram adicionados
|
||||
-- pela migration anterior (ou que já existiam no schema ao vivo).
|
||||
-- O frontend já controla os valores via Select com opções fixas,
|
||||
-- então os constraints são desnecessários e serão removidos.
|
||||
-- ==========================================================================
|
||||
|
||||
-- canal_preferido
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check;
|
||||
|
||||
-- horario_contato
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_horario_contato_check;
|
||||
|
||||
-- pronomes
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_pronomes_check;
|
||||
|
||||
-- nome_social
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_nome_social_check;
|
||||
|
||||
-- etnia
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_etnia_check;
|
||||
|
||||
-- convenio
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_convenio_check;
|
||||
|
||||
-- metodo_pagamento_preferido
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_preferido_check;
|
||||
|
||||
-- motivo_saida
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_motivo_saida_check;
|
||||
|
||||
-- status (já ajustado na migration anterior, mas garante)
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_status_check;
|
||||
|
||||
ALTER TABLE public.patients
|
||||
ADD CONSTRAINT patients_status_check CHECK (
|
||||
status = ANY (ARRAY[
|
||||
'Ativo'::text,
|
||||
'Em espera'::text,
|
||||
'Inativo'::text,
|
||||
'Alta'::text,
|
||||
'Encaminhado'::text,
|
||||
'Arquivado'::text
|
||||
])
|
||||
);
|
||||
|
||||
-- patient_scope (já ajustado na migration anterior, mas garante)
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
|
||||
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,56 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: tabela `patient_support_contacts`
|
||||
-- ==========================================================================
|
||||
-- Arquivo: supabase/migrations/20260328000004_create_patient_support_contacts.sql
|
||||
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
|
||||
--
|
||||
-- Contatos da rede de suporte do paciente.
|
||||
-- Alimenta o card "Contatos & rede de suporte" na tela de detalhe.
|
||||
-- is_primario = true → badge vermelho "emergência" no perfil.
|
||||
-- ==========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_support_contacts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
nome text,
|
||||
relacao text, -- Ex: mãe, psiquiatra, cônjuge
|
||||
tipo text, -- emergencia | familiar | profissional_saude | amigo | outro
|
||||
telefone text,
|
||||
email text,
|
||||
is_primario boolean DEFAULT false NOT NULL,
|
||||
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT patient_support_contacts_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- Índices
|
||||
CREATE INDEX IF NOT EXISTS psc_patient_idx ON public.patient_support_contacts USING btree (patient_id);
|
||||
CREATE INDEX IF NOT EXISTS psc_owner_idx ON public.patient_support_contacts USING btree (owner_id);
|
||||
|
||||
-- Trigger updated_at
|
||||
CREATE TRIGGER trg_psc_updated_at
|
||||
BEFORE UPDATE ON public.patient_support_contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.patient_support_contacts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "psc: owner full access"
|
||||
ON public.patient_support_contacts
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- Comentários
|
||||
COMMENT ON TABLE public.patient_support_contacts IS 'Rede de suporte do paciente. Exibida no card "Contatos & rede de suporte" do perfil.';
|
||||
COMMENT ON COLUMN public.patient_support_contacts.is_primario IS 'true = badge vermelho "emergência" no perfil do paciente.';
|
||||
COMMENT ON COLUMN public.patient_support_contacts.tipo IS 'emergencia | familiar | profissional_saude | amigo | outro';
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,454 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: tabelas de Documentos & Arquivos
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Proposito:
|
||||
-- Modulo completo de documentos do paciente.
|
||||
-- Tabelas: documents, document_access_logs, document_signatures,
|
||||
-- document_share_links.
|
||||
--
|
||||
-- Relacionamentos:
|
||||
-- documents.patient_id → patients(id)
|
||||
-- documents.owner_id → auth.users(id)
|
||||
-- documents.tenant_id → tenants(id)
|
||||
-- documents.agenda_evento_id → agenda_eventos(id) (opcional)
|
||||
-- document_access_logs.documento_id → documents(id)
|
||||
-- document_signatures.documento_id → documents(id)
|
||||
-- document_share_links.documento_id → documents(id)
|
||||
--
|
||||
-- RLS: owner_id = auth.uid() para documents, signatures e share_links.
|
||||
-- access_logs: somente INSERT (imutavel) + SELECT por tenant.
|
||||
-- ==========================================================================
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Tabela principal: documents
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.documents (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Contexto de acesso
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Vinculo com paciente
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
|
||||
-- Arquivo no Storage
|
||||
bucket_path text NOT NULL,
|
||||
storage_bucket text NOT NULL DEFAULT 'documents',
|
||||
nome_original text NOT NULL,
|
||||
mime_type text,
|
||||
tamanho_bytes bigint,
|
||||
|
||||
-- Classificacao
|
||||
tipo_documento text NOT NULL DEFAULT 'outro',
|
||||
-- laudo | receita | exame | termo_assinado | relatorio_externo
|
||||
-- identidade | convenio | declaracao | atestado | recibo | outro
|
||||
categoria text,
|
||||
descricao text,
|
||||
tags text[] DEFAULT '{}',
|
||||
|
||||
-- Vinculo opcional com sessao/nota
|
||||
agenda_evento_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
|
||||
session_note_id uuid,
|
||||
|
||||
-- Visibilidade & controle de acesso
|
||||
visibilidade text NOT NULL DEFAULT 'privado',
|
||||
-- privado | compartilhado_supervisor | compartilhado_portal
|
||||
compartilhado_portal boolean DEFAULT false NOT NULL,
|
||||
compartilhado_supervisor boolean DEFAULT false NOT NULL,
|
||||
compartilhado_em timestamptz,
|
||||
expira_compartilhamento timestamptz,
|
||||
|
||||
-- Upload pelo paciente (portal)
|
||||
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
|
||||
status_revisao text DEFAULT 'aprovado',
|
||||
-- pendente | aprovado | rejeitado
|
||||
revisado_por uuid,
|
||||
revisado_em timestamptz,
|
||||
|
||||
-- Quem fez upload
|
||||
uploaded_by uuid NOT NULL,
|
||||
uploaded_at timestamptz DEFAULT now() NOT NULL,
|
||||
|
||||
-- Soft delete com retencao (LGPD / CFP)
|
||||
deleted_at timestamptz,
|
||||
deleted_by uuid,
|
||||
retencao_ate timestamptz,
|
||||
|
||||
-- Controle
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT documents_pkey PRIMARY KEY (id),
|
||||
|
||||
-- Validacoes
|
||||
CONSTRAINT documents_tipo_check CHECK (
|
||||
tipo_documento = ANY (ARRAY[
|
||||
'laudo', 'receita', 'exame', 'termo_assinado', 'relatorio_externo',
|
||||
'identidade', 'convenio', 'declaracao', 'atestado', 'recibo', 'outro'
|
||||
])
|
||||
),
|
||||
CONSTRAINT documents_visibilidade_check CHECK (
|
||||
visibilidade = ANY (ARRAY['privado', 'compartilhado_supervisor', 'compartilhado_portal'])
|
||||
),
|
||||
CONSTRAINT documents_status_revisao_check CHECK (
|
||||
status_revisao = ANY (ARRAY['pendente', 'aprovado', 'rejeitado'])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Indices — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS docs_patient_idx
|
||||
ON public.documents USING btree (patient_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_owner_idx
|
||||
ON public.documents USING btree (owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_tenant_idx
|
||||
ON public.documents USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_tipo_idx
|
||||
ON public.documents USING btree (patient_id, tipo_documento);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_tags_idx
|
||||
ON public.documents USING gin (tags);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_uploaded_at_idx
|
||||
ON public.documents USING btree (patient_id, uploaded_at DESC);
|
||||
|
||||
-- Excluir soft-deleted da listagem padrao
|
||||
CREATE INDEX IF NOT EXISTS docs_active_idx
|
||||
ON public.documents USING btree (patient_id, uploaded_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Busca textual no nome do arquivo
|
||||
CREATE INDEX IF NOT EXISTS docs_nome_trgm_idx
|
||||
ON public.documents USING gin (nome_original gin_trgm_ops);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Trigger updated_at — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TRIGGER trg_documents_updated_at
|
||||
BEFORE UPDATE ON public.documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. Trigger: registrar na patient_timeline ao adicionar documento
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.patient_timeline (
|
||||
patient_id, tenant_id, evento_tipo,
|
||||
titulo, descricao, icone_cor,
|
||||
link_ref_tipo, link_ref_id,
|
||||
gerado_por, ocorrido_em
|
||||
) VALUES (
|
||||
NEW.patient_id,
|
||||
NEW.tenant_id,
|
||||
'documento_adicionado',
|
||||
'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
|
||||
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
|
||||
'blue',
|
||||
'documento',
|
||||
NEW.id,
|
||||
NEW.uploaded_by,
|
||||
NEW.uploaded_at
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_documents_timeline_insert
|
||||
AFTER INSERT ON public.documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_documents_timeline_insert();
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. RLS — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "documents: owner full access"
|
||||
ON public.documents
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 6. Comentarios — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.documents IS 'Documentos e arquivos vinculados a pacientes. Armazenados no Supabase Storage.';
|
||||
COMMENT ON COLUMN public.documents.owner_id IS 'Terapeuta dono do documento (auth.uid()).';
|
||||
COMMENT ON COLUMN public.documents.tenant_id IS 'Tenant do terapeuta.';
|
||||
COMMENT ON COLUMN public.documents.patient_id IS 'Paciente ao qual o documento pertence.';
|
||||
COMMENT ON COLUMN public.documents.bucket_path IS 'Caminho do arquivo no Supabase Storage bucket.';
|
||||
COMMENT ON COLUMN public.documents.storage_bucket IS 'Nome do bucket no Storage. Default: documents.';
|
||||
COMMENT ON COLUMN public.documents.nome_original IS 'Nome original do arquivo enviado.';
|
||||
COMMENT ON COLUMN public.documents.mime_type IS 'MIME type do arquivo. Ex: application/pdf, image/jpeg.';
|
||||
COMMENT ON COLUMN public.documents.tamanho_bytes IS 'Tamanho do arquivo em bytes.';
|
||||
COMMENT ON COLUMN public.documents.tipo_documento IS 'Tipo: laudo|receita|exame|termo_assinado|relatorio_externo|identidade|convenio|declaracao|atestado|recibo|outro.';
|
||||
COMMENT ON COLUMN public.documents.categoria IS 'Categoria livre para organizacao adicional.';
|
||||
COMMENT ON COLUMN public.documents.tags IS 'Tags livres para busca e filtro. Array de text.';
|
||||
COMMENT ON COLUMN public.documents.visibilidade IS 'privado|compartilhado_supervisor|compartilhado_portal.';
|
||||
COMMENT ON COLUMN public.documents.compartilhado_portal IS 'true = visivel para o paciente no portal.';
|
||||
COMMENT ON COLUMN public.documents.compartilhado_supervisor IS 'true = visivel para o supervisor.';
|
||||
COMMENT ON COLUMN public.documents.enviado_pelo_paciente IS 'true = upload feito pelo paciente via portal.';
|
||||
COMMENT ON COLUMN public.documents.status_revisao IS 'pendente|aprovado|rejeitado — para uploads do paciente.';
|
||||
COMMENT ON COLUMN public.documents.deleted_at IS 'Soft delete: data da exclusao. NULL = ativo.';
|
||||
COMMENT ON COLUMN public.documents.retencao_ate IS 'LGPD/CFP: arquivo retido ate esta data mesmo apos soft delete.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 7. Tabela: document_access_logs (imutavel — auditoria)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_access_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Acao realizada
|
||||
acao text NOT NULL,
|
||||
-- visualizou | baixou | imprimiu | compartilhou | assinou
|
||||
user_id uuid,
|
||||
ip inet,
|
||||
user_agent text,
|
||||
|
||||
acessado_em timestamptz DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT document_access_logs_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT dal_acao_check CHECK (
|
||||
acao = ANY (ARRAY['visualizou', 'baixou', 'imprimiu', 'compartilhou', 'assinou'])
|
||||
)
|
||||
);
|
||||
|
||||
-- Indices
|
||||
CREATE INDEX IF NOT EXISTS dal_documento_idx
|
||||
ON public.document_access_logs USING btree (documento_id, acessado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dal_tenant_idx
|
||||
ON public.document_access_logs USING btree (tenant_id, acessado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dal_user_idx
|
||||
ON public.document_access_logs USING btree (user_id, acessado_em DESC);
|
||||
|
||||
-- RLS — somente INSERT (imutavel) + SELECT
|
||||
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "dal: tenant members can insert"
|
||||
ON public.document_access_logs
|
||||
FOR INSERT
|
||||
WITH CHECK (true);
|
||||
|
||||
CREATE POLICY "dal: tenant members can select"
|
||||
ON public.document_access_logs
|
||||
FOR SELECT
|
||||
USING (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
));
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE public.document_access_logs IS 'Log imutavel de acessos a documentos. Conformidade CFP e LGPD. Sem UPDATE/DELETE.';
|
||||
COMMENT ON COLUMN public.document_access_logs.acao IS 'visualizou|baixou|imprimiu|compartilhou|assinou.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 8. Tabela: document_signatures (assinatura eletronica)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_signatures (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Signatario
|
||||
signatario_tipo text NOT NULL,
|
||||
-- paciente | responsavel_legal | terapeuta
|
||||
signatario_id uuid,
|
||||
signatario_nome text,
|
||||
signatario_email text,
|
||||
|
||||
-- Ordem e status
|
||||
ordem smallint DEFAULT 1 NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pendente',
|
||||
-- pendente | enviado | assinado | recusado | expirado
|
||||
|
||||
-- Dados da assinatura (preenchidos ao assinar)
|
||||
ip inet,
|
||||
user_agent text,
|
||||
assinado_em timestamptz,
|
||||
hash_documento text,
|
||||
|
||||
-- Controle
|
||||
criado_em timestamptz DEFAULT now(),
|
||||
atualizado_em timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT document_signatures_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT ds_signatario_tipo_check CHECK (
|
||||
signatario_tipo = ANY (ARRAY['paciente', 'responsavel_legal', 'terapeuta'])
|
||||
),
|
||||
CONSTRAINT ds_status_check CHECK (
|
||||
status = ANY (ARRAY['pendente', 'enviado', 'assinado', 'recusado', 'expirado'])
|
||||
)
|
||||
);
|
||||
|
||||
-- Indices
|
||||
CREATE INDEX IF NOT EXISTS ds_documento_idx
|
||||
ON public.document_signatures USING btree (documento_id, ordem);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ds_tenant_idx
|
||||
ON public.document_signatures USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ds_status_idx
|
||||
ON public.document_signatures USING btree (documento_id, status);
|
||||
|
||||
-- Trigger updated_at
|
||||
CREATE TRIGGER trg_ds_updated_at
|
||||
BEFORE UPDATE ON public.document_signatures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
-- Trigger: ao assinar, registrar na patient_timeline
|
||||
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
v_patient_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_doc_nome text;
|
||||
BEGIN
|
||||
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
|
||||
SELECT d.patient_id, d.tenant_id, d.nome_original
|
||||
INTO v_patient_id, v_tenant_id, v_doc_nome
|
||||
FROM public.documents d
|
||||
WHERE d.id = NEW.documento_id;
|
||||
|
||||
IF v_patient_id IS NOT NULL THEN
|
||||
INSERT INTO public.patient_timeline (
|
||||
patient_id, tenant_id, evento_tipo,
|
||||
titulo, descricao, icone_cor,
|
||||
link_ref_tipo, link_ref_id,
|
||||
gerado_por, ocorrido_em
|
||||
) VALUES (
|
||||
v_patient_id,
|
||||
v_tenant_id,
|
||||
'documento_assinado',
|
||||
'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
|
||||
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
|
||||
'green',
|
||||
'documento',
|
||||
NEW.documento_id,
|
||||
NEW.signatario_id,
|
||||
NEW.assinado_em
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_ds_timeline
|
||||
AFTER UPDATE ON public.document_signatures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_document_signature_timeline();
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.document_signatures ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "ds: tenant members access"
|
||||
ON public.document_signatures
|
||||
USING (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
))
|
||||
WITH CHECK (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
));
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE public.document_signatures IS 'Assinaturas eletronicas de documentos. Cada signatario tem seu registro.';
|
||||
COMMENT ON COLUMN public.document_signatures.signatario_tipo IS 'paciente|responsavel_legal|terapeuta.';
|
||||
COMMENT ON COLUMN public.document_signatures.status IS 'pendente|enviado|assinado|recusado|expirado.';
|
||||
COMMENT ON COLUMN public.document_signatures.hash_documento IS 'Hash SHA-256 do documento no momento da assinatura. Garante integridade.';
|
||||
COMMENT ON COLUMN public.document_signatures.ip IS 'IP do signatario no momento da assinatura.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 9. Tabela: document_share_links (links temporarios)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_share_links (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Token unico para o link
|
||||
token text NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
|
||||
|
||||
-- Limites
|
||||
expira_em timestamptz NOT NULL,
|
||||
usos_max smallint DEFAULT 5 NOT NULL,
|
||||
usos smallint DEFAULT 0 NOT NULL,
|
||||
|
||||
-- Quem criou
|
||||
criado_por uuid NOT NULL,
|
||||
criado_em timestamptz DEFAULT now(),
|
||||
|
||||
-- Controle
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
|
||||
CONSTRAINT document_share_links_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT dsl_token_unique UNIQUE (token)
|
||||
);
|
||||
|
||||
-- Indices
|
||||
CREATE INDEX IF NOT EXISTS dsl_documento_idx
|
||||
ON public.document_share_links USING btree (documento_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dsl_token_idx
|
||||
ON public.document_share_links USING btree (token)
|
||||
WHERE ativo = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dsl_expira_idx
|
||||
ON public.document_share_links USING btree (expira_em)
|
||||
WHERE ativo = true;
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "dsl: creator full access"
|
||||
ON public.document_share_links
|
||||
USING (criado_por = auth.uid())
|
||||
WITH CHECK (criado_por = auth.uid());
|
||||
|
||||
-- Politica publica de leitura por token (para acesso externo sem login)
|
||||
CREATE POLICY "dsl: public read by token"
|
||||
ON public.document_share_links
|
||||
FOR SELECT
|
||||
USING (ativo = true AND expira_em > now() AND usos < usos_max);
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE public.document_share_links IS 'Links temporarios assinados para compartilhar documento com profissional externo.';
|
||||
COMMENT ON COLUMN public.document_share_links.token IS 'Token unico gerado automaticamente (32 bytes hex).';
|
||||
COMMENT ON COLUMN public.document_share_links.expira_em IS 'Data/hora de expiracao do link.';
|
||||
COMMENT ON COLUMN public.document_share_links.usos_max IS 'Numero maximo de acessos permitidos.';
|
||||
COMMENT ON COLUMN public.document_share_links.usos IS 'Numero de vezes que o link ja foi acessado.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO 005
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,260 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: tabelas de Templates de Documentos
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Proposito:
|
||||
-- Templates de documentos (declaracao, atestado, recibo, relatorio etc.)
|
||||
-- e registro de cada documento gerado (instancia PDF).
|
||||
--
|
||||
-- Tabelas: document_templates, document_generated.
|
||||
--
|
||||
-- Relacionamentos:
|
||||
-- document_templates.tenant_id → tenants(id)
|
||||
-- document_templates.owner_id → auth.users(id)
|
||||
-- document_generated.template_id → document_templates(id)
|
||||
-- document_generated.patient_id → patients(id)
|
||||
-- document_generated.tenant_id → tenants(id)
|
||||
--
|
||||
-- Templates globais: is_global = true, tenant_id = NULL.
|
||||
-- Templates do tenant: is_global = false, tenant_id preenchido.
|
||||
-- ==========================================================================
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Tabela: document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.document_templates (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Contexto
|
||||
tenant_id uuid,
|
||||
owner_id uuid,
|
||||
|
||||
-- Identificacao
|
||||
nome_template text NOT NULL,
|
||||
tipo text NOT NULL DEFAULT 'outro',
|
||||
-- declaracao_comparecimento | atestado_psicologico
|
||||
-- relatorio_acompanhamento | recibo_pagamento
|
||||
-- termo_consentimento | encaminhamento | outro
|
||||
descricao text,
|
||||
|
||||
-- Corpo do template
|
||||
corpo_html text NOT NULL DEFAULT '',
|
||||
cabecalho_html text,
|
||||
rodape_html text,
|
||||
|
||||
-- Variaveis que o template utiliza
|
||||
variaveis text[] DEFAULT '{}',
|
||||
-- Ex: {paciente_nome, paciente_cpf, data_sessao, terapeuta_nome, ...}
|
||||
|
||||
-- Personalizacao visual
|
||||
logo_url text,
|
||||
|
||||
-- Escopo
|
||||
is_global boolean DEFAULT false NOT NULL,
|
||||
-- true = template padrao do sistema (visivel para todos)
|
||||
-- false = template criado pelo tenant/terapeuta
|
||||
|
||||
-- Controle
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT document_templates_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT dt_tipo_check CHECK (
|
||||
tipo = ANY (ARRAY[
|
||||
'declaracao_comparecimento', 'atestado_psicologico',
|
||||
'relatorio_acompanhamento', 'recibo_pagamento',
|
||||
'termo_consentimento', 'encaminhamento',
|
||||
'contrato_servicos', 'tcle', 'autorizacao_menor',
|
||||
'laudo_psicologico', 'parecer_psicologico',
|
||||
'termo_sigilo', 'declaracao_inicio_tratamento',
|
||||
'termo_alta', 'tcle_online', 'outro'
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Indices — document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS dt_tenant_idx
|
||||
ON public.document_templates USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_owner_idx
|
||||
ON public.document_templates USING btree (owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_global_idx
|
||||
ON public.document_templates USING btree (is_global)
|
||||
WHERE is_global = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_tipo_idx
|
||||
ON public.document_templates USING btree (tipo);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_nome_trgm_idx
|
||||
ON public.document_templates USING gin (nome_template gin_trgm_ops);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Trigger updated_at
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TRIGGER trg_dt_updated_at
|
||||
BEFORE UPDATE ON public.document_templates
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. RLS — document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Templates globais: todos podem ler
|
||||
CREATE POLICY "dt: global templates readable by all"
|
||||
ON public.document_templates
|
||||
FOR SELECT
|
||||
USING (is_global = true);
|
||||
|
||||
-- Templates do tenant: membros do tenant podem ler
|
||||
CREATE POLICY "dt: tenant members can select"
|
||||
ON public.document_templates
|
||||
FOR SELECT
|
||||
USING (
|
||||
is_global = false
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- Owner pode inserir/atualizar/deletar seus templates
|
||||
CREATE POLICY "dt: owner can insert"
|
||||
ON public.document_templates
|
||||
FOR INSERT
|
||||
WITH CHECK (owner_id = auth.uid() AND is_global = false);
|
||||
|
||||
CREATE POLICY "dt: owner can update"
|
||||
ON public.document_templates
|
||||
FOR UPDATE
|
||||
USING (owner_id = auth.uid() AND is_global = false)
|
||||
WITH CHECK (owner_id = auth.uid() AND is_global = false);
|
||||
|
||||
CREATE POLICY "dt: owner can delete"
|
||||
ON public.document_templates
|
||||
FOR DELETE
|
||||
USING (owner_id = auth.uid() AND is_global = false);
|
||||
|
||||
-- SaaS admin pode gerenciar templates globais (usa funcao public.is_saas_admin())
|
||||
CREATE POLICY "dt: saas admin can insert global"
|
||||
ON public.document_templates
|
||||
FOR INSERT
|
||||
WITH CHECK (is_global = true AND public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "dt: saas admin can update global"
|
||||
ON public.document_templates
|
||||
FOR UPDATE
|
||||
USING (is_global = true AND public.is_saas_admin())
|
||||
WITH CHECK (is_global = true AND public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "dt: saas admin can delete global"
|
||||
ON public.document_templates
|
||||
FOR DELETE
|
||||
USING (is_global = true AND public.is_saas_admin());
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. Comentarios — document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.document_templates IS 'Templates de documentos para geracao automatica (declaracao, atestado, recibo etc.).';
|
||||
COMMENT ON COLUMN public.document_templates.nome_template IS 'Nome do template. Ex: Declaracao de Comparecimento.';
|
||||
COMMENT ON COLUMN public.document_templates.tipo IS 'declaracao_comparecimento|atestado_psicologico|relatorio_acompanhamento|recibo_pagamento|termo_consentimento|encaminhamento|outro.';
|
||||
COMMENT ON COLUMN public.document_templates.corpo_html IS 'Corpo do template em HTML com variaveis {{nome_variavel}}.';
|
||||
COMMENT ON COLUMN public.document_templates.cabecalho_html IS 'HTML do cabecalho (logo, nome da clinica etc.).';
|
||||
COMMENT ON COLUMN public.document_templates.rodape_html IS 'HTML do rodape (CRP, endereco, contato etc.).';
|
||||
COMMENT ON COLUMN public.document_templates.variaveis IS 'Array com nomes das variaveis usadas no template. Ex: {paciente_nome, data_sessao}.';
|
||||
COMMENT ON COLUMN public.document_templates.is_global IS 'true = template padrao do sistema visivel para todos. false = template do tenant.';
|
||||
COMMENT ON COLUMN public.document_templates.logo_url IS 'URL do logo personalizado para o cabecalho do documento.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 6. Tabela: document_generated (cada PDF gerado)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_generated (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Origem
|
||||
template_id uuid NOT NULL REFERENCES public.document_templates(id) ON DELETE RESTRICT,
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Dados usados no preenchimento (snapshot — permite auditoria futura)
|
||||
dados_preenchidos jsonb NOT NULL DEFAULT '{}',
|
||||
|
||||
-- PDF gerado
|
||||
pdf_path text NOT NULL,
|
||||
storage_bucket text NOT NULL DEFAULT 'generated-docs',
|
||||
|
||||
-- Vinculo opcional com documento pai (se o PDF gerado tambem for registrado em documents)
|
||||
documento_id uuid REFERENCES public.documents(id) ON DELETE SET NULL,
|
||||
|
||||
-- Quem gerou
|
||||
gerado_por uuid NOT NULL,
|
||||
gerado_em timestamptz DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT document_generated_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 7. Indices — document_generated
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS dg_template_idx
|
||||
ON public.document_generated USING btree (template_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dg_patient_idx
|
||||
ON public.document_generated USING btree (patient_id, gerado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dg_tenant_idx
|
||||
ON public.document_generated USING btree (tenant_id, gerado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dg_gerado_por_idx
|
||||
ON public.document_generated USING btree (gerado_por, gerado_em DESC);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 8. RLS — document_generated
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "dg: generator full access"
|
||||
ON public.document_generated
|
||||
USING (gerado_por = auth.uid())
|
||||
WITH CHECK (gerado_por = auth.uid());
|
||||
|
||||
-- Membros do tenant podem visualizar
|
||||
CREATE POLICY "dg: tenant members can select"
|
||||
ON public.document_generated
|
||||
FOR SELECT
|
||||
USING (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
));
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 9. Comentarios — document_generated
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.document_generated IS 'Registro de cada documento PDF gerado a partir de um template.';
|
||||
COMMENT ON COLUMN public.document_generated.template_id IS 'Template usado para gerar o documento.';
|
||||
COMMENT ON COLUMN public.document_generated.dados_preenchidos IS 'Snapshot JSON dos dados usados no preenchimento. Permite auditoria futura.';
|
||||
COMMENT ON COLUMN public.document_generated.pdf_path IS 'Caminho do PDF gerado no Supabase Storage bucket.';
|
||||
COMMENT ON COLUMN public.document_generated.documento_id IS 'FK opcional para documents — se o PDF gerado tambem foi registrado como documento do paciente.';
|
||||
COMMENT ON COLUMN public.document_generated.gerado_por IS 'Usuario que gerou o documento (auth.uid()).';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO 006
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,93 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Storage Buckets para Documentos
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Cria os buckets no Supabase Storage para documentos de pacientes
|
||||
-- e PDFs gerados pelo sistema.
|
||||
-- ==========================================================================
|
||||
|
||||
-- Bucket: documents (uploads de terapeuta/paciente)
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'documents',
|
||||
'documents',
|
||||
false,
|
||||
52428800, -- 50 MB
|
||||
ARRAY[
|
||||
'application/pdf',
|
||||
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain'
|
||||
]
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Bucket: generated-docs (PDFs gerados pelo sistema)
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'generated-docs',
|
||||
'generated-docs',
|
||||
false,
|
||||
20971520, -- 20 MB
|
||||
ARRAY['application/pdf']
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Storage RLS Policies — bucket: documents
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
-- Upload: usuario autenticado pode fazer upload no path do seu tenant
|
||||
CREATE POLICY "documents: authenticated upload"
|
||||
ON storage.objects
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (bucket_id = 'documents');
|
||||
|
||||
-- Download: usuario autenticado pode ler arquivos do seu tenant
|
||||
CREATE POLICY "documents: authenticated read"
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'documents');
|
||||
|
||||
-- Delete: usuario autenticado pode deletar seus arquivos
|
||||
CREATE POLICY "documents: authenticated delete"
|
||||
ON storage.objects
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'documents');
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Storage RLS Policies — bucket: generated-docs
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY "generated-docs: authenticated upload"
|
||||
ON storage.objects
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (bucket_id = 'generated-docs');
|
||||
|
||||
CREATE POLICY "generated-docs: authenticated read"
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'generated-docs');
|
||||
|
||||
CREATE POLICY "generated-docs: authenticated delete"
|
||||
ON storage.objects
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'generated-docs');
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,275 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260417000001_dev_tables
|
||||
-- Área de Desenvolvimento (dev_*) — roadmap, auditoria, concorrentes, logs
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Tabelas usadas pela página /saas/desenvolvimento. Todas restritas a
|
||||
-- saas_admins via RLS (helper public.is_saas_admin()).
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Helper trigger: updated_at
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.dev_set_updated_at()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. dev_roadmap_phases — Fases (1, 2, 3...)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_roadmap_phases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
numero INTEGER NOT NULL UNIQUE,
|
||||
nome VARCHAR(160) NOT NULL,
|
||||
objetivo TEXT,
|
||||
timeline_sugerida VARCHAR(160),
|
||||
criterio_saida TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'planejada'
|
||||
CHECK (status IN ('planejada','em_andamento','concluida','arquivada')),
|
||||
data_inicio DATE,
|
||||
data_fim DATE,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_status ON public.dev_roadmap_phases(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases(ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_roadmap_phases_updated_at ON public.dev_roadmap_phases;
|
||||
CREATE TRIGGER trg_dev_roadmap_phases_updated_at
|
||||
BEFORE UPDATE ON public.dev_roadmap_phases
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. dev_roadmap_items — Itens das fases
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_roadmap_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phase_id BIGINT NOT NULL REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE,
|
||||
numero INTEGER,
|
||||
bloco VARCHAR(160),
|
||||
feature TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
esforco VARCHAR(4)
|
||||
CHECK (esforco IS NULL OR esforco IN ('S','M','L','XL')),
|
||||
prioridade VARCHAR(20)
|
||||
CHECK (prioridade IS NULL OR prioridade IN ('bloqueador','alta','media','diferencial')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
|
||||
CHECK (status IN ('pendente','em_andamento','concluido','cancelado','bloqueado')),
|
||||
notas TEXT,
|
||||
assignee VARCHAR(120),
|
||||
data_inicio DATE,
|
||||
data_conclusao DATE,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_phase ON public.dev_roadmap_items(phase_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_status ON public.dev_roadmap_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_prior ON public.dev_roadmap_items(prioridade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_ordem ON public.dev_roadmap_items(phase_id, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_roadmap_items_updated_at ON public.dev_roadmap_items;
|
||||
CREATE TRIGGER trg_dev_roadmap_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_roadmap_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. dev_auditoria_items — Bugs / débitos técnicos / decisões
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_auditoria_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
categoria VARCHAR(120),
|
||||
titulo TEXT NOT NULL,
|
||||
descricao_problema TEXT,
|
||||
solucao TEXT,
|
||||
severidade VARCHAR(20)
|
||||
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'aberto'
|
||||
CHECK (status IN ('aberto','em_analise','resolvido','wontfix','duplicado')),
|
||||
resolvido_em DATE,
|
||||
sessao_resolucao VARCHAR(160),
|
||||
arquivo_afetado TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_status ON public.dev_auditoria_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_severidade ON public.dev_auditoria_items(severidade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_categoria ON public.dev_auditoria_items(categoria);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_auditoria_items_updated_at ON public.dev_auditoria_items;
|
||||
CREATE TRIGGER trg_dev_auditoria_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_auditoria_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. dev_competitors — Concorrentes
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_competitors (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
slug VARCHAR(80) NOT NULL UNIQUE,
|
||||
nome VARCHAR(160) NOT NULL,
|
||||
pais VARCHAR(40),
|
||||
foco VARCHAR(160),
|
||||
pricing TEXT,
|
||||
posicionamento TEXT,
|
||||
url TEXT,
|
||||
ultima_pesquisa DATE,
|
||||
notas TEXT,
|
||||
ativo BOOLEAN NOT NULL DEFAULT true,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitors_ativo ON public.dev_competitors(ativo);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitors_pais ON public.dev_competitors(pais);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_competitors_updated_at ON public.dev_competitors;
|
||||
CREATE TRIGGER trg_dev_competitors_updated_at
|
||||
BEFORE UPDATE ON public.dev_competitors
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. dev_competitor_features — features de cada concorrente
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_competitor_features (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
|
||||
categoria VARCHAR(120),
|
||||
nome TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
fonte VARCHAR(20) NOT NULL DEFAULT 'publico'
|
||||
CHECK (fonte IN ('fetched','observacao','publico','hipotese')),
|
||||
fonte_url TEXT,
|
||||
data_fonte DATE,
|
||||
destaque BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_comp ON public.dev_competitor_features(competitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_cat ON public.dev_competitor_features(categoria);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_destaque ON public.dev_competitor_features(destaque);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_competitor_features_updated_at ON public.dev_competitor_features;
|
||||
CREATE TRIGGER trg_dev_competitor_features_updated_at
|
||||
BEFORE UPDATE ON public.dev_competitor_features
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. dev_comparison_matrix — AgenciaPsi × features-de-concorrente
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_comparison_matrix (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dominio VARCHAR(120),
|
||||
feature TEXT NOT NULL,
|
||||
nosso_status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
|
||||
CHECK (nosso_status IN ('tem','parcial','gap','na','a_definir')),
|
||||
nossa_nota TEXT,
|
||||
importancia VARCHAR(20)
|
||||
CHECK (importancia IS NULL OR importancia IN ('alta','media','baixa')),
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix(dominio);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_status ON public.dev_comparison_matrix(nosso_status);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_comparison_matrix_updated_at ON public.dev_comparison_matrix;
|
||||
CREATE TRIGGER trg_dev_comparison_matrix_updated_at
|
||||
BEFORE UPDATE ON public.dev_comparison_matrix
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- dev_comparison_competitor_status — opcional: status por concorrente por feature
|
||||
-- (se quisermos marcar que competitor X tem feature Y). Tabela ponte N-N.
|
||||
CREATE TABLE IF NOT EXISTS public.dev_comparison_competitor_status (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
comparison_id BIGINT NOT NULL REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE,
|
||||
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
|
||||
CHECK (status IN ('tem','parcial','gap','na','a_definir')),
|
||||
nota TEXT,
|
||||
fonte VARCHAR(20)
|
||||
CHECK (fonte IS NULL OR fonte IN ('fetched','observacao','publico','hipotese')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (comparison_id, competitor_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comp ON public.dev_comparison_competitor_status(competitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comparison ON public.dev_comparison_competitor_status(comparison_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_ccs_updated_at ON public.dev_comparison_competitor_status;
|
||||
CREATE TRIGGER trg_dev_ccs_updated_at
|
||||
BEFORE UPDATE ON public.dev_comparison_competitor_status
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. dev_generation_log — histórico de execuções (backup, dashboard, export...)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_generation_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tipo VARCHAR(40) NOT NULL,
|
||||
comando TEXT,
|
||||
sucesso BOOLEAN NOT NULL DEFAULT false,
|
||||
stdout TEXT,
|
||||
stderr TEXT,
|
||||
duration_ms INTEGER,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
trigger_user_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_tipo ON public.dev_generation_log(tipo);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_created ON public.dev_generation_log(created_at DESC);
|
||||
|
||||
-- =============================================================================
|
||||
-- RLS — tudo restrito a saas_admins (helper existente: public.is_saas_admin())
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
t TEXT;
|
||||
dev_tables TEXT[] := ARRAY[
|
||||
'dev_roadmap_phases',
|
||||
'dev_roadmap_items',
|
||||
'dev_auditoria_items',
|
||||
'dev_competitors',
|
||||
'dev_competitor_features',
|
||||
'dev_comparison_matrix',
|
||||
'dev_comparison_competitor_status',
|
||||
'dev_generation_log'
|
||||
];
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY dev_tables
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY;', t);
|
||||
|
||||
-- Drop policy se existir (idempotente)
|
||||
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I;', t || '_saas_admin_all', t);
|
||||
|
||||
-- Cria policy que permite tudo pra saas_admin
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON public.%I FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());',
|
||||
t || '_saas_admin_all',
|
||||
t
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Comentários
|
||||
-- =============================================================================
|
||||
COMMENT ON TABLE public.dev_roadmap_phases IS 'Fases do roadmap (MVP, Paridade, Diferenciação). Visível só pra saas_admins.';
|
||||
COMMENT ON TABLE public.dev_roadmap_items IS 'Itens de cada fase do roadmap.';
|
||||
COMMENT ON TABLE public.dev_auditoria_items IS 'Bugs, dívidas técnicas e decisões arquiteturais.';
|
||||
COMMENT ON TABLE public.dev_competitors IS 'Concorrentes analisados no benchmark.';
|
||||
COMMENT ON TABLE public.dev_competitor_features IS 'Features catalogadas de cada concorrente.';
|
||||
COMMENT ON TABLE public.dev_comparison_matrix IS 'Matriz de comparação AgenciaPsi × features esperadas do mercado.';
|
||||
COMMENT ON TABLE public.dev_comparison_competitor_status IS 'Qual concorrente tem qual feature (ponte N-N com matrix).';
|
||||
COMMENT ON TABLE public.dev_generation_log IS 'Histórico de execuções (backup, dashboard, export, seed, etc).';
|
||||
@@ -0,0 +1,48 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260417000002_dev_tables_ordem
|
||||
-- Adiciona coluna `ordem` em dev_auditoria_items e dev_competitor_features
|
||||
-- (pra suportar reordenação por drag-and-drop na UI).
|
||||
-- =============================================================================
|
||||
|
||||
-- dev_auditoria_items
|
||||
ALTER TABLE public.dev_auditoria_items
|
||||
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_ordem ON public.dev_auditoria_items(ordem);
|
||||
|
||||
-- Popular ordem existente (status + id pra evitar colisão)
|
||||
UPDATE public.dev_auditoria_items SET ordem = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'aberto' THEN 1
|
||||
WHEN 'em_analise' THEN 2
|
||||
WHEN 'resolvido' THEN 3
|
||||
WHEN 'wontfix' THEN 4
|
||||
WHEN 'duplicado' THEN 5
|
||||
ELSE 6
|
||||
END,
|
||||
id
|
||||
) AS rn
|
||||
FROM public.dev_auditoria_items
|
||||
) sub
|
||||
WHERE public.dev_auditoria_items.id = sub.id;
|
||||
|
||||
-- dev_competitor_features
|
||||
ALTER TABLE public.dev_competitor_features
|
||||
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_ordem
|
||||
ON public.dev_competitor_features(competitor_id, ordem);
|
||||
|
||||
-- Popular ordem existente (por competitor + categoria + id)
|
||||
UPDATE public.dev_competitor_features SET ordem = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY competitor_id
|
||||
ORDER BY COALESCE(categoria, 'zzz'), id
|
||||
) AS rn
|
||||
FROM public.dev_competitor_features
|
||||
) sub
|
||||
WHERE public.dev_competitor_features.id = sub.id;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000001_dev_verificacoes
|
||||
-- Nova aba "Verificações" em /saas/desenvolvimento
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Diferente de dev_auditoria_items (bugs conhecidos), esta tabela registra o
|
||||
-- PROCESSO de revisão sênior sessão-a-sessão: o que já foi olhado, o que falta
|
||||
-- olhar, o que foi encontrado em cada área do sistema.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.dev_verificacoes_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area VARCHAR(80) NOT NULL,
|
||||
categoria VARCHAR(120),
|
||||
titulo TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
resultado TEXT,
|
||||
acao_sugerida TEXT,
|
||||
severidade VARCHAR(20)
|
||||
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
|
||||
CHECK (status IN ('pendente','verificando','ok','problema','corrigido','wontfix')),
|
||||
verificado_em DATE,
|
||||
sessao_verificacao VARCHAR(160),
|
||||
arquivo_afetado TEXT,
|
||||
auditoria_item_id BIGINT REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_area ON public.dev_verificacoes_items(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_status ON public.dev_verificacoes_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_severidade ON public.dev_verificacoes_items(severidade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_ordem ON public.dev_verificacoes_items(area, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_verificacoes_updated_at ON public.dev_verificacoes_items;
|
||||
CREATE TRIGGER trg_dev_verificacoes_updated_at
|
||||
BEFORE UPDATE ON public.dev_verificacoes_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items;
|
||||
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.dev_verificacoes_items IS 'Revisão sênior por área/sessão — o que foi verificado e o que foi encontrado.';
|
||||
COMMENT ON COLUMN public.dev_verificacoes_items.area IS 'Domínio revisado: auth, router, agenda, financeiro, pacientes, comunicacao, etc.';
|
||||
COMMENT ON COLUMN public.dev_verificacoes_items.auditoria_item_id IS 'Link opcional: se a verificação virou um bug em dev_auditoria_items.';
|
||||
@@ -0,0 +1,403 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000002_patient_intake_security_hardening
|
||||
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
|
||||
-- avatars + storage policies.
|
||||
--
|
||||
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
|
||||
-- whitelist de valores, validação de token completa (active/expires/max_uses).
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. create_patient_intake_request_v2 — versão hardened
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Mudanças vs versão anterior:
|
||||
-- • A#16: valida active, expires_at, max_uses; incrementa uses no final
|
||||
-- • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
|
||||
-- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
|
||||
-- • A#27: length checks em TODOS os campos texto
|
||||
-- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
|
||||
-- lower em emails, whitelist para genero/estado_civil
|
||||
-- • Consent obrigatório (raise se false)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
|
||||
p_token text,
|
||||
p_payload jsonb
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_active boolean;
|
||||
v_expires timestamptz;
|
||||
v_max_uses int;
|
||||
v_uses int;
|
||||
v_intake_id uuid;
|
||||
v_birth_raw text;
|
||||
v_birth date;
|
||||
v_email text;
|
||||
v_email_alt text;
|
||||
v_nome text;
|
||||
v_consent boolean;
|
||||
v_genero text;
|
||||
v_estado_civil text;
|
||||
|
||||
-- Whitelists para campos tipados
|
||||
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
|
||||
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
|
||||
BEGIN
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Carrega invite e valida TUDO (A#16)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
|
||||
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
|
||||
FROM public.patient_invites
|
||||
WHERE token = p_token
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_active IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_expires IS NOT NULL AND now() > v_expires THEN
|
||||
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
|
||||
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Resolver tenant_id (A#19)
|
||||
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_tenant_id IS NULL THEN
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_owner_id
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização + validações de campos (A#27)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Nome obrigatório (max 200)
|
||||
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
|
||||
IF v_nome IS NULL THEN
|
||||
RAISE EXCEPTION 'Nome é obrigatório';
|
||||
END IF;
|
||||
IF length(v_nome) > 200 THEN
|
||||
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
|
||||
END IF;
|
||||
|
||||
-- Email principal obrigatório + lower + max 120
|
||||
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
|
||||
IF v_email IS NULL THEN
|
||||
RAISE EXCEPTION 'E-mail é obrigatório';
|
||||
END IF;
|
||||
IF length(v_email) > 120 THEN
|
||||
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
|
||||
END IF;
|
||||
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
RAISE EXCEPTION 'E-mail inválido';
|
||||
END IF;
|
||||
|
||||
-- Email alternativo opcional mas validado se presente
|
||||
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
|
||||
IF v_email_alt IS NOT NULL THEN
|
||||
IF length(v_email_alt) > 120 THEN
|
||||
RAISE EXCEPTION 'E-mail alternativo muito longo';
|
||||
END IF;
|
||||
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
RAISE EXCEPTION 'E-mail alternativo inválido';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Consent obrigatório
|
||||
v_consent := coalesce((p_payload->>'consent')::boolean, false);
|
||||
IF v_consent IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Consentimento é obrigatório';
|
||||
END IF;
|
||||
|
||||
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
|
||||
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
|
||||
v_birth := CASE
|
||||
WHEN v_birth_raw IS NULL THEN NULL
|
||||
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
|
||||
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
|
||||
ELSE NULL
|
||||
END;
|
||||
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
|
||||
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
|
||||
v_birth := NULL;
|
||||
END IF;
|
||||
|
||||
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
|
||||
v_genero := nullif(trim(p_payload->>'genero'), '');
|
||||
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
|
||||
v_genero := NULL;
|
||||
END IF;
|
||||
|
||||
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
|
||||
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
|
||||
v_estado_civil := NULL;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- INSERT com sanitização inline
|
||||
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
|
||||
-- do terapeuta, não deve vir do paciente.
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.patient_intake_requests (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
token,
|
||||
status,
|
||||
consent,
|
||||
|
||||
nome_completo,
|
||||
email_principal,
|
||||
email_alternativo,
|
||||
telefone,
|
||||
telefone_alternativo,
|
||||
|
||||
avatar_url,
|
||||
|
||||
data_nascimento,
|
||||
cpf,
|
||||
rg,
|
||||
genero,
|
||||
estado_civil,
|
||||
profissao,
|
||||
escolaridade,
|
||||
nacionalidade,
|
||||
naturalidade,
|
||||
|
||||
cep,
|
||||
pais,
|
||||
cidade,
|
||||
estado,
|
||||
endereco,
|
||||
numero,
|
||||
complemento,
|
||||
bairro,
|
||||
|
||||
observacoes,
|
||||
|
||||
encaminhado_por,
|
||||
onde_nos_conheceu
|
||||
)
|
||||
VALUES (
|
||||
v_owner_id,
|
||||
v_tenant_id,
|
||||
p_token,
|
||||
'new',
|
||||
v_consent,
|
||||
|
||||
v_nome,
|
||||
v_email,
|
||||
v_email_alt,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
|
||||
|
||||
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
|
||||
|
||||
v_birth,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'rg'), ''), 20),
|
||||
v_genero,
|
||||
v_estado_civil,
|
||||
left(nullif(trim(p_payload->>'profissao'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
|
||||
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
|
||||
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'pais'), ''), 60),
|
||||
left(nullif(trim(p_payload->>'cidade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'estado'), ''), 2),
|
||||
left(nullif(trim(p_payload->>'endereco'), ''), 200),
|
||||
left(nullif(trim(p_payload->>'numero'), ''), 20),
|
||||
left(nullif(trim(p_payload->>'complemento'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'bairro'), ''), 120),
|
||||
|
||||
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
|
||||
|
||||
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
|
||||
)
|
||||
RETURNING id INTO v_intake_id;
|
||||
|
||||
-- Incrementa contador de uso (A#16)
|
||||
UPDATE public.patient_invites
|
||||
SET uses = uses + 1
|
||||
WHERE token = p_token;
|
||||
|
||||
RETURN v_intake_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) IS
|
||||
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas (campo interno); exige consent=true.';
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
|
||||
-- Novo: gera gen_random_uuid() server-side e retorna.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_tenant_id uuid;
|
||||
v_new_token text;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
|
||||
v_new_token := replace(gen_random_uuid()::text, '-', '');
|
||||
|
||||
-- Resolve tenant_id do usuário (active)
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_uid
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- Desativa tokens ativos anteriores
|
||||
UPDATE public.patient_invites
|
||||
SET active = false
|
||||
WHERE owner_id = v_uid
|
||||
AND active = true;
|
||||
|
||||
-- Insere novo
|
||||
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
|
||||
VALUES (v_uid, v_tenant_id, v_new_token, true);
|
||||
|
||||
RETURN v_new_token;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
|
||||
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Substitui o client-side newToken() + direct insert em patient_invites.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_tenant_id uuid;
|
||||
v_token text;
|
||||
v_existing text;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
|
||||
SELECT token
|
||||
INTO v_existing
|
||||
FROM public.patient_invites
|
||||
WHERE owner_id = v_uid
|
||||
AND active = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN v_existing;
|
||||
END IF;
|
||||
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_uid
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
v_token := replace(gen_random_uuid()::text, '-', '');
|
||||
|
||||
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
|
||||
VALUES (v_uid, v_tenant_id, v_token, true);
|
||||
|
||||
RETURN v_token;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.issue_patient_invite() IS
|
||||
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
|
||||
-- -----------------------------------------------------------------------------
|
||||
UPDATE storage.buckets
|
||||
SET file_size_limit = 5242880, -- 5 MB
|
||||
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
|
||||
WHERE id = 'avatars';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 5. Storage policies — remover upload anon irrestrito (A#15)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
|
||||
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
|
||||
-- arquivo. Removemos essas policies. Upload público passa a exigir token
|
||||
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
|
||||
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;
|
||||
|
||||
-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
|
||||
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
|
||||
-- o terapeuta vê no painel de cadastros recebidos).
|
||||
CREATE POLICY "intake_read_owner_only"
|
||||
ON storage.objects FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'avatars'
|
||||
AND (storage.foldername(name))[1] = 'intakes'
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
|
||||
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';
|
||||
@@ -0,0 +1,280 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000003_patient_invite_attempts_log
|
||||
-- Resolve A#24: log de tentativas de submit no cadastro público externo.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Observação sobre IP: em RPC Postgres chamada via PostgREST o IP real do
|
||||
-- cliente não chega aqui (só o do connection pooler). Por isso o registro
|
||||
-- guarda o user_agent enviado pelo cliente (quando disponível) + metadados
|
||||
-- resolvidos (owner, tenant). Rate-limit real por IP deve ser feito em edge
|
||||
-- function no futuro (A#20).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_invite_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token text NOT NULL,
|
||||
ok boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
client_info text, -- user_agent enviado pelo cliente (cap 500 no INSERT)
|
||||
owner_id uuid, -- resolvido do token quando possível
|
||||
tenant_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_created ON public.patient_invite_attempts(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_token ON public.patient_invite_attempts(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_owner ON public.patient_invite_attempts(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_ok ON public.patient_invite_attempts(ok) WHERE ok = false;
|
||||
|
||||
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner vê suas próprias tentativas (qualquer flood/erro que envolveu seus links)
|
||||
DROP POLICY IF EXISTS patient_invite_attempts_owner_read ON public.patient_invite_attempts;
|
||||
CREATE POLICY patient_invite_attempts_owner_read
|
||||
ON public.patient_invite_attempts FOR SELECT
|
||||
TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.patient_invite_attempts IS
|
||||
'Log de tentativas (ok e falhas) de submit do form público de cadastro externo. Base para monitoramento de flood/tentativas maliciosas. Sem IP direto — proteção LGPD.';
|
||||
|
||||
COMMENT ON COLUMN public.patient_invite_attempts.client_info IS
|
||||
'User-agent enviado pelo cliente (opcional). Limitado a 500 chars no insert. Não contém PII.';
|
||||
|
||||
-- =============================================================================
|
||||
-- create_patient_intake_request_v2 — versão instrumentada
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Mesma função do hardening anterior, agora com log em patient_invite_attempts.
|
||||
-- O log é feito num bloco EXCEPTION que NUNCA propaga falha de log pro fluxo
|
||||
-- principal (log falhar jamais deve impedir o cadastro de ser aceito).
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
|
||||
p_token text,
|
||||
p_payload jsonb,
|
||||
p_client_info text DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_active boolean;
|
||||
v_expires timestamptz;
|
||||
v_max_uses int;
|
||||
v_uses int;
|
||||
v_intake_id uuid;
|
||||
v_birth_raw text;
|
||||
v_birth date;
|
||||
v_email text;
|
||||
v_email_alt text;
|
||||
v_nome text;
|
||||
v_consent boolean;
|
||||
v_genero text;
|
||||
v_estado_civil text;
|
||||
v_err_msg text;
|
||||
v_err_code text;
|
||||
v_clean_info text;
|
||||
|
||||
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
|
||||
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
|
||||
|
||||
-- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
|
||||
-- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
|
||||
BEGIN
|
||||
-- Sanitiza client_info recebido (cap + trim)
|
||||
v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Resolve invite + valida TUDO (A#16)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
|
||||
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
|
||||
FROM public.patient_invites
|
||||
WHERE token = p_token
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
v_err_code := 'TOKEN_INVALID';
|
||||
v_err_msg := 'Token inválido';
|
||||
-- Log + raise (owner_id NULL porque token não bateu)
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_active IS NOT TRUE THEN
|
||||
v_err_code := 'TOKEN_DISABLED';
|
||||
v_err_msg := 'Link desativado';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_expires IS NOT NULL AND now() > v_expires THEN
|
||||
v_err_code := 'TOKEN_EXPIRED';
|
||||
v_err_msg := 'Link expirado';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
|
||||
v_err_code := 'TOKEN_MAX_USES';
|
||||
v_err_msg := 'Limite de uso atingido';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Resolve tenant_id se invite não tiver (A#19)
|
||||
IF v_tenant_id IS NULL THEN
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_owner_id
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização + validações de campos (A#27)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
|
||||
IF v_nome IS NULL THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF length(v_nome) > 200 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
|
||||
IF v_email IS NULL THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF length(v_email) > 120 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
|
||||
IF v_email_alt IS NOT NULL THEN
|
||||
IF length(v_email_alt) > 120 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_consent := coalesce((p_payload->>'consent')::boolean, false);
|
||||
IF v_consent IS NOT TRUE THEN
|
||||
v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
|
||||
v_birth := CASE
|
||||
WHEN v_birth_raw IS NULL THEN NULL
|
||||
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
|
||||
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
|
||||
ELSE NULL
|
||||
END;
|
||||
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
|
||||
v_birth := NULL;
|
||||
END IF;
|
||||
|
||||
v_genero := nullif(trim(p_payload->>'genero'), '');
|
||||
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
|
||||
v_genero := NULL;
|
||||
END IF;
|
||||
|
||||
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
|
||||
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
|
||||
v_estado_civil := NULL;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- INSERT
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.patient_intake_requests (
|
||||
owner_id, tenant_id, token, status, consent,
|
||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||
avatar_url,
|
||||
data_nascimento, cpf, rg, genero, estado_civil,
|
||||
profissao, escolaridade, nacionalidade, naturalidade,
|
||||
cep, pais, cidade, estado, endereco, numero, complemento, bairro,
|
||||
observacoes, encaminhado_por, onde_nos_conheceu
|
||||
)
|
||||
VALUES (
|
||||
v_owner_id, v_tenant_id, p_token, 'new', v_consent,
|
||||
v_nome, v_email, v_email_alt,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
|
||||
v_birth,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'rg'), ''), 20),
|
||||
v_genero, v_estado_civil,
|
||||
left(nullif(trim(p_payload->>'profissao'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
|
||||
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'pais'), ''), 60),
|
||||
left(nullif(trim(p_payload->>'cidade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'estado'), ''), 2),
|
||||
left(nullif(trim(p_payload->>'endereco'), ''), 200),
|
||||
left(nullif(trim(p_payload->>'numero'), ''), 20),
|
||||
left(nullif(trim(p_payload->>'complemento'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'bairro'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
|
||||
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
|
||||
)
|
||||
RETURNING id INTO v_intake_id;
|
||||
|
||||
UPDATE public.patient_invites
|
||||
SET uses = uses + 1
|
||||
WHERE token = p_token;
|
||||
|
||||
-- Log de sucesso (best-effort, não propaga erro)
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
|
||||
RETURN v_intake_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) IS
|
||||
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas; exige consent=true; registra cada tentativa em patient_invite_attempts (A#24).';
|
||||
@@ -0,0 +1,149 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000004_dev_tests
|
||||
-- Nova aba "Testes" em /saas/desenvolvimento — catálogo de suítes de teste.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Espelha a estrutura de dev_verificacoes_items. Uma linha = uma suíte de
|
||||
-- teste (arquivo .spec.js ou grupo de testes). Serve para responder "quais
|
||||
-- áreas estão cobertas por teste?" sem rodar npm test.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.dev_test_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area VARCHAR(80) NOT NULL,
|
||||
categoria VARCHAR(120), -- unit, integration, e2e, manual
|
||||
titulo TEXT NOT NULL,
|
||||
arquivo TEXT,
|
||||
descricao TEXT,
|
||||
total_tests INTEGER DEFAULT 0,
|
||||
passing INTEGER DEFAULT 0,
|
||||
failing INTEGER DEFAULT 0,
|
||||
skipped INTEGER DEFAULT 0,
|
||||
cobertura_pct NUMERIC(5,2), -- cobertura estimada daquela área
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ok'
|
||||
CHECK (status IN ('ok','falhando','pendente','obsoleto','a_escrever')),
|
||||
last_run_at TIMESTAMPTZ,
|
||||
sessao_criacao VARCHAR(160),
|
||||
notas TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_area ON public.dev_test_items(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_status ON public.dev_test_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_ordem ON public.dev_test_items(area, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_test_items_updated_at ON public.dev_test_items;
|
||||
CREATE TRIGGER trg_dev_test_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_test_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS dev_test_items_saas_admin_all ON public.dev_test_items;
|
||||
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.dev_test_items IS
|
||||
'Catálogo de suítes de teste por área. Responde "o que está testado?" sem precisar rodar npm test.';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- Seed inicial — testes existentes em 2026-04-18
|
||||
-- =============================================================================
|
||||
INSERT INTO public.dev_test_items
|
||||
(area, categoria, titulo, arquivo, descricao, total_tests, passing, failing, skipped, cobertura_pct, status, last_run_at, sessao_criacao, notas, tags, ordem)
|
||||
VALUES
|
||||
('agenda', 'unit',
|
||||
'useRecurrence — geração de ocorrências',
|
||||
'src/features/agenda/composables/__tests__/useRecurrence.spec.js',
|
||||
$$Cobre: generateDates (weekly, biweekly, custom_weekdays, monthly, yearly), expandRules com exceções (cancel_session, patient_missed, reschedule_session, holiday_block), mergeWithStoredSessions, max_occurrences, range boundaries, remarcação inbound.$$,
|
||||
23, 23, 0, 0, NULL,
|
||||
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
|
||||
'Suite sólida. Cobre os branches críticos da expansão de recorrência. Testes sobreviveram à adição do cap de range (V#20) e ao filtro de tenant_id nas CRUDs (V#12).',
|
||||
ARRAY['unit','agenda','recurrence','critical'], 1),
|
||||
|
||||
('agenda', 'unit',
|
||||
'agendaMappers — transformação pra FullCalendar',
|
||||
'src/features/agenda/services/__tests__/agendaMappers.spec.js',
|
||||
$$Cobre: mapAgendaEventosToCalendarEvents (shape, campos extras), status → cor + ícone (agendado, realizado, faltou, cancelado, remarcado), aliases de FK (patients, determined_commitments), tipo fallback, ocorrência virtual (is_occurrence), resource events (clinic mosaic).$$,
|
||||
40, 40, 0, 0, NULL,
|
||||
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
|
||||
'Quatro testes estavam falhando antes do V#21 (status "remarcado" vs "remarcar" + cores faltou/cancelado invertidas). Agora 100%.',
|
||||
ARRAY['unit','agenda','mappers'], 2),
|
||||
|
||||
('auth', 'a_escrever',
|
||||
'guards.js — branches do router beforeEach',
|
||||
'src/router/__tests__/guards.spec.js (não existe)',
|
||||
$$Deveria cobrir: rotas públicas liberadas, redirect pra /auth/login sem session, área /account sem tenant, saas_admin só em /saas, tenant lockdown, trocaTenantScope, matchesRoles com aliases, cache de globalRole, cache de saasAdmin.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'guards.js tem ~650 linhas e só roda via navegação real. Sem teste unitário → mudanças no guard são de alto risco. Prioridade média para criar (mock do router + pinia).',
|
||||
ARRAY['unit','auth','router','guard','missing'], 3),
|
||||
|
||||
('auth', 'a_escrever',
|
||||
'session.js — hydrate e race conditions',
|
||||
'src/app/__tests__/session.spec.js (não existe)',
|
||||
$$Deveria cobrir: initSession com/sem session, refreshSession que não dispara se refreshing, SIGNED_IN redundante ignorado, SIGNED_OUT zera state, TOKEN_REFRESHED não derruba cache, hydrate preserva user em erro.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'Módulo tem histórico de race conditions (comentado no próprio arquivo). Teste unitário daria garantia contra regressão.',
|
||||
ARRAY['unit','auth','session','race','missing'], 4),
|
||||
|
||||
('stores', 'a_escrever',
|
||||
'tenantStore — singleflight + persist',
|
||||
'src/stores/__tests__/tenantStore.spec.js (não existe)',
|
||||
$$Deveria cobrir: loadSessionAndTenant com Promise compartilhada (V#3), ensureLoaded sem setInterval, tenant salvo só se pertence ao user, normalizeTenantRole, reset, persistência em localStorage.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'V#3 trocou polling por Promise singleflight — a correção não tem teste que proteja contra regressão.',
|
||||
ARRAY['unit','store','tenant','missing'], 5),
|
||||
|
||||
('utils', 'a_escrever',
|
||||
'roleNormalizer — saídas esperadas',
|
||||
'src/utils/__tests__/roleNormalizer.spec.js (não existe)',
|
||||
$$Fácil de testar — função pura, sem IO. Cobre: tenant_admin+therapist→therapist, tenant_admin+clinic→clinic_admin, tenant_admin+supervisor→supervisor, tenant_admin sem kind→clinic_admin, clinic_admin→clinic_admin, pass-through.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'Criado em V#4. É função pura — fácil de cobrir em 10min. Baixa prioridade técnica mas alto valor simbólico (garantir que os 2 consumidores — guards.js e tenantStore.js — concordam).',
|
||||
ARRAY['unit','utils','trivial'], 6),
|
||||
|
||||
('pacientes', 'a_escrever',
|
||||
'Cadastros externos — fluxo do paciente',
|
||||
'src/features/patients/__tests__/external-intake.spec.js (não existe)',
|
||||
$$Deveria cobrir: validação client-side (token regex, email, consent), truncation em todos os campos, payload final, não envio de notas_internas, comportamento com token inválido.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Página pública é ponto crítico de segurança. Teste de regressão importante após A#17/A#18/A#21 — garantir que nenhum dos valores "perigosos" voltem a ser enviados.',
|
||||
ARRAY['unit','pacientes','external','security-regression'], 7),
|
||||
|
||||
('database', 'manual',
|
||||
'RPCs de intake — validação de inputs maliciosos',
|
||||
'database-novo/tests/test_patient_intake_security.sql (sugerido)',
|
||||
$$Deveria cobrir: token inválido raise, token desativado raise (A#16), token expirado raise, max_uses raise, uses incrementa após sucesso, consent=false raise, payload com notas_internas é ignorado (A#17), tenant_id é preenchido (A#19), nome > 200 chars raise, email inválido raise, genero fora whitelist vira NULL, data_nascimento futura vira NULL.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Testes SQL diretos via psql. Importantes porque as validações estão dentro do RPC SECURITY DEFINER. Executar antes de cada deploy.',
|
||||
ARRAY['manual','sql','security','rpc'], 8),
|
||||
|
||||
('agenda', 'a_escrever',
|
||||
'useAgendaEvents — wrapper do repository',
|
||||
'src/features/agenda/composables/__tests__/useAgendaEvents.spec.js (não existe)',
|
||||
$$Deveria cobrir: loadMyRange chama listMyAgendaEvents, estado loading/error transições, sem ownerId retorna cedo, rollback em erro.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 2 — agenda',
|
||||
'Após refactor V#14 o composable virou fino. Teste garante que continue fino.',
|
||||
ARRAY['unit','agenda','composable','missing'], 9),
|
||||
|
||||
('e2e', 'a_escrever',
|
||||
'Fluxo completo: terapeuta cria link → paciente preenche → terapeuta vê',
|
||||
'(não existe)',
|
||||
$$Deveria cobrir o happy path integrado: login terapeuta, gera link via issue_patient_invite, abre /cadastro/paciente em aba anônima, preenche, submit, terapeuta vê em /therapist/patients/recebidos.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Não há E2E hoje. Playwright ou Cypress valem? Decidir provider. Alta prioridade pra confiança em deploy.',
|
||||
ARRAY['e2e','critical','missing','decisão-pendente'], 10);
|
||||
|
||||
SELECT id, area, categoria, status, total_tests, passing FROM public.dev_test_items ORDER BY ordem;
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000005_saas_rls_emergency_fix
|
||||
-- Corrige A#30 (P0) — 7 tabelas SaaS estavam com RLS desabilitado + grants
|
||||
-- totais pra anon/authenticated/service_role. Qualquer usuário anônimo
|
||||
-- podia alterar/deletar dados críticos (tenant_features, plan_prices,
|
||||
-- subscription_intents_personal/tenant, plan_public, ...).
|
||||
--
|
||||
-- Estratégia:
|
||||
-- 1. Habilitar RLS em todas as 7 tabelas
|
||||
-- 2. REVOKE ALL de anon (nunca deveria ter tido)
|
||||
-- 3. REVOKE ALL de authenticated (controle passa a ser via policy)
|
||||
-- 4. Policies explícitas por caso de uso
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. REVOKE grants inseguros
|
||||
-- -----------------------------------------------------------------------------
|
||||
REVOKE ALL ON public.tenant_features FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_prices FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_public FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_public_bullets FROM anon, authenticated;
|
||||
REVOKE ALL ON public.subscription_intents_personal FROM anon, authenticated;
|
||||
REVOKE ALL ON public.subscription_intents_tenant FROM anon, authenticated;
|
||||
REVOKE ALL ON public.tenant_feature_exceptions_log FROM anon, authenticated;
|
||||
|
||||
-- Concede o mínimo necessário (controlado por RLS abaixo)
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_features TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_prices TO authenticated;
|
||||
GRANT SELECT ON public.plan_public TO anon, authenticated;
|
||||
GRANT INSERT, UPDATE, DELETE ON public.plan_public TO authenticated;
|
||||
GRANT SELECT ON public.plan_public_bullets TO anon, authenticated;
|
||||
GRANT INSERT, UPDATE, DELETE ON public.plan_public_bullets TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_personal TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_tenant TO authenticated;
|
||||
GRANT SELECT ON public.tenant_feature_exceptions_log TO authenticated;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. HABILITAR RLS em todas
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. POLICIES — tenant_features
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- SELECT: membros do tenant leem as features do próprio tenant. Saas admin lê tudo.
|
||||
DROP POLICY IF EXISTS tenant_features_select ON public.tenant_features;
|
||||
CREATE POLICY tenant_features_select ON public.tenant_features
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
|
||||
);
|
||||
|
||||
-- WRITE: apenas tenant_admin do próprio tenant OU saas_admin.
|
||||
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
|
||||
CREATE POLICY tenant_features_write ON public.tenant_features
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. POLICIES — plan_prices (SaaS admin only pra escrita; authenticated lê)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS plan_prices_read ON public.plan_prices;
|
||||
CREATE POLICY plan_prices_read ON public.plan_prices
|
||||
FOR SELECT TO authenticated
|
||||
USING (true); -- preços são públicos pra usuários logados
|
||||
|
||||
DROP POLICY IF EXISTS plan_prices_write ON public.plan_prices;
|
||||
CREATE POLICY plan_prices_write ON public.plan_prices
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 5. POLICIES — plan_public + plan_public_bullets (anon pode ler — landing page)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS plan_public_read_anon ON public.plan_public;
|
||||
CREATE POLICY plan_public_read_anon ON public.plan_public
|
||||
FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_write ON public.plan_public;
|
||||
CREATE POLICY plan_public_write ON public.plan_public
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_bullets_read_anon ON public.plan_public_bullets;
|
||||
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets
|
||||
FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_bullets_write ON public.plan_public_bullets;
|
||||
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 6. POLICIES — subscription_intents_personal + _tenant
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Dono vê o próprio intent; saas admin vê tudo; owner cria/atualiza seus próprios.
|
||||
DROP POLICY IF EXISTS subscription_intents_personal_owner ON public.subscription_intents_personal;
|
||||
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal
|
||||
FOR ALL TO authenticated
|
||||
USING (user_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (user_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS subscription_intents_tenant_member ON public.subscription_intents_tenant;
|
||||
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 7. POLICY — tenant_feature_exceptions_log (somente leitura)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Log de auditoria. Inserts vêm de triggers/funções server-side (SECURITY DEFINER).
|
||||
DROP POLICY IF EXISTS tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log;
|
||||
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.tenant_features IS
|
||||
'Controle de features por tenant. RLS: member do tenant lê; tenant_admin ou saas_admin escreve. Antes da migration 20260418000005 estava com RLS off + GRANT ALL pra anon (A#30).';
|
||||
@@ -0,0 +1,214 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000001_tenant_features_b2_governance
|
||||
-- Resolve V#34 (isEnabled opt-out por padrão) + V#41 (dupla fonte entitlements
|
||||
-- vs tenant_features) — Opção B2 (plano + override com exceção comercial).
|
||||
--
|
||||
-- Mudanças:
|
||||
-- 1. Trigger tenant_features_guard_with_plan ganha bypass via session flag
|
||||
-- (current_setting('app.allow_feature_exception')) — só RPC pode setar.
|
||||
-- 2. Nova RPC set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)
|
||||
-- SECURITY DEFINER, com regras assimétricas:
|
||||
-- - p_enabled=false → tenant_admin OU saas_admin (preferência)
|
||||
-- - p_enabled=true AND plano permite → tenant_admin OU saas_admin
|
||||
-- - p_enabled=true AND plano NÃO permite → SOMENTE saas_admin + reason obrigatório
|
||||
-- Toda mudança grava em tenant_feature_exceptions_log.
|
||||
-- 3. Policy tenant_features_write restringida a saas_admin (writes diretos).
|
||||
-- Tenant_admin agora muda só via RPC.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Trigger: bypass controlado por session flag
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.tenant_features_guard_with_plan()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_allowed boolean;
|
||||
v_bypass text;
|
||||
BEGIN
|
||||
-- Só valida quando está habilitando
|
||||
IF new.enabled IS DISTINCT FROM true THEN
|
||||
RETURN new;
|
||||
END IF;
|
||||
|
||||
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
|
||||
-- após validar que o caller é saas_admin com reason.
|
||||
v_bypass := current_setting('app.allow_feature_exception', true);
|
||||
IF v_bypass = 'true' THEN
|
||||
RETURN new;
|
||||
END IF;
|
||||
|
||||
-- Permitido pelo plano do tenant?
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.v_tenant_entitlements_full v
|
||||
WHERE v.tenant_id = new.tenant_id
|
||||
AND v.feature_key = new.feature_key
|
||||
AND v.allowed = true
|
||||
) INTO v_allowed;
|
||||
|
||||
IF NOT v_allowed THEN
|
||||
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
|
||||
new.feature_key, new.tenant_id
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. RPC set_tenant_feature_exception
|
||||
-- (substitui versão anterior que retornava void; retorna jsonb agora)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP FUNCTION IF EXISTS public.set_tenant_feature_exception(uuid, text, boolean, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_tenant_feature_exception(
|
||||
p_tenant_id uuid,
|
||||
p_feature_key text,
|
||||
p_enabled boolean,
|
||||
p_reason text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller uuid := auth.uid();
|
||||
v_is_saas boolean := public.is_saas_admin();
|
||||
v_is_tenant_adm boolean;
|
||||
v_plan_allows boolean;
|
||||
v_feature_key text;
|
||||
v_reason text;
|
||||
v_is_exception boolean;
|
||||
BEGIN
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização (padrão V#31)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
IF p_enabled IS NULL THEN
|
||||
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
|
||||
IF v_feature_key IS NULL THEN
|
||||
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF length(v_feature_key) > 80 THEN
|
||||
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
|
||||
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
|
||||
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
|
||||
v_reason := substring(v_reason FROM 1 FOR 500);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
|
||||
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Plano permite essa feature?
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.v_tenant_entitlements vte
|
||||
WHERE vte.tenant_id = p_tenant_id
|
||||
AND vte.feature_key = v_feature_key
|
||||
) INTO v_plan_allows;
|
||||
|
||||
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Caller é tenant_admin desse tenant?
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
v_is_tenant_adm := EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = p_tenant_id
|
||||
AND tm.user_id = v_caller
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Autorização (assimétrica — V#34 Opção B2)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_is_exception THEN
|
||||
-- Override positivo fora do plano = exceção comercial
|
||||
IF NOT v_is_saas THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF v_reason IS NULL THEN
|
||||
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
ELSE
|
||||
-- Demais casos: tenant_admin OR saas_admin
|
||||
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
|
||||
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Persistência: bypass controlado do trigger guard quando é exceção
|
||||
-- (escopo de transação via SET LOCAL — só esta RPC vê)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_is_exception THEN
|
||||
PERFORM set_config('app.allow_feature_exception', 'true', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
|
||||
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
|
||||
ON CONFLICT (tenant_id, feature_key)
|
||||
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
|
||||
|
||||
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
|
||||
IF v_is_exception THEN
|
||||
PERFORM set_config('app.allow_feature_exception', 'false', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.tenant_feature_exceptions_log
|
||||
(tenant_id, feature_key, enabled, reason, created_by)
|
||||
VALUES
|
||||
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'tenant_id', p_tenant_id,
|
||||
'feature_key', v_feature_key,
|
||||
'enabled', p_enabled,
|
||||
'plan_allows', v_plan_allows,
|
||||
'is_exception', v_is_exception,
|
||||
'reason', v_reason
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) TO authenticated;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Policy: writes diretos só via saas_admin
|
||||
-- (tenant_admin agora muda só via RPC set_tenant_feature_exception)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
|
||||
DROP POLICY IF EXISTS tenant_features_write_saas_only ON public.tenant_features;
|
||||
|
||||
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000002_features_is_active
|
||||
-- V#40 — features hard-deleted: adiciona is_active para soft-delete.
|
||||
--
|
||||
-- Estratégia conservadora:
|
||||
-- - features.is_active boolean DEFAULT true NOT NULL
|
||||
-- - SaasFeaturesPage substitui DELETE por UPDATE is_active=false
|
||||
-- - Views que expõem features para o app (v_tenant_entitlements etc) NÃO são
|
||||
-- alteradas: features depreciadas ainda servem tenants legados via plan_features
|
||||
-- enquanto não houver migração explícita
|
||||
-- - Permite reativar feature acidentalmente deprecada
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.features
|
||||
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_features_is_active
|
||||
ON public.features (is_active) WHERE is_active = false;
|
||||
|
||||
COMMENT ON COLUMN public.features.is_active IS
|
||||
'V#40: false = feature depreciada, escondida no catálogo SaaS mas continua válida em planos/tenants existentes.';
|
||||
@@ -0,0 +1,69 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000003_delete_plan_safe
|
||||
-- V#36 — DELETE de plans sem checagem de assinaturas ativas pode quebrar tenants.
|
||||
--
|
||||
-- Cria RPC delete_plan_safe(plan_id) que:
|
||||
-- - Valida saas_admin
|
||||
-- - Conta subscriptions ativas (status='active') no plano
|
||||
-- - Se houver, RAISE EXCEPTION descritivo com a contagem
|
||||
-- - Se OK, desativa prices ativos e deleta o plano (atomic)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_plan_safe(
|
||||
p_plan_id uuid
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_active_count int;
|
||||
v_plan_key text;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF p_plan_id IS NULL THEN
|
||||
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
|
||||
IF v_plan_key IS NULL THEN
|
||||
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_active_count
|
||||
FROM public.subscriptions
|
||||
WHERE plan_id = p_plan_id
|
||||
AND status = 'active';
|
||||
|
||||
IF v_active_count > 0 THEN
|
||||
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
|
||||
v_plan_key, v_active_count
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- desativa preços ativos antes de deletar
|
||||
UPDATE public.plan_prices
|
||||
SET is_active = false,
|
||||
active_to = now()
|
||||
WHERE plan_id = p_plan_id
|
||||
AND is_active = true;
|
||||
|
||||
DELETE FROM public.plans WHERE id = p_plan_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'plan_key', v_plan_key
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.delete_plan_safe(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.delete_plan_safe(uuid) TO authenticated;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000004_consolidate_policies
|
||||
-- V#35 — Consolida policies duplicadas em plans, features, plan_features e
|
||||
-- subscriptions. Remove legado redundante e documenta as que ficam.
|
||||
--
|
||||
-- Análise (auditada via pg_policies):
|
||||
-- • plans/features/plan_features: cada uma tem "read * (auth)" duplicado
|
||||
-- com "*_read_authenticated" (mesmo USING true). Removidos os legados.
|
||||
-- • subscriptions:
|
||||
-- - "subscriptions read own" (USING user_id = auth.uid()) é SUBSET de
|
||||
-- "subscriptions_read_own" (USING user_id = auth.uid() OR is_saas_admin())
|
||||
-- - "subscriptions_select_own_personal" (user_id = auth.uid() AND tenant_id IS NULL)
|
||||
-- é SUBSET de "subscriptions_read_own"
|
||||
-- - "subscriptions_no_direct_update" (USING false) é no-op em OR com
|
||||
-- "subscriptions_update_only_saas_admin"
|
||||
-- Removidas as 3 redundâncias.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Drops dos legados redundantes
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "read plans (auth)" ON public.plans;
|
||||
DROP POLICY IF EXISTS "read features (auth)" ON public.features;
|
||||
DROP POLICY IF EXISTS "read plan_features (auth)" ON public.plan_features;
|
||||
|
||||
DROP POLICY IF EXISTS "subscriptions read own" ON public.subscriptions;
|
||||
DROP POLICY IF EXISTS "subscriptions_select_own_personal" ON public.subscriptions;
|
||||
DROP POLICY IF EXISTS "subscriptions_no_direct_update" ON public.subscriptions;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- COMMENT ON POLICY — documenta escopo das que ficaram
|
||||
-- -----------------------------------------------------------------------------
|
||||
COMMENT ON POLICY plans_read_authenticated ON public.plans IS 'Qualquer usuário autenticado lê o catálogo de planos (vitrine, upgrade UI).';
|
||||
COMMENT ON POLICY plans_write_saas_admin ON public.plans IS 'Somente saas_admin escreve. DELETE deve ser via RPC delete_plan_safe (V#36).';
|
||||
|
||||
COMMENT ON POLICY features_read_authenticated ON public.features IS 'Qualquer logado lê o catálogo de features.';
|
||||
COMMENT ON POLICY features_write_saas_admin ON public.features IS 'Somente saas_admin escreve. DELETE = soft delete via is_active=false (V#40).';
|
||||
|
||||
COMMENT ON POLICY plan_features_read_authenticated ON public.plan_features IS 'Qualquer logado lê o vínculo plano↔feature (necessário para entitlements).';
|
||||
COMMENT ON POLICY plan_features_write_saas_admin ON public.plan_features IS 'Somente saas_admin escreve.';
|
||||
|
||||
COMMENT ON POLICY subscriptions_read_own ON public.subscriptions IS 'Dono da assinatura (user_id) ou saas_admin. Cobre o caso pessoal.';
|
||||
COMMENT ON POLICY subscriptions_select_for_tenant_members ON public.subscriptions IS 'Membros ativos do tenant leem assinaturas do tenant.';
|
||||
COMMENT ON POLICY "subscriptions: read if linked owner_users" ON public.subscriptions IS 'Caso especial: usuários ligados ao owner via owner_users (terapeutas de uma clínica que precisam ver a assinatura do owner).';
|
||||
COMMENT ON POLICY subscriptions_insert_own_personal ON public.subscriptions IS 'Usuário cria a própria assinatura pessoal (intent → conversion).';
|
||||
COMMENT ON POLICY subscriptions_update_only_saas_admin ON public.subscriptions IS 'UPDATE direto somente via saas_admin. Mudanças de tenant devem passar por RPC dedicada.';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000005_restrict_intake_rpc
|
||||
-- A#20 — Restringe create_patient_intake_request_v2 a service_role.
|
||||
--
|
||||
-- Antes: anon (e PUBLIC) podia chamar direto. Bot bypassava qualquer
|
||||
-- proteção do front (Turnstile etc).
|
||||
-- Agora: edge function `submit-patient-intake` valida CAPTCHA e chama
|
||||
-- a RPC com service_role. Anon não chama mais a RPC direto.
|
||||
-- =============================================================================
|
||||
|
||||
-- Revoga PUBLIC (DEFAULT) e anon
|
||||
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) FROM PUBLIC, anon;
|
||||
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) FROM PUBLIC, anon;
|
||||
|
||||
-- Mantém grants explícitos pra authenticated (uso interno futuro) e service_role (edge function)
|
||||
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) TO authenticated, service_role;
|
||||
|
||||
-- Mesma proteção para RPC v1 legada (caso ainda exista)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public' AND p.proname = 'create_patient_intake_request'
|
||||
) THEN
|
||||
EXECUTE 'REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) FROM PUBLIC, anon';
|
||||
EXECUTE 'GRANT EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) TO authenticated, service_role';
|
||||
END IF;
|
||||
END$$;
|
||||
@@ -0,0 +1,136 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000006_layered_bot_defense
|
||||
-- A#20 (rev2) — Defesa em camadas self-hosted (substitui Turnstile).
|
||||
--
|
||||
-- Camadas:
|
||||
-- 1. Honeypot field (no front) → invisível, sempre ativo
|
||||
-- 2. Rate limit por IP no edge → submission_rate_limits
|
||||
-- 3. Math captcha CONDICIONAL → só se IP teve N falhas recentes
|
||||
-- 4. Logging em public_submission_attempts (genérico, não só intake)
|
||||
-- 5. Modo paranoid global → saas_security_config.captcha_required
|
||||
--
|
||||
-- Substitui chamadas Turnstile na edge function submit-patient-intake.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. saas_security_config (singleton)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.saas_security_config (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
honeypot_enabled boolean NOT NULL DEFAULT true,
|
||||
rate_limit_enabled boolean NOT NULL DEFAULT true,
|
||||
rate_limit_window_min integer NOT NULL DEFAULT 10,
|
||||
rate_limit_max_attempts integer NOT NULL DEFAULT 5,
|
||||
captcha_after_failures integer NOT NULL DEFAULT 3,
|
||||
captcha_required_globally boolean NOT NULL DEFAULT false,
|
||||
block_duration_min integer NOT NULL DEFAULT 30,
|
||||
captcha_required_window_min integer NOT NULL DEFAULT 60,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_security_config_singleton CHECK (id = true)
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_security_config (id) VALUES (true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.saas_security_config FROM anon, authenticated;
|
||||
GRANT SELECT, UPDATE ON public.saas_security_config TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS saas_security_config_read ON public.saas_security_config;
|
||||
CREATE POLICY saas_security_config_read ON public.saas_security_config
|
||||
FOR SELECT TO authenticated
|
||||
USING (true); -- qualquer logado pode ler config global (não tem segredo)
|
||||
|
||||
DROP POLICY IF EXISTS saas_security_config_write ON public.saas_security_config;
|
||||
CREATE POLICY saas_security_config_write ON public.saas_security_config
|
||||
FOR UPDATE TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.saas_security_config IS 'Singleton: configuração global de defesa contra bots em endpoints públicos.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. public_submission_attempts (log genérico)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.public_submission_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
endpoint text NOT NULL,
|
||||
ip_hash text,
|
||||
success boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
blocked_by text, -- 'honeypot' | 'rate_limit' | 'captcha' | 'rpc' | null
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_endpoint_created ON public.public_submission_attempts (endpoint, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_ip_hash_created ON public.public_submission_attempts (ip_hash, created_at DESC) WHERE ip_hash IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_failed ON public.public_submission_attempts (created_at DESC) WHERE success = false;
|
||||
|
||||
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.public_submission_attempts FROM anon, authenticated;
|
||||
GRANT SELECT ON public.public_submission_attempts TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS psa_read_saas_admin ON public.public_submission_attempts;
|
||||
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.public_submission_attempts IS 'Log de tentativas em endpoints públicos (intake, signup, agendador). Escrita apenas via RPC SECURITY DEFINER.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. submission_rate_limits (estado vigente por IP+endpoint)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.submission_rate_limits (
|
||||
ip_hash text NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
fail_count integer NOT NULL DEFAULT 0,
|
||||
window_start timestamptz NOT NULL DEFAULT now(),
|
||||
blocked_until timestamptz,
|
||||
requires_captcha_until timestamptz,
|
||||
last_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (ip_hash, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_srl_blocked_until ON public.submission_rate_limits (blocked_until) WHERE blocked_until IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_srl_endpoint ON public.submission_rate_limits (endpoint, last_attempt_at DESC);
|
||||
|
||||
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.submission_rate_limits FROM anon, authenticated;
|
||||
GRANT SELECT ON public.submission_rate_limits TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS srl_read_saas_admin ON public.submission_rate_limits;
|
||||
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.submission_rate_limits IS 'Estado de rate limit por IP+endpoint. Escrita apenas via RPC. SaaS admin lê pra dashboard.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. math_challenges (TTL 5min, limpa via cron)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.math_challenges (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
question text NOT NULL,
|
||||
answer integer NOT NULL,
|
||||
used boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL DEFAULT (now() + interval '5 minutes')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mc_expires ON public.math_challenges (expires_at);
|
||||
|
||||
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.math_challenges FROM anon, authenticated;
|
||||
-- nenhum grant: tabela acessada apenas via RPC SECURITY DEFINER
|
||||
|
||||
COMMENT ON TABLE public.math_challenges IS 'Challenges de math captcha. TTL 5min. Escrita/leitura apenas via RPC.';
|
||||
@@ -0,0 +1,299 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000007_bot_defense_rpcs
|
||||
-- A#20 (rev2) — RPCs da defesa em camadas:
|
||||
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
|
||||
-- • record_submission_attempt — log + atualiza contadores e bloqueios
|
||||
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
|
||||
-- • verify_math_challenge — valida {id, answer}, marca used
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- check_rate_limit
|
||||
-- Lê config + estado atual, decide o que retornar.
|
||||
-- Se fora da janela atual, "rolha" os contadores (reset).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.check_rate_limit(
|
||||
p_ip_hash text,
|
||||
p_endpoint text
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_security_config%ROWTYPE;
|
||||
rl submission_rate_limits%ROWTYPE;
|
||||
v_now timestamptz := now();
|
||||
v_window_start timestamptz;
|
||||
v_in_window boolean;
|
||||
v_requires_captcha boolean := false;
|
||||
v_blocked_until timestamptz;
|
||||
v_retry_after_seconds integer := 0;
|
||||
BEGIN
|
||||
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
|
||||
IF NOT FOUND THEN
|
||||
-- Sem config: fail-open (libera). Logado.
|
||||
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
|
||||
END IF;
|
||||
|
||||
-- Modo paranoid global: sempre captcha
|
||||
IF cfg.captcha_required_globally THEN
|
||||
v_requires_captcha := true;
|
||||
END IF;
|
||||
|
||||
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
|
||||
IF NOT cfg.rate_limit_enabled THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Sem ip_hash: libera (não dá pra rastrear)
|
||||
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', 'no_ip'
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO rl
|
||||
FROM submission_rate_limits
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
-- Bloqueio temporário ativo?
|
||||
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
|
||||
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'requires_captcha', false,
|
||||
'retry_after_seconds', v_retry_after_seconds,
|
||||
'reason', 'blocked'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Captcha condicional ativo?
|
||||
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
|
||||
v_requires_captcha := true;
|
||||
END IF;
|
||||
|
||||
-- Janela atual ainda válida?
|
||||
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
|
||||
v_in_window := FOUND AND rl.window_start >= v_window_start;
|
||||
|
||||
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
|
||||
-- Excedeu — bloqueia
|
||||
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
|
||||
UPDATE submission_rate_limits
|
||||
SET blocked_until = v_blocked_until,
|
||||
last_attempt_at = v_now
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'requires_captcha', false,
|
||||
'retry_after_seconds', v_retry_after_seconds,
|
||||
'reason', 'rate_limit_exceeded'
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- record_submission_attempt
|
||||
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
|
||||
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
|
||||
-- requires_captcha_until = now + captcha_required_window_min.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
|
||||
p_endpoint text,
|
||||
p_ip_hash text,
|
||||
p_success boolean,
|
||||
p_blocked_by text DEFAULT NULL,
|
||||
p_error_code text DEFAULT NULL,
|
||||
p_error_msg text DEFAULT NULL,
|
||||
p_user_agent text DEFAULT NULL,
|
||||
p_metadata jsonb DEFAULT NULL
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_security_config%ROWTYPE;
|
||||
v_now timestamptz := now();
|
||||
v_window_start timestamptz;
|
||||
rl submission_rate_limits%ROWTYPE;
|
||||
BEGIN
|
||||
-- Log sempre (mesmo sem ip)
|
||||
INSERT INTO public_submission_attempts
|
||||
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
|
||||
VALUES
|
||||
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
|
||||
left(coalesce(p_error_code, ''), 80),
|
||||
left(coalesce(p_error_msg, ''), 500),
|
||||
left(coalesce(p_user_agent, ''), 500),
|
||||
p_metadata);
|
||||
|
||||
-- Sem ip ou rate limit desligado: não atualiza contador
|
||||
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
|
||||
|
||||
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
|
||||
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
|
||||
|
||||
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
|
||||
|
||||
SELECT * INTO rl
|
||||
FROM submission_rate_limits
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
INSERT INTO submission_rate_limits
|
||||
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
|
||||
VALUES
|
||||
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
|
||||
ELSE
|
||||
IF rl.window_start < v_window_start THEN
|
||||
-- Reset janela
|
||||
UPDATE submission_rate_limits
|
||||
SET attempt_count = 1,
|
||||
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
|
||||
window_start = v_now,
|
||||
last_attempt_at = v_now,
|
||||
blocked_until = NULL
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
ELSE
|
||||
UPDATE submission_rate_limits
|
||||
SET attempt_count = attempt_count + 1,
|
||||
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
|
||||
last_attempt_at = v_now
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
END IF;
|
||||
|
||||
-- Se atingiu threshold de captcha condicional, marca
|
||||
IF NOT p_success THEN
|
||||
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
IF rl.fail_count >= cfg.captcha_after_failures
|
||||
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
|
||||
UPDATE submission_rate_limits
|
||||
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- generate_math_challenge
|
||||
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
|
||||
-- Operações: + - * (resultado sempre positivo)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_a integer;
|
||||
v_b integer;
|
||||
v_op text;
|
||||
v_ans integer;
|
||||
v_q text;
|
||||
v_id uuid;
|
||||
BEGIN
|
||||
v_a := 1 + floor(random() * 9)::int;
|
||||
v_b := 1 + floor(random() * 9)::int;
|
||||
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
|
||||
|
||||
-- garantir resultado positivo na subtração
|
||||
IF v_op = '-' AND v_b > v_a THEN
|
||||
v_a := v_a + v_b;
|
||||
END IF;
|
||||
|
||||
v_ans := CASE v_op
|
||||
WHEN '+' THEN v_a + v_b
|
||||
WHEN '-' THEN v_a - v_b
|
||||
WHEN '*' THEN v_a * v_b
|
||||
END;
|
||||
|
||||
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
|
||||
|
||||
INSERT INTO math_challenges (question, answer)
|
||||
VALUES (v_q, v_ans)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object('id', v_id, 'question', v_q);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- verify_math_challenge
|
||||
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
|
||||
p_id uuid,
|
||||
p_answer integer
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
mc math_challenges%ROWTYPE;
|
||||
BEGIN
|
||||
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
|
||||
|
||||
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
|
||||
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
UPDATE math_challenges SET used = true WHERE id = p_id;
|
||||
|
||||
RETURN mc.answer = p_answer;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- cleanup_expired_math_challenges (chamável via cron)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
|
||||
RETURNS integer
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH d AS (
|
||||
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*)::int FROM d;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
|
||||
@@ -0,0 +1,155 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000008_saas_twilio_config
|
||||
-- Permite saas_admin editar config Twilio operacional pelo painel, sem redeploy.
|
||||
--
|
||||
-- DECISÃO DE SEGURANÇA:
|
||||
-- • TWILIO_AUTH_TOKEN (secret) NÃO entra na tabela. Continua em env var
|
||||
-- da Edge Function. Painel apenas exibe se está configurado (best-effort).
|
||||
-- • TWILIO_ACCOUNT_SID (público no Twilio dashboard, identificador) → DB
|
||||
-- • TWILIO_WHATSAPP_WEBHOOK (URL) → DB
|
||||
-- • USD_BRL_RATE / MARGIN_MULTIPLIER (operacional) → DB
|
||||
--
|
||||
-- Edge function: lê primeiro do banco; cai pra env vars como fallback se row
|
||||
-- ainda não foi configurada (back-compat com deploys antigos).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.saas_twilio_config (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
account_sid text,
|
||||
whatsapp_webhook_url text,
|
||||
usd_brl_rate numeric(10,4) NOT NULL DEFAULT 5.5,
|
||||
margin_multiplier numeric(10,4) NOT NULL DEFAULT 1.4,
|
||||
notes text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_twilio_config_singleton CHECK (id = true),
|
||||
CONSTRAINT saas_twilio_config_rate_chk CHECK (usd_brl_rate > 0 AND usd_brl_rate < 100),
|
||||
CONSTRAINT saas_twilio_config_mult_chk CHECK (margin_multiplier >= 1 AND margin_multiplier <= 10),
|
||||
CONSTRAINT saas_twilio_config_sid_chk CHECK (account_sid IS NULL OR account_sid ~ '^AC[a-zA-Z0-9]{32}$'),
|
||||
CONSTRAINT saas_twilio_config_url_chk CHECK (whatsapp_webhook_url IS NULL OR whatsapp_webhook_url ~ '^https?://')
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_twilio_config (id) VALUES (true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.saas_twilio_config FROM anon, authenticated;
|
||||
GRANT SELECT ON public.saas_twilio_config TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS saas_twilio_config_read ON public.saas_twilio_config;
|
||||
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin()); -- só admin vê config (mesmo sem secret, é dado operacional)
|
||||
|
||||
COMMENT ON TABLE public.saas_twilio_config IS
|
||||
'Config operacional Twilio editável via painel. AUTH_TOKEN continua em env var por segurança.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- RPC get_twilio_config — retorna config atual (saas_admin OU service_role)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_twilio_config()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_twilio_config%ROWTYPE;
|
||||
BEGIN
|
||||
-- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
|
||||
-- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
|
||||
IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
|
||||
RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object(
|
||||
'account_sid', NULL,
|
||||
'whatsapp_webhook_url', NULL,
|
||||
'usd_brl_rate', 5.5,
|
||||
'margin_multiplier', 1.4
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'account_sid', cfg.account_sid,
|
||||
'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
|
||||
'usd_brl_rate', cfg.usd_brl_rate,
|
||||
'margin_multiplier', cfg.margin_multiplier,
|
||||
'notes', cfg.notes,
|
||||
'updated_at', cfg.updated_at,
|
||||
'updated_by', cfg.updated_by
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_twilio_config() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.get_twilio_config() TO authenticated, service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- RPC update_twilio_config — só saas_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.update_twilio_config(
|
||||
p_account_sid text DEFAULT NULL,
|
||||
p_whatsapp_webhook_url text DEFAULT NULL,
|
||||
p_usd_brl_rate numeric DEFAULT NULL,
|
||||
p_margin_multiplier numeric DEFAULT NULL,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller uuid := auth.uid();
|
||||
v_account_sid text;
|
||||
v_webhook_url text;
|
||||
v_notes text;
|
||||
BEGIN
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Sanitização
|
||||
v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
|
||||
v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
|
||||
v_notes := nullif(btrim(coalesce(p_notes, '')), '');
|
||||
|
||||
IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
|
||||
RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
|
||||
RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
|
||||
RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
|
||||
RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
|
||||
v_notes := substring(v_notes FROM 1 FOR 1000);
|
||||
END IF;
|
||||
|
||||
UPDATE saas_twilio_config
|
||||
SET account_sid = COALESCE(v_account_sid, account_sid),
|
||||
whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
|
||||
usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate),
|
||||
margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier),
|
||||
notes = COALESCE(v_notes, notes),
|
||||
updated_at = now(),
|
||||
updated_by = v_caller
|
||||
WHERE id = true;
|
||||
|
||||
RETURN public.get_twilio_config();
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) TO authenticated;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000009_patient_session_counts_rpc
|
||||
-- V#8 — Substitui o .limit(1000) arbitrário em PatientsListPage por RPC
|
||||
-- agregada que retorna contagens por paciente (sempre atualizada, sem teto).
|
||||
--
|
||||
-- Tenant scoping é feito via WHERE tenant_id IN (memberships do caller),
|
||||
-- consistente com a policy SELECT de agenda_eventos.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_patient_session_counts(
|
||||
p_patient_ids uuid[]
|
||||
)
|
||||
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT
|
||||
ae.patient_id,
|
||||
COUNT(*)::int AS session_count,
|
||||
MAX(ae.inicio_em) AS last_session_at
|
||||
FROM public.agenda_eventos ae
|
||||
WHERE ae.patient_id = ANY(p_patient_ids)
|
||||
AND ae.tenant_id IN (
|
||||
SELECT tm.tenant_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
GROUP BY ae.patient_id;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_patient_session_counts(uuid[]) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_patient_session_counts(uuid[]) TO authenticated;
|
||||
@@ -0,0 +1,304 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000010_documents_security_hardening
|
||||
-- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos
|
||||
-- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes
|
||||
-- pra próxima sessão (precisam de design/decisão).
|
||||
--
|
||||
-- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}"
|
||||
-- (storage.foldername(name))[1] = tenant_id
|
||||
-- =============================================================================
|
||||
|
||||
-- Tabelas de documents são owned por supabase_admin
|
||||
SET LOCAL ROLE supabase_admin;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs"
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects;
|
||||
|
||||
CREATE POLICY "documents: tenant member read" ON storage.objects
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'documents'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "documents: tenant member upload" ON storage.objects
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'documents'
|
||||
AND (storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "documents: tenant member delete" ON storage.objects
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
bucket_id = 'documents'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects;
|
||||
|
||||
CREATE POLICY "generated-docs: tenant member read" ON storage.objects
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'generated-docs'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'generated-docs'
|
||||
AND (storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
bucket_id = 'generated-docs'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#45: documents — policies separadas por cmd
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "documents: owner full access" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: select" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: insert" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: update" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: delete" ON public.documents;
|
||||
|
||||
-- SELECT: owner OR tenant_member ativo OR saas_admin
|
||||
CREATE POLICY "documents: select" ON public.documents
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller
|
||||
CREATE POLICY "documents: insert" ON public.documents
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE: só owner
|
||||
CREATE POLICY "documents: update" ON public.documents
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- DELETE: só owner ou saas_admin
|
||||
CREATE POLICY "documents: delete" ON public.documents
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#46: document_share_links — RPC validate_share_token + remover SELECT direto
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
sl document_share_links%ROWTYPE;
|
||||
v_doc documents%ROWTYPE;
|
||||
v_token text;
|
||||
BEGIN
|
||||
v_token := nullif(btrim(coalesce(p_token, '')), '');
|
||||
IF v_token IS NULL THEN
|
||||
RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF sl.ativo IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
|
||||
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
|
||||
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Incrementa uso atomicamente
|
||||
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
|
||||
|
||||
-- Loga acesso (best-effort)
|
||||
BEGIN
|
||||
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
|
||||
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
|
||||
FROM documents d WHERE d.id = sl.document_id;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- não derruba a request se log falhar (schema pode variar)
|
||||
NULL;
|
||||
END;
|
||||
|
||||
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'document_id', sl.document_id,
|
||||
'bucket', v_doc.storage_bucket,
|
||||
'bucket_path', v_doc.bucket_path,
|
||||
'nome_original', v_doc.nome_original,
|
||||
'mime_type', v_doc.mime_type,
|
||||
'tamanho_bytes', v_doc.tamanho_bytes
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role;
|
||||
|
||||
-- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário)
|
||||
DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links;
|
||||
DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links;
|
||||
|
||||
CREATE POLICY "dsl: creator full access" ON public.document_share_links
|
||||
FOR ALL TO authenticated
|
||||
USING (criado_por = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (criado_por = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: select" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: update" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures;
|
||||
|
||||
CREATE POLICY "ds: select" ON public.document_signatures
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller
|
||||
-- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row
|
||||
-- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo)
|
||||
CREATE POLICY "ds: insert" ON public.document_signatures
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
AND (signatario_id IS NULL OR signatario_id = auth.uid())
|
||||
);
|
||||
|
||||
-- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado')
|
||||
CREATE POLICY "ds: update" ON public.document_signatures
|
||||
FOR UPDATE TO authenticated
|
||||
USING (signatario_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- DELETE: signatário, saas_admin ou tenant_admin/owner
|
||||
CREATE POLICY "ds: delete" ON public.document_signatures
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
signatario_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#48: document_access_logs — INSERT com WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs;
|
||||
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#49: document_templates — INSERT com WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates;
|
||||
DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates;
|
||||
|
||||
CREATE POLICY "dt: owner can insert" ON public.document_templates
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
is_global = false
|
||||
AND owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (is_global = true AND public.is_saas_admin());
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000011_documents_portal_patient_policy
|
||||
-- V#50 — paciente vê documento via portal quando compartilhado_portal=true.
|
||||
--
|
||||
-- Adiciona policy SELECT ADICIONAL em documents (combina via OR com a policy
|
||||
-- existente "documents: select"). Paciente conseguem ler documentos próprios
|
||||
-- quando o terapeuta compartilhou via portal.
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "documents: portal patient read" ON public.documents;
|
||||
|
||||
CREATE POLICY "documents: portal patient read" ON public.documents
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
compartilhado_portal = true
|
||||
AND patient_id IN (
|
||||
SELECT p.id FROM public.patients p
|
||||
WHERE p.user_id = auth.uid()
|
||||
)
|
||||
AND (expira_compartilhamento IS NULL OR expira_compartilhamento > now())
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "documents: portal patient read" ON public.documents IS
|
||||
'V#50: paciente lê documento quando compartilhado_portal=true E patient_id pertence ao auth.uid + não expirou.';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000012_documents_content_hash
|
||||
-- V#51 — hash SHA-256 do conteúdo pra detecção de tampering.
|
||||
--
|
||||
-- Coluna nullable (documentos antigos não têm). Calculado client-side via
|
||||
-- crypto.subtle.digest('SHA-256') antes do upload pro storage.
|
||||
-- Integridade pode ser verificada baixando o arquivo e recalculando o hash.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.documents
|
||||
ADD COLUMN IF NOT EXISTS content_sha256 text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_content_sha256
|
||||
ON public.documents (content_sha256)
|
||||
WHERE content_sha256 IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.documents.content_sha256 IS
|
||||
'V#51: SHA-256 hex (64 chars) do conteúdo no momento do upload. Permite verificar integridade. NULL pra documentos legados pré-V#51.';
|
||||
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000013_cron_retention_jobs
|
||||
-- V#52 — retention automática de logs/challenges via pg_cron.
|
||||
--
|
||||
-- Jobs:
|
||||
-- • document_access_logs_cleanup — diário, retém 1 ano (CFP típico)
|
||||
-- • math_challenges_cleanup — horário, remove expirados há >1h
|
||||
-- • public_submission_attempts_cleanup — diário, retém 90 dias
|
||||
-- =============================================================================
|
||||
|
||||
-- Garante extensão (idempotente em ambientes que não têm)
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- document_access_logs: retém 1 ano (suficiente pra auditoria CFP)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('document_access_logs_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'document_access_logs_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'document_access_logs_cleanup',
|
||||
'0 3 * * *', -- todo dia às 03:00
|
||||
$$DELETE FROM public.document_access_logs WHERE created_at < now() - interval '1 year'$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- math_challenges: remove expirados (> 1h após expiração)
|
||||
-- (RPC cleanup_expired_math_challenges já existe desde 20260419000007)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('math_challenges_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'math_challenges_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'math_challenges_cleanup',
|
||||
'0 * * * *', -- toda hora
|
||||
$$SELECT public.cleanup_expired_math_challenges()$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- public_submission_attempts: retém 90 dias (analytics + alertas)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('public_submission_attempts_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'public_submission_attempts_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'public_submission_attempts_cleanup',
|
||||
'15 3 * * *', -- todo dia 03:15 (após o de docs)
|
||||
$$DELETE FROM public.public_submission_attempts WHERE created_at < now() - interval '90 days'$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- submission_rate_limits: limpa entradas antigas (>30 dias sem atividade)
|
||||
-- (estados expirados não fazem mal, mas tabela cresce sem limite)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('submission_rate_limits_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'submission_rate_limits_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'submission_rate_limits_cleanup',
|
||||
'30 3 * * *', -- todo dia 03:30
|
||||
$$DELETE FROM public.submission_rate_limits
|
||||
WHERE last_attempt_at < now() - interval '30 days'
|
||||
AND (blocked_until IS NULL OR blocked_until < now())
|
||||
AND (requires_captcha_until IS NULL OR requires_captcha_until < now())$$
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000014_financial_security_hardening
|
||||
-- Sessão 6 — revisão Financeiro. Resolve V#1-V#5 (2 críticos + 3 altos).
|
||||
-- V#6-V#11 adiados (médios/baixos com plano).
|
||||
--
|
||||
-- Auditoria prévia confirmou:
|
||||
-- • 0 financial_records com tenant_id NULL
|
||||
-- • 0 records com clinic_fee_amount > amount
|
||||
-- → seguro aplicar NOT NULL e CHECK constraints.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1: billing_contracts policy granular
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "billing_contracts: owner full access" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: select" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: insert" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: update" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: delete" ON public.billing_contracts;
|
||||
|
||||
CREATE POLICY "billing_contracts: select" ON public.billing_contracts
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "billing_contracts: update" ON public.billing_contracts
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "billing_contracts: delete" ON public.billing_contracts
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2: financial_records.tenant_id NOT NULL + trigger backfill
|
||||
-- (auditoria: 0 órfãos, seguro aplicar)
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.financial_records ALTER COLUMN tenant_id SET NOT NULL;
|
||||
|
||||
-- Trigger defensivo: se tentar inserir sem tenant_id, busca via owner_id->tenant_members
|
||||
CREATE OR REPLACE FUNCTION public.financial_records_inject_tenant()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
|
||||
SELECT tm.tenant_id INTO NEW.tenant_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
|
||||
ORDER BY tm.created_at DESC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_financial_records_inject_tenant ON public.financial_records;
|
||||
CREATE TRIGGER trg_financial_records_inject_tenant
|
||||
BEFORE INSERT ON public.financial_records
|
||||
FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5: financial_records CHECK contra net_amount negativo
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.financial_records
|
||||
DROP CONSTRAINT IF EXISTS financial_records_fee_lte_amount_chk;
|
||||
|
||||
ALTER TABLE public.financial_records
|
||||
ADD CONSTRAINT financial_records_fee_lte_amount_chk
|
||||
CHECK (clinic_fee_amount IS NULL OR (clinic_fee_amount >= 0 AND clinic_fee_amount <= amount));
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3: payment_settings — adicionar SELECT pra tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "payment_settings: tenant_admin read" ON public.payment_settings;
|
||||
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
tenant_id IS NOT NULL
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
-- (a policy ALL "owner full access" continua — owner mexe nos próprios)
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4: professional_pricing — adicionar SELECT pra tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "professional_pricing: tenant_admin read" ON public.professional_pricing;
|
||||
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,127 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000015_communication_security_hardening
|
||||
-- Sessão 6 — revisão Comunicação. Resolve V#1-V#5 (2 críticos + 3 altos).
|
||||
-- V#6-V#10 adiados (médios/baixos com plano completo no DB).
|
||||
--
|
||||
-- 🔴 V#1+V#2 são bugs P0: policies usavam (tenant_id = auth.uid()) — comparação
|
||||
-- de UUID de tenant com UUID de user. Tabelas inacessíveis na prática.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1: email_layout_config — fix BUG do tenant_id = auth.uid()
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "tenant owns email layout config" ON public.email_layout_config;
|
||||
DROP POLICY IF EXISTS "email_layout_config: tenant_admin all" ON public.email_layout_config;
|
||||
|
||||
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2: email_templates_tenant — MESMO bug
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "tenant manages own overrides" ON public.email_templates_tenant;
|
||||
DROP POLICY IF EXISTS "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant;
|
||||
|
||||
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3: notification_logs — SELECT pra tenant_member
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notif_logs_tenant_member" ON public.notification_logs;
|
||||
CREATE POLICY "notif_logs_tenant_member" ON public.notification_logs
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4: notification_queue — SELECT pra tenant_member
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notif_queue_tenant_member" ON public.notification_queue;
|
||||
CREATE POLICY "notif_queue_tenant_member" ON public.notification_queue
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5: notification_channels — SELECT pra tenant_member; INSERT tenant_admin; UPDATE/DELETE owner
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notification_channels_owner" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_select" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_modify" ON public.notification_channels;
|
||||
|
||||
CREATE POLICY "notif_channels_select" ON public.notification_channels
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR owner_id = auth.uid()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "notif_channels_insert" ON public.notification_channels
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "notif_channels_modify" ON public.notification_channels
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "notif_channels_delete" ON public.notification_channels
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
@@ -0,0 +1,157 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000016_tenants_calendario_hardening
|
||||
-- Sessão 7 — Tenants + Calendário scan (corrige críticos + altos + WITH CHECKs).
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Tenants V#1 (P0) — tenant_invites RLS off + 0 policies
|
||||
-- • Tenants V#2 — profiles_insert_own sem WITH CHECK
|
||||
-- • Tenants V#3 — support_sessions_saas_insert sem WITH CHECK
|
||||
-- • Tenants V#6 — user_settings_insert_own sem WITH CHECK
|
||||
-- • Calendário V#1 — feriados_insert + feriados_saas_insert sem WITH CHECK
|
||||
--
|
||||
-- Auditoria prévia: tenant_invites tem 0 rows (seguro habilitar RLS sem
|
||||
-- migração de dados).
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#1 (P0): tenant_invites
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.tenant_invites FROM anon, authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_invites TO authenticated;
|
||||
|
||||
-- SELECT: tenant_admin/admin/owner do tenant + saas_admin
|
||||
DROP POLICY IF EXISTS tenant_invites_select ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_select ON public.tenant_invites
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: só tenant_admin do tenant_id, e invited_by deve ser o caller
|
||||
DROP POLICY IF EXISTS tenant_invites_insert ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_insert ON public.tenant_invites
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
invited_by = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE: só revogação por tenant_admin do tenant. Aceitar é via RPC tenant_accept_invite (SECURITY DEFINER).
|
||||
DROP POLICY IF EXISTS tenant_invites_update ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_update ON public.tenant_invites
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: tenant_admin OR saas_admin
|
||||
DROP POLICY IF EXISTS tenant_invites_delete ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_delete ON public.tenant_invites
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.tenant_invites IS
|
||||
'Convites pra entrar em tenant. Aceitar deve ser via RPC tenant_accept_invite (SECURITY DEFINER). Criar/revogar via UI por tenant_admin.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#2: profiles INSERT WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS profiles_insert_own ON public.profiles;
|
||||
CREATE POLICY profiles_insert_own ON public.profiles
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (id = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#3: support_sessions INSERT WITH CHECK
|
||||
-- (admin_id deve ser o caller E o caller deve ser saas_admin)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS support_sessions_saas_insert ON public.support_sessions;
|
||||
CREATE POLICY support_sessions_saas_insert ON public.support_sessions
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
admin_id = auth.uid()
|
||||
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#6: user_settings INSERT WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS user_settings_insert_own ON public.user_settings;
|
||||
CREATE POLICY user_settings_insert_own ON public.user_settings
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Calendário V#1: feriados INSERT WITH CHECK (tenant + global)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS feriados_insert ON public.feriados;
|
||||
CREATE POLICY feriados_insert ON public.feriados
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IS NOT NULL
|
||||
AND owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS feriados_saas_insert ON public.feriados;
|
||||
CREATE POLICY feriados_saas_insert ON public.feriados
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Calendário V#2: feriados DELETE — adicionar tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS feriados_delete ON public.feriados;
|
||||
CREATE POLICY feriados_delete ON public.feriados
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR (tenant_id IS NOT NULL AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
))
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000017_addons_central_saas_hardening
|
||||
-- Sessão 8 — Addons + Central SaaS scan.
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Addons V#1 (CRÍTICO — dinheiro real): addon_transactions sem WITH CHECK
|
||||
-- • Addons V#2: addon_credits sem CHECK contra saldo negativo
|
||||
-- • Central SaaS V#1: saas_faq write permite tenant_admin/clinic_admin
|
||||
--
|
||||
-- Auditoria prévia: 0 addon_credits com balance < 0 (seguro CHECK).
|
||||
-- Edge functions consomem créditos via service_role (bypass RLS) — nova
|
||||
-- restrição não quebra pipeline.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Addons V#1: addon_transactions INSERT WITH CHECK (saas_admin only)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS addon_transactions_admin_insert ON public.addon_transactions;
|
||||
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Addons V#2: addon_credits CHECK contra saldo negativo
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_balance_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_balance_nonneg_chk
|
||||
CHECK (balance >= 0);
|
||||
|
||||
-- Aproveita: total_consumed também não deve ser negativo
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_consumed_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_consumed_nonneg_chk
|
||||
CHECK (total_consumed >= 0);
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_purchased_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_purchased_nonneg_chk
|
||||
CHECK (total_purchased >= 0);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Central SaaS V#1: saas_faq + saas_faq_itens write SÓ saas_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS faq_admin_write ON public.saas_faq;
|
||||
CREATE POLICY faq_saas_admin_write ON public.saas_faq
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS faq_itens_admin_write ON public.saas_faq_itens;
|
||||
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- (Policies de leitura — faq_auth_read, faq_public_read, faq_itens_auth_read — permanecem)
|
||||
@@ -0,0 +1,223 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000018_servicos_prontuarios_hardening
|
||||
-- Sessão 9 — Serviços/Prontuários scan.
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Serviços V#1+V#2 (CRÍTICOS): silos por owner em services/medicos/insurance_plans
|
||||
-- • Serviços V#3+V#4 (ALTOS): cascade silos em commitment_services/insurance_plan_services
|
||||
-- • Serviços V#5: WITH CHECK ausente em commitment_time_logs/determined_*
|
||||
--
|
||||
-- Padrão validado em 5 áreas anteriores (Documentos/Financeiro/Comunicação/etc):
|
||||
-- SELECT tenant_member, INSERT/UPDATE/DELETE owner+saas, com WITH CHECK explícito.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1 services — split em 4 policies
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "services: owner full access" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: select" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: insert" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: update" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: delete" ON public.services;
|
||||
|
||||
CREATE POLICY "services: select" ON public.services
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "services: insert" ON public.services
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "services: update" ON public.services
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "services: delete" ON public.services
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2 medicos — mesmo padrão
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "medicos: owner full access" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: select" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: insert" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: update" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: delete" ON public.medicos;
|
||||
|
||||
CREATE POLICY "medicos: select" ON public.medicos
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "medicos: insert" ON public.medicos
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "medicos: update" ON public.medicos
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "medicos: delete" ON public.medicos
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1 (parte 2) insurance_plans — mesmo padrão
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "insurance_plans: owner full access" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: select" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: insert" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: update" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: delete" ON public.insurance_plans;
|
||||
|
||||
CREATE POLICY "insurance_plans: select" ON public.insurance_plans
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "insurance_plans: update" ON public.insurance_plans
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "insurance_plans: delete" ON public.insurance_plans
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3 commitment_services — cascade via JOIN com services.tenant_id
|
||||
-- (tabela N:N sem tenant_id próprio; herda do services pai)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "commitment_services: owner full access" ON public.commitment_services;
|
||||
DROP POLICY IF EXISTS "commitment_services: tenant_member" ON public.commitment_services;
|
||||
|
||||
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = commitment_services.service_id
|
||||
AND (
|
||||
s.owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR s.tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = commitment_services.service_id
|
||||
AND (s.owner_id = auth.uid() OR public.is_saas_admin())
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4 insurance_plan_services — cascade via JOIN com insurance_plans
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "insurance_plan_services_owner" ON public.insurance_plan_services;
|
||||
DROP POLICY IF EXISTS "insurance_plan_services: tenant_member" ON public.insurance_plan_services;
|
||||
|
||||
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND (
|
||||
ip.owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR ip.tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND (ip.owner_id = auth.uid() OR public.is_saas_admin())
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5 — adicionar WITH CHECK em INSERT das 3 tabelas que não tinham
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS ctl_insert_for_active_member ON public.commitment_time_logs;
|
||||
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS dcf_insert_for_active_member ON public.determined_commitment_fields;
|
||||
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS dc_insert_for_active_member ON public.determined_commitments;
|
||||
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,107 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000001_patient_intake_invite_info_rpc
|
||||
-- A#31 — RPC read-only de lookup público do terapeuta/clínica a partir do
|
||||
-- token do patient_invite. Consumida pela edge function get-intake-invite-info
|
||||
-- para alimentar o "hero header" da página /cadastro/paciente.
|
||||
--
|
||||
-- Segurança:
|
||||
-- • SECURITY DEFINER (ignora RLS de profiles/company_profiles)
|
||||
-- • Valida token: existe, ativo, não-expirado, dentro do max_uses
|
||||
-- • Retorna APENAS campos explicitamente seguros (não-sensíveis)
|
||||
-- • Execute revogado de PUBLIC/anon; grantado só para service_role (edge)
|
||||
-- e authenticated (usos internos futuros)
|
||||
--
|
||||
-- Payload devolvido:
|
||||
-- { ok: true, info: { therapist: {...}, clinic: {...}|null } }
|
||||
-- { error: 'invalid-token' } — token inválido/expirado/esgotado
|
||||
-- { error: 'missing-token' } — input vazio
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_patient_intake_invite_info(p_token text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_token_clean text;
|
||||
v_invite RECORD;
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
v_token_clean := nullif(trim(coalesce(p_token, '')), '');
|
||||
IF v_token_clean IS NULL THEN
|
||||
RETURN jsonb_build_object('error', 'missing-token');
|
||||
END IF;
|
||||
|
||||
SELECT pi.owner_id, pi.tenant_id, pi.active, pi.expires_at, pi.max_uses, pi.uses
|
||||
INTO v_invite
|
||||
FROM public.patient_invites pi
|
||||
WHERE pi.token = v_token_clean
|
||||
LIMIT 1;
|
||||
|
||||
IF v_invite.owner_id IS NULL THEN
|
||||
RETURN jsonb_build_object('error', 'invalid-token');
|
||||
END IF;
|
||||
|
||||
IF v_invite.active IS DISTINCT FROM true THEN
|
||||
RETURN jsonb_build_object('error', 'invalid-token');
|
||||
END IF;
|
||||
|
||||
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
|
||||
RETURN jsonb_build_object('error', 'invalid-token');
|
||||
END IF;
|
||||
|
||||
IF v_invite.max_uses IS NOT NULL AND v_invite.uses >= v_invite.max_uses THEN
|
||||
RETURN jsonb_build_object('error', 'invalid-token');
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'therapist', jsonb_build_object(
|
||||
'display_name', coalesce(
|
||||
nullif(trim(p.full_name), ''),
|
||||
nullif(trim(p.nickname), ''),
|
||||
'Profissional'
|
||||
),
|
||||
'avatar_url', nullif(trim(coalesce(p.avatar_url, '')), ''),
|
||||
'work_description', nullif(trim(coalesce(p.work_description, '')), ''),
|
||||
'bio', nullif(trim(coalesce(p.bio, '')), ''),
|
||||
'phone', nullif(trim(coalesce(p.phone, '')), ''),
|
||||
'site_url', nullif(trim(coalesce(p.site_url, '')), ''),
|
||||
'instagram', nullif(trim(coalesce(p.social_instagram, '')), '')
|
||||
),
|
||||
'clinic', CASE
|
||||
WHEN cp.tenant_id IS NOT NULL THEN jsonb_build_object(
|
||||
'name', nullif(trim(coalesce(cp.nome_fantasia, '')), ''),
|
||||
'logo_url', nullif(trim(coalesce(cp.logo_url, '')), ''),
|
||||
'email', nullif(trim(coalesce(cp.email, '')), ''),
|
||||
'phone', nullif(trim(coalesce(cp.telefone, '')), ''),
|
||||
'site', nullif(trim(coalesce(cp.site, '')), ''),
|
||||
'city', nullif(trim(coalesce(cp.cidade, '')), ''),
|
||||
'state', nullif(trim(coalesce(cp.estado, '')), ''),
|
||||
'neighborhood', nullif(trim(coalesce(cp.bairro, '')), ''),
|
||||
'street', nullif(trim(coalesce(cp.logradouro, '')), ''),
|
||||
'number', nullif(trim(coalesce(cp.numero, '')), ''),
|
||||
'social', coalesce(cp.redes_sociais, '[]'::jsonb)
|
||||
)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
INTO v_result
|
||||
FROM public.profiles p
|
||||
LEFT JOIN public.company_profiles cp ON cp.tenant_id = v_invite.tenant_id
|
||||
WHERE p.id = v_invite.owner_id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_result IS NULL THEN
|
||||
RETURN jsonb_build_object('error', 'invalid-token');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('ok', true, 'info', v_result);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.get_patient_intake_invite_info(text) IS
|
||||
'A#31 — Lookup público read-only (via edge function) dos dados de apresentação do terapeuta/clínica dono do link de cadastro externo. Só retorna campos não-sensíveis.';
|
||||
@@ -0,0 +1,199 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000002_audit_logs_lgpd
|
||||
-- Sessao 11 - Fase 2a (Opcao C).
|
||||
--
|
||||
-- Resolve: LGPD Art. 37 - registro das operacoes de tratamento.
|
||||
-- Projeto ja tinha logs pontuais (document_access_logs, patient_status_history,
|
||||
-- notification_logs, addon_transactions) mas nao registrava:
|
||||
-- - Edicao de dados do paciente (nome/CPF/endereco)
|
||||
-- - CRUD de sessoes na agenda
|
||||
-- - CRUD de registros financeiros
|
||||
-- - CRUD de documentos (metadata)
|
||||
-- - Mudancas de permissao / members do tenant
|
||||
--
|
||||
-- Cria tabela audit_logs imutavel + funcao trigger generica + triggers nas
|
||||
-- tabelas criticas. RLS: tenant member le; ninguem INSERT/UPDATE/DELETE direto.
|
||||
-- =============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tabela audit_logs
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT,
|
||||
action TEXT NOT NULL CHECK (action IN ('insert', 'update', 'delete')),
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
changed_fields TEXT[],
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created
|
||||
ON public.audit_logs (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity
|
||||
ON public.audit_logs (entity_type, entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
|
||||
ON public.audit_logs (user_id, created_at DESC) WHERE user_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_fields
|
||||
ON public.audit_logs USING GIN (changed_fields);
|
||||
|
||||
COMMENT ON TABLE public.audit_logs IS
|
||||
'Registro imutavel de operacoes de tratamento (LGPD Art. 37). INSERT apenas via trigger SECURITY DEFINER.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Funcao trigger generica
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.log_audit_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id UUID;
|
||||
v_entity_id TEXT;
|
||||
v_old JSONB;
|
||||
v_new JSONB;
|
||||
v_changed TEXT[];
|
||||
v_heavy_fields TEXT[] := ARRAY[
|
||||
'content', 'content_html', 'content_json', 'raw_data',
|
||||
'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text'
|
||||
];
|
||||
v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at'];
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_tenant_id := OLD.tenant_id;
|
||||
v_entity_id := OLD.id::TEXT;
|
||||
v_old := to_jsonb(OLD) - v_heavy_fields;
|
||||
v_new := NULL;
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
v_tenant_id := NEW.tenant_id;
|
||||
v_entity_id := NEW.id::TEXT;
|
||||
v_old := NULL;
|
||||
v_new := to_jsonb(NEW) - v_heavy_fields;
|
||||
ELSE -- UPDATE
|
||||
v_tenant_id := NEW.tenant_id;
|
||||
v_entity_id := NEW.id::TEXT;
|
||||
v_old := to_jsonb(OLD) - v_heavy_fields;
|
||||
v_new := to_jsonb(NEW) - v_heavy_fields;
|
||||
|
||||
-- calcular campos realmente alterados
|
||||
SELECT array_agg(key ORDER BY key) INTO v_changed
|
||||
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
|
||||
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
|
||||
|
||||
-- se nada mudou, ignora
|
||||
IF v_changed IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- se mudou apenas campos de ruido (ex: updated_at), ignora
|
||||
IF v_changed <@ v_noise_fields THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.audit_logs (
|
||||
tenant_id, user_id, entity_type, entity_id, action,
|
||||
old_values, new_values, changed_fields
|
||||
) VALUES (
|
||||
v_tenant_id,
|
||||
auth.uid(),
|
||||
TG_TABLE_NAME,
|
||||
v_entity_id,
|
||||
lower(TG_OP),
|
||||
v_old,
|
||||
v_new,
|
||||
v_changed
|
||||
);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.log_audit_change() IS
|
||||
'Trigger generica de audit. Filtra campos pesados (content, signature_data) e ruido (updated_at).';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Triggers nas tabelas criticas
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- patients
|
||||
DROP TRIGGER IF EXISTS trg_audit_patients ON public.patients;
|
||||
CREATE TRIGGER trg_audit_patients
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.patients
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
|
||||
|
||||
-- agenda_eventos
|
||||
DROP TRIGGER IF EXISTS trg_audit_agenda_eventos ON public.agenda_eventos;
|
||||
CREATE TRIGGER trg_audit_agenda_eventos
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.agenda_eventos
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
|
||||
|
||||
-- financial_records
|
||||
DROP TRIGGER IF EXISTS trg_audit_financial_records ON public.financial_records;
|
||||
CREATE TRIGGER trg_audit_financial_records
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.financial_records
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
|
||||
|
||||
-- documents
|
||||
DROP TRIGGER IF EXISTS trg_audit_documents ON public.documents;
|
||||
CREATE TRIGGER trg_audit_documents
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.documents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
|
||||
|
||||
-- tenant_members (mudanca de permissao)
|
||||
DROP TRIGGER IF EXISTS trg_audit_tenant_members ON public.tenant_members;
|
||||
CREATE TRIGGER trg_audit_tenant_members
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.tenant_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS: tenant member le; saas_admin le tudo; ninguem escreve direto
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.audit_logs FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "audit_logs: select tenant" ON public.audit_logs;
|
||||
DROP POLICY IF EXISTS "audit_logs: saas_admin all" ON public.audit_logs;
|
||||
DROP POLICY IF EXISTS "audit_logs: no direct insert" ON public.audit_logs;
|
||||
DROP POLICY IF EXISTS "audit_logs: no direct update" ON public.audit_logs;
|
||||
DROP POLICY IF EXISTS "audit_logs: no direct delete" ON public.audit_logs;
|
||||
|
||||
CREATE POLICY "audit_logs: select tenant" ON public.audit_logs
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- Explicitamente NEGA insert/update/delete via API
|
||||
-- (SECURITY DEFINER na funcao trigger bypassa RLS; app nao consegue escrever direto)
|
||||
CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "audit_logs: no direct update" ON public.audit_logs
|
||||
FOR UPDATE TO authenticated
|
||||
USING (false) WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs
|
||||
FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Marca hardening na auditoria
|
||||
-- ---------------------------------------------------------------------------
|
||||
COMMENT ON COLUMN public.audit_logs.old_values IS 'Estado anterior (jsonb); NULL em INSERT; campos pesados removidos';
|
||||
COMMENT ON COLUMN public.audit_logs.new_values IS 'Estado posterior (jsonb); NULL em DELETE; campos pesados removidos';
|
||||
COMMENT ON COLUMN public.audit_logs.changed_fields IS 'Lista de campos alterados em UPDATE (NULL em INSERT/DELETE)';
|
||||
@@ -0,0 +1,148 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000003_audit_logs_unified_view
|
||||
-- Sessao 11 - Fase 2a (Opcao C).
|
||||
--
|
||||
-- View audit_log_unified que junta:
|
||||
-- - audit_logs (nova, trigger generico em patients/agenda/etc)
|
||||
-- - document_access_logs (visualizacao/download/impressao de documento)
|
||||
-- - patient_status_history (mudancas de status de paciente)
|
||||
-- - notification_logs (envio de SMS/email/WhatsApp)
|
||||
-- - addon_transactions (compras/consumos de recursos extras)
|
||||
--
|
||||
-- RLS: aplica-se das tabelas base. View usa security_invoker para herdar.
|
||||
-- =============================================================================
|
||||
|
||||
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
|
||||
|
||||
CREATE VIEW public.audit_log_unified
|
||||
WITH (security_invoker = true)
|
||||
AS
|
||||
-- 1) audit_logs (trigger generico)
|
||||
SELECT
|
||||
'audit:' || al.id::text AS uid,
|
||||
al.tenant_id AS tenant_id,
|
||||
al.user_id AS user_id,
|
||||
al.entity_type AS entity_type,
|
||||
al.entity_id AS entity_id,
|
||||
al.action AS action,
|
||||
CASE al.action
|
||||
WHEN 'insert' THEN 'Criou ' || al.entity_type
|
||||
WHEN 'update' THEN 'Alterou ' || al.entity_type
|
||||
|| COALESCE(' (' || array_to_string(al.changed_fields, ', ') || ')', '')
|
||||
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
|
||||
END AS description,
|
||||
al.created_at AS occurred_at,
|
||||
'audit_logs' AS source,
|
||||
jsonb_build_object(
|
||||
'old_values', al.old_values,
|
||||
'new_values', al.new_values,
|
||||
'changed_fields', al.changed_fields
|
||||
) AS details
|
||||
FROM public.audit_logs al
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 2) document_access_logs
|
||||
SELECT
|
||||
'doc_access:' || dal.id::text,
|
||||
dal.tenant_id,
|
||||
dal.user_id,
|
||||
'document' AS entity_type,
|
||||
dal.documento_id::text AS entity_id,
|
||||
dal.acao AS action,
|
||||
CASE dal.acao
|
||||
WHEN 'visualizou' THEN 'Visualizou documento'
|
||||
WHEN 'baixou' THEN 'Baixou documento'
|
||||
WHEN 'imprimiu' THEN 'Imprimiu documento'
|
||||
WHEN 'compartilhou' THEN 'Compartilhou documento'
|
||||
WHEN 'assinou' THEN 'Assinou documento'
|
||||
ELSE dal.acao
|
||||
END AS description,
|
||||
dal.acessado_em AS occurred_at,
|
||||
'document_access_logs' AS source,
|
||||
jsonb_build_object(
|
||||
'ip', dal.ip::text,
|
||||
'user_agent', dal.user_agent
|
||||
) AS details
|
||||
FROM public.document_access_logs dal
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 3) patient_status_history
|
||||
SELECT
|
||||
'psh:' || psh.id::text,
|
||||
psh.tenant_id,
|
||||
psh.alterado_por,
|
||||
'patient_status' AS entity_type,
|
||||
psh.patient_id::text AS entity_id,
|
||||
'status_change' AS action,
|
||||
'Status do paciente: '
|
||||
|| COALESCE(psh.status_anterior, '—') || ' → ' || psh.status_novo
|
||||
|| COALESCE(' (' || psh.motivo || ')', '') AS description,
|
||||
psh.alterado_em AS occurred_at,
|
||||
'patient_status_history' AS source,
|
||||
jsonb_build_object(
|
||||
'status_anterior', psh.status_anterior,
|
||||
'status_novo', psh.status_novo,
|
||||
'motivo', psh.motivo,
|
||||
'encaminhado_para', psh.encaminhado_para,
|
||||
'data_saida', psh.data_saida
|
||||
) AS details
|
||||
FROM public.patient_status_history psh
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 4) notification_logs
|
||||
SELECT
|
||||
'notif:' || nl.id::text,
|
||||
nl.tenant_id,
|
||||
nl.owner_id AS user_id,
|
||||
'notification' AS entity_type,
|
||||
nl.patient_id::text AS entity_id,
|
||||
nl.status AS action,
|
||||
'Notificação ' || nl.channel || ' '
|
||||
|| nl.status
|
||||
|| COALESCE(' para ' || nl.recipient_address, '') AS description,
|
||||
nl.created_at AS occurred_at,
|
||||
'notification_logs' AS source,
|
||||
jsonb_build_object(
|
||||
'channel', nl.channel,
|
||||
'template_key', nl.template_key,
|
||||
'status', nl.status,
|
||||
'provider', nl.provider,
|
||||
'failure_reason', nl.failure_reason
|
||||
) AS details
|
||||
FROM public.notification_logs nl
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 5) addon_transactions
|
||||
SELECT
|
||||
'addon:' || at.id::text,
|
||||
at.tenant_id,
|
||||
at.admin_user_id AS user_id,
|
||||
'addon_transaction' AS entity_type,
|
||||
at.id::text AS entity_id,
|
||||
at.type AS action,
|
||||
CASE at.type
|
||||
WHEN 'purchase' THEN 'Compra de ' || at.amount || ' créditos de ' || at.addon_type
|
||||
WHEN 'consumption' THEN 'Consumo de ' || abs(at.amount) || ' crédito(s) ' || at.addon_type
|
||||
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
|
||||
WHEN 'refund' THEN 'Reembolso de ' || abs(at.amount) || ' créditos ' || at.addon_type
|
||||
ELSE at.type || ' ' || at.addon_type
|
||||
END AS description,
|
||||
at.created_at AS occurred_at,
|
||||
'addon_transactions' AS source,
|
||||
jsonb_build_object(
|
||||
'addon_type', at.addon_type,
|
||||
'amount', at.amount,
|
||||
'balance_after', at.balance_after,
|
||||
'price_cents', at.price_cents,
|
||||
'payment_reference', at.payment_reference
|
||||
) AS details
|
||||
FROM public.addon_transactions at;
|
||||
|
||||
COMMENT ON VIEW public.audit_log_unified IS
|
||||
'Timeline unificada de eventos auditaveis (LGPD). Herda RLS das tabelas base via security_invoker.';
|
||||
|
||||
GRANT SELECT ON public.audit_log_unified TO authenticated;
|
||||
@@ -0,0 +1,225 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000004_lgpd_export_patient_rpc
|
||||
-- Sessao 11 - Fase 2b (Opcao C).
|
||||
--
|
||||
-- Implementa LGPD Art. 18 - direito de portabilidade do titular.
|
||||
-- RPC export_patient_data(p_patient_id uuid) retorna jsonb com todos os dados
|
||||
-- relacionados ao paciente. Registra o evento em audit_logs para rastreabilidade.
|
||||
--
|
||||
-- Seguranca:
|
||||
-- - SECURITY DEFINER permite agregar tabelas diversas bypassando RLS
|
||||
-- - Verificacao explicita: caller deve ser tenant_member ativo da clinica do paciente
|
||||
-- - Proibido acesso cross-tenant
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.export_patient_data(p_patient_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_patient RECORD;
|
||||
v_tenant_id UUID;
|
||||
v_caller UUID;
|
||||
v_is_member BOOLEAN;
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
v_caller := auth.uid();
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- carrega paciente
|
||||
SELECT * INTO v_patient FROM public.patients WHERE id = p_patient_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
v_tenant_id := v_patient.tenant_id;
|
||||
|
||||
-- verifica se caller e membro ativo do tenant do paciente
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = v_tenant_id
|
||||
AND tm.user_id = v_caller
|
||||
AND tm.status = 'active'
|
||||
) OR public.is_saas_admin() INTO v_is_member;
|
||||
|
||||
IF NOT v_is_member THEN
|
||||
RAISE EXCEPTION 'Sem permissao para exportar dados deste paciente' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- monta o payload
|
||||
v_result := jsonb_build_object(
|
||||
'export_metadata', jsonb_build_object(
|
||||
'generated_at', now(),
|
||||
'generated_by', v_caller,
|
||||
'tenant_id', v_tenant_id,
|
||||
'patient_id', p_patient_id,
|
||||
'lgpd_basis', 'Art. 18, II - portabilidade dos dados do titular',
|
||||
'controller', 'AgenciaPSI - Clinica responsavel',
|
||||
'format_version', '1.0'
|
||||
),
|
||||
'paciente', to_jsonb(v_patient),
|
||||
'contatos', COALESCE((
|
||||
SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at)
|
||||
FROM public.patient_contacts pc
|
||||
WHERE pc.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'contatos_apoio', COALESCE((
|
||||
SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at)
|
||||
FROM public.patient_support_contacts psc
|
||||
WHERE psc.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'historico_status', COALESCE((
|
||||
SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em)
|
||||
FROM public.patient_status_history psh
|
||||
WHERE psh.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'timeline', COALESCE((
|
||||
SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em)
|
||||
FROM public.patient_timeline pt
|
||||
WHERE pt.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'descontos', COALESCE((
|
||||
SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at)
|
||||
FROM public.patient_discounts pd
|
||||
WHERE pd.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'eventos_agenda', COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', ae.id,
|
||||
'tipo', ae.tipo,
|
||||
'inicio_em', ae.inicio_em,
|
||||
'fim_em', ae.fim_em,
|
||||
'status', ae.status,
|
||||
'observacoes', ae.observacoes,
|
||||
'created_at', ae.created_at
|
||||
) ORDER BY ae.inicio_em
|
||||
)
|
||||
FROM public.agenda_eventos ae
|
||||
WHERE ae.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'registros_financeiros', COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', fr.id,
|
||||
'amount', fr.amount,
|
||||
'discount_amount', fr.discount_amount,
|
||||
'final_amount', fr.final_amount,
|
||||
'status', fr.status,
|
||||
'due_date', fr.due_date,
|
||||
'paid_at', fr.paid_at,
|
||||
'payment_method', fr.payment_method,
|
||||
'notes', fr.notes,
|
||||
'created_at', fr.created_at
|
||||
) ORDER BY fr.created_at
|
||||
)
|
||||
FROM public.financial_records fr
|
||||
WHERE fr.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'documentos', COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', d.id,
|
||||
'nome_original', d.nome_original,
|
||||
'tipo_documento', d.tipo_documento,
|
||||
'categoria', d.categoria,
|
||||
'descricao', d.descricao,
|
||||
'mime_type', d.mime_type,
|
||||
'tamanho_bytes', d.tamanho_bytes,
|
||||
'status_revisao', d.status_revisao,
|
||||
'visibilidade', d.visibilidade,
|
||||
'uploaded_at', d.uploaded_at,
|
||||
'created_at', d.created_at
|
||||
) ORDER BY d.created_at
|
||||
)
|
||||
FROM public.documents d
|
||||
WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL
|
||||
), '[]'::jsonb),
|
||||
'notificacoes_enviadas', COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', nl.id,
|
||||
'channel', nl.channel,
|
||||
'template_key', nl.template_key,
|
||||
'recipient_address', nl.recipient_address,
|
||||
'status', nl.status,
|
||||
'sent_at', nl.sent_at,
|
||||
'delivered_at', nl.delivered_at,
|
||||
'read_at', nl.read_at,
|
||||
'failure_reason', nl.failure_reason,
|
||||
'created_at', nl.created_at
|
||||
) ORDER BY nl.created_at
|
||||
)
|
||||
FROM public.notification_logs nl
|
||||
WHERE nl.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'audit_trail', COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', al.id,
|
||||
'action', al.action,
|
||||
'entity_type', al.entity_type,
|
||||
'changed_fields', al.changed_fields,
|
||||
'user_id', al.user_id,
|
||||
'created_at', al.created_at
|
||||
) ORDER BY al.created_at
|
||||
)
|
||||
FROM public.audit_logs al
|
||||
WHERE al.tenant_id = v_tenant_id
|
||||
AND al.entity_type = 'patients'
|
||||
AND al.entity_id = p_patient_id::text
|
||||
), '[]'::jsonb),
|
||||
'acessos_a_documentos', COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', dal.id,
|
||||
'documento_id', dal.documento_id,
|
||||
'acao', dal.acao,
|
||||
'user_id', dal.user_id,
|
||||
'acessado_em', dal.acessado_em
|
||||
) ORDER BY dal.acessado_em
|
||||
)
|
||||
FROM public.document_access_logs dal
|
||||
WHERE dal.documento_id IN (
|
||||
SELECT id FROM public.documents WHERE patient_id = p_patient_id
|
||||
)
|
||||
), '[]'::jsonb),
|
||||
'grupos', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object('patient_group_id', pgp.patient_group_id))
|
||||
FROM public.patient_group_patient pgp
|
||||
WHERE pgp.patient_id = p_patient_id
|
||||
), '[]'::jsonb),
|
||||
'tags', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object('tag_id', ppt.tag_id))
|
||||
FROM public.patient_patient_tag ppt
|
||||
WHERE ppt.patient_id = p_patient_id
|
||||
), '[]'::jsonb)
|
||||
);
|
||||
|
||||
-- registra o export como evento auditavel
|
||||
INSERT INTO public.audit_logs (
|
||||
tenant_id, user_id, entity_type, entity_id, action,
|
||||
old_values, new_values, changed_fields, metadata
|
||||
) VALUES (
|
||||
v_tenant_id, v_caller, 'patients', p_patient_id::text, 'update',
|
||||
NULL, NULL, ARRAY['__lgpd_export__'],
|
||||
jsonb_build_object(
|
||||
'action_kind', 'lgpd_export',
|
||||
'lgpd_basis', 'Art. 18, II',
|
||||
'patient_name', v_patient.nome_completo
|
||||
)
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.export_patient_data(UUID) IS
|
||||
'LGPD Art. 18, II - exporta todos os dados do paciente em jsonb portavel. Registra evento em audit_logs.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.export_patient_data(UUID) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.export_patient_data(UUID) TO authenticated;
|
||||
@@ -0,0 +1,263 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000005_conversation_messages
|
||||
-- Sessao 11 - Fase 5a (CRM de WhatsApp / inbox).
|
||||
--
|
||||
-- Cria infraestrutura para receber mensagens inbound de WhatsApp (Twilio e
|
||||
-- Evolution API) e exibir num Kanban de conversas.
|
||||
--
|
||||
-- - conversation_messages — todas as mensagens (in/out) com link opcional
|
||||
-- ao paciente via telefone matching
|
||||
-- - function match_patient_by_phone(tenant_id, phone) — encontra paciente
|
||||
-- - view conversation_threads — agregado por paciente/numero pra UI Kanban
|
||||
--
|
||||
-- RLS: tenant members leem; service_role (edge function) escreve via SECURITY
|
||||
-- DEFINER match_and_insert. App nao escreve direto.
|
||||
-- =============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tabela de mensagens
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.conversation_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
|
||||
|
||||
channel TEXT NOT NULL CHECK (channel IN ('whatsapp', 'sms', 'email')),
|
||||
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
|
||||
|
||||
from_number TEXT,
|
||||
to_number TEXT,
|
||||
body TEXT,
|
||||
media_url TEXT,
|
||||
media_mime TEXT,
|
||||
|
||||
provider TEXT NOT NULL CHECK (provider IN ('twilio', 'evolution', 'manual')),
|
||||
provider_message_id TEXT,
|
||||
provider_raw JSONB,
|
||||
|
||||
-- estado Kanban
|
||||
kanban_status TEXT NOT NULL DEFAULT 'awaiting_us'
|
||||
CHECK (kanban_status IN ('urgent', 'awaiting_us', 'awaiting_patient', 'resolved')),
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
|
||||
read_at TIMESTAMPTZ,
|
||||
responded_at TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
|
||||
received_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_msg_tenant_created
|
||||
ON public.conversation_messages (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_msg_patient
|
||||
ON public.conversation_messages (patient_id, created_at DESC) WHERE patient_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_msg_from_number
|
||||
ON public.conversation_messages (tenant_id, from_number);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_msg_kanban
|
||||
ON public.conversation_messages (tenant_id, kanban_status, priority DESC, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_msg_provider_msg_id
|
||||
ON public.conversation_messages (provider_message_id) WHERE provider_message_id IS NOT NULL;
|
||||
|
||||
-- Trigger de updated_at (usa funcao existente set_updated_at)
|
||||
DROP TRIGGER IF EXISTS trg_conv_messages_updated_at ON public.conversation_messages;
|
||||
CREATE TRIGGER trg_conv_messages_updated_at
|
||||
BEFORE UPDATE ON public.conversation_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
COMMENT ON TABLE public.conversation_messages IS
|
||||
'Mensagens in/out de WhatsApp/SMS/email. Timeline de conversas do tenant com pacientes.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Funcao: normaliza telefone BR (remove tudo que nao seja digito, tira DDI 55)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.normalize_phone_br(p_phone TEXT)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_digits TEXT;
|
||||
BEGIN
|
||||
IF p_phone IS NULL THEN RETURN NULL; END IF;
|
||||
|
||||
-- remove tudo que nao seja digito
|
||||
v_digits := regexp_replace(p_phone, '\D', '', 'g');
|
||||
|
||||
-- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero)
|
||||
IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN
|
||||
v_digits := substr(v_digits, 3);
|
||||
END IF;
|
||||
|
||||
-- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero)
|
||||
IF length(v_digits) > 11 THEN
|
||||
v_digits := right(v_digits, 11);
|
||||
END IF;
|
||||
|
||||
RETURN v_digits;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.normalize_phone_br(TEXT) IS
|
||||
'Normaliza telefone BR para os ultimos 11 digitos (DDD+numero), removendo DDI +55 e formatacao.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Funcao: match paciente por telefone dentro de um tenant
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id UUID, p_phone TEXT)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_normalized TEXT;
|
||||
v_patient_id UUID;
|
||||
BEGIN
|
||||
v_normalized := public.normalize_phone_br(p_phone);
|
||||
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- prioridade: telefone principal, depois alternativo, depois responsavel
|
||||
SELECT id INTO v_patient_id FROM public.patients
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND public.normalize_phone_br(telefone) = v_normalized
|
||||
LIMIT 1;
|
||||
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
|
||||
|
||||
SELECT id INTO v_patient_id FROM public.patients
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND public.normalize_phone_br(telefone_alternativo) = v_normalized
|
||||
LIMIT 1;
|
||||
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
|
||||
|
||||
SELECT id INTO v_patient_id FROM public.patients
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND public.normalize_phone_br(telefone_responsavel) = v_normalized
|
||||
LIMIT 1;
|
||||
|
||||
RETURN v_patient_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.match_patient_by_phone(UUID, TEXT) IS
|
||||
'Encontra patient_id do tenant cujo telefone (principal/alternativo/responsavel) bate com o numero.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- View: threads agrupadas por paciente ou numero anonimo
|
||||
-- ---------------------------------------------------------------------------
|
||||
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
|
||||
|
||||
CREATE VIEW public.conversation_threads
|
||||
WITH (security_invoker = true)
|
||||
AS
|
||||
WITH base AS (
|
||||
SELECT
|
||||
cm.id,
|
||||
cm.tenant_id,
|
||||
cm.patient_id,
|
||||
cm.channel,
|
||||
cm.body,
|
||||
cm.direction,
|
||||
cm.kanban_status,
|
||||
cm.read_at,
|
||||
cm.created_at,
|
||||
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
|
||||
COALESCE(cm.patient_id::text, 'anon:' || COALESCE(
|
||||
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
|
||||
'unknown'
|
||||
)) AS thread_key
|
||||
FROM public.conversation_messages cm
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (tenant_id, thread_key)
|
||||
tenant_id, thread_key, patient_id, channel, contact_number,
|
||||
body AS last_message_body,
|
||||
direction AS last_message_direction,
|
||||
kanban_status,
|
||||
created_at AS last_message_at
|
||||
FROM base
|
||||
ORDER BY tenant_id, thread_key, created_at DESC
|
||||
),
|
||||
counts AS (
|
||||
SELECT
|
||||
tenant_id, thread_key,
|
||||
COUNT(*) AS message_count,
|
||||
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
|
||||
FROM base
|
||||
GROUP BY tenant_id, thread_key
|
||||
)
|
||||
SELECT
|
||||
l.tenant_id,
|
||||
l.thread_key,
|
||||
l.patient_id,
|
||||
p.nome_completo AS patient_name,
|
||||
l.contact_number,
|
||||
l.channel,
|
||||
c.message_count,
|
||||
c.unread_count,
|
||||
l.last_message_at,
|
||||
l.last_message_body,
|
||||
l.last_message_direction,
|
||||
l.kanban_status
|
||||
FROM latest l
|
||||
JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key
|
||||
LEFT JOIN public.patients p ON p.id = l.patient_id;
|
||||
|
||||
COMMENT ON VIEW public.conversation_threads IS
|
||||
'Agregado de conversas por paciente ou por numero anonimo. Base do Kanban.';
|
||||
|
||||
GRANT SELECT ON public.conversation_threads TO authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS: tenant member le; ninguem escreve direto (so via edge function service_role)
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_messages FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "conv_msg: select tenant" ON public.conversation_messages;
|
||||
DROP POLICY IF EXISTS "conv_msg: update kanban" ON public.conversation_messages;
|
||||
DROP POLICY IF EXISTS "conv_msg: no direct insert" ON public.conversation_messages;
|
||||
DROP POLICY IF EXISTS "conv_msg: no direct delete" ON public.conversation_messages;
|
||||
|
||||
CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- tenant member pode atualizar apenas kanban_status/read_at/responded_at/resolved_at
|
||||
-- (nao pode mexer em body, provider, etc)
|
||||
CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages
|
||||
FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
@@ -0,0 +1,275 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000005_search_global_rpc
|
||||
-- Busca global do topbar — RPC única que retorna resultados agrupados por
|
||||
-- entidade (pacientes, agendamentos, documentos, serviços).
|
||||
--
|
||||
-- Segurança:
|
||||
-- • SECURITY INVOKER → respeita RLS do chamador (terapeuta vê só os dele,
|
||||
-- clínica vê do tenant, saas_admin vê global). Sem reinvenção de permissão.
|
||||
-- • GRANT apenas para `authenticated` (paciente anônimo não tem busca global).
|
||||
--
|
||||
-- Índices trigram:
|
||||
-- • patients(nome_completo, email_principal, cpf)
|
||||
-- • services(name)
|
||||
-- • agenda_eventos(titulo, titulo_custom)
|
||||
-- • documents(nome_original) — já existe em 06_indexes/indexes.sql (skip)
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Índices trigram (GIN) pra ILIKE/similarity performarem
|
||||
-- pg_trgm instalado em schema `extensions`; ops class vive em `public`.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_patients_nome_trgm
|
||||
ON public.patients USING gin (nome_completo public.gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patients_email_trgm
|
||||
ON public.patients USING gin (email_principal public.gin_trgm_ops)
|
||||
WHERE email_principal IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patients_cpf_trgm
|
||||
ON public.patients USING gin (cpf public.gin_trgm_ops)
|
||||
WHERE cpf IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_services_name_trgm
|
||||
ON public.services USING gin (name public.gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_trgm
|
||||
ON public.agenda_eventos USING gin (titulo public.gin_trgm_ops)
|
||||
WHERE titulo IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_custom_trgm
|
||||
ON public.agenda_eventos USING gin (titulo_custom public.gin_trgm_ops)
|
||||
WHERE titulo_custom IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_intake_requests_nome_trgm
|
||||
ON public.patient_intake_requests USING gin (nome_completo public.gin_trgm_ops)
|
||||
WHERE status = 'new';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- RPC principal
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.search_global(
|
||||
p_q text,
|
||||
p_scope text[] DEFAULT NULL,
|
||||
p_limit int DEFAULT 8
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
STABLE
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_q text;
|
||||
v_pattern text;
|
||||
v_limit int;
|
||||
v_patients jsonb := '[]'::jsonb;
|
||||
v_appointments jsonb := '[]'::jsonb;
|
||||
v_documents jsonb := '[]'::jsonb;
|
||||
v_services jsonb := '[]'::jsonb;
|
||||
v_intakes jsonb := '[]'::jsonb;
|
||||
BEGIN
|
||||
-- Sanitize + length guards
|
||||
v_q := nullif(btrim(coalesce(p_q, '')), '');
|
||||
IF v_q IS NULL OR length(v_q) < 2 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'patients', '[]'::jsonb,
|
||||
'appointments', '[]'::jsonb,
|
||||
'documents', '[]'::jsonb,
|
||||
'services', '[]'::jsonb,
|
||||
'intakes', '[]'::jsonb
|
||||
);
|
||||
END IF;
|
||||
v_q := left(v_q, 80);
|
||||
v_pattern := '%' || v_q || '%';
|
||||
v_limit := GREATEST(1, LEAST(coalesce(p_limit, 8), 20));
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- Pacientes
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
p.id,
|
||||
p.nome_completo,
|
||||
p.email_principal,
|
||||
p.telefone,
|
||||
p.avatar_url,
|
||||
GREATEST(
|
||||
similarity(coalesce(p.nome_completo, ''), v_q),
|
||||
similarity(coalesce(p.email_principal, ''), v_q) * 0.7,
|
||||
similarity(coalesce(p.telefone, ''), v_q) * 0.5,
|
||||
similarity(coalesce(p.cpf, ''), v_q) * 0.6
|
||||
) AS score
|
||||
FROM public.patients p
|
||||
WHERE p.nome_completo ILIKE v_pattern
|
||||
OR p.email_principal ILIKE v_pattern
|
||||
OR p.telefone ILIKE v_pattern
|
||||
OR p.cpf ILIKE v_pattern
|
||||
ORDER BY score DESC, p.nome_completo ASC
|
||||
LIMIT v_limit
|
||||
)
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'label', nome_completo,
|
||||
'sublabel', coalesce(nullif(email_principal, ''), nullif(telefone, ''), ''),
|
||||
'avatar_url', avatar_url,
|
||||
'deeplink', '/therapist/patients/cadastro/' || id::text,
|
||||
'score', round(score::numeric, 3)
|
||||
)), '[]'::jsonb) INTO v_patients
|
||||
FROM ranked;
|
||||
END IF;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- Agendamentos (com nome do paciente via join)
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
e.id,
|
||||
coalesce(nullif(e.titulo_custom, ''), nullif(e.titulo, ''), 'Sessão') AS label,
|
||||
e.inicio_em,
|
||||
pat.nome_completo AS patient_name,
|
||||
GREATEST(
|
||||
similarity(coalesce(e.titulo, ''), v_q),
|
||||
similarity(coalesce(e.titulo_custom, ''), v_q),
|
||||
similarity(coalesce(pat.nome_completo, ''), v_q) * 0.9
|
||||
) AS score
|
||||
FROM public.agenda_eventos e
|
||||
LEFT JOIN public.patients pat ON pat.id = e.patient_id
|
||||
WHERE e.titulo ILIKE v_pattern
|
||||
OR e.titulo_custom ILIKE v_pattern
|
||||
OR pat.nome_completo ILIKE v_pattern
|
||||
ORDER BY score DESC, e.inicio_em DESC
|
||||
LIMIT v_limit
|
||||
)
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'label', label,
|
||||
'sublabel', trim(both ' · ' from
|
||||
coalesce(patient_name, '') || ' · '
|
||||
|| to_char(inicio_em, 'DD/MM/YYYY HH24:MI')),
|
||||
'deeplink', '/therapist/agenda?event=' || id::text,
|
||||
'score', round(score::numeric, 3)
|
||||
)), '[]'::jsonb) INTO v_appointments
|
||||
FROM ranked;
|
||||
END IF;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- Documentos
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
d.id,
|
||||
d.patient_id,
|
||||
d.nome_original,
|
||||
d.tipo_documento,
|
||||
pat.nome_completo AS patient_name,
|
||||
GREATEST(
|
||||
similarity(coalesce(d.nome_original, ''), v_q),
|
||||
similarity(coalesce(d.descricao, ''), v_q) * 0.7
|
||||
) AS score
|
||||
FROM public.documents d
|
||||
LEFT JOIN public.patients pat ON pat.id = d.patient_id
|
||||
WHERE d.nome_original ILIKE v_pattern
|
||||
OR d.descricao ILIKE v_pattern
|
||||
ORDER BY score DESC, d.nome_original ASC
|
||||
LIMIT v_limit
|
||||
)
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'label', nome_original,
|
||||
'sublabel', trim(both ' · ' from
|
||||
coalesce(patient_name, '') || ' · '
|
||||
|| coalesce(tipo_documento, '')),
|
||||
'deeplink', '/therapist/patients/' || patient_id::text || '/documents',
|
||||
'score', round(score::numeric, 3)
|
||||
)), '[]'::jsonb) INTO v_documents
|
||||
FROM ranked;
|
||||
END IF;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- Serviços (ativos)
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.price,
|
||||
s.duration_min,
|
||||
GREATEST(
|
||||
similarity(coalesce(s.name, ''), v_q),
|
||||
similarity(coalesce(s.description, ''), v_q) * 0.7
|
||||
) AS score
|
||||
FROM public.services s
|
||||
WHERE s.active IS TRUE
|
||||
AND (s.name ILIKE v_pattern
|
||||
OR s.description ILIKE v_pattern)
|
||||
ORDER BY score DESC, s.name ASC
|
||||
LIMIT v_limit
|
||||
)
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'label', name,
|
||||
'sublabel', trim(both ' · ' from
|
||||
'R$ ' || to_char(price, 'FM999G999G990D00') || ' · '
|
||||
|| coalesce(duration_min::text || ' min', '')),
|
||||
'deeplink', '/configuracoes/precificacao',
|
||||
'score', round(score::numeric, 3)
|
||||
)), '[]'::jsonb) INTO v_services
|
||||
FROM ranked;
|
||||
END IF;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- Intakes pendentes (patient_intake_requests com status='new')
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
r.id,
|
||||
r.nome_completo,
|
||||
r.email_principal,
|
||||
r.telefone,
|
||||
r.created_at,
|
||||
GREATEST(
|
||||
similarity(coalesce(r.nome_completo, ''), v_q),
|
||||
similarity(coalesce(r.email_principal, ''), v_q) * 0.7,
|
||||
similarity(coalesce(r.telefone, ''), v_q) * 0.5
|
||||
) AS score
|
||||
FROM public.patient_intake_requests r
|
||||
WHERE r.status = 'new'
|
||||
AND (r.nome_completo ILIKE v_pattern
|
||||
OR r.email_principal ILIKE v_pattern
|
||||
OR r.telefone ILIKE v_pattern)
|
||||
ORDER BY score DESC, r.created_at DESC
|
||||
LIMIT v_limit
|
||||
)
|
||||
SELECT coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'label', coalesce(nullif(trim(nome_completo), ''), '(sem nome)'),
|
||||
'sublabel', trim(both ' · ' from
|
||||
coalesce(nullif(email_principal, ''), nullif(telefone, ''), '') || ' · '
|
||||
|| 'recebido ' || to_char(created_at, 'DD/MM/YYYY')),
|
||||
'deeplink', '/therapist/patients/cadastro/recebidos?id=' || id::text,
|
||||
'score', round(score::numeric, 3)
|
||||
)), '[]'::jsonb) INTO v_intakes
|
||||
FROM ranked;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'patients', v_patients,
|
||||
'appointments', v_appointments,
|
||||
'documents', v_documents,
|
||||
'services', v_services,
|
||||
'intakes', v_intakes
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION public.search_global(text, text[], int) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.search_global(text, text[], int) TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.search_global(text, text[], int) IS
|
||||
'Busca global do topbar — retorna jsonb agrupado por entidade. SECURITY INVOKER (RLS do chamador aplica).';
|
||||
@@ -0,0 +1,117 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000006_conv_messages_notifications
|
||||
-- Sessao 11 - Fase 5a (extensao).
|
||||
--
|
||||
-- Integra conversation_messages ao sistema de notifications existente:
|
||||
-- - Adiciona 'inbound_message' ao CHECK do notifications.type
|
||||
-- - Trigger em conversation_messages: quando chega inbound, fan-out para
|
||||
-- members do tenant apropriados (responsible_member do paciente, ou
|
||||
-- todos tenant_admin/clinic_admin/therapist ativos se sem vinculo)
|
||||
-- =============================================================================
|
||||
|
||||
-- Ajusta CHECK do type
|
||||
ALTER TABLE public.notifications
|
||||
DROP CONSTRAINT IF EXISTS notifications_type_check;
|
||||
|
||||
ALTER TABLE public.notifications
|
||||
ADD CONSTRAINT notifications_type_check
|
||||
CHECK (type = ANY (ARRAY[
|
||||
'new_scheduling',
|
||||
'new_patient',
|
||||
'recurrence_alert',
|
||||
'session_status',
|
||||
'inbound_message'
|
||||
]));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Trigger function: fan-out mensagem inbound para notifications dos members
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target_user UUID;
|
||||
v_title TEXT;
|
||||
v_detail TEXT;
|
||||
v_initials TEXT;
|
||||
v_deeplink TEXT;
|
||||
v_patient_name TEXT;
|
||||
v_payload JSONB;
|
||||
BEGIN
|
||||
-- so processa inbound
|
||||
IF NEW.direction <> 'inbound' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- busca nome do paciente (se vinculado)
|
||||
IF NEW.patient_id IS NOT NULL THEN
|
||||
SELECT nome_completo INTO v_patient_name FROM public.patients WHERE id = NEW.patient_id;
|
||||
END IF;
|
||||
|
||||
-- titulo e detalhes
|
||||
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
|
||||
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
|
||||
|
||||
-- iniciais
|
||||
IF v_patient_name IS NOT NULL THEN
|
||||
v_initials := upper(left(v_patient_name, 1)) ||
|
||||
COALESCE(upper(left(split_part(v_patient_name, ' ', 2), 1)), '');
|
||||
ELSE
|
||||
v_initials := '?';
|
||||
END IF;
|
||||
|
||||
-- deeplink para a pagina de conversas (clinic padrao; therapist tambem funciona via mesma rota na area dele)
|
||||
v_deeplink := '/admin/conversas';
|
||||
|
||||
v_payload := jsonb_build_object(
|
||||
'title', v_title,
|
||||
'detail', v_detail,
|
||||
'avatar_initials', v_initials,
|
||||
'deeplink', v_deeplink,
|
||||
'channel', NEW.channel,
|
||||
'conversation_message_id', NEW.id,
|
||||
'patient_id', NEW.patient_id,
|
||||
'from_number', NEW.from_number
|
||||
);
|
||||
|
||||
-- ─── decide destinatarios ─────────────────────────────────────────────
|
||||
|
||||
-- Caso 1: paciente vinculado e tem responsible_member_id
|
||||
IF NEW.patient_id IS NOT NULL THEN
|
||||
SELECT tm.user_id INTO v_target_user
|
||||
FROM public.patients p
|
||||
JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
|
||||
WHERE p.id = NEW.patient_id
|
||||
AND tm.status = 'active'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_target_user IS NOT NULL THEN
|
||||
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||
VALUES (v_target_user, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Caso 2: fallback — fan-out pra todos tenant_admin/clinic_admin/therapist ativos
|
||||
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||
SELECT tm.user_id, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = NEW.tenant_id
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('clinic_admin', 'tenant_admin', 'therapist');
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Trigger
|
||||
DROP TRIGGER IF EXISTS trg_fanout_inbound_to_notifications ON public.conversation_messages;
|
||||
CREATE TRIGGER trg_fanout_inbound_to_notifications
|
||||
AFTER INSERT ON public.conversation_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications();
|
||||
|
||||
COMMENT ON FUNCTION public.fanout_inbound_message_to_notifications() IS
|
||||
'Cria registros em notifications pra members apropriados quando chega mensagem inbound. Respeita responsible_member do paciente.';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000007_notif_channels_saas_admin_insert
|
||||
--
|
||||
-- Fix: SaaS admin nao conseguia INSERT em notification_channels via /saas/whatsapp
|
||||
-- porque a policy de insert exigia owner_id = auth.uid() e o saas_admin esta
|
||||
-- inserindo em nome do tenant_admin (outro user). As policies de update/delete
|
||||
-- ja tinham OR is_saas_admin() — o insert foi esquecido.
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
|
||||
|
||||
CREATE POLICY "notif_channels_insert" ON public.notification_channels
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "notif_channels_insert" ON public.notification_channels IS
|
||||
'SaaS admin pode inserir em nome de qualquer tenant; tenant_member insere pra si mesmo.';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000008_conv_messages_realtime
|
||||
--
|
||||
-- Adiciona conversation_messages na publicacao supabase_realtime para que
|
||||
-- INSERT/UPDATE sejam entregues ao subscribe do frontend.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.conversation_messages;
|
||||
|
||||
-- REPLICA IDENTITY FULL permite que o payload do Realtime traga a row completa
|
||||
-- (necessario pra usar filters e receber old/new em UPDATEs)
|
||||
ALTER TABLE public.conversation_messages REPLICA IDENTITY FULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260420000009_conv_messages_delivery_status
|
||||
--
|
||||
-- Adiciona colunas para rastrear status de entrega/leitura das mensagens
|
||||
-- outbound (envio pelo sistema). Evolution dispara evento messages.update
|
||||
-- com status = SENT | DELIVERED | READ que vamos capturar.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.conversation_messages
|
||||
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS read_by_recipient_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS delivery_status TEXT
|
||||
CHECK (delivery_status IS NULL OR delivery_status IN ('pending','sent','delivered','read','failed'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_msg_delivery_status
|
||||
ON public.conversation_messages (tenant_id, delivery_status)
|
||||
WHERE direction = 'outbound';
|
||||
@@ -0,0 +1,91 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Storage Bucket para midia de WhatsApp
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Cria bucket privado `whatsapp-media` para armazenar audio/imagem/video/
|
||||
-- documentos recebidos via Evolution API. URLs do WhatsApp sao encriptadas
|
||||
-- com mediaKey da Meta — precisamos decriptar via Evolution getBase64 e
|
||||
-- subir pro nosso storage para playback no browser.
|
||||
--
|
||||
-- Privacidade LGPD:
|
||||
-- - Bucket privado (public=false)
|
||||
-- - Upload apenas via service_role (edge function)
|
||||
-- - Leitura via signed URLs gerados on-demand pelo frontend (expiracao curta)
|
||||
-- - Paths tenant-scoped: <tenant_id>/<yyyy>/<mm>/<uuid>.<ext>
|
||||
-- ==========================================================================
|
||||
|
||||
-- Bucket whatsapp-media
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'whatsapp-media',
|
||||
'whatsapp-media',
|
||||
false,
|
||||
26214400, -- 25 MB (WhatsApp aceita ate 16MB audio/video, margem extra)
|
||||
ARRAY[
|
||||
-- Audio
|
||||
'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/wav', 'audio/webm',
|
||||
-- Imagem
|
||||
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
|
||||
-- Video
|
||||
'video/mp4', 'video/3gpp', 'video/quicktime', 'video/webm',
|
||||
-- Documento
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'application/zip',
|
||||
'application/octet-stream'
|
||||
]
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Storage RLS Policies — whatsapp-media
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Politica: APENAS service_role faz upload (edge function).
|
||||
-- Usuarios autenticados leem se forem membros ativos do tenant no path[0].
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
-- Read: membro ativo do tenant cujo id e o primeiro segmento do path
|
||||
CREATE POLICY "whatsapp-media: read tenant members"
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'whatsapp-media'
|
||||
AND (
|
||||
-- SaaS admins leem qualquer tenant
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
-- Membros ativos do tenant (tenant_id e o primeiro segmento do path)
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND (storage.foldername(name))[1] = tm.tenant_id::text
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Insert: bloqueado para authenticated (apenas service_role sobe)
|
||||
-- Sem policy de INSERT para authenticated = bloqueado por default no RLS.
|
||||
|
||||
-- Delete: SaaS admin pode deletar (retention policy futura)
|
||||
CREATE POLICY "whatsapp-media: delete saas admin"
|
||||
ON storage.objects
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'whatsapp-media'
|
||||
AND public.is_saas_admin()
|
||||
);
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,116 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Notas internas de conversa (CRM Grupo 3.3)
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Notas internas da equipe em cada thread de conversa (WhatsApp/SMS/etc).
|
||||
-- NAO sao enviadas ao paciente — apenas visiveis aos membros do tenant.
|
||||
--
|
||||
-- thread_key segue o padrao de conversation_threads:
|
||||
-- - '<uuid>' → thread de paciente conhecido
|
||||
-- - 'anon:<phone>' → thread de numero nao identificado
|
||||
--
|
||||
-- RLS:
|
||||
-- - READ/CREATE: qualquer membro ativo do tenant
|
||||
-- - UPDATE/DELETE: apenas o criador da nota OU saas_admin
|
||||
-- ==========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.conversation_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
thread_key TEXT NOT NULL,
|
||||
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
|
||||
contact_number TEXT,
|
||||
body TEXT NOT NULL CHECK (length(body) > 0 AND length(body) <= 4000),
|
||||
created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_notes_tenant_thread
|
||||
ON public.conversation_notes (tenant_id, thread_key, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_notes_patient
|
||||
ON public.conversation_notes (patient_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL AND patient_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_notes_created_by
|
||||
ON public.conversation_notes (created_by, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Trigger de updated_at (usa funcao existente set_updated_at)
|
||||
DROP TRIGGER IF EXISTS trg_conv_notes_updated_at ON public.conversation_notes;
|
||||
CREATE TRIGGER trg_conv_notes_updated_at
|
||||
BEFORE UPDATE ON public.conversation_notes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
COMMENT ON TABLE public.conversation_notes IS
|
||||
'Notas internas por thread de conversa. Visiveis apenas aos membros do tenant; nao enviadas ao paciente.';
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- RLS
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: membro ativo do tenant OU saas_admin
|
||||
DROP POLICY IF EXISTS "conv_notes: select tenant members" ON public.conversation_notes;
|
||||
CREATE POLICY "conv_notes: select tenant members"
|
||||
ON public.conversation_notes
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_notes.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: membro ativo do tenant, created_by deve ser o proprio usuario
|
||||
DROP POLICY IF EXISTS "conv_notes: insert tenant members" ON public.conversation_notes;
|
||||
CREATE POLICY "conv_notes: insert tenant members"
|
||||
ON public.conversation_notes
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
created_by = auth.uid()
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_notes.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE: apenas criador OU saas_admin
|
||||
DROP POLICY IF EXISTS "conv_notes: update creator or saas" ON public.conversation_notes;
|
||||
CREATE POLICY "conv_notes: update creator or saas"
|
||||
ON public.conversation_notes
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (created_by = auth.uid() OR public.is_saas_admin())
|
||||
)
|
||||
WITH CHECK (
|
||||
created_by = (SELECT created_by FROM public.conversation_notes WHERE id = conversation_notes.id)
|
||||
);
|
||||
|
||||
-- DELETE: soft delete via UPDATE deleted_at (nao permite hard delete)
|
||||
-- Mantemos politica de DELETE bloqueada por default (sem policy = nao permitido)
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,226 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Tags de conversa (CRM Grupo 3.1)
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Tags aplicaveis a uma thread de conversa (urgente, primeira consulta,
|
||||
-- remarcacao, etc). Cada tenant pode criar tags custom alem das padrao.
|
||||
--
|
||||
-- Tabelas:
|
||||
-- - conversation_tags — definicoes (system com tenant_id NULL + custom)
|
||||
-- - conversation_thread_tags — join (tenant_id, thread_key, tag_id)
|
||||
--
|
||||
-- thread_key: mesma logica de conversation_threads
|
||||
-- - '<uuid>' → thread de paciente
|
||||
-- - 'anon:<phone>' → thread anonima
|
||||
-- ==========================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tabela: conversation_tags (definicoes)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.conversation_tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system tag
|
||||
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
|
||||
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
|
||||
color TEXT NOT NULL DEFAULT '#6366f1' CHECK (color ~ '^#[0-9a-fA-F]{6}$'),
|
||||
icon TEXT, -- classe de icone primeicons (ex: 'pi pi-exclamation-triangle')
|
||||
position INT NOT NULL DEFAULT 100,
|
||||
is_system BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Unique: (tenant_id, slug). Para tenant_id NULL (system), um indice parcial separado.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_tenant_slug
|
||||
ON public.conversation_tags (tenant_id, slug)
|
||||
WHERE tenant_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_system_slug
|
||||
ON public.conversation_tags (slug)
|
||||
WHERE tenant_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_tags_tenant
|
||||
ON public.conversation_tags (tenant_id, position);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_conv_tags_updated_at ON public.conversation_tags;
|
||||
CREATE TRIGGER trg_conv_tags_updated_at
|
||||
BEFORE UPDATE ON public.conversation_tags
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
COMMENT ON TABLE public.conversation_tags IS
|
||||
'Definicoes de tags aplicaveis a threads. tenant_id NULL = tag do sistema (todos veem).';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tabela: conversation_thread_tags (join many-to-many)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.conversation_thread_tags (
|
||||
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
thread_key TEXT NOT NULL,
|
||||
tag_id UUID NOT NULL REFERENCES public.conversation_tags(id) ON DELETE CASCADE,
|
||||
tagged_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
tagged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, thread_key, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tenant_thread
|
||||
ON public.conversation_thread_tags (tenant_id, thread_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tag
|
||||
ON public.conversation_thread_tags (tag_id);
|
||||
|
||||
COMMENT ON TABLE public.conversation_thread_tags IS
|
||||
'Join de tags aplicadas a cada thread de conversa.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Seed de tags padrao (system)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO public.conversation_tags (tenant_id, name, slug, color, icon, position, is_system)
|
||||
VALUES
|
||||
(NULL, 'Urgente', 'urgente', '#ef4444', 'pi pi-exclamation-triangle', 10, true),
|
||||
(NULL, 'Primeira consulta','primeira-consulta','#0ea5e9', 'pi pi-user-plus', 20, true),
|
||||
(NULL, 'Remarcação', 'remarcacao', '#f59e0b', 'pi pi-calendar-times', 30, true),
|
||||
(NULL, 'Confirmada', 'confirmada', '#22c55e', 'pi pi-check-circle', 40, true),
|
||||
(NULL, 'Follow-up', 'follow-up', '#a855f7', 'pi pi-reply', 50, true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS: conversation_tags
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: system tags (tenant_id NULL) = todos; custom = membros ativos do tenant
|
||||
DROP POLICY IF EXISTS "conv_tags: select" ON public.conversation_tags;
|
||||
CREATE POLICY "conv_tags: select"
|
||||
ON public.conversation_tags
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
tenant_id IS NULL
|
||||
OR public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_tags.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: membros ativos do tenant criam custom. Nao podem criar system (tenant_id NULL)
|
||||
DROP POLICY IF EXISTS "conv_tags: insert custom" ON public.conversation_tags;
|
||||
CREATE POLICY "conv_tags: insert custom"
|
||||
ON public.conversation_tags
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IS NOT NULL
|
||||
AND is_system = false
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_tags.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE: tenant members para tags proprias (custom). Sistema bloqueado.
|
||||
DROP POLICY IF EXISTS "conv_tags: update custom" ON public.conversation_tags;
|
||||
CREATE POLICY "conv_tags: update custom"
|
||||
ON public.conversation_tags
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (
|
||||
is_system = false
|
||||
AND tenant_id IS NOT NULL
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_tags.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (is_system = false);
|
||||
|
||||
-- DELETE: tenant members removem tags custom. Sistema bloqueado.
|
||||
DROP POLICY IF EXISTS "conv_tags: delete custom" ON public.conversation_tags;
|
||||
CREATE POLICY "conv_tags: delete custom"
|
||||
ON public.conversation_tags
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
is_system = false
|
||||
AND tenant_id IS NOT NULL
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_tags.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS: conversation_thread_tags
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "conv_thread_tags: select" ON public.conversation_thread_tags;
|
||||
CREATE POLICY "conv_thread_tags: select"
|
||||
ON public.conversation_thread_tags
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_thread_tags.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "conv_thread_tags: insert" ON public.conversation_thread_tags;
|
||||
CREATE POLICY "conv_thread_tags: insert"
|
||||
ON public.conversation_thread_tags
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
tagged_by = auth.uid()
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_thread_tags.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "conv_thread_tags: delete" ON public.conversation_thread_tags;
|
||||
CREATE POLICY "conv_thread_tags: delete"
|
||||
ON public.conversation_thread_tags
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.tenant_id = conversation_thread_tags.tenant_id
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO
|
||||
-- ==========================================================================
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user