Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node:*)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && sed -i \\\\\n 's/console\\\\.time\\(tlabel\\)/const _perfEnd = logPerf\\('\\\\''router.guard'\\\\'', tlabel\\)/g' \\\\\n src/router/guards.js && echo \"console.time substituído\")",
|
||||
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && sed -i \\\\\n 's/console\\\\.timeEnd\\(tlabel\\)/_perfEnd\\(\\)/g' \\\\\n src/router/guards.js && echo \"console.timeEnd substituído\")",
|
||||
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && npm install --save-dev vitest @vitest/ui 2>&1 | tail -5)",
|
||||
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS\" -name \"*.sql\" -type f 2>/dev/null | head -10)",
|
||||
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS\" -name \"*.sql\" -type f 2>/dev/null | xargs grep -l \"agenda_eventos\" | head -3)",
|
||||
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS/2026-03-11\" -name \"*.sql\" -type f 2>/dev/null | head -3)",
|
||||
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai\" -type f -name \"*.sql\" 2>/dev/null | xargs grep -l \"agenda_eventos\" 2>/dev/null | head -5)",
|
||||
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src -name \"*[Pp]ricing*\" -o -name \"*[Pp]reco*\" -o -name \"*[Vv]alor*\" 2>/dev/null | head -20)",
|
||||
"Bash(where python:*)",
|
||||
"Bash(cd \"/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport zipfile\nimport xml.etree.ElementTree as ET\n\nfor fname in ['spec-wizard.docx', 'spec-v2.docx']:\n print\\('=== ' + fname + ' ==='\\)\n try:\n with zipfile.ZipFile\\(fname, 'r'\\) as z:\n with z.open\\('word/document.xml'\\) as f:\n tree = ET.parse\\(f\\)\n root = tree.getroot\\(\\)\n texts = []\n for para in root.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'\\):\n parts = []\n for t in para.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'\\):\n if t.text:\n parts.append\\(t.text\\)\n line = ''.join\\(parts\\)\n texts.append\\(line\\)\n print\\('\\\\n'.join\\(texts\\)\\)\n except Exception as e:\n print\\('Error: ' + str\\(e\\)\\)\n print\\(\\)\n\")",
|
||||
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nwith open\\('/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql', 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nprint\\(f'Total lines: {len\\(lines\\)}'\\)\n\" 2>&1)",
|
||||
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)",
|
||||
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(powershell -Command \"$content = [System.IO.File]::ReadAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', [System.Text.Encoding]::UTF8\\); $content = $content -replace [char]0x201C, ''\"\"'' -replace [char]0x201D, ''\"\"''; [System.IO.File]::WriteAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', $content, [System.Text.Encoding]::UTF8\\)\")",
|
||||
"Bash(xargs cat:*)",
|
||||
"Bash(xxd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-21/schema.sql\")",
|
||||
"Bash(iconv -f UTF-16LE -t UTF-8 \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-21/schema.sql\")",
|
||||
"Bash(mkdir -p docs/billing docs/planos docs/subscription-health docs/estrategia docs/specs)",
|
||||
"Bash(mkdir -p database/backups database/migrations database/seeds database/fixes database/snippets)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(mv disparando-whatsapp-local.md docs/whatsapp.md)",
|
||||
"Bash(mv comandos.txt docs/)",
|
||||
"Bash(mv dados-padrões-da-agenda.txt docs/)",
|
||||
"Bash(mv USER_ARCHETYPES.html docs/)",
|
||||
"Bash(mv Novo-DB/migration_*.sql database/migrations/)",
|
||||
"Bash(mv Novo-DB/seed_*.sql database/seeds/)",
|
||||
"Bash(mv Novo-DB/fix_*.sql database/fixes/)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(npm uninstall:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/schema.sql\")",
|
||||
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/data.sql\")",
|
||||
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/full_dump.sql\")",
|
||||
"Bash(wc:*)",
|
||||
"Bash(python _wizard_patch.py)",
|
||||
"Bash(rm _wizard_patch.py)",
|
||||
"Bash(npm ls:*)",
|
||||
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src/features/patients -type f \\\\\\(-name *.vue -o -name *.js \\\\\\))"
|
||||
]
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -14,5 +14,16 @@ dist-*/
|
||||
api-generator/typedoc.json
|
||||
**/.DS_Store
|
||||
Dev-documentacao/
|
||||
supabase/
|
||||
supabase/*
|
||||
!supabase/functions/
|
||||
evolution-api/
|
||||
|
||||
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
|
||||
database-novo/backups/
|
||||
|
||||
# Outputs do Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Config local do Claude Code (cada dev tem o seu)
|
||||
.claude/settings.local.json
|
||||
|
||||
+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`
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
# HANDOFF — 2026-04-19 (Sessão 6)
|
||||
|
||||
Documento de continuidade. Quando você voltar, comece lendo esta página. Todo o trabalho está registrado no banco (`/saas/desenvolvimento` → **Verificações**, **Auditoria**, **Testes**) — este arquivo é só o mapa.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Estado atual
|
||||
|
||||
| Tipo | Aberto | Notas |
|
||||
|---|---|---|
|
||||
| **A# auditoria** | **0** | ✅ todas as 30 resolvidas |
|
||||
| **V# verificações** | **9** | auth(3), pacientes(3), saas(2), router(1) |
|
||||
| **T# testes** | **2 a escrever** | T#9 useAgendaEvents wrapper, T#10 E2E |
|
||||
| **Áreas não auditadas** | **3** | financeiro, comunicação, documentos/prontuários |
|
||||
| **Migrations não commitadas** | **8** | Sessão 6 (ver lista abaixo) |
|
||||
| **Vitest** | **179/179** | 8 suites |
|
||||
| **SQL integration tests** | **33/33** | `database-novo/tests/run.cjs` |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Sessão 6 (hoje, 2026-04-19) — resumo
|
||||
|
||||
### Bloco 1 — V#34 + V#41 (Opção B2: plano + override + exceção comercial)
|
||||
- **Migration** `20260419000001_tenant_features_b2_governance.sql`:
|
||||
- Trigger `tenant_features_guard_with_plan` ganhou bypass via session flag (`current_setting('app.allow_feature_exception')`)
|
||||
- Nova RPC `set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)` SECURITY DEFINER com regras assimétricas:
|
||||
- `enabled=false` → tenant_admin OU saas_admin (preferência do cliente)
|
||||
- `enabled=true` AND plano permite → tenant_admin OU saas_admin
|
||||
- `enabled=true` AND plano NÃO permite → **só saas_admin + reason obrigatório** (exceção comercial)
|
||||
- Policy `tenant_features_write_saas_only` — writes diretos só saas_admin
|
||||
- **Store** `tenantFeaturesStore.isEnabled` reescrito (B2): override negativo desliga, override positivo liga, sem override segue plano
|
||||
- **Store** `setForTenant` agora chama RPC com `reason` opcional
|
||||
- **UI nova** `/saas/tenant-features` (selector de tenant, catálogo, dialog com `reason` obrigatório p/ exceção, log de mudanças)
|
||||
- **JSDoc** documentando separação semântica (`entitlementsStore.has` = "plano permite?" vs `tenantFeaturesStore.isEnabled` = "ativo agora?")
|
||||
- **Testes** `src/stores/__tests__/tenantFeaturesStore.spec.js` — 17 cenários incluindo regressão V#34
|
||||
|
||||
### Bloco 2 — Pendentes Sessão 5
|
||||
- **V#42** — `entitlementsStore.loadFor*` no catch agora NÃO marca como carregado (estado fica como "not loaded" → próximo request retenta) + `logError` adicionado
|
||||
- **V#40** — `features.is_active` (migration `...02`) + UI: soft delete + filtro "Mostrar depreciados" + Tag de status + botão Reativar
|
||||
- **V#36** — RPC `delete_plan_safe` (migration `...03`): bloqueia DELETE se houver subscriptions ativas. SaasPlansPage migrada
|
||||
- **V#35** — Migration `...04`: 17 → 11 policies. Removidas 3 read-auth duplicadas em plans/features/plan_features + 3 subsets/no-ops em subscriptions. `COMMENT ON POLICY` em todas
|
||||
|
||||
### Bloco 3 — Testes T#5 / T#7 / T#8
|
||||
- **T#5** `tenantStore.spec.js` — 15 testes: singleflight, regressão V#5 (não herdar tenant de outro user), erros, setActiveTenant, reset, getters. Stub localStorage in-memory (env=node sem jsdom)
|
||||
- **T#7** `validators.spec.js` — 38 testes: sanitização do intake (digitsOnly, CPF/CNPJ, phone, email, CEP, toISODate)
|
||||
- **T#8** `database-novo/tests/run.cjs` — runner Node + docker exec. **33 cenários SQL** cobrindo set_tenant_feature_exception, delete_plan_safe, intake, features.is_active, defesa em camadas, twilio config
|
||||
|
||||
### Bloco 4 — A#20 (CAPTCHA) — rev2 self-hosted
|
||||
**Decisão:** descartado Cloudflare Turnstile / hCaptcha em favor de defesa em camadas self-hosted. Razões: zero LGPD, zero provider, zero fricção pro paciente legítimo (UX importa muito em paciente vulnerável buscando atendimento).
|
||||
|
||||
5 camadas:
|
||||
1. **Honeypot** — campo invisível (frontend), bot rejeita
|
||||
2. **Validação** básica
|
||||
3. **Rate limit por IP** — `check_rate_limit` RPC
|
||||
4. **Math captcha condicional** — só ativa após N falhas do mesmo IP (default 3)
|
||||
5. **Modo paranoid** global toggle (saas_security_config.captcha_required_globally)
|
||||
|
||||
Implementação:
|
||||
- Migration `...06` (4 tabelas: `saas_security_config`, `public_submission_attempts`, `submission_rate_limits`, `math_challenges`)
|
||||
- Migration `...07` (RPCs: `check_rate_limit`, `record_submission_attempt`, `generate_math_challenge`, `verify_math_challenge`, `cleanup_expired_math_challenges`)
|
||||
- Edge function `submit-patient-intake` reescrita (dual endpoint: submit + `/captcha-challenge`)
|
||||
- Componente `MathCaptchaChallenge.vue` (lazy)
|
||||
- Tela `/saas/security` com card explicativo (6 seções), KPIs 24h, toggles, sliders, dashboard de IPs ativos, modo paranoid
|
||||
|
||||
### Bloco 5 — SaaS Twilio Config (operacional editável)
|
||||
- Migration `...08` (tabela `saas_twilio_config` singleton + RPCs `get_twilio_config` / `update_twilio_config`)
|
||||
- **Decisão de segurança**: `TWILIO_AUTH_TOKEN` permanece em env var (único secret); SID/webhook/rate/margem migram pra DB
|
||||
- Edge function `twilio-whatsapp-provision` lê do banco com fallback pra env (back-compat)
|
||||
- Tela `/saas/twilio-config` com card explicativo + status do AUTH_TOKEN (ping pra detectar se está setado)
|
||||
- **Bug fix**: `friendlyErrorMessage()` em `twilioWhatsappService.js` traduz "Edge Function returned a non-2xx status code" pra mensagens contextuais
|
||||
|
||||
---
|
||||
|
||||
## 🛑 Pendências (V# abertos por área)
|
||||
|
||||
### auth (3)
|
||||
- **V#2** (médio) — Listener `supabase.auth.onAuthStateChange` duplicado
|
||||
- **V#6** (baixo) — `globalRole` cache sem TTL e sem invalidação por realtime
|
||||
- **V#10** (médio) — Bloqueio SaaS em tenant-app só por `startsWith` de path (frágil)
|
||||
|
||||
### pacientes (3)
|
||||
- **V#3** (médio) — Pacientes não tem composables/services — toda lógica em pages
|
||||
- **V#8** (baixo) — `agenda_eventos.select` patient_id com `limit 1000` arbitrário
|
||||
- **V#9** (médio) — `PatientsCadastroPage` com **1985 linhas** (precisa quebrar)
|
||||
|
||||
### router (1)
|
||||
- **V#9** (baixo) — `ensureMenuBuilt` roda em toda navegação autenticada
|
||||
|
||||
### saas (2)
|
||||
- **V#17** (baixo) — 23 `console.*` em páginas SaaS
|
||||
- **V#18** (baixo) — `tenantFeaturesStore` sem TTL real (cache pode ficar stale)
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Migrations criadas hoje (ordem cronológica)
|
||||
|
||||
```
|
||||
database-novo/migrations/
|
||||
├── 20260419000001_tenant_features_b2_governance.sql (V#34/V#41)
|
||||
├── 20260419000002_features_is_active.sql (V#40)
|
||||
├── 20260419000003_delete_plan_safe.sql (V#36)
|
||||
├── 20260419000004_consolidate_policies.sql (V#35)
|
||||
├── 20260419000005_restrict_intake_rpc.sql (A#20 — REVOKE anon)
|
||||
├── 20260419000006_layered_bot_defense.sql (A#20 rev2 — schema)
|
||||
├── 20260419000007_bot_defense_rpcs.sql (A#20 rev2 — RPCs)
|
||||
└── 20260419000008_saas_twilio_config.sql (Twilio config)
|
||||
```
|
||||
|
||||
Todas aplicadas no banco local. **Se for pra cloud, aplicar nessa ordem.**
|
||||
|
||||
---
|
||||
|
||||
## 🗜️ Arquivos criados/modificados (código)
|
||||
|
||||
**Criados:**
|
||||
```
|
||||
src/components/security/MathCaptchaChallenge.vue
|
||||
src/views/pages/saas/SaasTenantFeaturesPage.vue
|
||||
src/views/pages/saas/SaasSecurityPage.vue
|
||||
src/views/pages/saas/SaasTwilioConfigPage.vue
|
||||
src/stores/__tests__/tenantStore.spec.js
|
||||
src/stores/__tests__/tenantFeaturesStore.spec.js
|
||||
src/utils/__tests__/validators.spec.js
|
||||
database-novo/tests/run.cjs
|
||||
supabase/functions/submit-patient-intake/index.js (reescrita)
|
||||
```
|
||||
|
||||
**Modificados:**
|
||||
```
|
||||
src/stores/tenantFeaturesStore.js (lógica B2 + RPC)
|
||||
src/stores/entitlementsStore.js (V#42 fix + JSDoc)
|
||||
src/services/twilioWhatsappService.js (friendlyErrorMessage)
|
||||
src/views/pages/saas/SaasFeaturesPage.vue (V#40 soft delete)
|
||||
src/views/pages/saas/SaasPlansPage.vue (V#36 RPC)
|
||||
src/views/pages/saas/SaasTwilioWhatsappPage.vue (toast warn em vez de error)
|
||||
src/views/pages/clinic/clinic/ClinicFeaturesPage.vue (texto erro plano-denied)
|
||||
src/views/pages/public/CadastroPacienteExterno.vue (honeypot + math captcha)
|
||||
src/router/routes.saas.js (3 rotas novas)
|
||||
src/navigation/menus/saas.menu.js (3 itens menu novos)
|
||||
supabase/functions/twilio-whatsapp-provision/index.ts (lê config DB)
|
||||
.env (limpo Turnstile vars)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Números finais
|
||||
|
||||
| Métrica | Antes (Sessão 5) | Hoje |
|
||||
|---|---|---|
|
||||
| A# auditoria | 30 (1 aberto) | 30 (0 abertos) |
|
||||
| V# verificações | 42 (15 abertos) | 42 (9 abertos) |
|
||||
| Suites de teste vitest | 6 (109 tests) | 8 (179 tests) |
|
||||
| Suites SQL integration | 0 | 1 (33 tests) |
|
||||
| Migrations totais | 5 (Sessão 5) | 13 (+8 hoje) |
|
||||
| Telas SaaS novas | — | 3 (`/tenant-features`, `/security`, `/twilio-config`) |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordem sugerida quando voltar
|
||||
|
||||
1. **Decidir se commita** o trabalho da Sessão 6 (~30 arquivos, 8 migrations).
|
||||
2. **Continuar Sessão 6** (em andamento): A+B+C+D escolhidos, faltando concluir B/C/D.
|
||||
3. Outras opções:
|
||||
- **Nova área de revisão sênior** — financeiro / comunicação / documentos
|
||||
- **Deploy real** (Supabase cloud + secrets + edge functions)
|
||||
- **Testes T#9 + T#10**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Nada commitado (ainda)
|
||||
|
||||
Working directory tem ~30 arquivos modificados desde Sessão 5. Revise com `git status` + `git diff` antes de decidir o que comitar.
|
||||
|
||||
**Nada quebrou:** vitest 179/179 + SQL 33/33.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Sessões 1-6 acumuladas — hardening, defesa em profundidade, +192 testes
|
||||
|
||||
Repositório estava sem commit há ~5 sessões de trabalho intenso. Este commit
|
||||
consolida tudo que foi feito desde o último marco (`d088a89`).
|
||||
|
||||
**A# auditoria:** 30 itens registrados, **0 abertos**.
|
||||
**V# verificações:** 52 registradas (10 novas em Documentos), **5 abertas** (todas adiadas com plano).
|
||||
**T# testes:** 10/10 escritas. **192 vitest + 33 SQL + 5 E2E** passando.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 1 — auth/router/session
|
||||
|
||||
- A#7 resolvido (`window.__guardsBound`)
|
||||
- 10 verificações registradas (V#1–V#10), 5 corrigidas:
|
||||
- `session.js` logger inconsistente
|
||||
- `tenantStore.ensureLoaded` polling
|
||||
- `normalizeRole` duplicado → extraído pra `src/utils/roleNormalizer.js`
|
||||
- `console.error` em `router/index.js`
|
||||
- `.single()` em `fetchRole`
|
||||
|
||||
---
|
||||
|
||||
## Sessão 2 — agenda
|
||||
|
||||
- 11 verificações (V#11–V#21), 10 corrigidas:
|
||||
- `useRecurrence` CRUD ganhou filtro `tenant_id` (alto)
|
||||
- `agenda.service.js` vazio deletado
|
||||
- `agendaRepository` ↔ `useAgendaEvents` consolidados (composable virou wrapper fino, 181→67 linhas)
|
||||
- `AGENDA_EVENT_SELECT` centralizado em `agendaSelects.js`
|
||||
- `_tenantGuards.js` compartilhado
|
||||
- V#21 status `remarcar` → `remarcado` padronizado em 14 edições
|
||||
|
||||
---
|
||||
|
||||
## Sessão 3 — pacientes
|
||||
|
||||
- 10 verificações (V#22–V#31), 6 corrigidas + 4 documentadas
|
||||
- 5 arquivos obsoletos deletados (PatientsCadastroPage Bkp, preview, prontuário design 1/2/3)
|
||||
- `tenant_id` em todas queries de patients (alto)
|
||||
- 9 `console.*` migrados pra logger
|
||||
- `hydrateAssociations` paralelizada (5 round-trips → 2)
|
||||
- `.maybeSingle()` onde precisava
|
||||
|
||||
---
|
||||
|
||||
## Sessão 4 — security review (página pública de cadastro)
|
||||
|
||||
- V#31 virou security review completa: **15 vulnerabilidades (A#15–A#29), 14 corrigidas**
|
||||
- **Críticos:**
|
||||
- **A#15** bucket `avatars` público → 5MB + mime whitelist + policies restritas
|
||||
- **A#16** RPC v2 ignorava `active/expires/max_uses` → validação completa + incrementa `uses`
|
||||
- **A#17** `notas_internas` exposto ao paciente → removido do form e RPC
|
||||
- **A#18** `Math.random()` pra token → RPCs server-side via `gen_random_uuid()`
|
||||
- **A#19** intake sem `tenant_id` → RPC resolve via `patient_invites` ou `tenant_members`
|
||||
- **Médios:** log `patient_invite_attempts` (A#24), política LGPD (A#25), botão mock só em DEV (A#26), length caps server-side (A#27)
|
||||
- **Baixos:** duplicado `PatientsExternalLinkPage` deletado, `Landingpage-v1 - bkp.vue` deletado
|
||||
|
||||
---
|
||||
|
||||
## Sessão 5 — SaaS (planos, preços, recursos)
|
||||
|
||||
- 10 verificações (V#33–V#42)
|
||||
- **🔴 P0:** A#30 — 7 tabelas SaaS com RLS OFF + `GRANT ALL` pra anon. Migration `...05_saas_rls_emergency_fix` aplicou REVOKE + ENABLE RLS + 9 policies corretas
|
||||
- 109/109 testes passando
|
||||
|
||||
---
|
||||
|
||||
## Sessão 6 (HOJE, 2026-04-19) — bloco principal
|
||||
|
||||
### V#34 + V#41 — Opção B2 (plano + override + exceção comercial)
|
||||
|
||||
Resolve `tenantFeaturesStore.isEnabled` que retornava `true` por default
|
||||
(qualquer feature aparecia ativa pra qualquer tenant) E a dupla-fonte com
|
||||
`entitlementsStore`.
|
||||
|
||||
**Backend** (migration `...01`):
|
||||
- Trigger `tenant_features_guard_with_plan` ganhou bypass via session flag
|
||||
- RPC `set_tenant_feature_exception` SECURITY DEFINER com regras assimétricas:
|
||||
- `enabled=false` → tenant_admin OU saas_admin (preferência)
|
||||
- `enabled=true` AND plano permite → tenant_admin OU saas_admin
|
||||
- `enabled=true` AND plano NÃO permite → **só saas_admin + reason obrigatório**
|
||||
- Policy `tenant_features_write_saas_only`
|
||||
|
||||
**Frontend:**
|
||||
- `tenantFeaturesStore.isEnabled` reescrito (B2): override negativo desliga, override positivo liga (exceção), sem override segue plano
|
||||
- `setForTenant` chama RPC com `reason`
|
||||
- Tela nova `/saas/tenant-features` com dialog de motivo obrigatório
|
||||
- JSDoc separação semântica: `entitlementsStore.has` = "plano permite?" vs `tenantFeaturesStore.isEnabled` = "ativo agora?"
|
||||
- 17 testes em `tenantFeaturesStore.spec.js`
|
||||
|
||||
### Pendentes Sessão 5 fechados
|
||||
|
||||
- **V#35** — 17→11 policies (consolidadas plans/features/plan_features/subscriptions) + `COMMENT ON POLICY`
|
||||
- **V#36** — RPC `delete_plan_safe` bloqueia DELETE com subscriptions ativas
|
||||
- **V#40** — `features.is_active` (soft delete) + UI com filtro/Reativar
|
||||
- **V#42** — `entitlementsStore.loadFor*` no catch não marca como carregado + `logError`
|
||||
|
||||
### Testes T#5/T#7/T#8
|
||||
|
||||
- **T#5** `tenantStore.spec.js` — 15 testes (singleflight, regressão V#5, erros, setActiveTenant, reset, getters)
|
||||
- **T#7** `validators.spec.js` — 38 testes (sanitização do intake)
|
||||
- **T#8** `database-novo/tests/run.cjs` — runner Node + docker exec, 33 cenários SQL
|
||||
|
||||
### A#20 (CAPTCHA) — rev2 self-hosted
|
||||
|
||||
**Decisão:** descartado Cloudflare Turnstile / hCaptcha em favor de defesa em
|
||||
camadas self-hosted. Razões: zero LGPD, zero provider, zero fricção pro paciente
|
||||
legítimo (UX importa em paciente vulnerável buscando atendimento).
|
||||
|
||||
**5 camadas:**
|
||||
1. **Honeypot** — campo invisível
|
||||
2. **Validação** básica
|
||||
3. **Rate limit por IP** — `check_rate_limit` RPC
|
||||
4. **Math captcha condicional** — só ativa após N falhas (default 3)
|
||||
5. **Modo paranoid** global toggle
|
||||
|
||||
**Implementação:**
|
||||
- Migrations `...06` (4 tabelas) + `...07` (RPCs)
|
||||
- Edge function `submit-patient-intake` reescrita (dual endpoint)
|
||||
- Componente `MathCaptchaChallenge.vue` lazy
|
||||
- Tela `/saas/security` com card explicativo (6 seções), KPIs 24h, toggles, sliders, dashboard de IPs
|
||||
|
||||
### SaaS Twilio Config (UI editável)
|
||||
|
||||
- Migration `...08` (singleton + RPCs `get_twilio_config`/`update_twilio_config`)
|
||||
- **AUTH_TOKEN permanece em env var** (único secret); SID/webhook/rate/margem migram pra DB
|
||||
- Edge function lê do banco com fallback pra env (back-compat)
|
||||
- Tela `/saas/twilio-config` com card + status do AUTH_TOKEN
|
||||
- Bug fix: `friendlyErrorMessage()` traduz "Edge Function returned a non-2xx status code"
|
||||
|
||||
### Revisão sênior em Documentos/prontuários
|
||||
|
||||
10 V# novas registradas, 7 corrigidas, 3 adiadas.
|
||||
|
||||
**Críticos:**
|
||||
- 🔴 **V#43/V#44** vazamento entre clínicas via storage policies — corrigido com tenant scoping no path `(storage.foldername(name))[1]::uuid IN tenant_members`
|
||||
- 🔴 **V#45** documents policy pobre (só `owner_id = auth.uid()`) — separada em SELECT/INSERT/UPDATE/DELETE com tenant scoping
|
||||
|
||||
**Altos:**
|
||||
- 🟠 **V#46** share_links sem incremento de usos — RPC `validate_share_token` atomicamente valida + incrementa + loga
|
||||
- 🟠 **V#47** signatures policy ALL — separada (UPDATE só pra signatário)
|
||||
|
||||
**Médios:**
|
||||
- 🟡 **V#48** access_logs WITH CHECK
|
||||
- 🟡 **V#49** templates WITH CHECK
|
||||
|
||||
### B-block (V# avulsos)
|
||||
|
||||
- **V#2** Listener `onAuthStateChange` consolidado (session.js virou autoridade + API `onSessionEvent`)
|
||||
- **V#6** `globalRoleCache` TTL 5min
|
||||
- **V#10** Bloqueio SaaS via `meta.area`/`meta.saasAdmin` em vez de `path.startsWith`
|
||||
- **V#8** RPC `get_patient_session_counts` substitui `.limit(1000)` arbitrário
|
||||
- **V#9 router** short-circuit `lastEnsureKey` em ensureMenuBuilt
|
||||
- **V#17** 25 `console.*` eliminados em src/views/pages/saas/
|
||||
- **V#18** TTL real em tenantFeaturesStore
|
||||
|
||||
### T#9 + T#10
|
||||
|
||||
- **T#9** `useAgendaEvents.spec.js` — 13 testes do wrapper
|
||||
- **T#10** Playwright + Chromium instalados; 5 specs E2E em `e2e/patient-intake.spec.js`
|
||||
- **Bug fix achado pelo E2E**: `CadastroPacienteExterno.enviar` não extraía body do erro 403 — corrigido
|
||||
|
||||
---
|
||||
|
||||
## 📦 Migrations consolidadas (todas as sessões)
|
||||
|
||||
```
|
||||
20260417000001_dev_tables (Sessão pré-1: tabelas dev)
|
||||
20260417000002_dev_tables_ordem
|
||||
20260418000001_dev_verificacoes (Sessão 1)
|
||||
20260418000002_patient_intake_security_hardening (Sessão 4)
|
||||
20260418000003_patient_invite_attempts_log (Sessão 4)
|
||||
20260418000004_dev_tests (Sessão 1)
|
||||
20260418000005_saas_rls_emergency_fix (Sessão 5 — P0)
|
||||
20260419000001_tenant_features_b2_governance (Sessão 6 — V#34/V#41)
|
||||
20260419000002_features_is_active (Sessão 6 — V#40)
|
||||
20260419000003_delete_plan_safe (Sessão 6 — V#36)
|
||||
20260419000004_consolidate_policies (Sessão 6 — V#35)
|
||||
20260419000005_restrict_intake_rpc (Sessão 6 — A#20)
|
||||
20260419000006_layered_bot_defense (Sessão 6 — A#20 rev2)
|
||||
20260419000007_bot_defense_rpcs (Sessão 6 — A#20 rev2)
|
||||
20260419000008_saas_twilio_config (Sessão 6)
|
||||
20260419000009_patient_session_counts_rpc (Sessão 6 — V#8)
|
||||
20260419000010_documents_security_hardening (Sessão 6 — V#43-V#49)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Pastas/arquivos novos importantes
|
||||
|
||||
- `e2e/` — specs Playwright (T#10)
|
||||
- `playwright.config.js` — config E2E
|
||||
- `database-novo/tests/run.cjs` — runner SQL integration tests (T#8)
|
||||
- `database-novo/backups/` agora ignorado (regenerável via `db.cjs backup`)
|
||||
- `src/components/security/MathCaptchaChallenge.vue` — A#20 rev2
|
||||
- `src/views/pages/saas/SaasTenantFeaturesPage.vue` — V#34
|
||||
- `src/views/pages/saas/SaasSecurityPage.vue` — A#20 rev2 + card educativo
|
||||
- `src/views/pages/saas/SaasTwilioConfigPage.vue` — UI Twilio editável
|
||||
- `src/utils/roleNormalizer.js` — Sessão 1
|
||||
- `src/features/agenda/services/_tenantGuards.js` + `agendaSelects.js` — Sessão 2
|
||||
- 6 specs novas em `__tests__/` (vitest)
|
||||
- `supabase/functions/submit-patient-intake/` — edge function reescrita A#20 rev2
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ .gitignore ajustado neste commit
|
||||
|
||||
- `supabase/*` + `!supabase/functions/` (mantém edge functions, ignora `.temp/`/`migrations/`/etc gerados pelo CLI)
|
||||
- `database-novo/backups/` (backups regeneráveis)
|
||||
- `test-results/`, `playwright-report/` (outputs Playwright)
|
||||
- `.claude/settings.local.json` (config local do harness)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Números finais
|
||||
|
||||
| Métrica | Início | Fim |
|
||||
|---|---|---|
|
||||
| A# abertos | 30 (a registrar) | **0** |
|
||||
| V# abertos | 52 (a registrar) | **5** (adiados) |
|
||||
| T# escritas | 0/10 | **10/10** |
|
||||
| Vitest | — | **192/192** |
|
||||
| SQL integration | — | **33/33** |
|
||||
| E2E (Playwright) | — | **5/5** |
|
||||
| Migrations | 0 | **17** |
|
||||
| Telas SaaS novas | — | 3 |
|
||||
| Edge functions reescritas | — | 1 (`submit-patient-intake`) |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Adiados (próximas sessões — plano completo no DB)
|
||||
|
||||
- **V#3 + V#9 pacientes** — refatoração de composables/services (PatientsCadastroPage 1985 linhas). Sessão dedicada de 1-2h
|
||||
- **V#50/V#51/V#52 documentos** — portal-paciente policy, hash SHA-256, retention cron
|
||||
- **Áreas não auditadas:** financeiro, comunicação
|
||||
- **Deploy real**: cloud Supabase + secrets + edge functions
|
||||
File diff suppressed because one or more lines are too long
+469
-138
@@ -5,15 +5,17 @@
|
||||
// Uso: node db.cjs <comando> [opcoes]
|
||||
//
|
||||
// Comandos:
|
||||
// setup Instalação do zero (schema + seeds)
|
||||
// backup Exporta backup com data atual
|
||||
// restore [data] Restaura de um backup (ex: 2026-03-23)
|
||||
// setup Instalação do zero (schema + fixes + seeds + migrations)
|
||||
// backup Exporta backup com data atual (+ supabase_restore.sql)
|
||||
// restore [data] Restaura de um backup (ex: 2026-04-17)
|
||||
// migrate Aplica migrations pendentes
|
||||
// seed [grupo] Roda seeds (all|users|system|test_data)
|
||||
// status Mostra estado atual do banco
|
||||
// diff Compara schema atual vs último backup
|
||||
// reset Reseta o banco e reinstala tudo
|
||||
// verify Verifica integridade dos dados essenciais
|
||||
// schema-export Exporta schema granular para schema/00_full..10_grants
|
||||
// dashboard Gera dashboard HTML interativo (tabelas + infra)
|
||||
// help Mostra ajuda
|
||||
// =============================================================================
|
||||
|
||||
@@ -30,6 +32,9 @@ const CONFIG = JSON.parse(fs.readFileSync(path.join(ROOT, 'db.config.json'), 'ut
|
||||
const CONTAINER = CONFIG.container;
|
||||
const DB = CONFIG.database;
|
||||
const USER = CONFIG.user;
|
||||
const MIGRATIONS_DIR = path.resolve(ROOT, CONFIG.migrationsDir || 'migrations');
|
||||
const SEEDS_DIR = path.resolve(ROOT, CONFIG.seedsDir || 'seeds');
|
||||
const FIXES_DIR = path.resolve(ROOT, CONFIG.fixesDir || 'fixes');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (sem dependências externas)
|
||||
@@ -45,27 +50,13 @@ const c = {
|
||||
gray: '\x1b[90m'
|
||||
};
|
||||
|
||||
function log(msg) {
|
||||
console.log(msg);
|
||||
}
|
||||
function info(msg) {
|
||||
log(`${c.cyan}ℹ${c.reset} ${msg}`);
|
||||
}
|
||||
function ok(msg) {
|
||||
log(`${c.green}✔${c.reset} ${msg}`);
|
||||
}
|
||||
function warn(msg) {
|
||||
log(`${c.yellow}⚠${c.reset} ${msg}`);
|
||||
}
|
||||
function err(msg) {
|
||||
log(`${c.red}✖${c.reset} ${msg}`);
|
||||
}
|
||||
function title(msg) {
|
||||
log(`\n${c.bold}${c.blue}═══ ${msg} ═══${c.reset}\n`);
|
||||
}
|
||||
function step(msg) {
|
||||
log(`${c.gray} →${c.reset} ${msg}`);
|
||||
}
|
||||
function log(msg) { console.log(msg); }
|
||||
function info(msg) { log(`${c.cyan}ℹ${c.reset} ${msg}`); }
|
||||
function ok(msg) { log(`${c.green}✔${c.reset} ${msg}`); }
|
||||
function warn(msg) { log(`${c.yellow}⚠${c.reset} ${msg}`); }
|
||||
function err(msg) { log(`${c.red}✖${c.reset} ${msg}`); }
|
||||
function title(msg){ log(`\n${c.bold}${c.blue}═══ ${msg} ═══${c.reset}\n`); }
|
||||
function step(msg) { log(`${c.gray} →${c.reset} ${msg}`); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -86,21 +77,31 @@ function dockerRunning() {
|
||||
|
||||
function psql(sql, opts = {}) {
|
||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} ${opts.tuples ? '-t' : ''} ${opts.quiet ? '-q' : ''} -c "${sql.replace(/"/g, '\\"')}"`;
|
||||
return execSync(cmd, { encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } }).trim();
|
||||
return execSync(cmd, {
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function psqlFile(filePath) {
|
||||
const absPath = path.resolve(filePath);
|
||||
const content = fs.readFileSync(absPath, 'utf8');
|
||||
// Prepend SET client_encoding to ensure UTF-8 inside the session
|
||||
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
|
||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
|
||||
return execSync(cmd, { input: utf8Content, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } });
|
||||
return execSync(cmd, {
|
||||
input: utf8Content,
|
||||
encoding: 'utf8',
|
||||
timeout: 120000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
|
||||
});
|
||||
}
|
||||
|
||||
function pgDump(args) {
|
||||
const cmd = `docker exec -e PGCLIENTENCODING=UTF8 ${CONTAINER} pg_dump -U ${USER} -d ${DB} ${args}`;
|
||||
return execSync(cmd, { encoding: 'utf8', timeout: 120000, maxBuffer: 50 * 1024 * 1024 });
|
||||
return execSync(cmd, { encoding: 'utf8', timeout: 180000, maxBuffer: 100 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
function today() {
|
||||
@@ -168,6 +169,89 @@ function recordMigration(filename, hash, category) {
|
||||
psql(`INSERT INTO _db_migrations (filename, hash, category) VALUES ('${filename}', '${hash}', '${category}') ON CONFLICT (filename) DO UPDATE SET hash = EXCLUDED.hash, applied_at = now();`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supabase-compatible dump builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSupabaseDump(backupDir) {
|
||||
step('Gerando dump compatível com Supabase (supabase_restore.sql)...');
|
||||
|
||||
const parts = [];
|
||||
|
||||
parts.push('-- ==========================================================');
|
||||
parts.push('-- AgenciaPsi — Supabase-compatible full restore dump');
|
||||
parts.push(`-- Gerado em: ${new Date().toISOString()}`);
|
||||
parts.push('-- ');
|
||||
parts.push('-- USO: Para restaurar este dump em um Supabase limpo:');
|
||||
parts.push('-- 1. npx supabase db reset (limpa tudo)');
|
||||
parts.push('-- 2. Rode este arquivo:');
|
||||
parts.push(`-- docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB} \\`);
|
||||
parts.push('-- < backups/YYYY-MM-DD/supabase_restore.sql');
|
||||
parts.push('--');
|
||||
parts.push('-- OU via pipe:');
|
||||
parts.push('-- cat backups/YYYY-MM-DD/supabase_restore.sql | \\');
|
||||
parts.push(`-- docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB}`);
|
||||
parts.push('-- ==========================================================');
|
||||
parts.push('');
|
||||
parts.push("SET client_encoding TO 'UTF8';");
|
||||
parts.push('SET statement_timeout = 0;');
|
||||
parts.push('SET lock_timeout = 0;');
|
||||
parts.push("SET standard_conforming_strings = on;");
|
||||
parts.push('');
|
||||
|
||||
// 1. Schema public: drop + recreate
|
||||
parts.push('-- [1/5] Limpar schema public');
|
||||
parts.push('DROP SCHEMA IF EXISTS public CASCADE;');
|
||||
parts.push('CREATE SCHEMA public;');
|
||||
parts.push('GRANT ALL ON SCHEMA public TO postgres;');
|
||||
parts.push('GRANT ALL ON SCHEMA public TO public;');
|
||||
parts.push('');
|
||||
|
||||
// 2. Schema-only dump (public schema only, no infra)
|
||||
parts.push('-- [2/5] Estrutura (tabelas, índices, constraints, triggers, functions)');
|
||||
const schemaDump = pgDump('--schema-only --no-owner --no-privileges --schema=public');
|
||||
parts.push(schemaDump);
|
||||
parts.push('');
|
||||
|
||||
// 3. Auth users data (crucial for Supabase)
|
||||
parts.push('-- [3/5] Dados auth.users (essencial para autenticação)');
|
||||
try {
|
||||
const authData = pgDump('--data-only --no-owner --no-privileges --table=auth.users --table=auth.identities --table=auth.sessions --table=auth.refresh_tokens --table=auth.mfa_factors --table=auth.mfa_challenges');
|
||||
parts.push(authData);
|
||||
} catch {
|
||||
parts.push('-- AVISO: Não foi possível exportar dados de auth (pode estar vazio)');
|
||||
}
|
||||
parts.push('');
|
||||
|
||||
// 4. All public data
|
||||
parts.push('-- [4/5] Dados das tabelas public');
|
||||
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions',
|
||||
'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics',
|
||||
'supabase_migrations', 'auth'];
|
||||
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
|
||||
const publicData = pgDump(`--data-only --no-owner --no-privileges ${excludeFlags}`);
|
||||
parts.push(publicData);
|
||||
parts.push('');
|
||||
|
||||
// 5. Storage buckets/objects metadata (if any)
|
||||
parts.push('-- [5/5] Storage buckets (metadados)');
|
||||
try {
|
||||
const storageBuckets = pgDump('--data-only --no-owner --no-privileges --table=storage.buckets');
|
||||
parts.push(storageBuckets);
|
||||
} catch {
|
||||
parts.push('-- AVISO: Nenhum bucket de storage encontrado');
|
||||
}
|
||||
parts.push('');
|
||||
|
||||
parts.push('-- Restore finalizado.');
|
||||
|
||||
const dumpContent = parts.join('\n');
|
||||
const dumpPath = path.join(backupDir, 'supabase_restore.sql');
|
||||
fs.writeFileSync(dumpPath, dumpContent);
|
||||
|
||||
return dumpPath;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -179,57 +263,80 @@ commands.setup = function () {
|
||||
title('Setup — Instalação do zero');
|
||||
requireDocker();
|
||||
|
||||
// 1. Schema
|
||||
// 1. Schema base
|
||||
const schemaFile = path.join(ROOT, CONFIG.schema);
|
||||
if (!fs.existsSync(schemaFile)) {
|
||||
err(`Schema não encontrado: ${schemaFile}`);
|
||||
process.exit(1);
|
||||
if (fs.existsSync(schemaFile)) {
|
||||
info('Aplicando schema base...');
|
||||
psqlFile(schemaFile);
|
||||
ok('Schema aplicado');
|
||||
} else {
|
||||
warn(`Schema não encontrado: ${schemaFile}`);
|
||||
warn('Rode "node db.cjs schema-export" depois de uma migração fresh para gerar o schema.');
|
||||
}
|
||||
|
||||
info('Aplicando schema...');
|
||||
psqlFile(schemaFile);
|
||||
ok('Schema aplicado');
|
||||
|
||||
// 2. Fixes
|
||||
info('Aplicando fixes...');
|
||||
for (const fix of CONFIG.fixes) {
|
||||
const fixPath = path.join(ROOT, 'fixes', fix);
|
||||
if (fs.existsSync(fixPath)) {
|
||||
step(fix);
|
||||
psqlFile(fixPath);
|
||||
// 2. Fixes (aplicados antes dos seeds para corrigir o schema)
|
||||
if (Array.isArray(CONFIG.fixes) && CONFIG.fixes.length > 0) {
|
||||
info('Aplicando fixes...');
|
||||
let fixCount = 0;
|
||||
for (const fix of CONFIG.fixes) {
|
||||
const fixPath = path.join(FIXES_DIR, fix);
|
||||
if (fs.existsSync(fixPath)) {
|
||||
step(fix);
|
||||
psqlFile(fixPath);
|
||||
fixCount++;
|
||||
}
|
||||
}
|
||||
ok(`${fixCount} fix(es) aplicado(s)`);
|
||||
}
|
||||
ok(`${CONFIG.fixes.length} fixes aplicados`);
|
||||
|
||||
// 3. Seeds
|
||||
// 3. Seeds (users + system)
|
||||
commands.seed('all');
|
||||
|
||||
// 4. Migration table
|
||||
// 4. Migration tracking table
|
||||
ensureMigrationTable();
|
||||
|
||||
// 5. Record seeds as applied
|
||||
const allSeeds = [...CONFIG.seeds.users, ...CONFIG.seeds.system];
|
||||
// 5. Record seeds + fixes como aplicados
|
||||
const allSeeds = [...(CONFIG.seeds?.users || []), ...(CONFIG.seeds?.system || [])];
|
||||
for (const seed of allSeeds) {
|
||||
const seedPath = path.join(ROOT, 'seeds', seed);
|
||||
const seedPath = path.join(SEEDS_DIR, seed);
|
||||
if (fs.existsSync(seedPath)) {
|
||||
recordMigration(seed, fileHash(seedPath), 'seed');
|
||||
}
|
||||
}
|
||||
for (const fix of CONFIG.fixes) {
|
||||
const fixPath = path.join(ROOT, 'fixes', fix);
|
||||
for (const fix of (CONFIG.fixes || [])) {
|
||||
const fixPath = path.join(FIXES_DIR, fix);
|
||||
if (fs.existsSync(fixPath)) {
|
||||
recordMigration(fix, fileHash(fixPath), 'fix');
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Aplicar migrations incrementais (opcional - só se tiver pendentes)
|
||||
if (fs.existsSync(MIGRATIONS_DIR)) {
|
||||
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
|
||||
if (files.length > 0) {
|
||||
info(`Aplicando ${files.length} migration(s)...`);
|
||||
for (const file of files) {
|
||||
step(file);
|
||||
try {
|
||||
psqlFile(path.join(MIGRATIONS_DIR, file));
|
||||
recordMigration(file, fileHash(path.join(MIGRATIONS_DIR, file)), 'migration');
|
||||
} catch (e) {
|
||||
warn(` ${file} falhou (pode já ter sido aplicada via schema/seeds): ${e.message.split('\n')[0]}`);
|
||||
recordMigration(file, fileHash(path.join(MIGRATIONS_DIR, file)), 'migration');
|
||||
}
|
||||
}
|
||||
ok(`${files.length} migration(s) aplicada(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
ok('Setup completo!');
|
||||
log('');
|
||||
|
||||
// 6. Auto-backup
|
||||
// 7. Auto-backup
|
||||
info('Criando backup pós-setup...');
|
||||
commands.backup();
|
||||
|
||||
// 7. Verify
|
||||
// 8. Verify
|
||||
commands.verify();
|
||||
};
|
||||
|
||||
@@ -242,7 +349,8 @@ commands.backup = function () {
|
||||
const dir = path.join(ROOT, 'backups', date);
|
||||
ensureDir(dir);
|
||||
|
||||
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics'];
|
||||
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions',
|
||||
'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics', 'supabase_migrations'];
|
||||
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
|
||||
|
||||
step('Exportando schema...');
|
||||
@@ -257,15 +365,20 @@ commands.backup = function () {
|
||||
const full = pgDump('--no-owner --no-privileges');
|
||||
fs.writeFileSync(path.join(dir, 'full_dump.sql'), full);
|
||||
|
||||
const sizes = ['schema.sql', 'data.sql', 'full_dump.sql'].map((f) => {
|
||||
const stat = fs.statSync(path.join(dir, f));
|
||||
// Dump compatível com Supabase (restauração completa)
|
||||
buildSupabaseDump(dir);
|
||||
|
||||
const files = ['schema.sql', 'data.sql', 'full_dump.sql', 'supabase_restore.sql'];
|
||||
const sizes = files.map((f) => {
|
||||
const fPath = path.join(dir, f);
|
||||
if (!fs.existsSync(fPath)) return null;
|
||||
const stat = fs.statSync(fPath);
|
||||
return `${f}: ${(stat.size / 1024).toFixed(0)}KB`;
|
||||
});
|
||||
}).filter(Boolean);
|
||||
|
||||
ok(`Backup salvo em backups/${date}/`);
|
||||
sizes.forEach((s) => step(s));
|
||||
|
||||
// Cleanup old backups
|
||||
cleanupBackups();
|
||||
};
|
||||
|
||||
@@ -278,8 +391,8 @@ function cleanupBackups() {
|
||||
let removed = 0;
|
||||
for (const b of backups) {
|
||||
if (b < cutoffStr) {
|
||||
const dir = path.join(ROOT, 'backups', b);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
const bDir = path.join(ROOT, 'backups', b);
|
||||
fs.rmSync(bDir, { recursive: true, force: true });
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
@@ -309,10 +422,6 @@ commands.restore = function (dateArg) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fullDump = path.join(dir, 'full_dump.sql');
|
||||
const schemaFile = path.join(dir, 'schema.sql');
|
||||
const dataFile = path.join(dir, 'data.sql');
|
||||
|
||||
// Safety backup before restore
|
||||
info('Criando backup de segurança antes do restore...');
|
||||
try {
|
||||
@@ -321,24 +430,26 @@ commands.restore = function (dateArg) {
|
||||
warn('Não foi possível criar backup de segurança (banco pode estar vazio)');
|
||||
}
|
||||
|
||||
if (fs.existsSync(fullDump)) {
|
||||
info(`Restaurando de backups/${date}/full_dump.sql ...`);
|
||||
const supaRestore = path.join(dir, 'supabase_restore.sql');
|
||||
const fullDump = path.join(dir, 'full_dump.sql');
|
||||
const schemaFile = path.join(dir, 'schema.sql');
|
||||
const dataFile = path.join(dir, 'data.sql');
|
||||
|
||||
// Drop and recreate public schema
|
||||
if (fs.existsSync(supaRestore)) {
|
||||
info(`Restaurando de backups/${date}/supabase_restore.sql (Supabase-compatible)...`);
|
||||
psqlFile(supaRestore);
|
||||
} else if (fs.existsSync(fullDump)) {
|
||||
info(`Restaurando de backups/${date}/full_dump.sql ...`);
|
||||
step('Limpando schema public...');
|
||||
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
|
||||
|
||||
step('Aplicando full dump...');
|
||||
psqlFile(fullDump);
|
||||
} else if (fs.existsSync(schemaFile) && fs.existsSync(dataFile)) {
|
||||
info(`Restaurando de backups/${date}/ (schema + data)...`);
|
||||
|
||||
step('Limpando schema public...');
|
||||
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
|
||||
|
||||
step('Aplicando schema...');
|
||||
psqlFile(schemaFile);
|
||||
|
||||
step('Aplicando dados...');
|
||||
psqlFile(dataFile);
|
||||
} else {
|
||||
@@ -348,7 +459,6 @@ commands.restore = function (dateArg) {
|
||||
|
||||
ok(`Banco restaurado de backups/${date}/`);
|
||||
|
||||
// Verify
|
||||
commands.verify();
|
||||
};
|
||||
|
||||
@@ -358,14 +468,13 @@ commands.migrate = function () {
|
||||
requireDocker();
|
||||
ensureMigrationTable();
|
||||
|
||||
const migrationsDir = path.join(ROOT, 'migrations');
|
||||
if (!fs.existsSync(migrationsDir)) {
|
||||
if (!fs.existsSync(MIGRATIONS_DIR)) {
|
||||
info('Nenhuma pasta migrations/ encontrada.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(migrationsDir)
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
@@ -389,7 +498,7 @@ commands.migrate = function () {
|
||||
info(`${pending.length} migration(s) pendente(s):`);
|
||||
for (const file of pending) {
|
||||
step(`Aplicando ${file}...`);
|
||||
const filePath = path.join(migrationsDir, file);
|
||||
const filePath = path.join(MIGRATIONS_DIR, file);
|
||||
try {
|
||||
psqlFile(filePath);
|
||||
recordMigration(file, fileHash(filePath), 'migration');
|
||||
@@ -423,12 +532,12 @@ commands.seed = function (group) {
|
||||
let total = 0;
|
||||
|
||||
for (const g of groups) {
|
||||
const seeds = CONFIG.seeds[g];
|
||||
const seeds = CONFIG.seeds?.[g];
|
||||
if (!seeds || seeds.length === 0) continue;
|
||||
|
||||
info(`Grupo: ${g}`);
|
||||
for (const seed of seeds) {
|
||||
const seedPath = path.join(ROOT, 'seeds', seed);
|
||||
const seedPath = path.join(SEEDS_DIR, seed);
|
||||
if (!fs.existsSync(seedPath)) {
|
||||
warn(` Arquivo não encontrado: ${seed}`);
|
||||
continue;
|
||||
@@ -452,10 +561,8 @@ commands.status = function () {
|
||||
title('Status');
|
||||
requireDocker();
|
||||
|
||||
// Docker
|
||||
ok(`Container: ${CONTAINER} (rodando)`);
|
||||
|
||||
// Backups
|
||||
const backups = listBackups();
|
||||
if (backups.length > 0) {
|
||||
ok(`Último backup: ${backups[0]}`);
|
||||
@@ -464,20 +571,17 @@ commands.status = function () {
|
||||
warn('Nenhum backup encontrado');
|
||||
}
|
||||
|
||||
// Migrations
|
||||
try {
|
||||
const applied = getAppliedMigrations();
|
||||
if (applied.length > 0) {
|
||||
info(`Migrations aplicadas: ${applied.length}`);
|
||||
info(`Registros em _db_migrations: ${applied.length}`);
|
||||
applied.slice(-5).forEach((m) => {
|
||||
step(`${m.filename} ${c.gray}(${m.category}, ${m.applied_at})${c.reset}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Pending
|
||||
const migrationsDir = path.join(ROOT, 'migrations');
|
||||
if (fs.existsSync(migrationsDir)) {
|
||||
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql'));
|
||||
if (fs.existsSync(MIGRATIONS_DIR)) {
|
||||
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
|
||||
const pending = files.filter((f) => !applied.map((m) => m.filename).includes(f));
|
||||
if (pending.length > 0) {
|
||||
warn(`${pending.length} migration(s) pendente(s):`);
|
||||
@@ -488,28 +592,16 @@ commands.status = function () {
|
||||
info('Tabela _db_migrations não existe (rode setup primeiro)');
|
||||
}
|
||||
|
||||
// DB counts
|
||||
log('');
|
||||
info('Dados no banco:');
|
||||
const counts = [
|
||||
['auth.users', 'SELECT count(*) FROM auth.users'],
|
||||
['profiles', 'SELECT count(*) FROM profiles'],
|
||||
['tenants', 'SELECT count(*) FROM tenants'],
|
||||
['plans', 'SELECT count(*) FROM plans'],
|
||||
['features', 'SELECT count(*) FROM features'],
|
||||
['plan_features', 'SELECT count(*) FROM plan_features'],
|
||||
['subscriptions', 'SELECT count(*) FROM subscriptions'],
|
||||
['email_templates_global', 'SELECT count(*) FROM email_templates_global'],
|
||||
['notification_templates', 'SELECT count(*) FROM notification_templates']
|
||||
];
|
||||
|
||||
for (const [label, sql] of counts) {
|
||||
const statusTables = CONFIG.status?.tables || [];
|
||||
for (const table of statusTables) {
|
||||
try {
|
||||
const count = psql(sql, { tuples: true }).trim();
|
||||
const count = psql(`SELECT count(*) FROM ${table}`, { tuples: true }).trim();
|
||||
const color = parseInt(count) > 0 ? c.green : c.red;
|
||||
step(`${label}: ${color}${count}${c.reset}`);
|
||||
step(`${table}: ${color}${count}${c.reset}`);
|
||||
} catch {
|
||||
step(`${label}: ${c.gray}(tabela não existe)${c.reset}`);
|
||||
step(`${table}: ${c.gray}(tabela não existe)${c.reset}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -534,10 +626,8 @@ commands.diff = function () {
|
||||
|
||||
info('Exportando schema atual...');
|
||||
const currentSchema = pgDump('--schema-only --no-owner --no-privileges');
|
||||
|
||||
const lastSchema = fs.readFileSync(lastSchemaPath, 'utf8');
|
||||
|
||||
// Extract table definitions for comparison
|
||||
const extractTables = (sql) => {
|
||||
const tables = {};
|
||||
const regex = /CREATE TABLE (?:IF NOT EXISTS )?(\S+)\s*\(([\s\S]*?)\);/g;
|
||||
@@ -550,13 +640,9 @@ commands.diff = function () {
|
||||
|
||||
const currentTables = extractTables(currentSchema);
|
||||
const lastTables = extractTables(lastSchema);
|
||||
|
||||
const allTables = new Set([...Object.keys(currentTables), ...Object.keys(lastTables)]);
|
||||
|
||||
let added = 0,
|
||||
removed = 0,
|
||||
changed = 0,
|
||||
unchanged = 0;
|
||||
let added = 0, removed = 0, changed = 0, unchanged = 0;
|
||||
|
||||
for (const table of [...allTables].sort()) {
|
||||
if (!lastTables[table]) {
|
||||
@@ -583,7 +669,6 @@ commands.reset = function () {
|
||||
title('Reset — CUIDADO');
|
||||
requireDocker();
|
||||
|
||||
// Safety backup
|
||||
info('Criando backup antes do reset...');
|
||||
try {
|
||||
commands.backup();
|
||||
@@ -595,7 +680,6 @@ commands.reset = function () {
|
||||
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
|
||||
ok('Schema public resetado');
|
||||
|
||||
// Re-run setup
|
||||
commands.setup();
|
||||
};
|
||||
|
||||
@@ -604,23 +688,12 @@ commands.verify = function () {
|
||||
title('Verificação de integridade');
|
||||
requireDocker();
|
||||
|
||||
const checks = [
|
||||
{ name: 'auth.users', sql: 'SELECT count(*) FROM auth.users', min: 1 },
|
||||
{ name: 'profiles', sql: 'SELECT count(*) FROM profiles', min: 1 },
|
||||
{ name: 'tenants', sql: 'SELECT count(*) FROM tenants', min: 1 },
|
||||
{ name: 'plans', sql: 'SELECT count(*) FROM plans', min: 7 },
|
||||
{ name: 'features', sql: 'SELECT count(*) FROM features', min: 20 },
|
||||
{ name: 'plan_features', sql: 'SELECT count(*) FROM plan_features', min: 50 },
|
||||
{ name: 'subscriptions', sql: 'SELECT count(*) FROM subscriptions', min: 1 },
|
||||
{ name: 'email_templates', sql: 'SELECT count(*) FROM email_templates_global', min: 10 }
|
||||
];
|
||||
let pass = 0, fail = 0;
|
||||
const verify = CONFIG.verify || { tables: [], views: [] };
|
||||
|
||||
let pass = 0,
|
||||
fail = 0;
|
||||
|
||||
for (const check of checks) {
|
||||
for (const check of (verify.tables || [])) {
|
||||
try {
|
||||
const count = parseInt(psql(check.sql, { tuples: true }).trim());
|
||||
const count = parseInt(psql(`SELECT count(*) FROM ${check.name}`, { tuples: true }).trim());
|
||||
if (count >= check.min) {
|
||||
ok(`${check.name}: ${count} (mín: ${check.min})`);
|
||||
pass++;
|
||||
@@ -634,14 +707,15 @@ commands.verify = function () {
|
||||
}
|
||||
}
|
||||
|
||||
// Check entitlements view
|
||||
try {
|
||||
const ent = psql('SELECT count(*) FROM v_tenant_entitlements;', { tuples: true }).trim();
|
||||
ok(`v_tenant_entitlements: ${ent} registros`);
|
||||
pass++;
|
||||
} catch {
|
||||
err('v_tenant_entitlements: view não existe');
|
||||
fail++;
|
||||
for (const view of (verify.views || [])) {
|
||||
try {
|
||||
const cnt = psql(`SELECT count(*) FROM ${view};`, { tuples: true }).trim();
|
||||
ok(`${view}: ${cnt} registros`);
|
||||
pass++;
|
||||
} catch {
|
||||
err(`${view}: view não existe`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
log('');
|
||||
@@ -652,6 +726,225 @@ commands.verify = function () {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- SCHEMA-EXPORT ----
|
||||
commands['schema-export'] = function () {
|
||||
title('Schema Export');
|
||||
requireDocker();
|
||||
|
||||
const schemaDir = path.join(ROOT, 'schema');
|
||||
const dirs = {
|
||||
full: path.join(schemaDir, '00_full'),
|
||||
extensions: path.join(schemaDir, '01_extensions'),
|
||||
types: path.join(schemaDir, '02_types'),
|
||||
functions: path.join(schemaDir, '03_functions'),
|
||||
tables: path.join(schemaDir, '04_tables'),
|
||||
views: path.join(schemaDir, '05_views'),
|
||||
indexes: path.join(schemaDir, '06_indexes'),
|
||||
foreignKeys: path.join(schemaDir, '07_foreign_keys'),
|
||||
triggers: path.join(schemaDir, '08_triggers'),
|
||||
policies: path.join(schemaDir, '09_policies'),
|
||||
grants: path.join(schemaDir, '10_grants'),
|
||||
};
|
||||
// Limpa diretórios antes para remover arquivos stale de exports anteriores
|
||||
for (const dir of Object.values(dirs)) {
|
||||
if (fs.existsSync(dir)) {
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
if (f.endsWith('.sql')) fs.rmSync(path.join(dir, f), { force: true });
|
||||
}
|
||||
}
|
||||
ensureDir(dir);
|
||||
}
|
||||
|
||||
// 00_full — dump completo
|
||||
step('00_full/schema.sql (dump completo)...');
|
||||
const fullSchema = pgDump('--schema-only --no-owner --no-privileges');
|
||||
fs.writeFileSync(path.join(dirs.full, 'schema.sql'), fullSchema);
|
||||
|
||||
// 01_extensions
|
||||
step('01_extensions...');
|
||||
const extSql = `SELECT 'CREATE EXTENSION IF NOT EXISTS ' || quote_ident(extname) || ' WITH SCHEMA ' || quote_ident(nspname) || ';' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace = n.oid WHERE extname NOT IN ('plpgsql') ORDER BY extname;`;
|
||||
const extResult = psql(extSql, { tuples: true });
|
||||
const extLines = extResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
|
||||
if (extLines.length > 0) {
|
||||
const content = `-- Extensions\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${extLines.length}\n\n` + extLines.join('\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.extensions, 'extensions.sql'), content);
|
||||
}
|
||||
|
||||
// 02_types — enums públicos
|
||||
step('02_types...');
|
||||
const typesSql = `
|
||||
SELECT pg_catalog.format_type(t.oid, NULL) || ' AS ENUM (' ||
|
||||
string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) || ');'
|
||||
FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
JOIN pg_namespace n ON t.typnamespace = n.oid
|
||||
WHERE n.nspname = 'public'
|
||||
GROUP BY t.oid, t.typname;`;
|
||||
const typesResult = psql(typesSql, { tuples: true });
|
||||
const typesLines = typesResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
|
||||
if (typesLines.length > 0) {
|
||||
const typesContent = `-- Public Enums & Types\n-- Gerado automaticamente em ${new Date().toISOString()}\n\n` +
|
||||
typesLines.map(l => `CREATE TYPE ${l}`).join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.types, 'public_types.sql'), typesContent);
|
||||
}
|
||||
|
||||
try {
|
||||
const authTypesSql = typesSql.replace("'public'", "'auth'");
|
||||
const authTypesResult = psql(authTypesSql, { tuples: true });
|
||||
const authLines = authTypesResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
|
||||
if (authLines.length > 0) {
|
||||
const authContent = `-- Auth Enums & Types\n-- Gerado automaticamente em ${new Date().toISOString()}\n\n` +
|
||||
authLines.map(l => `CREATE TYPE ${l}`).join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.types, 'auth_types.sql'), authContent);
|
||||
}
|
||||
} catch { /* auth types may not exist */ }
|
||||
|
||||
// 03_functions
|
||||
step('03_functions...');
|
||||
const funcBlocks = fullSchema.match(/CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+[\s\S]*?(?:\$\$[^$]*\$\$|\$[a-zA-Z_]+\$[\s\S]*?\$[a-zA-Z_]+\$)\s*(?:;|$)/gi) || [];
|
||||
const funcsBySchema = {};
|
||||
for (const block of funcBlocks) {
|
||||
const nameMatch = block.match(/FUNCTION\s+([\w.]+)\./);
|
||||
const schema = nameMatch ? nameMatch[1] : 'public';
|
||||
if (!funcsBySchema[schema]) funcsBySchema[schema] = [];
|
||||
funcsBySchema[schema].push(block);
|
||||
}
|
||||
for (const [schema, funcs] of Object.entries(funcsBySchema)) {
|
||||
const content = `-- Functions: ${schema}\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${funcs.length}\n\n` +
|
||||
funcs.join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.functions, `${schema}.sql`), content);
|
||||
}
|
||||
if (funcBlocks.length > 0) {
|
||||
const allFuncs = `-- All Functions\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${funcBlocks.length}\n\n` +
|
||||
funcBlocks.join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.functions, '_all.sql'), allFuncs);
|
||||
}
|
||||
|
||||
// 04_tables — agrupado por domínio
|
||||
step('04_tables...');
|
||||
const tableBlocks = fullSchema.match(/CREATE TABLE (?:IF NOT EXISTS )?public\.\S+\s*\([\s\S]*?\);/gi) || [];
|
||||
const domainTables = CONFIG.domains || {};
|
||||
const tableToDomain = {};
|
||||
for (const [domain, tables] of Object.entries(domainTables)) {
|
||||
for (const t of tables) tableToDomain[t] = domain;
|
||||
}
|
||||
const tablesByDomain = {};
|
||||
for (const block of tableBlocks) {
|
||||
const nameMatch = block.match(/CREATE TABLE (?:IF NOT EXISTS )?public\.(\S+)/i);
|
||||
if (!nameMatch) continue;
|
||||
const name = nameMatch[1];
|
||||
const domain = tableToDomain[name] || 'outros';
|
||||
if (!tablesByDomain[domain]) tablesByDomain[domain] = [];
|
||||
tablesByDomain[domain].push(block);
|
||||
}
|
||||
for (const [domain, blocks] of Object.entries(tablesByDomain)) {
|
||||
const content = `-- Tables: ${domain}\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${blocks.length}\n\n` +
|
||||
blocks.join('\n\n') + '\n';
|
||||
const filename = domain.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') + '.sql';
|
||||
fs.writeFileSync(path.join(dirs.tables, filename), content);
|
||||
}
|
||||
|
||||
// 05_views
|
||||
step('05_views...');
|
||||
const viewBlocks = fullSchema.match(/CREATE(?:\s+OR\s+REPLACE)?\s+VIEW\s+public\.[\s\S]*?;/gi) || [];
|
||||
if (viewBlocks.length > 0) {
|
||||
const content = `-- Views\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${viewBlocks.length}\n\n` +
|
||||
viewBlocks.join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.views, 'views.sql'), content);
|
||||
}
|
||||
|
||||
// 06_indexes
|
||||
step('06_indexes...');
|
||||
const indexLines = fullSchema.match(/CREATE (?:UNIQUE )?INDEX\s+\S+\s+ON\s+public\.\S+[\s\S]*?;/gi) || [];
|
||||
if (indexLines.length > 0) {
|
||||
const content = `-- Indexes\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${indexLines.length}\n\n` +
|
||||
indexLines.join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.indexes, 'indexes.sql'), content);
|
||||
}
|
||||
|
||||
// 07_foreign_keys (+ PKs + UNIQUEs)
|
||||
step('07_foreign_keys...');
|
||||
const constraintLines = fullSchema.match(/ALTER TABLE ONLY public\.\S+\s+ADD CONSTRAINT\s+[\s\S]*?;/gi) || [];
|
||||
if (constraintLines.length > 0) {
|
||||
const content = `-- Constraints (PK, FK, UNIQUE, CHECK)\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${constraintLines.length}\n\n` +
|
||||
constraintLines.join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.foreignKeys, 'constraints.sql'), content);
|
||||
}
|
||||
|
||||
// 08_triggers
|
||||
step('08_triggers...');
|
||||
const triggerLines = fullSchema.match(/CREATE TRIGGER\s+[\s\S]*?;/gi) || [];
|
||||
if (triggerLines.length > 0) {
|
||||
const content = `-- Triggers\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${triggerLines.length}\n\n` +
|
||||
triggerLines.join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.triggers, 'triggers.sql'), content);
|
||||
}
|
||||
|
||||
// 09_policies
|
||||
step('09_policies...');
|
||||
const policyEnables = fullSchema.match(/ALTER TABLE public\.\S+ ENABLE ROW LEVEL SECURITY;/gi) || [];
|
||||
const policyCreates = fullSchema.match(/CREATE POLICY\s+[\s\S]*?;/gi) || [];
|
||||
if (policyEnables.length > 0 || policyCreates.length > 0) {
|
||||
const content = `-- RLS Policies\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Enable RLS: ${policyEnables.length} tabelas\n-- Policies: ${policyCreates.length}\n\n` +
|
||||
'-- Enable RLS\n' + policyEnables.join('\n') + '\n\n' +
|
||||
'-- Policies\n' + policyCreates.join('\n\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.policies, 'policies.sql'), content);
|
||||
}
|
||||
|
||||
// 10_grants
|
||||
step('10_grants...');
|
||||
const grantLines = fullSchema.match(/(?:GRANT|REVOKE)\s+[\s\S]*?;/gi) || [];
|
||||
const publicGrants = grantLines.filter(g => /public\./i.test(g));
|
||||
if (publicGrants.length > 0) {
|
||||
const content = `-- Grants\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${publicGrants.length}\n\n` +
|
||||
publicGrants.join('\n') + '\n';
|
||||
fs.writeFileSync(path.join(dirs.grants, 'grants.sql'), content);
|
||||
}
|
||||
|
||||
// Summary
|
||||
log('');
|
||||
ok('Schema exportado para schema/');
|
||||
const summary = [
|
||||
['00_full', '1 arquivo'],
|
||||
['01_extensions', fs.readdirSync(dirs.extensions).length + ' arquivo(s)'],
|
||||
['02_types', fs.readdirSync(dirs.types).length + ' arquivo(s)'],
|
||||
['03_functions', fs.readdirSync(dirs.functions).length + ' arquivo(s), ' + funcBlocks.length + ' functions'],
|
||||
['04_tables', fs.readdirSync(dirs.tables).length + ' arquivo(s), ' + tableBlocks.length + ' tabelas'],
|
||||
['05_views', viewBlocks.length + ' views'],
|
||||
['06_indexes', indexLines.length + ' indexes'],
|
||||
['07_foreign_keys', constraintLines.length + ' constraints'],
|
||||
['08_triggers', triggerLines.length + ' triggers'],
|
||||
['09_policies', policyCreates.length + ' policies'],
|
||||
['10_grants', publicGrants.length + ' grants'],
|
||||
];
|
||||
for (const [dir, desc] of summary) {
|
||||
step(`${dir}: ${desc}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- DASHBOARD ----
|
||||
commands.dashboard = function (dateArg) {
|
||||
title('Dashboard');
|
||||
|
||||
const scriptPath = path.join(ROOT, 'generate-dashboard.cjs');
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
err(`Script não encontrado: ${scriptPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = dateArg ? [scriptPath, dateArg] : [scriptPath];
|
||||
const result = spawnSync('node', args, {
|
||||
stdio: 'inherit',
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
err('Falha ao gerar dashboard.');
|
||||
process.exit(result.status || 1);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- HELP ----
|
||||
commands.help = function () {
|
||||
log(`
|
||||
@@ -661,15 +954,17 @@ ${c.cyan}Uso:${c.reset} node db.cjs <comando> [opções]
|
||||
|
||||
${c.cyan}Comandos:${c.reset}
|
||||
|
||||
${c.bold}setup${c.reset} Instalação do zero (schema + fixes + seeds)
|
||||
${c.bold}setup${c.reset} Instalação do zero (schema + fixes + seeds + migrations)
|
||||
Cria backup automático após concluir
|
||||
|
||||
${c.bold}backup${c.reset} Exporta banco para backups/YYYY-MM-DD/
|
||||
Gera: schema.sql, data.sql, full_dump.sql
|
||||
Gera: schema.sql, data.sql, full_dump.sql,
|
||||
${c.green}supabase_restore.sql${c.reset} (restauração completa)
|
||||
|
||||
${c.bold}restore [data]${c.reset} Restaura de um backup
|
||||
Prioriza supabase_restore.sql se existir
|
||||
Sem data = último backup disponível
|
||||
Ex: node db.cjs restore 2026-03-23
|
||||
Ex: node db.cjs restore 2026-04-17
|
||||
|
||||
${c.bold}migrate${c.reset} Aplica migrations pendentes (pasta migrations/)
|
||||
Backup automático antes de aplicar
|
||||
@@ -685,9 +980,39 @@ ${c.cyan}Comandos:${c.reset}
|
||||
${c.yellow}⚠ Cria backup antes de resetar${c.reset}
|
||||
|
||||
${c.bold}verify${c.reset} Verifica integridade dos dados essenciais
|
||||
(tabelas + views definidas em db.config.json → verify)
|
||||
|
||||
${c.bold}schema-export${c.reset} Exporta schema separado em schema/
|
||||
00_full, 01_extensions, 02_types, 03_functions,
|
||||
04_tables (agrupado por domínio), 05_views,
|
||||
06_indexes, 07_foreign_keys, 08_triggers,
|
||||
09_policies, 10_grants
|
||||
|
||||
${c.bold}dashboard [data]${c.reset} Gera dashboard HTML interativo do banco
|
||||
Tabelas por domínio + seção Infraestrutura + busca
|
||||
Sem data = usa schema do backup mais recente
|
||||
|
||||
${c.bold}help${c.reset} Mostra esta ajuda
|
||||
|
||||
${c.cyan}Backup Supabase-compatible:${c.reset}
|
||||
|
||||
O comando ${c.bold}backup${c.reset} gera automaticamente o arquivo
|
||||
${c.green}supabase_restore.sql${c.reset} que contém TUDO necessário
|
||||
para restaurar o banco do zero:
|
||||
|
||||
• Schema public (tabelas, índices, triggers, functions)
|
||||
• Dados auth.users + identities (autenticação)
|
||||
• Todos os dados das tabelas public
|
||||
• Metadados de storage buckets
|
||||
|
||||
Para restaurar manualmente:
|
||||
${c.gray}# Opção 1: via CLI${c.reset}
|
||||
node db.cjs restore
|
||||
|
||||
${c.gray}# Opção 2: direto no container${c.reset}
|
||||
cat backups/2026-04-17/supabase_restore.sql | \\
|
||||
docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB}
|
||||
|
||||
${c.cyan}Exemplos:${c.reset}
|
||||
|
||||
${c.gray}# Primeira vez — instala tudo${c.reset}
|
||||
@@ -704,6 +1029,12 @@ ${c.cyan}Exemplos:${c.reset}
|
||||
|
||||
${c.gray}# Ver o que tem no banco${c.reset}
|
||||
node db.cjs status
|
||||
|
||||
${c.gray}# Atualizar as pastas schema/*${c.reset}
|
||||
node db.cjs schema-export
|
||||
|
||||
${c.gray}# Gerar dashboard HTML${c.reset}
|
||||
node db.cjs dashboard
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"user": "postgres",
|
||||
"backupRetentionDays": 30,
|
||||
"schema": "schema/00_full/schema.sql",
|
||||
"migrationsDir": "migrations",
|
||||
"seedsDir": "seeds",
|
||||
"fixesDir": "fixes",
|
||||
"seeds": {
|
||||
"users": [
|
||||
"seed_001_fixed.sql",
|
||||
@@ -15,7 +18,11 @@
|
||||
"seed_011_features.sql",
|
||||
"seed_012_plan_features.sql",
|
||||
"seed_013_subscriptions.sql",
|
||||
"seed_014_global_data.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"
|
||||
@@ -30,5 +37,310 @@
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"Agenda / Agendamento": [
|
||||
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
|
||||
"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"
|
||||
],
|
||||
"Central SaaS (docs/FAQ)": [
|
||||
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
|
||||
],
|
||||
"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",
|
||||
"Central SaaS (docs/FAQ)": "#c084fc",
|
||||
"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": "Integração WhatsApp Business (envio/recebimento)",
|
||||
"env": "Local (Docker)",
|
||||
"status": "ativo",
|
||||
"notes": "Container via evolution-api/. whatsapp_instances e notification_channels já cadastrados. Integração real está sendo costurada."
|
||||
},
|
||||
{
|
||||
"name": "Twilio (SMS/Voz)",
|
||||
"role": "Provedor de SMS e voz para notificações",
|
||||
"env": "Cloud",
|
||||
"status": "ativo",
|
||||
"notes": "twilio_subaccount_usage rastreia consumo por tenant. SaasTwilioWhatsappPage gerencia contas."
|
||||
}
|
||||
]
|
||||
},
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,173 +3,124 @@
|
||||
// AgenciaPsi — Dashboard Generator
|
||||
// =============================================================================
|
||||
// Uso:
|
||||
// node generate-dashboard.js → usa backup mais recente
|
||||
// node generate-dashboard.js 2026-03-27 → usa backup de data específica
|
||||
// node generate-dashboard.cjs → usa backup mais recente
|
||||
// node generate-dashboard.cjs 2026-04-17 → usa backup de data específica
|
||||
//
|
||||
// Lê de: ./database-novo/backups/YYYY-MM-DD/schema.sql
|
||||
// Gera: ./dashboard.html (na mesma pasta do script)
|
||||
// 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 fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BACKUPS_DIR = path.join(__dirname, 'backups');
|
||||
const OUTPUT_FILE = path.join(__dirname, 'dashboard.html');
|
||||
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');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cores por domínio
|
||||
// Carrega config (domínios, cores e infraestrutura)
|
||||
// ---------------------------------------------------------------------------
|
||||
const DOMAIN_COLORS = {
|
||||
'SaaS / Planos': '#4f8cff',
|
||||
'Tenants / Multi-tenant': '#6ee7b7',
|
||||
'Pacientes': '#f472b6',
|
||||
'Agenda': '#fb923c',
|
||||
'Financeiro': '#a78bfa',
|
||||
'Serviços / Commitments': '#34d399',
|
||||
'Notificações': '#38bdf8',
|
||||
'Email / Comunicação': '#fbbf24',
|
||||
'SaaS Admin': '#94a3b8',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapeamento domínio → tabelas
|
||||
// Adicione novas tabelas aqui quando criar migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
const DOMAIN_TABLES = {
|
||||
'SaaS / Planos': [
|
||||
'plans','plan_features','plan_prices','plan_public','plan_public_bullets',
|
||||
'features','modules','module_features','subscriptions','subscription_events',
|
||||
'subscription_intents_personal','subscription_intents_tenant','subscription_intents_legacy',
|
||||
'addon_products','addon_credits','addon_transactions',
|
||||
'tenant_features','tenant_modules','entitlements_invalidation','tenant_feature_exceptions_log',
|
||||
],
|
||||
'Tenants / Multi-tenant': [
|
||||
'tenants','tenant_members','tenant_invites','owner_users',
|
||||
'profiles','company_profiles','billing_contracts','payment_settings','support_sessions',
|
||||
],
|
||||
'Pacientes': [
|
||||
'patients','patient_groups','patient_group_patient','patient_tags',
|
||||
'patient_patient_tag','patient_discounts','patient_invites','patient_intake_requests',
|
||||
],
|
||||
'Agenda': [
|
||||
'agenda_eventos','agenda_configuracoes','agenda_bloqueios','agenda_excecoes',
|
||||
'agenda_online_slots','agenda_regras_semanais','agenda_slots_bloqueados_semanais',
|
||||
'agenda_slots_regras','agendador_configuracoes','agendador_solicitacoes',
|
||||
'feriados','recurrence_rules','recurrence_exceptions','recurrence_rule_services',
|
||||
],
|
||||
'Financeiro': [
|
||||
'financial_records','financial_categories','financial_exceptions',
|
||||
'therapist_payouts','therapist_payout_records',
|
||||
'insurance_plans','insurance_plan_services','professional_pricing',
|
||||
],
|
||||
'Serviços / Commitments': [
|
||||
'services','determined_commitments','determined_commitment_fields',
|
||||
'commitment_services','commitment_time_logs',
|
||||
],
|
||||
'Notificações': [
|
||||
'notifications','notification_channels','notification_templates',
|
||||
'notification_queue','notification_logs','notification_preferences',
|
||||
'notification_schedules','twilio_subaccount_usage',
|
||||
],
|
||||
'Email / Comunicação': [
|
||||
'email_templates_global','email_templates_tenant','email_layout_config',
|
||||
'global_notices','notice_dismissals','login_carousel_slides',
|
||||
],
|
||||
'SaaS Admin': [
|
||||
'saas_admins','saas_docs','saas_doc_votos','saas_faq','saas_faq_itens',
|
||||
'user_settings','dev_user_credentials','_db_migrations',
|
||||
],
|
||||
};
|
||||
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];
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (!fs.existsSync(BACKUPS_DIR)) {
|
||||
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
|
||||
console.error(` Certifique-se que o script está na raiz do projeto.`);
|
||||
process.exit(1);
|
||||
}
|
||||
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();
|
||||
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);
|
||||
}
|
||||
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];
|
||||
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);
|
||||
}
|
||||
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 database-novo/backups/${date}/`);
|
||||
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 };
|
||||
return { schemaPath, date, available };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseSchema(content) {
|
||||
const tables = {};
|
||||
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 = [];
|
||||
// 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;
|
||||
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',
|
||||
});
|
||||
}
|
||||
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: [] };
|
||||
}
|
||||
|
||||
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 });
|
||||
// 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]);
|
||||
// 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 };
|
||||
return { tables, views };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -177,35 +128,43 @@ function parseSchema(content) {
|
||||
// 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));
|
||||
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';
|
||||
}
|
||||
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;
|
||||
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 generated = new Date().toLocaleString('pt-BR');
|
||||
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');
|
||||
|
||||
// Serializa dados para embutir no HTML
|
||||
const jsonData = JSON.stringify({ tables, views, domains });
|
||||
const jsonColors = JSON.stringify(DOMAIN_COLORS);
|
||||
// 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);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
// 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">
|
||||
@@ -213,7 +172,7 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
<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:#4f8cff;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6}
|
||||
: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}
|
||||
|
||||
@@ -229,12 +188,12 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
|
||||
.layout{display:flex;height:calc(100vh - 56px)}
|
||||
|
||||
.sidebar{width:240px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
|
||||
.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(79,140,255,.08)}
|
||||
.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}
|
||||
|
||||
@@ -282,7 +241,27 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
.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(79,140,255,.3);color:#fff;border-radius:2px}
|
||||
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>
|
||||
@@ -295,6 +274,7 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
<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">
|
||||
@@ -304,24 +284,36 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
<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,q='';
|
||||
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 \${!dom?'active':''}" onclick="sel(null)">
|
||||
<div class="sb-dot" style="background:#4f8cff"></div>Todos
|
||||
<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="sel(\`+JSON.stringify(d)+\`)">
|
||||
<div class="sb-dot" style="background:\${gc(d)}"></div>\${d}
|
||||
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain(\`+JSON.stringify(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;
|
||||
}
|
||||
|
||||
@@ -330,9 +322,25 @@ function buildMN(){
|
||||
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">"\${q}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
|
||||
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){
|
||||
@@ -341,23 +349,23 @@ function buildMN(){
|
||||
<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="sel(\`+JSON.stringify(d)+\`)">
|
||||
<div class="dc-n">\${d}</div>
|
||||
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain(\`+JSON.stringify(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"><div class="sec-h">
|
||||
<div class="sec-t" style="color:\${gc(d)}">\${d}</div>
|
||||
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"><div class="sec-h">
|
||||
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>\`;
|
||||
@@ -366,6 +374,19 @@ function buildMN(){
|
||||
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]);
|
||||
@@ -396,11 +417,44 @@ function tog(n){
|
||||
document.getElementById('tg-'+n)?.classList.toggle('open');
|
||||
}
|
||||
function sel(d){
|
||||
dom=d;q='';document.getElementById('si').value='';
|
||||
dom=d;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function scrollToDomain(d){
|
||||
// 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-'+(D.slugs[d]||''));
|
||||
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;q='';document.getElementById('si').value='';
|
||||
dom=T2D[name]||null;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('tc-'+name);
|
||||
@@ -409,14 +463,14 @@ function jump(name){
|
||||
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='#4f8cff';
|
||||
el.style.borderColor='#6366f1';
|
||||
setTimeout(()=>el.style.borderColor='',2000);
|
||||
},80);
|
||||
}
|
||||
let st;
|
||||
function search(v){
|
||||
clearTimeout(st);q=v.trim();
|
||||
st=setTimeout(()=>{dom=null;buildSB();buildMN();},200);
|
||||
st=setTimeout(()=>{dom=null;view='overview';buildSB();buildMN();},200);
|
||||
}
|
||||
buildSB();buildMN();
|
||||
</script>
|
||||
@@ -437,21 +491,26 @@ 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 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 DOMAIN_TABLES no script para mapeá-las.\n`);
|
||||
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(`\n✔ Gerado: ${OUTPUT_FILE}`);
|
||||
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
|
||||
console.log(` Abra no browser: file://${OUTPUT_FILE}\n`);
|
||||
console.log(` Abra no browser: file://${OUTPUT_FILE.replace(/\\/g, '/')}\n`);
|
||||
|
||||
@@ -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());
|
||||
+4271
-8943
File diff suppressed because it is too large
Load Diff
@@ -1,256 +1,14 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Extensions e Schemas
|
||||
-- Extraído de schema.sql (2026-03-23)
|
||||
-- =============================================================================
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict ABfzP9IZJ8pAzvgt6E9jKpFn1phQ3b3Lgk09BZZTle5el6ODr77nIXlXnCf1PS1
|
||||
|
||||
-- Dumped from database version 17.6
|
||||
-- Dumped by pg_dump version 17.6
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET transaction_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
--
|
||||
-- Name: _realtime; Type: SCHEMA; Schema: -; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SCHEMA _realtime;
|
||||
|
||||
|
||||
ALTER SCHEMA _realtime OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: auth; Type: SCHEMA; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SCHEMA auth;
|
||||
|
||||
|
||||
ALTER SCHEMA auth OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: pg_cron; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA pg_catalog;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pg_cron; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pg_cron IS 'Job scheduler for PostgreSQL';
|
||||
|
||||
|
||||
--
|
||||
-- Name: extensions; Type: SCHEMA; Schema: -; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE SCHEMA extensions;
|
||||
|
||||
|
||||
ALTER SCHEMA extensions OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: graphql; Type: SCHEMA; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SCHEMA graphql;
|
||||
|
||||
|
||||
ALTER SCHEMA graphql OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: graphql_public; Type: SCHEMA; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SCHEMA graphql_public;
|
||||
|
||||
|
||||
ALTER SCHEMA graphql_public OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: pg_net; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pg_net; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pg_net IS 'Async HTTP';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pgbouncer; Type: SCHEMA; Schema: -; Owner: pgbouncer
|
||||
--
|
||||
|
||||
CREATE SCHEMA pgbouncer;
|
||||
|
||||
|
||||
ALTER SCHEMA pgbouncer OWNER TO pgbouncer;
|
||||
|
||||
--
|
||||
-- Name: realtime; Type: SCHEMA; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SCHEMA realtime;
|
||||
|
||||
|
||||
ALTER SCHEMA realtime OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: storage; Type: SCHEMA; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SCHEMA storage;
|
||||
|
||||
|
||||
ALTER SCHEMA storage OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: supabase_functions; Type: SCHEMA; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SCHEMA supabase_functions;
|
||||
|
||||
|
||||
ALTER SCHEMA supabase_functions OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: vault; Type: SCHEMA; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SCHEMA vault;
|
||||
|
||||
|
||||
ALTER SCHEMA vault OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: btree_gist; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
-- Extensions
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:04.148Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION btree_gist; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION btree_gist IS 'support for indexing common datatypes in GiST';
|
||||
|
||||
|
||||
--
|
||||
-- Name: citext; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION citext; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION citext IS 'data type for case-insensitive character strings';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pg_graphql; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA pg_catalog;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_graphql WITH SCHEMA graphql;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pg_graphql; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pg_graphql IS 'pg_graphql: GraphQL support';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pg_stat_statements; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA extensions;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pg_stat_statements; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pg_stat_statements IS 'track planning and execution statistics of all SQL statements executed';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
||||
|
||||
|
||||
--
|
||||
-- Name: supabase_vault; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS supabase_vault WITH SCHEMA vault;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION supabase_vault; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION supabase_vault IS 'Supabase Vault Extension';
|
||||
|
||||
|
||||
--
|
||||
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner:
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
|
||||
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Types (Enums) — auth schema
|
||||
-- =============================================================================
|
||||
|
||||
--
|
||||
-- Name: aal_level; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.aal_level AS ENUM (
|
||||
'aal1',
|
||||
'aal2',
|
||||
'aal3'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.aal_level OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: code_challenge_method; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.code_challenge_method AS ENUM (
|
||||
's256',
|
||||
'plain'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.code_challenge_method OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: factor_status; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.factor_status AS ENUM (
|
||||
'unverified',
|
||||
'verified'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.factor_status OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: factor_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.factor_type AS ENUM (
|
||||
'totp',
|
||||
'webauthn',
|
||||
'phone'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.factor_type OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: oauth_authorization_status; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.oauth_authorization_status AS ENUM (
|
||||
'pending',
|
||||
'approved',
|
||||
'denied',
|
||||
'expired'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.oauth_authorization_status OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: oauth_client_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.oauth_client_type AS ENUM (
|
||||
'public',
|
||||
'confidential'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.oauth_client_type OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: oauth_registration_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.oauth_registration_type AS ENUM (
|
||||
'dynamic',
|
||||
'manual'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.oauth_registration_type OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: oauth_response_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.oauth_response_type AS ENUM (
|
||||
'code'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.oauth_response_type OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: one_time_token_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TYPE auth.one_time_token_type AS ENUM (
|
||||
'confirmation_token',
|
||||
'reauthentication_token',
|
||||
'recovery_token',
|
||||
'email_change_token_new',
|
||||
'email_change_token_current',
|
||||
'phone_change_token'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE auth.one_time_token_type OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: commitment_log_source; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Types — realtime + storage schemas
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TYPE realtime.action AS ENUM (
|
||||
'INSERT',
|
||||
'UPDATE',
|
||||
'DELETE',
|
||||
'TRUNCATE',
|
||||
'ERROR'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE realtime.action OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: equality_op; Type: TYPE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE realtime.equality_op AS ENUM (
|
||||
'eq',
|
||||
'neq',
|
||||
'lt',
|
||||
'lte',
|
||||
'gt',
|
||||
'gte',
|
||||
'in'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE realtime.equality_op OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: user_defined_filter; Type: TYPE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE realtime.user_defined_filter AS (
|
||||
column_name text,
|
||||
op realtime.equality_op,
|
||||
value text
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE realtime.user_defined_filter OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: wal_column; Type: TYPE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE realtime.wal_column AS (
|
||||
name text,
|
||||
type_name text,
|
||||
type_oid oid,
|
||||
value jsonb,
|
||||
is_pkey boolean,
|
||||
is_selectable boolean
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE realtime.wal_column OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: wal_rls; Type: TYPE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE realtime.wal_rls AS (
|
||||
wal jsonb,
|
||||
is_rls_enabled boolean,
|
||||
subscription_ids uuid[],
|
||||
errors text[]
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE realtime.wal_rls OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: buckettype; Type: TYPE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TYPE storage.buckettype AS ENUM (
|
||||
'STANDARD',
|
||||
'ANALYTICS',
|
||||
'VECTOR'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE storage.buckettype OWNER TO supabase_storage_admin;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Types (Enums) — public schema
|
||||
-- =============================================================================
|
||||
-- commitment_log_source, determined_field_type, financial_record_type,
|
||||
-- recurrence_exception_type, recurrence_type, status_agenda_serie,
|
||||
-- status_evento_agenda, status_excecao_agenda, tipo_evento_agenda,
|
||||
-- tipo_excecao_agenda
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TYPE public.commitment_log_source AS ENUM (
|
||||
'manual',
|
||||
'auto'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.commitment_log_source OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: determined_field_type; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.determined_field_type AS ENUM (
|
||||
'text',
|
||||
'textarea',
|
||||
'number',
|
||||
'date',
|
||||
'select',
|
||||
'boolean'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.determined_field_type OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: financial_record_type; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.financial_record_type AS ENUM (
|
||||
'receita',
|
||||
'despesa'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.financial_record_type OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: recurrence_exception_type; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.recurrence_exception_type AS ENUM (
|
||||
'cancel_session',
|
||||
'reschedule_session',
|
||||
'patient_missed',
|
||||
'therapist_canceled',
|
||||
'holiday_block'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.recurrence_exception_type OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: recurrence_type; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.recurrence_type AS ENUM (
|
||||
'weekly',
|
||||
'biweekly',
|
||||
'monthly',
|
||||
'yearly',
|
||||
'custom_weekdays'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.recurrence_type OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: status_agenda_serie; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.status_agenda_serie AS ENUM (
|
||||
'ativo',
|
||||
'pausado',
|
||||
'cancelado'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.status_agenda_serie OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: status_evento_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.status_evento_agenda AS ENUM (
|
||||
'agendado',
|
||||
'realizado',
|
||||
'faltou',
|
||||
'cancelado',
|
||||
'remarcar'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.status_evento_agenda OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: status_excecao_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.status_excecao_agenda AS ENUM (
|
||||
'pendente',
|
||||
'ativo',
|
||||
'arquivado'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.status_excecao_agenda OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: tipo_evento_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.tipo_evento_agenda AS ENUM (
|
||||
'sessao',
|
||||
'bloqueio'
|
||||
);
|
||||
|
||||
|
||||
ALTER TYPE public.tipo_evento_agenda OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: tipo_excecao_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TYPE public.tipo_excecao_agenda AS ENUM (
|
||||
'bloqueio',
|
||||
'horario_extra'
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,650 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — Agenda
|
||||
-- =============================================================================
|
||||
-- agenda_cfg_sync, agendador_dias_disponiveis, agendador_gerar_slug,
|
||||
-- agendador_slots_disponiveis, cancel_recurrence_from,
|
||||
-- cancelar_eventos_serie, fn_agenda_regras_semanais_no_overlap,
|
||||
-- split_recurrence_at, sync_busy_mirror, set_updated_at_recurrence
|
||||
-- =============================================================================
|
||||
|
||||
CREATE FUNCTION public.agenda_cfg_sync() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
begin
|
||||
if new.agenda_view_mode = 'custom' then
|
||||
new.usar_horario_admin_custom := true;
|
||||
new.admin_inicio_visualizacao := new.agenda_custom_start;
|
||||
new.admin_fim_visualizacao := new.agenda_custom_end;
|
||||
else
|
||||
new.usar_horario_admin_custom := false;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.agenda_cfg_sync() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agendador_dias_disponiveis(text, integer, integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer) RETURNS TABLE(data date, tem_slots boolean)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_antecedencia int;
|
||||
v_agora timestamptz;
|
||||
v_data date;
|
||||
v_data_inicio date;
|
||||
v_data_fim date;
|
||||
v_db_dow int;
|
||||
v_tem_slot boolean;
|
||||
v_bloqueado boolean;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_antecedencia
|
||||
FROM public.agendador_configuracoes c
|
||||
WHERE c.link_slug = p_slug AND c.ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
|
||||
v_agora := now();
|
||||
v_data_inicio := make_date(p_ano, p_mes, 1);
|
||||
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
|
||||
|
||||
v_data := v_data_inicio;
|
||||
WHILE v_data <= v_data_fim LOOP
|
||||
v_db_dow := extract(dow from v_data::timestamp)::int;
|
||||
|
||||
-- ── Dia inteiro bloqueado? (agenda_bloqueios) ─────────────────────────
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_bloqueios b
|
||||
WHERE b.owner_id = v_owner_id
|
||||
AND b.data_inicio <= v_data
|
||||
AND COALESCE(b.data_fim, v_data) >= v_data
|
||||
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
|
||||
AND (
|
||||
(NOT b.recorrente)
|
||||
OR (b.recorrente AND b.dia_semana = v_db_dow)
|
||||
)
|
||||
) INTO v_bloqueado;
|
||||
|
||||
IF v_bloqueado THEN
|
||||
v_data := v_data + 1;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- ── Tem slots disponíveis no dia? ─────────────────────────────────────
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_online_slots s
|
||||
WHERE s.owner_id = v_owner_id
|
||||
AND s.weekday = v_db_dow
|
||||
AND s.enabled = true
|
||||
AND (v_data::text || ' ' || s.time::text)::timestamp
|
||||
AT TIME ZONE 'America/Sao_Paulo'
|
||||
>= v_agora + (v_antecedencia || ' hours')::interval
|
||||
) INTO v_tem_slot;
|
||||
|
||||
IF v_tem_slot THEN
|
||||
data := v_data;
|
||||
tem_slots := true;
|
||||
RETURN NEXT;
|
||||
END IF;
|
||||
|
||||
v_data := v_data + 1;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agendador_gerar_slug(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.agendador_gerar_slug() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_slug text;
|
||||
v_exists boolean;
|
||||
BEGIN
|
||||
-- só gera se ativou e não tem slug ainda
|
||||
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
|
||||
LOOP
|
||||
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agendador_configuracoes
|
||||
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
|
||||
) INTO v_exists;
|
||||
EXIT WHEN NOT v_exists;
|
||||
END LOOP;
|
||||
NEW.link_slug := v_slug;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.agendador_gerar_slug() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agendador_slots_disponiveis(text, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) RETURNS TABLE(hora time without time zone, disponivel boolean)
|
||||
CREATE FUNCTION public.agendador_gerar_slug() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_slug text;
|
||||
v_exists boolean;
|
||||
BEGIN
|
||||
-- só gera se ativou e não tem slug ainda
|
||||
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
|
||||
LOOP
|
||||
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agendador_configuracoes
|
||||
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
|
||||
) INTO v_exists;
|
||||
EXIT WHEN NOT v_exists;
|
||||
END LOOP;
|
||||
NEW.link_slug := v_slug;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.agendador_gerar_slug() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agendador_slots_disponiveis(text, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) RETURNS TABLE(hora time without time zone, disponivel boolean)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_duracao int;
|
||||
v_antecedencia int;
|
||||
v_agora timestamptz;
|
||||
v_db_dow int;
|
||||
v_slot time;
|
||||
v_slot_fim time;
|
||||
v_slot_ts timestamptz;
|
||||
v_ocupado boolean;
|
||||
-- loop de recorrências
|
||||
v_rule RECORD;
|
||||
v_rule_start_dow int;
|
||||
v_first_occ date;
|
||||
v_day_diff int;
|
||||
v_ex_type text;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.duracao_sessao_min, c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_duracao, v_antecedencia
|
||||
FROM public.agendador_configuracoes c
|
||||
WHERE c.link_slug = p_slug AND c.ativo = true
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
|
||||
v_agora := now();
|
||||
v_db_dow := extract(dow from p_data::timestamp)::int;
|
||||
|
||||
-- ── Dia inteiro bloqueado? (agenda_bloqueios sem hora) ───────────────────
|
||||
-- Se sim, não há nenhum slot disponível — retorna vazio.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.agenda_bloqueios b
|
||||
WHERE b.owner_id = v_owner_id
|
||||
AND b.data_inicio <= p_data
|
||||
AND COALESCE(b.data_fim, p_data) >= p_data
|
||||
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
|
||||
AND (
|
||||
(NOT b.recorrente)
|
||||
OR (b.recorrente AND b.dia_semana = v_db_dow)
|
||||
)
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
FOR v_slot IN
|
||||
SELECT s.time
|
||||
FROM public.agenda_online_slots s
|
||||
WHERE s.owner_id = v_owner_id
|
||||
AND s.weekday = v_db_dow
|
||||
AND s.enabled = true
|
||||
ORDER BY s.time
|
||||
LOOP
|
||||
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
|
||||
v_ocupado := false;
|
||||
|
||||
-- ── Antecedência mínima ──────────────────────────────────────────────────
|
||||
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp
|
||||
AT TIME ZONE 'America/Sao_Paulo';
|
||||
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
|
||||
v_ocupado := true;
|
||||
END IF;
|
||||
|
||||
-- ── Bloqueio de horário específico (agenda_bloqueios com hora) ───────────
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_bloqueios b
|
||||
WHERE b.owner_id = v_owner_id
|
||||
AND b.data_inicio <= p_data
|
||||
AND COALESCE(b.data_fim, p_data) >= p_data
|
||||
AND b.hora_inicio IS NOT NULL
|
||||
AND b.hora_inicio < v_slot_fim
|
||||
AND b.hora_fim > v_slot
|
||||
AND (
|
||||
(NOT b.recorrente)
|
||||
OR (b.recorrente AND b.dia_semana = v_db_dow)
|
||||
)
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
-- ── Eventos avulsos internos (agenda_eventos) ────────────────────────────
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agenda_eventos e
|
||||
WHERE e.owner_id = v_owner_id
|
||||
AND e.status::text NOT IN ('cancelado', 'faltou')
|
||||
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date = p_data
|
||||
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
|
||||
AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
|
||||
IF NOT v_ocupado THEN
|
||||
FOR v_rule IN
|
||||
SELECT
|
||||
r.id,
|
||||
r.start_date::date AS start_date,
|
||||
r.end_date::date AS end_date,
|
||||
r.start_time::time AS start_time,
|
||||
r.end_time::time AS end_time,
|
||||
COALESCE(r.interval, 1)::int AS interval
|
||||
FROM public.recurrence_rules r
|
||||
WHERE r.owner_id = v_owner_id
|
||||
AND r.status = 'ativo'
|
||||
AND p_data >= r.start_date::date
|
||||
AND (r.end_date IS NULL OR p_data <= r.end_date::date)
|
||||
AND v_db_dow = ANY(r.weekdays)
|
||||
AND r.start_time::time < v_slot_fim
|
||||
AND r.end_time::time > v_slot
|
||||
LOOP
|
||||
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
|
||||
v_first_occ := v_rule.start_date
|
||||
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
|
||||
v_day_diff := (p_data - v_first_occ)::int;
|
||||
|
||||
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
|
||||
v_ex_type := NULL;
|
||||
SELECT ex.type INTO v_ex_type
|
||||
FROM public.recurrence_exceptions ex
|
||||
WHERE ex.recurrence_id = v_rule.id
|
||||
AND ex.original_date = p_data
|
||||
LIMIT 1;
|
||||
|
||||
IF v_ex_type IS NULL OR v_ex_type NOT IN (
|
||||
'cancel_session', 'patient_missed',
|
||||
'therapist_canceled', 'holiday_block',
|
||||
'reschedule_session'
|
||||
) THEN
|
||||
v_ocupado := true;
|
||||
EXIT;
|
||||
END IF;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
-- ── Recorrências remarcadas para este dia ────────────────────────────────
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.recurrence_exceptions ex
|
||||
JOIN public.recurrence_rules r ON r.id = ex.recurrence_id
|
||||
WHERE r.owner_id = v_owner_id
|
||||
AND r.status = 'ativo'
|
||||
AND ex.type = 'reschedule_session'
|
||||
AND ex.new_date = p_data
|
||||
AND COALESCE(ex.new_start_time, r.start_time)::time < v_slot_fim
|
||||
AND COALESCE(ex.new_end_time, r.end_time)::time > v_slot
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
-- ── Solicitações públicas pendentes ──────────────────────────────────────
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.agendador_solicitacoes sol
|
||||
WHERE sol.owner_id = v_owner_id
|
||||
AND sol.status = 'pendente'
|
||||
AND sol.data_solicitada = p_data
|
||||
AND sol.hora_solicitada = v_slot
|
||||
AND (sol.reservado_ate IS NULL OR sol.reservado_ate > v_agora)
|
||||
) INTO v_ocupado;
|
||||
END IF;
|
||||
|
||||
hora := v_slot;
|
||||
disponivel := NOT v_ocupado;
|
||||
RETURN NEXT;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: auto_create_financial_record_from_session(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.auto_create_financial_record_from_session() RETURNS trigger
|
||||
CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.recurrence_rules
|
||||
SET
|
||||
end_date = p_from_date - INTERVAL '1 day',
|
||||
open_ended = false,
|
||||
status = CASE
|
||||
WHEN p_from_date <= start_date THEN 'cancelado'
|
||||
ELSE status
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = p_recurrence_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: cancel_subscription(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.cancel_subscription(p_subscription_id uuid) RETURNS public.subscriptions
|
||||
CREATE FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone DEFAULT now()) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
UPDATE public.agenda_eventos
|
||||
SET status = 'cancelado',
|
||||
updated_at = now()
|
||||
WHERE serie_id = p_serie_id
|
||||
AND inicio_em >= p_a_partir_de
|
||||
AND status NOT IN ('realizado', 'cancelado');
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone); Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone) IS 'Cancela todos os eventos futuros de uma série a partir de p_a_partir_de (inclusive).
|
||||
Não cancela eventos já realizados.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: change_subscription_plan(uuid, uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid) RETURNS public.subscriptions
|
||||
CREATE FUNCTION public.fn_agenda_regras_semanais_no_overlap() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
declare
|
||||
v_count int;
|
||||
begin
|
||||
if new.ativo is false then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select count(*) into v_count
|
||||
from public.agenda_regras_semanais r
|
||||
where r.owner_id = new.owner_id
|
||||
and r.dia_semana = new.dia_semana
|
||||
and r.ativo is true
|
||||
and (tg_op = 'INSERT' or r.id <> new.id)
|
||||
and (new.hora_inicio < r.hora_fim and new.hora_fim > r.hora_inicio);
|
||||
|
||||
if v_count > 0 then
|
||||
raise exception 'Janela sobreposta: já existe uma regra ativa nesse intervalo.';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.fn_agenda_regras_semanais_no_overlap() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: get_financial_report(uuid, date, date, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month'::text) RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
|
||||
CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.set_updated_at_recurrence() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: split_recurrence_at(uuid, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old public.recurrence_rules;
|
||||
v_new_id uuid;
|
||||
BEGIN
|
||||
-- busca a regra original
|
||||
SELECT * INTO v_old
|
||||
FROM public.recurrence_rules
|
||||
WHERE id = p_recurrence_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id;
|
||||
END IF;
|
||||
|
||||
-- encerra a regra antiga na data anterior
|
||||
UPDATE public.recurrence_rules
|
||||
SET
|
||||
end_date = p_from_date - INTERVAL '1 day',
|
||||
open_ended = false,
|
||||
updated_at = now()
|
||||
WHERE id = p_recurrence_id;
|
||||
|
||||
-- cria nova regra a partir de p_from_date
|
||||
INSERT INTO public.recurrence_rules (
|
||||
tenant_id, owner_id, therapist_id, patient_id,
|
||||
determined_commitment_id, type, interval, weekdays,
|
||||
start_time, end_time, timezone, duration_min,
|
||||
start_date, end_date, max_occurrences, open_ended,
|
||||
modalidade, titulo_custom, observacoes, extra_fields, status
|
||||
)
|
||||
SELECT
|
||||
tenant_id, owner_id, therapist_id, patient_id,
|
||||
determined_commitment_id, type, interval, weekdays,
|
||||
start_time, end_time, timezone, duration_min,
|
||||
p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
|
||||
modalidade, titulo_custom, observacoes, extra_fields, status
|
||||
FROM public.recurrence_rules
|
||||
WHERE id = p_recurrence_id
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
RETURN v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: subscription_intents_view_insert(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.subscription_intents_view_insert() RETURNS trigger
|
||||
CREATE FUNCTION public.sync_busy_mirror_agenda_eventos() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
declare
|
||||
clinic_tenant uuid;
|
||||
is_personal boolean;
|
||||
should_mirror boolean;
|
||||
begin
|
||||
-- Anti-recursão: espelho não espelha
|
||||
if (tg_op <> 'DELETE') then
|
||||
if new.mirror_of_event_id is not null then
|
||||
return new;
|
||||
end if;
|
||||
else
|
||||
if old.mirror_of_event_id is not null then
|
||||
return old;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Define se é pessoal e se deve espelhar
|
||||
if (tg_op = 'DELETE') then
|
||||
is_personal := (old.tenant_id = old.owner_id);
|
||||
should_mirror := (old.visibility_scope in ('busy_only','private'));
|
||||
else
|
||||
is_personal := (new.tenant_id = new.owner_id);
|
||||
should_mirror := (new.visibility_scope in ('busy_only','private'));
|
||||
end if;
|
||||
|
||||
-- Se não é pessoal, não faz nada
|
||||
if not is_personal then
|
||||
if (tg_op = 'DELETE') then
|
||||
return old;
|
||||
end if;
|
||||
return new;
|
||||
end if;
|
||||
|
||||
-- DELETE: remove espelhos existentes
|
||||
if (tg_op = 'DELETE') then
|
||||
delete from public.agenda_eventos e
|
||||
where e.mirror_of_event_id = old.id
|
||||
and e.mirror_source = 'personal_busy_mirror';
|
||||
|
||||
return old;
|
||||
end if;
|
||||
|
||||
-- INSERT/UPDATE:
|
||||
-- Se não deve espelhar, remove espelhos e sai
|
||||
if not should_mirror then
|
||||
delete from public.agenda_eventos e
|
||||
where e.mirror_of_event_id = new.id
|
||||
and e.mirror_source = 'personal_busy_mirror';
|
||||
|
||||
return new;
|
||||
end if;
|
||||
|
||||
-- Para cada clínica onde o usuário é therapist active, cria/atualiza o "Ocupado"
|
||||
for clinic_tenant in
|
||||
select tm.tenant_id
|
||||
from public.tenant_members tm
|
||||
where tm.user_id = new.owner_id
|
||||
and tm.role = 'therapist'
|
||||
and tm.status = 'active'
|
||||
and tm.tenant_id <> new.owner_id
|
||||
loop
|
||||
insert into public.agenda_eventos (
|
||||
tenant_id,
|
||||
owner_id,
|
||||
terapeuta_id,
|
||||
paciente_id,
|
||||
tipo,
|
||||
status,
|
||||
titulo,
|
||||
observacoes,
|
||||
inicio_em,
|
||||
fim_em,
|
||||
mirror_of_event_id,
|
||||
mirror_source,
|
||||
visibility_scope,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
clinic_tenant,
|
||||
new.owner_id,
|
||||
new.owner_id,
|
||||
null,
|
||||
'bloqueio'::public.tipo_evento_agenda,
|
||||
'agendado'::public.status_evento_agenda,
|
||||
'Ocupado',
|
||||
null,
|
||||
new.inicio_em,
|
||||
new.fim_em,
|
||||
new.id,
|
||||
'personal_busy_mirror',
|
||||
'public',
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
on conflict (tenant_id, mirror_of_event_id) where mirror_of_event_id is not null
|
||||
do update set
|
||||
owner_id = excluded.owner_id,
|
||||
terapeuta_id = excluded.terapeuta_id,
|
||||
tipo = excluded.tipo,
|
||||
status = excluded.status,
|
||||
titulo = excluded.titulo,
|
||||
observacoes = excluded.observacoes,
|
||||
inicio_em = excluded.inicio_em,
|
||||
fim_em = excluded.fim_em,
|
||||
updated_at = now();
|
||||
end loop;
|
||||
|
||||
-- Limpa espelhos de clínicas onde o vínculo therapist active não existe mais
|
||||
delete from public.agenda_eventos e
|
||||
where e.mirror_of_event_id = new.id
|
||||
and e.mirror_source = 'personal_busy_mirror'
|
||||
and not exists (
|
||||
select 1
|
||||
from public.tenant_members tm
|
||||
where tm.user_id = new.owner_id
|
||||
and tm.role = 'therapist'
|
||||
and tm.status = 'active'
|
||||
and tm.tenant_id = e.tenant_id
|
||||
);
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.sync_busy_mirror_agenda_eventos() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: sync_overdue_financial_records(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.sync_overdue_financial_records() RETURNS integer
|
||||
@@ -1,11 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — auth schema
|
||||
-- auth.email(), auth.jwt(), auth.role(), auth.uid()
|
||||
-- =============================================================================
|
||||
|
||||
--
|
||||
-- Name: email(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
-- Functions: auth
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.221Z
|
||||
-- Total: 4
|
||||
|
||||
CREATE FUNCTION auth.email() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
@@ -17,20 +12,6 @@ CREATE FUNCTION auth.email() RETURNS text
|
||||
)::text
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION auth.email() OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION email(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION auth.email() IS 'Deprecated. Use auth.jwt() -> ''email'' instead.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: jwt(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION auth.jwt() RETURNS jsonb
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
@@ -41,13 +22,6 @@ CREATE FUNCTION auth.jwt() RETURNS jsonb
|
||||
)::jsonb
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: role(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION auth.role() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
@@ -58,20 +32,6 @@ CREATE FUNCTION auth.role() RETURNS text
|
||||
)::text
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION auth.role() OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION role(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION auth.role() IS 'Deprecated. Use auth.jwt() -> ''role'' instead.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: uid(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION auth.uid() RETURNS uuid
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
@@ -81,13 +41,3 @@ CREATE FUNCTION auth.uid() RETURNS uuid
|
||||
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
|
||||
)::uuid
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION auth.uid() OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION uid(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION auth.uid() IS 'Deprecated. Use auth.jwt() -> ''sub'' instead.';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3
-96
@@ -1,7 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — infraestrutura
|
||||
-- extensions.grant_pg_*, pgbouncer.get_auth, etc.
|
||||
-- =============================================================================
|
||||
-- Functions: extensions
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.222Z
|
||||
-- Total: 6
|
||||
|
||||
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
@@ -35,20 +34,6 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION extensions.grant_pg_cron_access() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION grant_pg_cron_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cron';
|
||||
|
||||
|
||||
--
|
||||
-- Name: grant_pg_graphql_access(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $_$
|
||||
@@ -102,20 +87,6 @@ BEGIN
|
||||
END;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION extensions.grant_pg_graphql_access() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION grant_pg_graphql_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg_graphql';
|
||||
|
||||
|
||||
--
|
||||
-- Name: grant_pg_net_access(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -145,20 +116,6 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION extensions.grant_pg_net_access() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION grant_pg_net_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net';
|
||||
|
||||
|
||||
--
|
||||
-- Name: pgrst_ddl_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -187,13 +144,6 @@ BEGIN
|
||||
END LOOP;
|
||||
END; $$;
|
||||
|
||||
|
||||
ALTER FUNCTION extensions.pgrst_ddl_watch() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: pgrst_drop_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -220,13 +170,6 @@ BEGIN
|
||||
END LOOP;
|
||||
END; $$;
|
||||
|
||||
|
||||
ALTER FUNCTION extensions.pgrst_drop_watch() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: set_graphql_placeholder(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $_$
|
||||
@@ -278,39 +221,3 @@ CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger
|
||||
|
||||
END;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION extensions.set_graphql_placeholder() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION set_graphql_placeholder(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION extensions.set_graphql_placeholder() IS 'Reintroduces placeholder function for graphql_public.graphql';
|
||||
|
||||
|
||||
--
|
||||
-- Name: get_auth(text); Type: FUNCTION; Schema: pgbouncer; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO ''
|
||||
AS $_$
|
||||
begin
|
||||
raise debug 'PgBouncer auth request: %', p_usename;
|
||||
|
||||
return query
|
||||
select
|
||||
rolname::text,
|
||||
case when rolvaliduntil < now()
|
||||
then null
|
||||
else rolpassword::text
|
||||
end
|
||||
from pg_authid
|
||||
where rolname=$1 and rolcanlogin;
|
||||
end;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION pgbouncer.get_auth(p_usename text) OWNER TO supabase_admin;
|
||||
@@ -1,818 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — Financeiro
|
||||
-- =============================================================================
|
||||
-- auto_create_financial_record_from_session, create_financial_record_for_session,
|
||||
-- create_therapist_payout, get_financial_report, get_financial_summary,
|
||||
-- list_financial_records, mark_as_paid, mark_payout_as_paid,
|
||||
-- seed_default_financial_categories, sync_overdue_financial_records,
|
||||
-- trg_fn_financial_records_auto_overdue, set_insurance/services_updated_at
|
||||
-- =============================================================================
|
||||
|
||||
CREATE FUNCTION public.auto_create_financial_record_from_session() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_price NUMERIC(10,2);
|
||||
v_services_total NUMERIC(10,2);
|
||||
v_already_billed BOOLEAN;
|
||||
BEGIN
|
||||
-- ── Guards de saída rápida ──────────────────────────────────────────────
|
||||
|
||||
-- Só processa quando o status muda PARA 'realizado'
|
||||
IF NEW.status::TEXT <> 'realizado' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Só processa quando houve mudança real de status
|
||||
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Só sessões (não bloqueios, feriados, etc.)
|
||||
IF NEW.tipo::TEXT <> 'sessao' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Paciente obrigatório para vincular a cobrança
|
||||
IF NEW.patient_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Sessões de pacote têm cobrança gerenciada por billing_contract
|
||||
IF NEW.billing_contract_id IS NOT NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Idempotência: já existe financial_record para este evento?
|
||||
SELECT billed INTO v_already_billed
|
||||
FROM public.agenda_eventos
|
||||
WHERE id = NEW.id;
|
||||
|
||||
IF v_already_billed = TRUE THEN
|
||||
-- Confirma no financial_records também (dupla verificação)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.financial_records
|
||||
WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── Busca do preço ──────────────────────────────────────────────────────
|
||||
|
||||
v_price := NULL;
|
||||
|
||||
-- Prioridade 1: soma dos serviços da regra de recorrência
|
||||
IF NEW.recurrence_id IS NOT NULL THEN
|
||||
SELECT COALESCE(SUM(rrs.final_price), 0)
|
||||
INTO v_services_total
|
||||
FROM public.recurrence_rule_services rrs
|
||||
WHERE rrs.rule_id = NEW.recurrence_id;
|
||||
|
||||
IF v_services_total > 0 THEN
|
||||
v_price := v_services_total;
|
||||
END IF;
|
||||
|
||||
-- Prioridade 2: price direto da regra (fallback se sem serviços)
|
||||
IF v_price IS NULL OR v_price = 0 THEN
|
||||
SELECT price INTO v_price
|
||||
FROM public.recurrence_rules
|
||||
WHERE id = NEW.recurrence_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Prioridade 3: price do próprio evento de agenda
|
||||
IF v_price IS NULL OR v_price = 0 THEN
|
||||
v_price := NEW.price;
|
||||
END IF;
|
||||
|
||||
-- Sem preço → não criar registro (não é erro, apenas skip silencioso)
|
||||
IF v_price IS NULL OR v_price <= 0 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- ── Criação do financial_record ─────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.financial_records (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
patient_id,
|
||||
agenda_evento_id,
|
||||
type,
|
||||
amount,
|
||||
discount_amount,
|
||||
final_amount,
|
||||
clinic_fee_pct,
|
||||
clinic_fee_amount,
|
||||
status,
|
||||
due_date
|
||||
-- payment_method: NULL até o momento do pagamento (mark_as_paid preenche)
|
||||
) VALUES (
|
||||
NEW.owner_id,
|
||||
NEW.tenant_id,
|
||||
NEW.patient_id,
|
||||
NEW.id,
|
||||
'receita',
|
||||
v_price,
|
||||
0,
|
||||
v_price,
|
||||
0, -- clinic_fee_pct: sem campo de configuração global no schema atual.
|
||||
0, -- clinic_fee_amount: calculado manualmente ou via update posterior.
|
||||
'pending',
|
||||
(NEW.inicio_em::DATE + 7) -- vencimento padrão: 7 dias após a sessão
|
||||
);
|
||||
|
||||
-- ── Marca sessão como billed ────────────────────────────────────────────
|
||||
-- UPDATE em billed (não em status) → não re-dispara este trigger
|
||||
UPDATE public.agenda_eventos
|
||||
SET billed = TRUE
|
||||
WHERE id = NEW.id;
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- Log silencioso: nunca bloquear a agenda por falha financeira
|
||||
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%',
|
||||
NEW.id, SQLERRM;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.auto_create_financial_record_from_session() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION auto_create_financial_record_from_session(); Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION public.auto_create_financial_record_from_session() IS 'Trigger que cria automaticamente um financial_record (receita, pending) quando uma sessão de agenda é marcada como realizada. Prioridade de preço: recurrence_rule_services > recurrence_rules.price > agenda_eventos.price. Skip silencioso se sem preço, pacote ou registro já existente.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: can_delete_patient(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.can_delete_patient(p_patient_id uuid) RETURNS boolean
|
||||
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) RETURNS SETOF public.financial_records
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_existing public.financial_records%ROWTYPE;
|
||||
v_new public.financial_records%ROWTYPE;
|
||||
BEGIN
|
||||
-- Idempotência: retorna o registro existente se já foi criado
|
||||
SELECT * INTO v_existing
|
||||
FROM public.financial_records
|
||||
WHERE agenda_evento_id = p_agenda_evento_id
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
RETURN NEXT v_existing;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Cria o novo registro
|
||||
INSERT INTO public.financial_records (
|
||||
tenant_id,
|
||||
owner_id,
|
||||
patient_id,
|
||||
agenda_evento_id,
|
||||
amount,
|
||||
discount_amount,
|
||||
final_amount,
|
||||
status,
|
||||
due_date
|
||||
) VALUES (
|
||||
p_tenant_id,
|
||||
p_owner_id,
|
||||
p_patient_id,
|
||||
p_agenda_evento_id,
|
||||
p_amount,
|
||||
0,
|
||||
p_amount,
|
||||
'pending',
|
||||
p_due_date
|
||||
)
|
||||
RETURNING * INTO v_new;
|
||||
|
||||
-- Marca o evento da agenda como billed = true
|
||||
UPDATE public.agenda_eventos
|
||||
SET billed = TRUE
|
||||
WHERE id = p_agenda_evento_id;
|
||||
|
||||
RETURN NEXT v_new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: create_patient_intake_request(text, text, text, text, text, boolean); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text DEFAULT NULL::text, p_phone text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_consent boolean DEFAULT false) RETURNS uuid
|
||||
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) RETURNS public.therapist_payouts
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_payout public.therapist_payouts%ROWTYPE;
|
||||
v_total_sessions INTEGER;
|
||||
v_gross NUMERIC(10,2);
|
||||
v_clinic_fee NUMERIC(10,2);
|
||||
v_net NUMERIC(10,2);
|
||||
BEGIN
|
||||
-- ── Verificação de permissão ────────────────────────────────────────────
|
||||
-- Apenas o próprio terapeuta ou o tenant_admin pode criar o repasse
|
||||
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.';
|
||||
END IF;
|
||||
|
||||
-- ── Verifica se já existe repasse para o mesmo período ─────────────────
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.therapist_payouts
|
||||
WHERE owner_id = p_therapist_id
|
||||
AND tenant_id = p_tenant_id
|
||||
AND period_start = p_period_start
|
||||
AND period_end = p_period_end
|
||||
AND status <> 'cancelled'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'Já existe um repasse ativo para o período % a % deste terapeuta.',
|
||||
p_period_start, p_period_end;
|
||||
END IF;
|
||||
|
||||
-- ── Agrega os financial_records elegíveis ──────────────────────────────
|
||||
-- Elegíveis: paid, receita, owner=terapeuta, tenant correto, paid_at no período,
|
||||
-- não soft-deleted, ainda não vinculados a nenhum payout.
|
||||
SELECT
|
||||
COUNT(*) AS total_sessions,
|
||||
COALESCE(SUM(amount), 0) AS gross_amount,
|
||||
COALESCE(SUM(clinic_fee_amount), 0) AS clinic_fee_total,
|
||||
COALESCE(SUM(net_amount), 0) AS net_amount
|
||||
INTO
|
||||
v_total_sessions, v_gross, v_clinic_fee, v_net
|
||||
FROM public.financial_records fr
|
||||
WHERE fr.owner_id = p_therapist_id
|
||||
AND fr.tenant_id = p_tenant_id
|
||||
AND fr.type = 'receita'
|
||||
AND fr.status = 'paid'
|
||||
AND fr.deleted_at IS NULL
|
||||
AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.therapist_payout_records tpr
|
||||
WHERE tpr.financial_record_id = fr.id
|
||||
);
|
||||
|
||||
-- Sem registros elegíveis → não criar payout vazio
|
||||
IF v_total_sessions = 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'Nenhum registro financeiro elegível encontrado para o período % a %.',
|
||||
p_period_start, p_period_end;
|
||||
END IF;
|
||||
|
||||
-- ── Cria o repasse ─────────────────────────────────────────────────────
|
||||
INSERT INTO public.therapist_payouts (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
period_start,
|
||||
period_end,
|
||||
total_sessions,
|
||||
gross_amount,
|
||||
clinic_fee_total,
|
||||
net_amount,
|
||||
status
|
||||
) VALUES (
|
||||
p_therapist_id,
|
||||
p_tenant_id,
|
||||
p_period_start,
|
||||
p_period_end,
|
||||
v_total_sessions,
|
||||
v_gross,
|
||||
v_clinic_fee,
|
||||
v_net,
|
||||
'pending'
|
||||
)
|
||||
RETURNING * INTO v_payout;
|
||||
|
||||
-- ── Vincula os financial_records ao repasse ────────────────────────────
|
||||
INSERT INTO public.therapist_payout_records (payout_id, financial_record_id)
|
||||
SELECT v_payout.id, fr.id
|
||||
FROM public.financial_records fr
|
||||
WHERE fr.owner_id = p_therapist_id
|
||||
AND fr.tenant_id = p_tenant_id
|
||||
AND fr.type = 'receita'
|
||||
AND fr.status = 'paid'
|
||||
AND fr.deleted_at IS NULL
|
||||
AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.therapist_payout_records tpr
|
||||
WHERE tpr.financial_record_id = fr.id
|
||||
);
|
||||
|
||||
RETURN v_payout;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date); Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) IS 'Cria um repasse para o terapeuta com todos os financial_records paid+receita do período que ainda não estejam vinculados a outro repasse. Lança exceção se não houver registros elegíveis ou se já houver repasse ativo no período.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: current_member_id(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.current_member_id(p_tenant_id uuid) RETURNS uuid
|
||||
CREATE FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month'::text) RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
|
||||
-- ── Valida p_group_by antes de executar ──────────────────────────────────
|
||||
-- (lança erro se valor inválido; plpgsql seria necessário para isso em SQL puro,
|
||||
-- então usamos um CTE de validação com CASE WHEN para retornar vazio em vez de erro)
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
fr.type,
|
||||
fr.amount,
|
||||
fr.final_amount,
|
||||
fr.status,
|
||||
fr.deleted_at,
|
||||
-- Chave de agrupamento calculada conforme p_group_by
|
||||
CASE p_group_by
|
||||
WHEN 'month' THEN TO_CHAR(
|
||||
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
|
||||
'YYYY-MM'
|
||||
)
|
||||
WHEN 'week' THEN TO_CHAR(
|
||||
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
|
||||
'IYYY-"W"IW'
|
||||
)
|
||||
WHEN 'category' THEN COALESCE(fr.category_id::TEXT, fr.category, 'sem_categoria')
|
||||
WHEN 'patient' THEN COALESCE(fr.patient_id::TEXT, 'sem_paciente')
|
||||
ELSE NULL -- group_by inválido → group_key NULL → retorno vazio
|
||||
END AS gkey,
|
||||
-- Label legível (enriquecido via JOIN abaixo quando possível)
|
||||
CASE p_group_by
|
||||
WHEN 'month' THEN TO_CHAR(
|
||||
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
|
||||
'YYYY-MM'
|
||||
)
|
||||
WHEN 'week' THEN TO_CHAR(
|
||||
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
|
||||
'IYYY-"W"IW'
|
||||
)
|
||||
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
|
||||
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::TEXT, 'Sem paciente')
|
||||
ELSE NULL
|
||||
END AS glabel
|
||||
FROM public.financial_records fr
|
||||
LEFT JOIN public.financial_categories fc
|
||||
ON fc.id = fr.category_id
|
||||
LEFT JOIN public.patients p
|
||||
ON p.id = fr.patient_id
|
||||
WHERE fr.owner_id = p_owner_id
|
||||
AND fr.deleted_at IS NULL
|
||||
AND COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE)
|
||||
BETWEEN p_start_date AND p_end_date
|
||||
)
|
||||
|
||||
SELECT
|
||||
gkey AS group_key,
|
||||
glabel AS group_label,
|
||||
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
|
||||
AS total_receitas,
|
||||
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
|
||||
AS total_despesas,
|
||||
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
|
||||
- COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
|
||||
AS saldo,
|
||||
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE status = 'pending'), 0) AS total_pendente,
|
||||
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE status = 'overdue'), 0) AS total_overdue,
|
||||
|
||||
COUNT(*) AS count_records
|
||||
|
||||
FROM base
|
||||
WHERE gkey IS NOT NULL -- descarta p_group_by inválido
|
||||
GROUP BY gkey, glabel
|
||||
ORDER BY gkey ASC;
|
||||
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text); Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text) IS 'Relatório financeiro agrupado por mês, semana ISO, categoria ou paciente. p_group_by aceita: ''month'' | ''week'' | ''category'' | ''patient''. Totais de receita/despesa consideram apenas registros com status=paid. total_pendente e total_overdue incluem todos os tipos (receita + despesa).';
|
||||
|
||||
|
||||
--
|
||||
-- Name: get_financial_summary(uuid, integer, integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.get_financial_summary(p_owner_id uuid, p_year integer, p_month integer) RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
SELECT
|
||||
-- Receitas pagas no período
|
||||
COALESCE(SUM(amount) FILTER (
|
||||
WHERE type = 'receita' AND status = 'paid'
|
||||
), 0) AS total_receitas,
|
||||
|
||||
-- Despesas pagas no período
|
||||
COALESCE(SUM(amount) FILTER (
|
||||
WHERE type = 'despesa' AND status = 'paid'
|
||||
), 0) AS total_despesas,
|
||||
|
||||
-- Tudo pendente ou vencido (receitas + despesas)
|
||||
COALESCE(SUM(amount) FILTER (
|
||||
WHERE status IN ('pending', 'overdue')
|
||||
), 0) AS total_pendente,
|
||||
|
||||
-- Saldo líquido (receitas pagas − despesas pagas)
|
||||
COALESCE(SUM(amount) FILTER (
|
||||
WHERE type = 'receita' AND status = 'paid'
|
||||
), 0)
|
||||
- COALESCE(SUM(amount) FILTER (
|
||||
WHERE type = 'despesa' AND status = 'paid'
|
||||
), 0) AS saldo_liquido,
|
||||
|
||||
-- Total repassado à clínica (apenas receitas pagas)
|
||||
COALESCE(SUM(clinic_fee_amount) FILTER (
|
||||
WHERE type = 'receita' AND status = 'paid'
|
||||
), 0) AS total_repasse,
|
||||
|
||||
-- Contadores (excluindo soft-deleted)
|
||||
COUNT(*) FILTER (WHERE type = 'receita' AND deleted_at IS NULL) AS count_receitas,
|
||||
COUNT(*) FILTER (WHERE type = 'despesa' AND deleted_at IS NULL) AS count_despesas
|
||||
|
||||
FROM public.financial_records
|
||||
WHERE owner_id = p_owner_id
|
||||
AND deleted_at IS NULL
|
||||
AND EXTRACT(YEAR FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year
|
||||
AND EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.get_financial_summary(p_owner_id uuid, p_year integer, p_month integer) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: get_my_email(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.get_my_email() RETURNS text
|
||||
CREATE FUNCTION public.list_financial_records(p_owner_id uuid, p_year integer DEFAULT NULL::integer, p_month integer DEFAULT NULL::integer, p_type text DEFAULT NULL::text, p_status text DEFAULT NULL::text, p_patient_id uuid DEFAULT NULL::uuid, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0) RETURNS SETOF public.financial_records
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
SELECT *
|
||||
FROM public.financial_records
|
||||
WHERE owner_id = p_owner_id
|
||||
AND deleted_at IS NULL
|
||||
AND (p_type IS NULL OR type::TEXT = p_type)
|
||||
AND (p_status IS NULL OR status = p_status)
|
||||
AND (p_patient_id IS NULL OR patient_id = p_patient_id)
|
||||
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year)
|
||||
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month)
|
||||
ORDER BY COALESCE(paid_at, due_date::TIMESTAMPTZ, created_at) DESC
|
||||
LIMIT p_limit
|
||||
OFFSET p_offset;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.list_financial_records(p_owner_id uuid, p_year integer, p_month integer, p_type text, p_status text, p_patient_id uuid, p_limit integer, p_offset integer) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: mark_as_paid(uuid, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.mark_as_paid(p_financial_record_id uuid, p_payment_method text) RETURNS SETOF public.financial_records
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_record public.financial_records%ROWTYPE;
|
||||
BEGIN
|
||||
-- Garante que o registro pertence ao usuário autenticado (RLS não aplica em SECURITY DEFINER)
|
||||
SELECT * INTO v_record
|
||||
FROM public.financial_records
|
||||
WHERE id = p_financial_record_id
|
||||
AND owner_id = auth.uid()
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.';
|
||||
END IF;
|
||||
|
||||
IF v_record.status NOT IN ('pending', 'overdue') THEN
|
||||
RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.';
|
||||
END IF;
|
||||
|
||||
UPDATE public.financial_records
|
||||
SET status = 'paid',
|
||||
paid_at = NOW(),
|
||||
payment_method = p_payment_method,
|
||||
updated_at = NOW()
|
||||
WHERE id = p_financial_record_id
|
||||
RETURNING * INTO v_record;
|
||||
|
||||
RETURN NEXT v_record;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.mark_as_paid(p_financial_record_id uuid, p_payment_method text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: mark_payout_as_paid(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.mark_payout_as_paid(p_payout_id uuid) RETURNS public.therapist_payouts
|
||||
CREATE FUNCTION public.mark_payout_as_paid(p_payout_id uuid) RETURNS public.therapist_payouts
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_payout public.therapist_payouts%ROWTYPE;
|
||||
BEGIN
|
||||
-- Busca o payout
|
||||
SELECT * INTO v_payout
|
||||
FROM public.therapist_payouts
|
||||
WHERE id = p_payout_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id;
|
||||
END IF;
|
||||
|
||||
-- Verifica permissão: apenas tenant_admin do tenant do repasse
|
||||
IF NOT public.is_tenant_admin(v_payout.tenant_id) THEN
|
||||
RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.';
|
||||
END IF;
|
||||
|
||||
-- Verifica status
|
||||
IF v_payout.status <> 'pending' THEN
|
||||
RAISE EXCEPTION
|
||||
'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.',
|
||||
v_payout.status;
|
||||
END IF;
|
||||
|
||||
-- Atualiza
|
||||
UPDATE public.therapist_payouts
|
||||
SET
|
||||
status = 'paid',
|
||||
paid_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = p_payout_id
|
||||
RETURNING * INTO v_payout;
|
||||
|
||||
RETURN v_payout;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.mark_payout_as_paid(p_payout_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION mark_payout_as_paid(p_payout_id uuid); Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION public.mark_payout_as_paid(p_payout_id uuid) IS 'Marca um repasse de terapeuta como pago. Apenas o tenant_admin pode chamar. Apenas repasses com status=pending podem ser finalizados.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: my_tenants(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.my_tenants() RETURNS TABLE(tenant_id uuid, role text, status text, kind text)
|
||||
CREATE FUNCTION public.seed_default_financial_categories(p_user_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.financial_categories (user_id, name, type, color, icon, sort_order)
|
||||
VALUES
|
||||
(p_user_id, 'Sessão', 'receita', '#22c55e', 'pi pi-heart', 1),
|
||||
(p_user_id, 'Supervisão', 'receita', '#6366f1', 'pi pi-users', 2),
|
||||
(p_user_id, 'Convênio', 'receita', '#3b82f6', 'pi pi-building', 3),
|
||||
(p_user_id, 'Grupo terapêutico', 'receita', '#f59e0b', 'pi pi-sitemap', 4),
|
||||
(p_user_id, 'Outro (receita)', 'receita', '#8b5cf6', 'pi pi-plus-circle', 5),
|
||||
(p_user_id, 'Aluguel sala', 'despesa', '#ef4444', 'pi pi-home', 1),
|
||||
(p_user_id, 'Plataforma/SaaS', 'despesa', '#f97316', 'pi pi-desktop', 2),
|
||||
(p_user_id, 'Repasse clínica', 'despesa', '#64748b', 'pi pi-arrow-right-arrow-left', 3),
|
||||
(p_user_id, 'Supervisão (custo)', 'despesa', '#6366f1', 'pi pi-users', 4),
|
||||
(p_user_id, 'Outro (despesa)', 'despesa', '#94a3b8', 'pi pi-minus-circle', 5)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.seed_default_financial_categories(p_user_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: seed_determined_commitments(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
|
||||
CREATE FUNCTION public.set_insurance_plans_updated_at() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.set_insurance_plans_updated_at() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: set_owner_id(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.set_owner_id() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
begin
|
||||
if new.owner_id is null then
|
||||
new.owner_id := auth.uid();
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.set_owner_id() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: set_services_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.set_services_updated_at() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.set_services_updated_at() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: set_tenant_feature_exception(uuid, text, boolean, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.set_tenant_feature_exception(p_tenant_id uuid, p_feature_key text, p_enabled boolean, p_reason text DEFAULT NULL::text) RETURNS void
|
||||
CREATE FUNCTION public.set_updated_at() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.set_updated_at() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: set_updated_at_recurrence(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
|
||||
CREATE FUNCTION public.sync_overdue_financial_records() RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
UPDATE public.financial_records
|
||||
SET
|
||||
status = 'overdue',
|
||||
updated_at = NOW()
|
||||
WHERE status = 'pending'
|
||||
AND due_date IS NOT NULL
|
||||
AND due_date < CURRENT_DATE
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.sync_overdue_financial_records() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: FUNCTION sync_overdue_financial_records(); Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON FUNCTION public.sync_overdue_financial_records() IS 'Marca como overdue todos os financial_records pendentes com due_date vencido. Pode ser chamada manualmente, via pg_cron ou via Supabase Edge Function agendada.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenant_accept_invite(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.tenant_accept_invite(p_token uuid) RETURNS jsonb
|
||||
CREATE FUNCTION public.trg_fn_financial_records_auto_overdue() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'pending'
|
||||
AND NEW.due_date IS NOT NULL
|
||||
AND NEW.due_date < CURRENT_DATE
|
||||
THEN
|
||||
NEW.status := 'overdue';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.trg_fn_financial_records_auto_overdue() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: unstick_notification_queue(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.unstick_notification_queue() RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_unstuck integer;
|
||||
BEGIN
|
||||
UPDATE public.notification_queue
|
||||
SET status = 'pendente',
|
||||
attempts = attempts + 1,
|
||||
last_error = 'Timeout: preso em processando por >10min',
|
||||
next_retry_at = now() + interval '2 minutes'
|
||||
WHERE status = 'processando'
|
||||
AND updated_at < now() - interval '10 minutes';
|
||||
|
||||
GET DIAGNOSTICS v_unstuck = ROW_COUNT;
|
||||
RETURN v_unstuck;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.unstick_notification_queue() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: update_payment_settings_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger
|
||||
CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.update_payment_settings_updated_at() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: update_professional_pricing_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.update_professional_pricing_updated_at() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.update_professional_pricing_updated_at() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: user_has_feature(uuid, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.user_has_feature(_user_id uuid, _feature text) RETURNS boolean
|
||||
@@ -1,776 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — Compromissos, Suporte, SaaS
|
||||
-- =============================================================================
|
||||
-- seed_determined_commitments, delete_commitment_full,
|
||||
-- delete_determined_commitment, guard_locked_commitment,
|
||||
-- create_support_session, revoke_support_session, validate_support_session,
|
||||
-- saas_votar_doc, faq_votar, notice_track_click/view,
|
||||
-- sanitize_phone_br, create_clinic_tenant
|
||||
-- =============================================================================
|
||||
|
||||
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_tenant uuid;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := auth.uid();
|
||||
if v_uid is null then
|
||||
raise exception 'Not authenticated';
|
||||
end if;
|
||||
|
||||
v_name := nullif(trim(coalesce(p_name, '')), '');
|
||||
if v_name is null then
|
||||
v_name := 'Clínica';
|
||||
end if;
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name, 'clinic', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.create_clinic_tenant(p_name text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: financial_records; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.financial_records (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid,
|
||||
type public.financial_record_type DEFAULT 'receita'::public.financial_record_type NOT NULL,
|
||||
amount numeric(10,2) NOT NULL,
|
||||
description text,
|
||||
category text,
|
||||
payment_method text,
|
||||
paid_at timestamp with time zone,
|
||||
due_date date,
|
||||
installments smallint DEFAULT 1,
|
||||
installment_number smallint DEFAULT 1,
|
||||
installment_group uuid,
|
||||
agenda_evento_id uuid,
|
||||
patient_id uuid,
|
||||
clinic_fee_pct numeric(5,2) DEFAULT 0,
|
||||
clinic_fee_amount numeric(10,2) DEFAULT 0,
|
||||
net_amount numeric(10,2) GENERATED ALWAYS AS ((amount - clinic_fee_amount)) STORED,
|
||||
insurance_plan_id uuid,
|
||||
notes text,
|
||||
tags text[],
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
discount_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
final_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
category_id uuid,
|
||||
CONSTRAINT financial_records_amount_check CHECK ((amount >= (0)::numeric)),
|
||||
CONSTRAINT financial_records_clinic_fee_amount_check CHECK ((clinic_fee_amount >= (0)::numeric)),
|
||||
CONSTRAINT financial_records_clinic_fee_pct_check CHECK (((clinic_fee_pct >= (0)::numeric) AND (clinic_fee_pct <= (100)::numeric))),
|
||||
CONSTRAINT financial_records_discount_amount_check CHECK ((discount_amount >= (0)::numeric)),
|
||||
CONSTRAINT financial_records_final_amount_check CHECK ((final_amount >= (0)::numeric)),
|
||||
CONSTRAINT financial_records_installments_check CHECK ((installments >= 1)),
|
||||
CONSTRAINT financial_records_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'partial'::text, 'overdue'::text, 'cancelled'::text, 'refunded'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.financial_records OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) RETURNS SETOF public.financial_records
|
||||
CREATE FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer DEFAULT 60) RETURNS json
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_admin_id uuid;
|
||||
v_role text;
|
||||
v_token text;
|
||||
v_expires timestamp with time zone;
|
||||
v_session support_sessions;
|
||||
BEGIN
|
||||
-- Verifica autenticação
|
||||
v_admin_id := auth.uid();
|
||||
IF v_admin_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- Verifica role saas_admin
|
||||
SELECT role INTO v_role
|
||||
FROM public.profiles
|
||||
WHERE id = v_admin_id;
|
||||
|
||||
IF v_role <> 'saas_admin' THEN
|
||||
RAISE EXCEPTION 'Acesso negado. Somente saas_admin pode criar sessões de suporte.'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- Valida TTL (1 a 120 minutos)
|
||||
IF p_ttl_minutes < 1 OR p_ttl_minutes > 120 THEN
|
||||
RAISE EXCEPTION 'TTL inválido. Use entre 1 e 120 minutos.'
|
||||
USING ERRCODE = 'P0003';
|
||||
END IF;
|
||||
|
||||
-- Valida tenant
|
||||
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'Tenant não encontrado.'
|
||||
USING ERRCODE = 'P0004';
|
||||
END IF;
|
||||
|
||||
-- Gera token único (64 chars hex, sem pgcrypto)
|
||||
v_token := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
|
||||
v_expires := now() + (p_ttl_minutes || ' minutes')::interval;
|
||||
|
||||
-- Insere sessão
|
||||
INSERT INTO public.support_sessions (tenant_id, admin_id, token, expires_at)
|
||||
VALUES (p_tenant_id, v_admin_id, v_token, v_expires)
|
||||
RETURNING * INTO v_session;
|
||||
|
||||
RETURN json_build_object(
|
||||
'token', v_session.token,
|
||||
'expires_at', v_session.expires_at,
|
||||
'session_id', v_session.id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: therapist_payouts; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.therapist_payouts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
period_end date NOT NULL,
|
||||
total_sessions integer DEFAULT 0 NOT NULL,
|
||||
gross_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
clinic_fee_total numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
net_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT therapist_payouts_clinic_fee_total_check CHECK ((clinic_fee_total >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_gross_amount_check CHECK ((gross_amount >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_net_amount_check CHECK ((net_amount >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_period_chk CHECK ((period_end >= period_start)),
|
||||
CONSTRAINT therapist_payouts_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'cancelled'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.therapist_payouts OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: create_therapist_payout(uuid, uuid, date, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) RETURNS public.therapist_payouts
|
||||
CREATE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
declare
|
||||
v_is_native boolean;
|
||||
v_fields int := 0;
|
||||
v_logs int := 0;
|
||||
v_parent int := 0;
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
raise exception 'Not authenticated';
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1
|
||||
from public.tenant_members tm
|
||||
where tm.tenant_id = p_tenant_id
|
||||
and tm.user_id = auth.uid()
|
||||
and tm.status = 'active'
|
||||
) then
|
||||
raise exception 'Not allowed';
|
||||
end if;
|
||||
|
||||
select dc.is_native
|
||||
into v_is_native
|
||||
from public.determined_commitments dc
|
||||
where dc.tenant_id = p_tenant_id
|
||||
and dc.id = p_commitment_id;
|
||||
|
||||
if v_is_native is null then
|
||||
raise exception 'Commitment not found';
|
||||
end if;
|
||||
|
||||
if v_is_native = true then
|
||||
raise exception 'Cannot delete native commitment';
|
||||
end if;
|
||||
|
||||
delete from public.determined_commitment_fields
|
||||
where tenant_id = p_tenant_id
|
||||
and commitment_id = p_commitment_id;
|
||||
get diagnostics v_fields = row_count;
|
||||
|
||||
delete from public.commitment_time_logs
|
||||
where tenant_id = p_tenant_id
|
||||
and commitment_id = p_commitment_id;
|
||||
get diagnostics v_logs = row_count;
|
||||
|
||||
delete from public.determined_commitments
|
||||
where tenant_id = p_tenant_id
|
||||
and id = p_commitment_id;
|
||||
get diagnostics v_parent = row_count;
|
||||
|
||||
if v_parent <> 1 then
|
||||
raise exception 'Parent not deleted (RLS/owner issue).';
|
||||
end if;
|
||||
|
||||
return jsonb_build_object(
|
||||
'ok', true,
|
||||
'deleted', jsonb_build_object(
|
||||
'fields', v_fields,
|
||||
'logs', v_logs,
|
||||
'commitment', v_parent
|
||||
)
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: delete_determined_commitment(uuid, uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
declare
|
||||
v_is_native boolean;
|
||||
v_fields_deleted int := 0;
|
||||
v_logs_deleted int := 0;
|
||||
v_commitment_deleted int := 0;
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
raise exception 'Not authenticated';
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1
|
||||
from public.tenant_members tm
|
||||
where tm.tenant_id = p_tenant_id
|
||||
and tm.user_id = auth.uid()
|
||||
and tm.status = 'active'
|
||||
) then
|
||||
raise exception 'Not allowed';
|
||||
end if;
|
||||
|
||||
select dc.is_native
|
||||
into v_is_native
|
||||
from public.determined_commitments dc
|
||||
where dc.tenant_id = p_tenant_id
|
||||
and dc.id = p_commitment_id;
|
||||
|
||||
if v_is_native is null then
|
||||
raise exception 'Commitment not found for tenant';
|
||||
end if;
|
||||
|
||||
if v_is_native = true then
|
||||
raise exception 'Cannot delete native commitment';
|
||||
end if;
|
||||
|
||||
delete from public.determined_commitment_fields f
|
||||
where f.tenant_id = p_tenant_id
|
||||
and f.commitment_id = p_commitment_id;
|
||||
get diagnostics v_fields_deleted = row_count;
|
||||
|
||||
delete from public.commitment_time_logs l
|
||||
where l.tenant_id = p_tenant_id
|
||||
and l.commitment_id = p_commitment_id;
|
||||
get diagnostics v_logs_deleted = row_count;
|
||||
|
||||
delete from public.determined_commitments dc
|
||||
where dc.tenant_id = p_tenant_id
|
||||
and dc.id = p_commitment_id;
|
||||
get diagnostics v_commitment_deleted = row_count;
|
||||
|
||||
if v_commitment_deleted <> 1 then
|
||||
raise exception 'Delete did not remove the commitment (tenant mismatch?)';
|
||||
end if;
|
||||
|
||||
return jsonb_build_object(
|
||||
'ok', true,
|
||||
'tenant_id', p_tenant_id,
|
||||
'commitment_id', p_commitment_id,
|
||||
'deleted', jsonb_build_object(
|
||||
'fields', v_fields_deleted,
|
||||
'logs', v_logs_deleted,
|
||||
'commitment', v_commitment_deleted
|
||||
)
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: dev_list_auth_users(integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.dev_list_auth_users(p_limit integer DEFAULT 50) RETURNS TABLE(id uuid, email text, created_at timestamp with time zone)
|
||||
CREATE FUNCTION public.faq_votar(faq_id uuid) RETURNS void
|
||||
LANGUAGE sql SECURITY DEFINER
|
||||
AS $$
|
||||
update public.saas_faq
|
||||
set votos = votos + 1,
|
||||
updated_at = now()
|
||||
where id = faq_id
|
||||
and ativo = true;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.faq_votar(faq_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: fix_all_subscription_mismatches(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
r record;
|
||||
begin
|
||||
for r in
|
||||
select distinct s.user_id as owner_id
|
||||
from public.subscriptions s
|
||||
where s.status = 'active'
|
||||
and s.user_id is not null
|
||||
loop
|
||||
perform public.rebuild_owner_entitlements(r.owner_id);
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.fix_all_subscription_mismatches() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: fn_agenda_regras_semanais_no_overlap(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.fn_agenda_regras_semanais_no_overlap() RETURNS trigger
|
||||
CREATE FUNCTION public.guard_locked_commitment() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
begin
|
||||
if (old.is_locked = true) then
|
||||
if (tg_op = 'DELETE') then
|
||||
raise exception 'Compromisso bloqueado não pode ser excluído.';
|
||||
end if;
|
||||
|
||||
if (tg_op = 'UPDATE') then
|
||||
if (new.active = false) then
|
||||
raise exception 'Compromisso bloqueado não pode ser desativado.';
|
||||
end if;
|
||||
|
||||
-- trava renomear (mantém o "Sessão" sempre igual)
|
||||
if (new.name is distinct from old.name) then
|
||||
raise exception 'Compromisso bloqueado não pode ser renomeado.';
|
||||
end if;
|
||||
|
||||
-- se quiser travar descrição também, descomente:
|
||||
-- if (new.description is distinct from old.description) then
|
||||
-- raise exception 'Compromisso bloqueado não pode alterar descrição.';
|
||||
-- end if;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.guard_locked_commitment() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: guard_no_change_core_plan_key(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.guard_no_change_core_plan_key() RETURNS trigger
|
||||
CREATE FUNCTION public.notice_track_click(p_notice_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
begin
|
||||
update public.global_notices
|
||||
set clicks_count = clicks_count + 1
|
||||
where id = p_notice_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notice_track_click(p_notice_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notice_track_view(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.notice_track_view(p_notice_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
begin
|
||||
update public.global_notices
|
||||
set views_count = views_count + 1
|
||||
where id = p_notice_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notice_track_view(p_notice_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notify_on_intake(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.notify_on_intake() RETURNS trigger
|
||||
CREATE FUNCTION public.revoke_support_session(p_token text) RETURNS boolean
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_admin_id uuid;
|
||||
v_role text;
|
||||
BEGIN
|
||||
v_admin_id := auth.uid();
|
||||
IF v_admin_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
SELECT role INTO v_role FROM public.profiles WHERE id = v_admin_id;
|
||||
IF v_role <> 'saas_admin' THEN
|
||||
RAISE EXCEPTION 'Acesso negado.' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.support_sessions
|
||||
WHERE token = p_token
|
||||
AND admin_id = v_admin_id;
|
||||
|
||||
RETURN FOUND;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.revoke_support_session(p_token text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: rotate_patient_invite_token(text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.rotate_patient_invite_token(p_new_token text) RETURNS uuid
|
||||
CREATE FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_uid uuid := auth.uid();
|
||||
v_voto_antigo boolean;
|
||||
begin
|
||||
if v_uid is null then
|
||||
raise exception 'Não autenticado';
|
||||
end if;
|
||||
|
||||
-- Verifica se já votou
|
||||
select util into v_voto_antigo
|
||||
from public.saas_doc_votos
|
||||
where doc_id = p_doc_id and user_id = v_uid;
|
||||
|
||||
if found then
|
||||
-- Já votou igual → cancela o voto (toggle)
|
||||
if v_voto_antigo = p_util then
|
||||
delete from public.saas_doc_votos
|
||||
where doc_id = p_doc_id and user_id = v_uid;
|
||||
|
||||
update public.saas_docs set
|
||||
votos_util = greatest(0, votos_util - (case when p_util then 1 else 0 end)),
|
||||
votos_nao_util = greatest(0, votos_nao_util - (case when not p_util then 1 else 0 end)),
|
||||
updated_at = now()
|
||||
where id = p_doc_id;
|
||||
|
||||
return jsonb_build_object('acao', 'removido', 'util', null);
|
||||
else
|
||||
-- Mudou de voto
|
||||
update public.saas_doc_votos set util = p_util, updated_at = now()
|
||||
where doc_id = p_doc_id and user_id = v_uid;
|
||||
|
||||
update public.saas_docs set
|
||||
votos_util = greatest(0, votos_util + (case when p_util then 1 else -1 end)),
|
||||
votos_nao_util = greatest(0, votos_nao_util + (case when not p_util then 1 else -1 end)),
|
||||
updated_at = now()
|
||||
where id = p_doc_id;
|
||||
|
||||
return jsonb_build_object('acao', 'atualizado', 'util', p_util);
|
||||
end if;
|
||||
else
|
||||
-- Primeiro voto
|
||||
insert into public.saas_doc_votos (doc_id, user_id, util)
|
||||
values (p_doc_id, v_uid, p_util);
|
||||
|
||||
update public.saas_docs set
|
||||
votos_util = votos_util + (case when p_util then 1 else 0 end),
|
||||
votos_nao_util = votos_nao_util + (case when not p_util then 1 else 0 end),
|
||||
updated_at = now()
|
||||
where id = p_doc_id;
|
||||
|
||||
return jsonb_build_object('acao', 'registrado', 'util', p_util);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: safe_delete_patient(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
||||
CREATE FUNCTION public.sanitize_phone_br(raw_phone text) RETURNS text
|
||||
LANGUAGE plpgsql IMMUTABLE
|
||||
AS $$ DECLARE digits text;
|
||||
BEGIN
|
||||
digits := regexp_replace(COALESCE(raw_phone, ''), '[^0-9]', '', 'g');
|
||||
IF digits = '' THEN RETURN ''; END IF;
|
||||
IF length(digits) = 10 OR length(digits) = 11 THEN
|
||||
digits := '55' || digits;
|
||||
END IF;
|
||||
RETURN digits;
|
||||
END; $$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.sanitize_phone_br(raw_phone text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: seed_default_financial_categories(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.seed_default_financial_categories(p_user_id uuid) RETURNS void
|
||||
CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
-- Sessão (locked + sempre ativa)
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
|
||||
end if;
|
||||
|
||||
-- Leitura
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
||||
end if;
|
||||
|
||||
-- Supervisão
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
|
||||
end if;
|
||||
|
||||
-- Aula ✅ (corrigido)
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
||||
end if;
|
||||
|
||||
-- Análise pessoal
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
|
||||
end if;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Campos padrão (idempotentes por (commitment_id, key))
|
||||
-- -------------------------------------------------------
|
||||
|
||||
-- Leitura
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'book') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'book', 'Livro', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'author') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'author', 'Autor', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Supervisão
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'supervisor') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'supervisor', 'Supervisor', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'topic') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'topic', 'Assunto', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Aula
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'theme') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'theme', 'Tema', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'group') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'group', 'Turma', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Análise
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'analyst') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'analyst', 'Analista', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'focus') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'focus', 'Foco', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.seed_determined_commitments(p_tenant_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: set_insurance_plans_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.set_insurance_plans_updated_at() RETURNS trigger
|
||||
CREATE FUNCTION public.validate_support_session(p_token text) RETURNS json
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_session support_sessions;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(trim(p_token)) < 32 THEN
|
||||
RETURN json_build_object('valid', false, 'tenant_id', null);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_session
|
||||
FROM public.support_sessions
|
||||
WHERE token = p_token
|
||||
AND expires_at > now()
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN json_build_object('valid', false, 'tenant_id', null);
|
||||
END IF;
|
||||
|
||||
RETURN json_build_object(
|
||||
'valid', true,
|
||||
'tenant_id', v_session.tenant_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.validate_support_session(p_token text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: whoami(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
|
||||
@@ -1,404 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — Notificações
|
||||
-- =============================================================================
|
||||
-- cancel_notifications_on_opt_out, cancel_notifications_on_session_cancel,
|
||||
-- cancel_patient_pending_notifications, cleanup_notification_queue,
|
||||
-- notify_on_intake, notify_on_scheduling, notify_on_session_status,
|
||||
-- populate_notification_queue, unstick_notification_queue
|
||||
-- =============================================================================
|
||||
|
||||
CREATE FUNCTION public.cancel_notifications_on_opt_out() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
-- WhatsApp opt-out
|
||||
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(
|
||||
NEW.patient_id, 'whatsapp'
|
||||
);
|
||||
END IF;
|
||||
-- Email opt-out
|
||||
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(
|
||||
NEW.patient_id, 'email'
|
||||
);
|
||||
END IF;
|
||||
-- SMS opt-out
|
||||
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(
|
||||
NEW.patient_id, 'sms'
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.cancel_notifications_on_opt_out() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: cancel_notifications_on_session_cancel(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status IN ('cancelado', 'excluido')
|
||||
AND OLD.status NOT IN ('cancelado', 'excluido')
|
||||
THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(
|
||||
NEW.patient_id, NULL, NEW.id
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.cancel_notifications_on_session_cancel() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: cancel_patient_pending_notifications(uuid, text, uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL::text, p_evento_id uuid DEFAULT NULL::uuid) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_canceled integer;
|
||||
BEGIN
|
||||
UPDATE public.notification_queue
|
||||
SET status = 'cancelado',
|
||||
updated_at = now()
|
||||
WHERE patient_id = p_patient_id
|
||||
AND status IN ('pendente', 'processando')
|
||||
AND (p_channel IS NULL OR channel = p_channel)
|
||||
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
|
||||
|
||||
GET DIAGNOSTICS v_canceled = ROW_COUNT;
|
||||
RETURN v_canceled;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text, p_evento_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: cancel_recurrence_from(uuid, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
|
||||
CREATE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL::text, p_evento_id uuid DEFAULT NULL::uuid) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_canceled integer;
|
||||
BEGIN
|
||||
UPDATE public.notification_queue
|
||||
SET status = 'cancelado',
|
||||
updated_at = now()
|
||||
WHERE patient_id = p_patient_id
|
||||
AND status IN ('pendente', 'processando')
|
||||
AND (p_channel IS NULL OR channel = p_channel)
|
||||
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
|
||||
|
||||
GET DIAGNOSTICS v_canceled = ROW_COUNT;
|
||||
RETURN v_canceled;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text, p_evento_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: cancel_recurrence_from(uuid, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
|
||||
CREATE FUNCTION public.cleanup_notification_queue() RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_deleted integer;
|
||||
BEGIN
|
||||
DELETE FROM public.notification_queue
|
||||
WHERE status IN ('enviado', 'cancelado', 'ignorado')
|
||||
AND created_at < now() - interval '90 days';
|
||||
|
||||
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
||||
RETURN v_deleted;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.cleanup_notification_queue() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: create_clinic_tenant(text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_tenant uuid;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := auth.uid();
|
||||
if v_uid is null then
|
||||
raise exception 'Not authenticated';
|
||||
end if;
|
||||
|
||||
v_name := nullif(trim(coalesce(p_name, '')), '');
|
||||
if v_name is null then
|
||||
v_name := 'Clínica';
|
||||
end if;
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name, 'clinic', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.create_clinic_tenant(p_name text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: financial_records; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.financial_records (
|
||||
CREATE FUNCTION public.notify_on_intake() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'new' THEN
|
||||
INSERT INTO public.notifications (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
type,
|
||||
ref_id,
|
||||
ref_table,
|
||||
payload
|
||||
)
|
||||
VALUES (
|
||||
NEW.owner_id,
|
||||
NEW.tenant_id,
|
||||
'new_patient',
|
||||
NEW.id,
|
||||
'patient_intake_requests',
|
||||
jsonb_build_object(
|
||||
'title', 'Novo cadastro externo',
|
||||
'detail', COALESCE(NEW.nome_completo, 'Paciente'),
|
||||
'deeplink', '/therapist/patients/cadastro/recebidos',
|
||||
'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notify_on_intake() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notify_on_scheduling(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.notify_on_scheduling() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$ BEGIN IF NEW.status = 'pendente' THEN
|
||||
INSERT INTO public.notifications ( owner_id, tenant_id, type, ref_id, ref_table, payload ) VALUES (
|
||||
NEW.owner_id, NEW.tenant_id,
|
||||
'new_scheduling', NEW.id, 'agendador_solicitacoes', jsonb_build_object( 'title', 'Nova solicitação de agendamento', 'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || ' — ' || COALESCE(NEW.tipo, ''), 'deeplink', '/therapist/agendamentos-recebidos', 'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome, ''), 1)) ) ); END IF; RETURN NEW; END; $$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notify_on_scheduling() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notify_on_session_status(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.notify_on_session_status() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_nome text;
|
||||
BEGIN
|
||||
IF NEW.status IN ('faltou', 'cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
|
||||
|
||||
SELECT nome_completo
|
||||
INTO v_nome
|
||||
FROM public.patients
|
||||
WHERE id = NEW.patient_id
|
||||
LIMIT 1;
|
||||
|
||||
INSERT INTO public.notifications (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
type,
|
||||
ref_id,
|
||||
ref_table,
|
||||
payload
|
||||
)
|
||||
VALUES (
|
||||
NEW.owner_id,
|
||||
NEW.tenant_id,
|
||||
'session_status',
|
||||
NEW.id,
|
||||
'agenda_eventos',
|
||||
jsonb_build_object(
|
||||
'title', CASE WHEN NEW.status = 'faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
|
||||
'detail', COALESCE(v_nome, 'Paciente') || ' — ' || to_char(NEW.inicio_em, 'DD/MM HH24:MI'),
|
||||
'deeplink', '/therapist/agenda',
|
||||
'avatar_initials', upper(left(COALESCE(v_nome, '?'), 2))
|
||||
)
|
||||
);
|
||||
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notify_on_session_status() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: on_new_user_seed_patient_groups(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.on_new_user_seed_patient_groups() RETURNS trigger
|
||||
CREATE FUNCTION public.populate_notification_queue() RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.notification_queue (
|
||||
tenant_id, owner_id, agenda_evento_id, patient_id,
|
||||
channel, template_key, schedule_key,
|
||||
resolved_vars, recipient_address,
|
||||
scheduled_at, idempotency_key
|
||||
)
|
||||
SELECT
|
||||
ae.tenant_id,
|
||||
ae.owner_id,
|
||||
ae.id AS agenda_evento_id,
|
||||
ae.patient_id,
|
||||
ch.channel,
|
||||
'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
|
||||
ns.schedule_key,
|
||||
jsonb_build_object(
|
||||
'nome_paciente', COALESCE(p.nome_completo, 'Paciente'),
|
||||
'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo', 'DD/MM/YYYY'),
|
||||
'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo', 'HH24:MI'),
|
||||
'nome_terapeuta', COALESCE(prof.full_name, 'Terapeuta'),
|
||||
'modalidade', COALESCE(ae.modalidade, 'Presencial'),
|
||||
'titulo', COALESCE(ae.titulo, 'Sessão')
|
||||
),
|
||||
CASE ch.channel
|
||||
WHEN 'whatsapp' THEN COALESCE(p.telefone, '')
|
||||
WHEN 'sms' THEN COALESCE(p.telefone, '')
|
||||
WHEN 'email' THEN COALESCE(p.email_principal, '')
|
||||
END,
|
||||
CASE
|
||||
WHEN (ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)::time
|
||||
< ns.allowed_time_start
|
||||
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)
|
||||
+ ns.allowed_time_start
|
||||
WHEN (ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)::time
|
||||
> ns.allowed_time_end
|
||||
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)
|
||||
+ ns.allowed_time_start
|
||||
ELSE ae.inicio_em - (ns.offset_minutes || ' minutes')::interval
|
||||
END,
|
||||
ae.id::text || ':' || ns.schedule_key || ':' || ch.channel || ':'
|
||||
|| ae.inicio_em::date::text
|
||||
FROM public.agenda_eventos ae
|
||||
JOIN public.patients p ON p.id = ae.patient_id
|
||||
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id
|
||||
JOIN public.notification_schedules ns
|
||||
ON ns.owner_id = ae.owner_id
|
||||
AND ns.is_active = true
|
||||
AND ns.deleted_at IS NULL
|
||||
AND ns.trigger_type = 'before_event'
|
||||
AND ns.event_type = 'lembrete_sessao'
|
||||
JOIN public.notification_channels nc
|
||||
ON nc.owner_id = ae.owner_id
|
||||
AND nc.is_active = true
|
||||
AND nc.deleted_at IS NULL
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel = 'whatsapp'
|
||||
UNION ALL
|
||||
SELECT 'email' AS channel WHERE ns.email_enabled AND nc.channel = 'email'
|
||||
UNION ALL
|
||||
SELECT 'sms' AS channel WHERE ns.sms_enabled AND nc.channel = 'sms'
|
||||
) ch
|
||||
LEFT JOIN public.notification_preferences np
|
||||
ON np.patient_id = ae.patient_id
|
||||
AND np.owner_id = ae.owner_id
|
||||
AND np.deleted_at IS NULL
|
||||
WHERE
|
||||
ae.inicio_em > now()
|
||||
AND ae.inicio_em <= now() + interval '48 hours'
|
||||
AND ae.status NOT IN ('cancelado', 'faltou')
|
||||
AND CASE ch.channel
|
||||
WHEN 'whatsapp' THEN COALESCE(p.telefone, '') != ''
|
||||
WHEN 'sms' THEN COALESCE(p.telefone, '') != ''
|
||||
WHEN 'email' THEN COALESCE(p.email_principal, '') != ''
|
||||
END
|
||||
AND CASE ch.channel
|
||||
WHEN 'whatsapp' THEN COALESCE(np.whatsapp_opt_in, true)
|
||||
WHEN 'email' THEN COALESCE(np.email_opt_in, true)
|
||||
WHEN 'sms' THEN COALESCE(np.sms_opt_in, false)
|
||||
END
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.profiles tp
|
||||
WHERE tp.id = ae.owner_id
|
||||
AND COALESCE(tp.notify_reminders, true) = true
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.populate_notification_queue() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: prevent_promoting_to_system(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.prevent_promoting_to_system() RETURNS trigger
|
||||
CREATE FUNCTION public.unstick_notification_queue() RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_unstuck integer;
|
||||
BEGIN
|
||||
UPDATE public.notification_queue
|
||||
SET status = 'pendente',
|
||||
attempts = attempts + 1,
|
||||
last_error = 'Timeout: preso em processando por >10min',
|
||||
next_retry_at = now() + interval '2 minutes'
|
||||
WHERE status = 'processando'
|
||||
AND updated_at < now() - interval '10 minutes';
|
||||
|
||||
GET DIAGNOSTICS v_unstuck = ROW_COUNT;
|
||||
RETURN v_unstuck;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.unstick_notification_queue() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: update_payment_settings_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger
|
||||
@@ -1,433 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — Pacientes
|
||||
-- =============================================================================
|
||||
-- can_delete_patient, safe_delete_patient, create_patient_intake_request,
|
||||
-- create_patient_intake_request_v2, rotate_patient_invite_token,
|
||||
-- patients_validate_member_consistency, prevent_system_group_changes,
|
||||
-- seed_default_patient_groups
|
||||
-- =============================================================================
|
||||
|
||||
CREATE FUNCTION public.can_delete_patient(p_patient_id uuid) RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT NOT EXISTS (
|
||||
SELECT 1 FROM public.agenda_eventos WHERE patient_id = p_patient_id
|
||||
UNION ALL
|
||||
SELECT 1 FROM public.recurrence_rules WHERE patient_id = p_patient_id
|
||||
UNION ALL
|
||||
SELECT 1 FROM public.billing_contracts WHERE patient_id = p_patient_id
|
||||
);
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.can_delete_patient(p_patient_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: cancel_notifications_on_opt_out(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.cancel_notifications_on_opt_out() RETURNS trigger
|
||||
CREATE FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text DEFAULT NULL::text, p_phone text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_consent boolean DEFAULT false) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
declare
|
||||
v_owner uuid;
|
||||
v_active boolean;
|
||||
v_expires timestamptz;
|
||||
v_max_uses int;
|
||||
v_uses int;
|
||||
v_id uuid;
|
||||
begin
|
||||
select owner_id, active, expires_at, max_uses, uses
|
||||
into v_owner, v_active, v_expires, v_max_uses, v_uses
|
||||
from public.patient_invites
|
||||
where token = p_token
|
||||
limit 1;
|
||||
|
||||
if v_owner is null then
|
||||
raise exception 'Token inválido';
|
||||
end if;
|
||||
|
||||
if v_active is not true then
|
||||
raise exception 'Link desativado';
|
||||
end if;
|
||||
|
||||
if v_expires is not null and now() > v_expires then
|
||||
raise exception 'Link expirado';
|
||||
end if;
|
||||
|
||||
if v_max_uses is not null and v_uses >= v_max_uses then
|
||||
raise exception 'Limite de uso atingido';
|
||||
end if;
|
||||
|
||||
if p_name is null or length(trim(p_name)) = 0 then
|
||||
raise exception 'Nome é obrigatório';
|
||||
end if;
|
||||
|
||||
insert into public.patient_intake_requests
|
||||
(owner_id, token, name, email, phone, notes, consent, status)
|
||||
values
|
||||
(v_owner, p_token, trim(p_name),
|
||||
nullif(lower(trim(p_email)), ''),
|
||||
nullif(trim(p_phone), ''),
|
||||
nullif(trim(p_notes), ''),
|
||||
coalesce(p_consent, false),
|
||||
'new')
|
||||
returning id into v_id;
|
||||
|
||||
update public.patient_invites
|
||||
set uses = uses + 1
|
||||
where token = p_token;
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text, p_phone text, p_notes text, p_consent boolean) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: create_patient_intake_request_v2(text, jsonb); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE 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 $_$
|
||||
declare
|
||||
v_owner_id uuid;
|
||||
v_intake_id uuid;
|
||||
v_birth_raw text;
|
||||
v_birth date;
|
||||
begin
|
||||
select owner_id
|
||||
into v_owner_id
|
||||
from public.patient_invites
|
||||
where token = p_token;
|
||||
|
||||
if v_owner_id is null then
|
||||
raise exception 'Token inválido ou expirado';
|
||||
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;
|
||||
|
||||
insert into public.patient_intake_requests (
|
||||
owner_id,
|
||||
token,
|
||||
status,
|
||||
consent,
|
||||
|
||||
nome_completo,
|
||||
email_principal,
|
||||
telefone,
|
||||
|
||||
avatar_url, -- 🔥 AQUI
|
||||
|
||||
data_nascimento,
|
||||
cpf,
|
||||
rg,
|
||||
genero,
|
||||
estado_civil,
|
||||
profissao,
|
||||
escolaridade,
|
||||
nacionalidade,
|
||||
naturalidade,
|
||||
|
||||
cep,
|
||||
pais,
|
||||
cidade,
|
||||
estado,
|
||||
endereco,
|
||||
numero,
|
||||
complemento,
|
||||
bairro,
|
||||
|
||||
observacoes,
|
||||
notas_internas,
|
||||
|
||||
encaminhado_por,
|
||||
onde_nos_conheceu
|
||||
)
|
||||
values (
|
||||
v_owner_id,
|
||||
p_token,
|
||||
'new',
|
||||
coalesce((p_payload->>'consent')::boolean, false),
|
||||
|
||||
nullif(trim(p_payload->>'nome_completo'), ''),
|
||||
nullif(trim(p_payload->>'email_principal'), ''),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
||||
|
||||
nullif(trim(p_payload->>'avatar_url'), ''), -- 🔥 AQUI
|
||||
|
||||
v_birth,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
||||
nullif(trim(p_payload->>'rg'), ''),
|
||||
nullif(trim(p_payload->>'genero'), ''),
|
||||
nullif(trim(p_payload->>'estado_civil'), ''),
|
||||
nullif(trim(p_payload->>'profissao'), ''),
|
||||
nullif(trim(p_payload->>'escolaridade'), ''),
|
||||
nullif(trim(p_payload->>'nacionalidade'), ''),
|
||||
nullif(trim(p_payload->>'naturalidade'), ''),
|
||||
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
||||
nullif(trim(p_payload->>'pais'), ''),
|
||||
nullif(trim(p_payload->>'cidade'), ''),
|
||||
nullif(trim(p_payload->>'estado'), ''),
|
||||
nullif(trim(p_payload->>'endereco'), ''),
|
||||
nullif(trim(p_payload->>'numero'), ''),
|
||||
nullif(trim(p_payload->>'complemento'), ''),
|
||||
nullif(trim(p_payload->>'bairro'), ''),
|
||||
|
||||
nullif(trim(p_payload->>'observacoes'), ''),
|
||||
nullif(trim(p_payload->>'notas_internas'), ''),
|
||||
|
||||
nullif(trim(p_payload->>'encaminhado_por'), ''),
|
||||
nullif(trim(p_payload->>'onde_nos_conheceu'), '')
|
||||
)
|
||||
returning id into v_intake_id;
|
||||
|
||||
return v_intake_id;
|
||||
end;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.create_patient_intake_request_v2(p_token text, p_payload jsonb) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: create_support_session(uuid, integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer DEFAULT 60) RETURNS json
|
||||
CREATE FUNCTION public.patients_validate_member_consistency() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_responsible uuid;
|
||||
v_tenant_therapist uuid;
|
||||
BEGIN
|
||||
-- responsible_member sempre deve existir e ser do tenant
|
||||
SELECT tenant_id INTO v_tenant_responsible
|
||||
FROM public.tenant_members
|
||||
WHERE id = NEW.responsible_member_id;
|
||||
|
||||
IF v_tenant_responsible IS NULL THEN
|
||||
RAISE EXCEPTION 'Responsible member not found';
|
||||
END IF;
|
||||
|
||||
IF NEW.tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_id is required';
|
||||
END IF;
|
||||
|
||||
IF v_tenant_responsible <> NEW.tenant_id THEN
|
||||
RAISE EXCEPTION 'Responsible member must belong to the same tenant';
|
||||
END IF;
|
||||
|
||||
-- therapist scope: therapist_member_id deve existir e ser do mesmo tenant
|
||||
IF NEW.patient_scope = 'therapist' THEN
|
||||
IF NEW.therapist_member_id IS NULL THEN
|
||||
RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist';
|
||||
END IF;
|
||||
|
||||
SELECT tenant_id INTO v_tenant_therapist
|
||||
FROM public.tenant_members
|
||||
WHERE id = NEW.therapist_member_id;
|
||||
|
||||
IF v_tenant_therapist IS NULL THEN
|
||||
RAISE EXCEPTION 'Therapist member not found';
|
||||
END IF;
|
||||
|
||||
IF v_tenant_therapist <> NEW.tenant_id THEN
|
||||
RAISE EXCEPTION 'Therapist member must belong to the same tenant';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.patients_validate_member_consistency() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: patients_validate_responsible_member_tenant(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.patients_validate_responsible_member_tenant() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
declare
|
||||
m_tenant uuid;
|
||||
begin
|
||||
select tenant_id into m_tenant
|
||||
from public.tenant_members
|
||||
where id = new.responsible_member_id;
|
||||
|
||||
if m_tenant is null then
|
||||
raise exception 'Responsible member not found';
|
||||
end if;
|
||||
|
||||
if new.tenant_id is null then
|
||||
raise exception 'tenant_id is required';
|
||||
end if;
|
||||
|
||||
if m_tenant <> new.tenant_id then
|
||||
raise exception 'Responsible member must belong to the same tenant';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.patients_validate_responsible_member_tenant() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: populate_notification_queue(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.populate_notification_queue() RETURNS void
|
||||
CREATE FUNCTION public.prevent_system_group_changes() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
begin
|
||||
-- Se for grupo do sistema, regras rígidas:
|
||||
if old.is_system = true then
|
||||
|
||||
-- nunca pode deletar
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'Grupos padrão do sistema não podem ser alterados ou excluídos.';
|
||||
end if;
|
||||
|
||||
if tg_op = 'UPDATE' then
|
||||
-- permite SOMENTE mudar tenant_id e/ou updated_at
|
||||
-- qualquer mudança de conteúdo permanece proibida
|
||||
if
|
||||
new.nome is distinct from old.nome or
|
||||
new.descricao is distinct from old.descricao or
|
||||
new.cor is distinct from old.cor or
|
||||
new.is_active is distinct from old.is_active or
|
||||
new.is_system is distinct from old.is_system or
|
||||
new.owner_id is distinct from old.owner_id or
|
||||
new.therapist_id is distinct from old.therapist_id or
|
||||
new.created_at is distinct from old.created_at
|
||||
then
|
||||
raise exception 'Grupos padrão do sistema não podem ser alterados ou excluídos.';
|
||||
end if;
|
||||
|
||||
-- chegou aqui: só tenant_id/updated_at mudaram -> ok
|
||||
return new;
|
||||
end if;
|
||||
|
||||
end if;
|
||||
|
||||
-- não-system: deixa passar
|
||||
if tg_op = 'DELETE' then
|
||||
return old;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.prevent_system_group_changes() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: provision_account_tenant(uuid, text, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text) RETURNS uuid
|
||||
CREATE FUNCTION public.rotate_patient_invite_token(p_new_token text) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_id uuid;
|
||||
begin
|
||||
-- pega o usuário logado
|
||||
v_uid := auth.uid();
|
||||
if v_uid is null then
|
||||
raise exception 'Usuário não autenticado';
|
||||
end if;
|
||||
|
||||
-- desativa tokens antigos ativos do usuário
|
||||
update public.patient_invites
|
||||
set active = false
|
||||
where owner_id = v_uid
|
||||
and active = true;
|
||||
|
||||
-- cria novo token
|
||||
insert into public.patient_invites (owner_id, token, active)
|
||||
values (v_uid, p_new_token, true)
|
||||
returning id into v_id;
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.rotate_patient_invite_token(p_new_token text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: saas_votar_doc(uuid, boolean); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) RETURNS jsonb
|
||||
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Bloqueia se houver histórico
|
||||
IF NOT public.can_delete_patient(p_patient_id) THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'error', 'has_history',
|
||||
'message', 'Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Verifica ownership via RLS (owner_id ou responsible_member_id)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.patients
|
||||
WHERE id = p_patient_id
|
||||
AND (
|
||||
owner_id = auth.uid()
|
||||
OR responsible_member_id IN (
|
||||
SELECT id FROM public.tenant_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
) THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'error', 'forbidden',
|
||||
'message', 'Sem permissão para excluir este paciente.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.patients WHERE id = p_patient_id;
|
||||
|
||||
RETURN jsonb_build_object('ok', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.safe_delete_patient(p_patient_id uuid) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: sanitize_phone_br(text); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.sanitize_phone_br(raw_phone text) RETURNS text
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Functions: pgbouncer
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.222Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO ''
|
||||
AS $_$
|
||||
begin
|
||||
raise debug 'PgBouncer auth request: %', p_usename;
|
||||
|
||||
return query
|
||||
select
|
||||
rolname::text,
|
||||
case when rolvaliduntil < now()
|
||||
then null
|
||||
else rolpassword::text
|
||||
end
|
||||
from pg_authid
|
||||
where rolname=$1 and rolcanlogin;
|
||||
end;
|
||||
$_$;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — realtime schema
|
||||
-- =============================================================================
|
||||
-- Functions: realtime
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.223Z
|
||||
-- Total: 12
|
||||
|
||||
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
|
||||
LANGUAGE plpgsql
|
||||
@@ -302,13 +302,6 @@ perform set_config('role', null, true);
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: broadcast_changes(text, text, text, text, text, record, record, text); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text DEFAULT 'ROW'::text) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -333,13 +326,6 @@ END;
|
||||
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: build_prepared_statement_sql(text, regclass, realtime.wal_column[]); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[]) RETURNS text
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
@@ -368,13 +354,6 @@ CREATE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name te
|
||||
entity
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[]) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: cast(text, regtype); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime."cast"(val text, type_ regtype) RETURNS jsonb
|
||||
LANGUAGE plpgsql IMMUTABLE
|
||||
AS $$
|
||||
@@ -386,13 +365,6 @@ CREATE FUNCTION realtime."cast"(val text, type_ regtype) RETURNS jsonb
|
||||
end
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime."cast"(val text, type_ regtype) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: check_equality_op(realtime.equality_op, regtype, text, text); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text) RETURNS boolean
|
||||
LANGUAGE plpgsql IMMUTABLE
|
||||
AS $$
|
||||
@@ -427,13 +399,6 @@ CREATE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtyp
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) RETURNS boolean
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $_$
|
||||
@@ -465,13 +430,6 @@ CREATE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[
|
||||
on f.column_name = col.name;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: list_changes(name, name, integer, integer); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer) RETURNS SETOF realtime.wal_rls
|
||||
LANGUAGE sql
|
||||
SET log_min_messages TO 'fatal'
|
||||
@@ -533,13 +491,6 @@ CREATE FUNCTION realtime.list_changes(publication name, slot_name name, max_chan
|
||||
and xyz.subscription_ids[1] is not null
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: quote_wal2json(regclass); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.quote_wal2json(entity regclass) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE STRICT
|
||||
AS $$
|
||||
@@ -573,13 +524,6 @@ CREATE FUNCTION realtime.quote_wal2json(entity regclass) RETURNS text
|
||||
pc.oid = entity
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.quote_wal2json(entity regclass) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: send(jsonb, text, text, boolean); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -612,13 +556,6 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: subscription_check_filters(); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.subscription_check_filters() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -688,34 +625,12 @@ CREATE FUNCTION realtime.subscription_check_filters() RETURNS trigger
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.subscription_check_filters() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: to_regrole(text); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.to_regrole(role_name text) RETURNS regrole
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$ select role_name::regrole $$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.to_regrole(role_name text) OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: topic(); Type: FUNCTION; Schema: realtime; Owner: supabase_realtime_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION realtime.topic() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
select nullif(current_setting('realtime.topic', true), '')::text;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION realtime.topic() OWNER TO supabase_realtime_admin;
|
||||
|
||||
--
|
||||
-- Name: can_insert_object(text, text, uuid, jsonb); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — storage schema
|
||||
-- =============================================================================
|
||||
-- Functions: storage
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.224Z
|
||||
-- Total: 15
|
||||
|
||||
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
@@ -14,13 +14,6 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: enforce_bucket_name_length(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.enforce_bucket_name_length() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -32,13 +25,6 @@ begin
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.enforce_bucket_name_length() OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: extension(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.extension(name text) RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -53,13 +39,6 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.extension(name text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: filename(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.filename(name text) RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -71,13 +50,6 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.filename(name text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: foldername(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.foldername(name text) RETURNS text[]
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -89,13 +61,6 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.foldername(name text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: get_common_prefix(text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.get_common_prefix(p_key text, p_prefix text, p_delimiter text) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
@@ -106,13 +71,6 @@ SELECT CASE
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.get_common_prefix(p_key text, p_prefix text, p_delimiter text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: get_size_by_bucket(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.get_size_by_bucket() RETURNS TABLE(size bigint, bucket_id text)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -124,13 +82,6 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.get_size_by_bucket() OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: list_multipart_uploads_with_delimiter(text, text, text, integer, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.list_multipart_uploads_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, next_key_token text DEFAULT ''::text, next_upload_token text DEFAULT ''::text) RETURNS TABLE(key text, id text, created_at timestamp with time zone)
|
||||
LANGUAGE plpgsql
|
||||
AS $_$
|
||||
@@ -172,13 +123,6 @@ BEGIN
|
||||
END;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.list_multipart_uploads_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer, next_key_token text, next_upload_token text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: list_objects_with_delimiter(text, text, text, integer, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.list_objects_with_delimiter(_bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, start_after text DEFAULT ''::text, next_token text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, metadata jsonb, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone)
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $_$
|
||||
@@ -389,13 +333,6 @@ BEGIN
|
||||
END;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.list_objects_with_delimiter(_bucket_id text, prefix_param text, delimiter_param text, max_keys integer, start_after text, next_token text, sort_order text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: operation(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.operation() RETURNS text
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $$
|
||||
@@ -404,13 +341,6 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.operation() OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: protect_delete(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.protect_delete() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -425,13 +355,6 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.protect_delete() OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: search(text, text, integer, integer, integer, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.search(prefix text, bucketname text, limits integer DEFAULT 100, levels integer DEFAULT 1, offsets integer DEFAULT 0, search text DEFAULT ''::text, sortcolumn text DEFAULT 'name'::text, sortorder text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $_$
|
||||
@@ -681,13 +604,6 @@ BEGIN
|
||||
END;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.search(prefix text, bucketname text, limits integer, levels integer, offsets integer, search text, sortcolumn text, sortorder text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: search_by_timestamp(text, text, integer, integer, text, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.search_by_timestamp(p_prefix text, p_bucket_id text, p_limit integer, p_level integer, p_start_after text, p_sort_order text, p_sort_column text, p_sort_column_after text) RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $_$
|
||||
@@ -790,13 +706,6 @@ BEGIN
|
||||
END;
|
||||
$_$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.search_by_timestamp(p_prefix text, p_bucket_id text, p_limit integer, p_level integer, p_start_after text, p_sort_order text, p_sort_column text, p_sort_column_after text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: search_v2(text, text, integer, integer, text, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.search_v2(prefix text, bucket_name text, limits integer DEFAULT 100, levels integer DEFAULT 1, start_after text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text, sort_column text DEFAULT 'name'::text, sort_column_after text DEFAULT ''::text) RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $$
|
||||
@@ -852,13 +761,6 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.search_v2(prefix text, bucket_name text, limits integer, levels integer, start_after text, sort_order text, sort_column text, sort_column_after text) OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: update_updated_at_column(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION storage.update_updated_at_column() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -867,11 +769,3 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION storage.update_updated_at_column() OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: http_request(); Type: FUNCTION; Schema: supabase_functions; Owner: supabase_functions_admin
|
||||
--
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Functions — supabase_functions schema
|
||||
-- =============================================================================
|
||||
-- Functions: supabase_functions
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.224Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
@@ -77,11 +77,3 @@ CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION supabase_functions.http_request() OWNER TO supabase_functions_admin;
|
||||
|
||||
--
|
||||
-- Name: extensions; Type: TABLE; Schema: _realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Tables: Addons / Créditos
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
|
||||
-- Total: 3
|
||||
|
||||
CREATE TABLE public.addon_credits (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid,
|
||||
addon_type text NOT NULL,
|
||||
balance integer DEFAULT 0 NOT NULL,
|
||||
total_purchased integer DEFAULT 0 NOT NULL,
|
||||
total_consumed integer DEFAULT 0 NOT NULL,
|
||||
low_balance_threshold integer DEFAULT 10,
|
||||
low_balance_notified boolean DEFAULT false,
|
||||
daily_limit integer,
|
||||
hourly_limit integer,
|
||||
daily_used integer DEFAULT 0,
|
||||
hourly_used integer DEFAULT 0,
|
||||
daily_reset_at timestamp with time zone,
|
||||
hourly_reset_at timestamp with time zone,
|
||||
from_number_override text,
|
||||
expires_at timestamp with time zone,
|
||||
is_active boolean DEFAULT true,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.addon_products (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
slug text NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
addon_type text NOT NULL,
|
||||
icon text DEFAULT 'pi pi-box'::text,
|
||||
credits_amount integer DEFAULT 0,
|
||||
price_cents integer DEFAULT 0 NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text,
|
||||
is_active boolean DEFAULT true,
|
||||
is_visible boolean DEFAULT true,
|
||||
sort_order integer DEFAULT 0,
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE public.addon_transactions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid,
|
||||
addon_type text NOT NULL,
|
||||
type text NOT NULL,
|
||||
amount integer NOT NULL,
|
||||
balance_before integer DEFAULT 0 NOT NULL,
|
||||
balance_after integer DEFAULT 0 NOT NULL,
|
||||
product_id uuid,
|
||||
queue_id uuid,
|
||||
description text,
|
||||
admin_user_id uuid,
|
||||
payment_method text,
|
||||
payment_reference text,
|
||||
price_cents integer,
|
||||
currency text DEFAULT 'BRL'::text,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
metadata jsonb DEFAULT '{}'::jsonb
|
||||
);
|
||||
+5
-208
@@ -1,11 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Agenda + Recorrências + Agendador Online
|
||||
-- =============================================================================
|
||||
-- agenda_bloqueios, agenda_configuracoes, agenda_eventos, agenda_excecoes,
|
||||
-- agenda_online_slots, agenda_regras_semanais, agenda_slots_bloqueados_semanais,
|
||||
-- agenda_slots_regras, recurrence_rules, recurrence_exceptions,
|
||||
-- recurrence_rule_services, agendador_configuracoes, agendador_solicitacoes
|
||||
-- =============================================================================
|
||||
-- Tables: Agenda / Agendamento
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE TABLE public.agenda_bloqueios (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -25,13 +20,6 @@ CREATE TABLE public.agenda_bloqueios (
|
||||
CONSTRAINT agenda_bloqueios_tipo_check CHECK ((tipo = ANY (ARRAY['feriado_nacional'::text, 'feriado_municipal'::text, 'bloqueio'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_bloqueios OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agenda_configuracoes; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agenda_configuracoes (
|
||||
owner_id uuid NOT NULL,
|
||||
duracao_padrao_minutos integer DEFAULT 50 NOT NULL,
|
||||
@@ -68,7 +56,9 @@ CREATE TABLE public.agenda_configuracoes (
|
||||
tenant_id uuid,
|
||||
jornada_igual_todos boolean DEFAULT true,
|
||||
slot_mode text DEFAULT 'fixed'::text NOT NULL,
|
||||
atendimento_mode text DEFAULT 'particular'::text,
|
||||
CONSTRAINT agenda_configuracoes_admin_slot_visual_minutos_check CHECK ((admin_slot_visual_minutos = ANY (ARRAY[5, 10, 15, 20, 30, 60]))),
|
||||
CONSTRAINT agenda_configuracoes_atendimento_mode_check CHECK (((atendimento_mode IS NULL) OR (atendimento_mode = ANY (ARRAY['particular'::text, 'convenio'::text, 'ambos'::text])))),
|
||||
CONSTRAINT agenda_configuracoes_check CHECK (((usar_horario_admin_custom = false) OR ((admin_inicio_visualizacao IS NOT NULL) AND (admin_fim_visualizacao IS NOT NULL) AND (admin_fim_visualizacao > admin_inicio_visualizacao)))),
|
||||
CONSTRAINT agenda_configuracoes_duracao_padrao_minutos_check CHECK (((duracao_padrao_minutos >= 10) AND (duracao_padrao_minutos <= 240))),
|
||||
CONSTRAINT agenda_configuracoes_granularidade_min_check CHECK (((granularidade_min IS NULL) OR (granularidade_min = ANY (ARRAY[5, 10, 15, 20, 30, 45, 50, 60])))),
|
||||
@@ -87,13 +77,6 @@ CREATE TABLE public.agenda_configuracoes (
|
||||
CONSTRAINT session_duration_min_chk CHECK (((session_duration_min >= 10) AND (session_duration_min <= 240)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agenda_eventos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -129,20 +112,6 @@ CREATE TABLE public.agenda_eventos (
|
||||
CONSTRAINT agenda_eventos_check CHECK ((fim_em > inicio_em))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_eventos OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: COLUMN agenda_eventos.price; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.agenda_eventos.price IS 'Valor da sessão em BRL. Preenchido automaticamente pela tabela professional_pricing do profissional.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_excecoes; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agenda_excecoes (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -161,13 +130,6 @@ CREATE TABLE public.agenda_excecoes (
|
||||
CONSTRAINT agenda_excecoes_fonte_check CHECK ((fonte = ANY (ARRAY['manual'::text, 'feriado_google'::text, 'sistema'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_excecoes OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agenda_online_slots; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agenda_online_slots (
|
||||
id bigint NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -180,34 +142,6 @@ CREATE TABLE public.agenda_online_slots (
|
||||
CONSTRAINT agenda_online_slots_weekday_check CHECK ((weekday = ANY (ARRAY[0, 1, 2, 3, 4, 5, 6])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_online_slots OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agenda_online_slots_id_seq; Type: SEQUENCE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.agenda_online_slots_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE public.agenda_online_slots_id_seq OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agenda_online_slots_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.agenda_online_slots_id_seq OWNED BY public.agenda_online_slots.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_regras_semanais; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agenda_regras_semanais (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -224,13 +158,6 @@ CREATE TABLE public.agenda_regras_semanais (
|
||||
CONSTRAINT agenda_regras_semanais_modalidade_check CHECK (((modalidade = ANY (ARRAY['online'::text, 'presencial'::text, 'ambos'::text])) OR (modalidade IS NULL)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_regras_semanais OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agenda_slots_bloqueados_semanais; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agenda_slots_bloqueados_semanais (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -244,13 +171,6 @@ CREATE TABLE public.agenda_slots_bloqueados_semanais (
|
||||
CONSTRAINT agenda_slots_bloqueados_semanais_dia_semana_check CHECK (((dia_semana >= 0) AND (dia_semana <= 6)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_slots_bloqueados_semanais OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agenda_slots_regras; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agenda_slots_regras (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -272,14 +192,6 @@ CREATE TABLE public.agenda_slots_regras (
|
||||
CONSTRAINT agenda_slots_regras_passo_minutos_check CHECK (((passo_minutos >= 5) AND (passo_minutos <= 240)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agenda_slots_regras OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: agendador_configuracoes; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE TABLE public.agendador_configuracoes (
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid,
|
||||
@@ -323,27 +235,6 @@ CREATE TABLE public.agendador_configuracoes (
|
||||
CONSTRAINT agendador_configuracoes_reserva_check CHECK (((reserva_horas >= 1) AND (reserva_horas <= 48)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agendador_configuracoes OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: COLUMN agendador_configuracoes.pagamento_modo; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.agendador_configuracoes.pagamento_modo IS 'sem_pagamento | pagar_na_hora | pix_antecipado';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN agendador_configuracoes.pagamento_metodos_visiveis; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.agendador_configuracoes.pagamento_metodos_visiveis IS 'Métodos exibidos ao paciente quando pagamento_modo = pagar_na_hora. Ex: {pix, deposito, dinheiro, cartao, convenio}';
|
||||
|
||||
|
||||
--
|
||||
-- Name: agendador_solicitacoes; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.agendador_solicitacoes (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -376,97 +267,3 @@ CREATE TABLE public.agendador_solicitacoes (
|
||||
CONSTRAINT agendador_sol_status_check CHECK ((status = ANY (ARRAY['pendente'::text, 'autorizado'::text, 'recusado'::text, 'expirado'::text, 'convertido'::text]))),
|
||||
CONSTRAINT agendador_sol_tipo_check CHECK ((tipo = ANY (ARRAY['primeira'::text, 'retorno'::text, 'reagendar'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.agendador_solicitacoes OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: billing_contracts; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE TABLE public.recurrence_exceptions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
recurrence_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
original_date date NOT NULL,
|
||||
type public.recurrence_exception_type NOT NULL,
|
||||
new_date date,
|
||||
new_start_time time without time zone,
|
||||
new_end_time time without time zone,
|
||||
modalidade text,
|
||||
observacoes text,
|
||||
titulo_custom text,
|
||||
extra_fields jsonb,
|
||||
reason text,
|
||||
agenda_evento_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.recurrence_exceptions OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: recurrence_rule_services; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.recurrence_rule_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
rule_id uuid NOT NULL,
|
||||
service_id uuid NOT NULL,
|
||||
quantity integer DEFAULT 1 NOT NULL,
|
||||
unit_price numeric(10,2) NOT NULL,
|
||||
discount_pct numeric(5,2) DEFAULT 0,
|
||||
discount_flat numeric(10,2) DEFAULT 0,
|
||||
final_price numeric(10,2) NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT recurrence_rule_services_disc_flat_chk CHECK ((discount_flat >= (0)::numeric)),
|
||||
CONSTRAINT recurrence_rule_services_disc_pct_chk CHECK (((discount_pct >= (0)::numeric) AND (discount_pct <= (100)::numeric))),
|
||||
CONSTRAINT recurrence_rule_services_final_price_chk CHECK ((final_price >= (0)::numeric)),
|
||||
CONSTRAINT recurrence_rule_services_quantity_chk CHECK ((quantity > 0))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.recurrence_rule_services OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: recurrence_rules; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.recurrence_rules (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
therapist_id uuid,
|
||||
patient_id uuid,
|
||||
determined_commitment_id uuid,
|
||||
type public.recurrence_type DEFAULT 'weekly'::public.recurrence_type NOT NULL,
|
||||
"interval" smallint DEFAULT 1 NOT NULL,
|
||||
weekdays smallint[] DEFAULT '{}'::smallint[] NOT NULL,
|
||||
start_time time without time zone NOT NULL,
|
||||
end_time time without time zone NOT NULL,
|
||||
timezone text DEFAULT 'America/Sao_Paulo'::text NOT NULL,
|
||||
duration_min smallint DEFAULT 50 NOT NULL,
|
||||
start_date date NOT NULL,
|
||||
end_date date,
|
||||
max_occurrences integer,
|
||||
open_ended boolean DEFAULT true NOT NULL,
|
||||
modalidade text DEFAULT 'presencial'::text,
|
||||
titulo_custom text,
|
||||
observacoes text,
|
||||
extra_fields jsonb,
|
||||
status text DEFAULT 'ativo'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
price numeric(10,2),
|
||||
insurance_plan_id uuid,
|
||||
insurance_guide_number text,
|
||||
insurance_value numeric(10,2),
|
||||
insurance_plan_service_id uuid,
|
||||
CONSTRAINT recurrence_rules_dates_chk CHECK (((end_date IS NULL) OR (end_date >= start_date))),
|
||||
CONSTRAINT recurrence_rules_interval_chk CHECK (("interval" >= 1)),
|
||||
CONSTRAINT recurrence_rules_status_check CHECK ((status = ANY (ARRAY['ativo'::text, 'pausado'::text, 'cancelado'::text]))),
|
||||
CONSTRAINT recurrence_rules_times_chk CHECK ((end_time > start_time))
|
||||
);
|
||||
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — auth schema (Supabase GoTrue)
|
||||
-- =============================================================================
|
||||
-- auth.users, auth.identities, auth.sessions, auth.refresh_tokens,
|
||||
-- auth.mfa_*, auth.saml_*, auth.sso_*, auth.flow_state, etc.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE auth.audit_log_entries (
|
||||
instance_id uuid,
|
||||
id uuid NOT NULL,
|
||||
payload json,
|
||||
created_at timestamp with time zone,
|
||||
ip_address character varying(64) DEFAULT ''::character varying NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.audit_log_entries OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE audit_log_entries; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.audit_log_entries IS 'Auth: Audit trail for user actions.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: flow_state; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.flow_state (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid,
|
||||
auth_code text,
|
||||
code_challenge_method auth.code_challenge_method,
|
||||
code_challenge text,
|
||||
provider_type text NOT NULL,
|
||||
provider_access_token text,
|
||||
provider_refresh_token text,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
authentication_method text NOT NULL,
|
||||
auth_code_issued_at timestamp with time zone,
|
||||
invite_token text,
|
||||
referrer text,
|
||||
oauth_client_state_id uuid,
|
||||
linking_target_id uuid,
|
||||
email_optional boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.flow_state OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE flow_state; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.flow_state IS 'Stores metadata for all OAuth/SSO login flows';
|
||||
|
||||
|
||||
--
|
||||
-- Name: identities; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.identities (
|
||||
provider_id text NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
identity_data jsonb NOT NULL,
|
||||
provider text NOT NULL,
|
||||
last_sign_in_at timestamp with time zone,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
email text GENERATED ALWAYS AS (lower((identity_data ->> 'email'::text))) STORED,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.identities OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE identities; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.identities IS 'Auth: Stores identities associated to a user.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN identities.email; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN auth.identities.email IS 'Auth: Email is a generated column that references the optional email property in the identity_data';
|
||||
|
||||
|
||||
--
|
||||
-- Name: instances; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.instances (
|
||||
id uuid NOT NULL,
|
||||
uuid uuid,
|
||||
raw_base_config text,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.instances OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE instances; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.instances IS 'Auth: Manages users across multiple sites.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: mfa_amr_claims; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.mfa_amr_claims (
|
||||
session_id uuid NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
authentication_method text NOT NULL,
|
||||
id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.mfa_amr_claims OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE mfa_amr_claims; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.mfa_amr_claims IS 'auth: stores authenticator method reference claims for multi factor authentication';
|
||||
|
||||
|
||||
--
|
||||
-- Name: mfa_challenges; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.mfa_challenges (
|
||||
id uuid NOT NULL,
|
||||
factor_id uuid NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
verified_at timestamp with time zone,
|
||||
ip_address inet NOT NULL,
|
||||
otp_code text,
|
||||
web_authn_session_data jsonb
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.mfa_challenges OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE mfa_challenges; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.mfa_challenges IS 'auth: stores metadata about challenge requests made';
|
||||
|
||||
|
||||
--
|
||||
-- Name: mfa_factors; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.mfa_factors (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
friendly_name text,
|
||||
factor_type auth.factor_type NOT NULL,
|
||||
status auth.factor_status NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
secret text,
|
||||
phone text,
|
||||
last_challenged_at timestamp with time zone,
|
||||
web_authn_credential jsonb,
|
||||
web_authn_aaguid uuid,
|
||||
last_webauthn_challenge_data jsonb
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.mfa_factors OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE mfa_factors; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.mfa_factors IS 'auth: stores metadata about factors';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN mfa_factors.last_webauthn_challenge_data; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN auth.mfa_factors.last_webauthn_challenge_data IS 'Stores the latest WebAuthn challenge data including attestation/assertion for customer verification';
|
||||
|
||||
|
||||
--
|
||||
-- Name: oauth_authorizations; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.oauth_authorizations (
|
||||
id uuid NOT NULL,
|
||||
authorization_id text NOT NULL,
|
||||
client_id uuid NOT NULL,
|
||||
user_id uuid,
|
||||
redirect_uri text NOT NULL,
|
||||
scope text NOT NULL,
|
||||
state text,
|
||||
resource text,
|
||||
code_challenge text,
|
||||
code_challenge_method auth.code_challenge_method,
|
||||
response_type auth.oauth_response_type DEFAULT 'code'::auth.oauth_response_type NOT NULL,
|
||||
status auth.oauth_authorization_status DEFAULT 'pending'::auth.oauth_authorization_status NOT NULL,
|
||||
authorization_code text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone DEFAULT (now() + '00:03:00'::interval) NOT NULL,
|
||||
approved_at timestamp with time zone,
|
||||
nonce text,
|
||||
CONSTRAINT oauth_authorizations_authorization_code_length CHECK ((char_length(authorization_code) <= 255)),
|
||||
CONSTRAINT oauth_authorizations_code_challenge_length CHECK ((char_length(code_challenge) <= 128)),
|
||||
CONSTRAINT oauth_authorizations_expires_at_future CHECK ((expires_at > created_at)),
|
||||
CONSTRAINT oauth_authorizations_nonce_length CHECK ((char_length(nonce) <= 255)),
|
||||
CONSTRAINT oauth_authorizations_redirect_uri_length CHECK ((char_length(redirect_uri) <= 2048)),
|
||||
CONSTRAINT oauth_authorizations_resource_length CHECK ((char_length(resource) <= 2048)),
|
||||
CONSTRAINT oauth_authorizations_scope_length CHECK ((char_length(scope) <= 4096)),
|
||||
CONSTRAINT oauth_authorizations_state_length CHECK ((char_length(state) <= 4096))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.oauth_authorizations OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: oauth_client_states; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.oauth_client_states (
|
||||
id uuid NOT NULL,
|
||||
provider_type text NOT NULL,
|
||||
code_verifier text,
|
||||
created_at timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.oauth_client_states OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE oauth_client_states; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.oauth_client_states IS 'Stores OAuth states for third-party provider authentication flows where Supabase acts as the OAuth client.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: oauth_clients; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.oauth_clients (
|
||||
id uuid NOT NULL,
|
||||
client_secret_hash text,
|
||||
registration_type auth.oauth_registration_type NOT NULL,
|
||||
redirect_uris text NOT NULL,
|
||||
grant_types text NOT NULL,
|
||||
client_name text,
|
||||
client_uri text,
|
||||
logo_uri text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
client_type auth.oauth_client_type DEFAULT 'confidential'::auth.oauth_client_type NOT NULL,
|
||||
token_endpoint_auth_method text NOT NULL,
|
||||
CONSTRAINT oauth_clients_client_name_length CHECK ((char_length(client_name) <= 1024)),
|
||||
CONSTRAINT oauth_clients_client_uri_length CHECK ((char_length(client_uri) <= 2048)),
|
||||
CONSTRAINT oauth_clients_logo_uri_length CHECK ((char_length(logo_uri) <= 2048)),
|
||||
CONSTRAINT oauth_clients_token_endpoint_auth_method_check CHECK ((token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, 'client_secret_post'::text, 'none'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.oauth_clients OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: oauth_consents; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.oauth_consents (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
client_id uuid NOT NULL,
|
||||
scopes text NOT NULL,
|
||||
granted_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
revoked_at timestamp with time zone,
|
||||
CONSTRAINT oauth_consents_revoked_after_granted CHECK (((revoked_at IS NULL) OR (revoked_at >= granted_at))),
|
||||
CONSTRAINT oauth_consents_scopes_length CHECK ((char_length(scopes) <= 2048)),
|
||||
CONSTRAINT oauth_consents_scopes_not_empty CHECK ((char_length(TRIM(BOTH FROM scopes)) > 0))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.oauth_consents OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: one_time_tokens; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.one_time_tokens (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
token_type auth.one_time_token_type NOT NULL,
|
||||
token_hash text NOT NULL,
|
||||
relates_to text NOT NULL,
|
||||
created_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT one_time_tokens_token_hash_check CHECK ((char_length(token_hash) > 0))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.one_time_tokens OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.refresh_tokens (
|
||||
instance_id uuid,
|
||||
id bigint NOT NULL,
|
||||
token character varying(255),
|
||||
user_id character varying(255),
|
||||
revoked boolean,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
parent character varying(255),
|
||||
session_id uuid
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.refresh_tokens OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE refresh_tokens; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.refresh_tokens IS 'Auth: Store of tokens used to refresh JWT tokens once they expire.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens_id_seq; Type: SEQUENCE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE auth.refresh_tokens_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE auth.refresh_tokens_id_seq OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: refresh_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE auth.refresh_tokens_id_seq OWNED BY auth.refresh_tokens.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: saml_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.saml_providers (
|
||||
id uuid NOT NULL,
|
||||
sso_provider_id uuid NOT NULL,
|
||||
entity_id text NOT NULL,
|
||||
metadata_xml text NOT NULL,
|
||||
metadata_url text,
|
||||
attribute_mapping jsonb,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
name_id_format text,
|
||||
CONSTRAINT "entity_id not empty" CHECK ((char_length(entity_id) > 0)),
|
||||
CONSTRAINT "metadata_url not empty" CHECK (((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))),
|
||||
CONSTRAINT "metadata_xml not empty" CHECK ((char_length(metadata_xml) > 0))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.saml_providers OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE saml_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.saml_providers IS 'Auth: Manages SAML Identity Provider connections.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: saml_relay_states; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.saml_relay_states (
|
||||
id uuid NOT NULL,
|
||||
sso_provider_id uuid NOT NULL,
|
||||
request_id text NOT NULL,
|
||||
for_email text,
|
||||
redirect_to text,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
flow_state_id uuid,
|
||||
CONSTRAINT "request_id not empty" CHECK ((char_length(request_id) > 0))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.saml_relay_states OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE saml_relay_states; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.saml_relay_states IS 'Auth: Contains SAML Relay State information for each Service Provider initiated login.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: schema_migrations; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.schema_migrations (
|
||||
version character varying(255) NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.schema_migrations OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE schema_migrations; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.schema_migrations IS 'Auth: Manages updates to the auth system.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: sessions; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.sessions (
|
||||
id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
factor_id uuid,
|
||||
aal auth.aal_level,
|
||||
not_after timestamp with time zone,
|
||||
refreshed_at timestamp without time zone,
|
||||
user_agent text,
|
||||
ip inet,
|
||||
tag text,
|
||||
oauth_client_id uuid,
|
||||
refresh_token_hmac_key text,
|
||||
refresh_token_counter bigint,
|
||||
scopes text,
|
||||
CONSTRAINT sessions_scopes_length CHECK ((char_length(scopes) <= 4096))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.sessions OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE sessions; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.sessions IS 'Auth: Stores session data associated to a user.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN sessions.not_after; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN auth.sessions.not_after IS 'Auth: Not after is a nullable column that contains a timestamp after which the session should be regarded as expired.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN sessions.refresh_token_hmac_key; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN auth.sessions.refresh_token_hmac_key IS 'Holds a HMAC-SHA256 key used to sign refresh tokens for this session.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN sessions.refresh_token_counter; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN auth.sessions.refresh_token_counter IS 'Holds the ID (counter) of the last issued refresh token.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: sso_domains; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.sso_domains (
|
||||
id uuid NOT NULL,
|
||||
sso_provider_id uuid NOT NULL,
|
||||
domain text NOT NULL,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
CONSTRAINT "domain not empty" CHECK ((char_length(domain) > 0))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.sso_domains OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE sso_domains; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.sso_domains IS 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: sso_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.sso_providers (
|
||||
id uuid NOT NULL,
|
||||
resource_id text,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
disabled boolean,
|
||||
CONSTRAINT "resource_id not empty" CHECK (((resource_id = NULL::text) OR (char_length(resource_id) > 0)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.sso_providers OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE sso_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.sso_providers IS 'Auth: Manages SSO identity provider information; see saml_providers for SAML.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN sso_providers.resource_id; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN auth.sso_providers.resource_id IS 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TABLE auth.users (
|
||||
instance_id uuid,
|
||||
id uuid NOT NULL,
|
||||
aud character varying(255),
|
||||
role character varying(255),
|
||||
email character varying(255),
|
||||
encrypted_password character varying(255),
|
||||
email_confirmed_at timestamp with time zone,
|
||||
invited_at timestamp with time zone,
|
||||
confirmation_token character varying(255),
|
||||
confirmation_sent_at timestamp with time zone,
|
||||
recovery_token character varying(255),
|
||||
recovery_sent_at timestamp with time zone,
|
||||
email_change_token_new character varying(255),
|
||||
email_change character varying(255),
|
||||
email_change_sent_at timestamp with time zone,
|
||||
last_sign_in_at timestamp with time zone,
|
||||
raw_app_meta_data jsonb,
|
||||
raw_user_meta_data jsonb,
|
||||
is_super_admin boolean,
|
||||
created_at timestamp with time zone,
|
||||
updated_at timestamp with time zone,
|
||||
phone text DEFAULT NULL::character varying,
|
||||
phone_confirmed_at timestamp with time zone,
|
||||
phone_change text DEFAULT ''::character varying,
|
||||
phone_change_token character varying(255) DEFAULT ''::character varying,
|
||||
phone_change_sent_at timestamp with time zone,
|
||||
confirmed_at timestamp with time zone GENERATED ALWAYS AS (LEAST(email_confirmed_at, phone_confirmed_at)) STORED,
|
||||
email_change_token_current character varying(255) DEFAULT ''::character varying,
|
||||
email_change_confirm_status smallint DEFAULT 0,
|
||||
banned_until timestamp with time zone,
|
||||
reauthentication_token character varying(255) DEFAULT ''::character varying,
|
||||
reauthentication_sent_at timestamp with time zone,
|
||||
is_sso_user boolean DEFAULT false NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
is_anonymous boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT users_email_change_confirm_status_check CHECK (((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE auth.users OWNER TO supabase_auth_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE users; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE auth.users IS 'Auth: Stores user login data within a secure schema.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN users.is_sso_user; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN auth.users.is_sso_user IS 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: addon_credits; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Tables: Central SaaS (docs/FAQ)
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
|
||||
-- Total: 4
|
||||
|
||||
CREATE TABLE public.saas_doc_votos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
doc_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
util boolean NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_docs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
titulo text NOT NULL,
|
||||
conteudo text DEFAULT ''::text NOT NULL,
|
||||
medias jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
tipo_acesso text DEFAULT 'usuario'::text NOT NULL,
|
||||
pagina_path text NOT NULL,
|
||||
docs_relacionados uuid[] DEFAULT '{}'::uuid[] NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
categoria text,
|
||||
exibir_no_faq boolean DEFAULT false NOT NULL,
|
||||
votos_util integer DEFAULT 0 NOT NULL,
|
||||
votos_nao_util integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT saas_docs_tipo_acesso_check CHECK ((tipo_acesso = ANY (ARRAY['admin'::text, 'usuario'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_faq (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
pergunta text NOT NULL,
|
||||
categoria text,
|
||||
publico boolean DEFAULT false NOT NULL,
|
||||
votos integer DEFAULT 0 NOT NULL,
|
||||
titulo text,
|
||||
conteudo text,
|
||||
tipo_acesso text DEFAULT 'usuario'::text NOT NULL,
|
||||
pagina_path text NOT NULL,
|
||||
pagina_label text,
|
||||
medias jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
faqs_relacionados uuid[] DEFAULT '{}'::uuid[] NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_faq_itens (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
doc_id uuid NOT NULL,
|
||||
pergunta text NOT NULL,
|
||||
resposta text,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
+118
-101
@@ -1,11 +1,99 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Notificações + Email Templates
|
||||
-- =============================================================================
|
||||
-- notification_channels, notification_logs, notification_preferences,
|
||||
-- notification_queue, notification_schedules, notification_templates,
|
||||
-- notifications, email_templates_global, email_templates_tenant,
|
||||
-- email_layout_config
|
||||
-- =============================================================================
|
||||
-- Tables: Comunicação / Notificações
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
|
||||
-- Total: 14
|
||||
|
||||
CREATE TABLE public.email_layout_config (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
header_config jsonb DEFAULT '{"layout": null, "content": "", "enabled": false}'::jsonb NOT NULL,
|
||||
footer_config jsonb DEFAULT '{"layout": null, "content": "", "enabled": false}'::jsonb NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.email_templates_global (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
key text NOT NULL,
|
||||
domain text NOT NULL,
|
||||
channel text DEFAULT 'email'::text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
body_html text NOT NULL,
|
||||
body_text text,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
variables jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.email_templates_tenant (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid,
|
||||
template_key text NOT NULL,
|
||||
subject text,
|
||||
body_html text,
|
||||
body_text text,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
synced_version integer,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.global_notices (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
title text,
|
||||
message text DEFAULT ''::text NOT NULL,
|
||||
variant text DEFAULT 'info'::text NOT NULL,
|
||||
roles text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
contexts text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
starts_at timestamp with time zone,
|
||||
ends_at timestamp with time zone,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
priority integer DEFAULT 0 NOT NULL,
|
||||
dismissible boolean DEFAULT true NOT NULL,
|
||||
persist_dismiss boolean DEFAULT true NOT NULL,
|
||||
dismiss_scope text DEFAULT 'device'::text NOT NULL,
|
||||
show_once boolean DEFAULT false NOT NULL,
|
||||
max_views integer,
|
||||
cooldown_minutes integer,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
action_type text DEFAULT 'none'::text NOT NULL,
|
||||
action_label text,
|
||||
action_url text,
|
||||
action_route text,
|
||||
views_count integer DEFAULT 0 NOT NULL,
|
||||
clicks_count integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid,
|
||||
content_align text DEFAULT 'left'::text NOT NULL,
|
||||
link_target text DEFAULT '_blank'::text NOT NULL,
|
||||
CONSTRAINT global_notices_action_type_check CHECK ((action_type = ANY (ARRAY['none'::text, 'internal'::text, 'external'::text]))),
|
||||
CONSTRAINT global_notices_content_align_check CHECK ((content_align = ANY (ARRAY['left'::text, 'center'::text, 'right'::text, 'justify'::text]))),
|
||||
CONSTRAINT global_notices_dismiss_scope_check CHECK ((dismiss_scope = ANY (ARRAY['session'::text, 'device'::text, 'user'::text]))),
|
||||
CONSTRAINT global_notices_link_target_check CHECK ((link_target = ANY (ARRAY['_blank'::text, '_self'::text, '_parent'::text, '_top'::text]))),
|
||||
CONSTRAINT global_notices_variant_check CHECK ((variant = ANY (ARRAY['info'::text, 'success'::text, 'warning'::text, 'error'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.login_carousel_slides (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
icon text DEFAULT 'pi-star'::text NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.notice_dismissals (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
notice_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
dismissed_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.notification_channels (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -23,18 +111,18 @@ CREATE TABLE public.notification_channels (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
twilio_subaccount_sid text,
|
||||
twilio_phone_number text,
|
||||
twilio_phone_sid text,
|
||||
webhook_url text,
|
||||
cost_per_message_usd numeric(8,6) DEFAULT 0,
|
||||
price_per_message_brl numeric(8,4) DEFAULT 0,
|
||||
provisioned_at timestamp with time zone,
|
||||
CONSTRAINT notification_channels_channel_check CHECK ((channel = ANY (ARRAY['whatsapp'::text, 'email'::text, 'sms'::text]))),
|
||||
CONSTRAINT notification_channels_connection_status_check CHECK ((connection_status = ANY (ARRAY['connected'::text, 'disconnected'::text, 'connecting'::text, 'qr_pending'::text, 'error'::text]))),
|
||||
CONSTRAINT notification_channels_provider_check CHECK ((provider = ANY (ARRAY['evolution_api'::text, 'meta_official'::text, 'twilio'::text, 'zenvia'::text, 'sendgrid'::text, 'resend'::text, 'smtp'::text, 'zapi'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.notification_channels OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notification_logs; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.notification_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
@@ -64,13 +152,6 @@ CREATE TABLE public.notification_logs (
|
||||
CONSTRAINT notification_logs_status_check CHECK ((status = ANY (ARRAY['sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'bounced'::text, 'opted_out'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.notification_logs OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notification_preferences; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.notification_preferences (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
@@ -92,13 +173,6 @@ CREATE TABLE public.notification_preferences (
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.notification_preferences OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notification_queue; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.notification_queue (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
@@ -125,13 +199,6 @@ CREATE TABLE public.notification_queue (
|
||||
CONSTRAINT notification_queue_status_check CHECK ((status = ANY (ARRAY['pendente'::text, 'processando'::text, 'enviado'::text, 'falhou'::text, 'cancelado'::text, 'ignorado'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.notification_queue OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notification_schedules; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.notification_schedules (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
@@ -156,13 +223,6 @@ CREATE TABLE public.notification_schedules (
|
||||
CONSTRAINT notification_schedules_trigger_type_check CHECK ((trigger_type = ANY (ARRAY['before_event'::text, 'after_event'::text, 'immediate'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.notification_schedules OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notification_templates; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.notification_templates (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
@@ -189,13 +249,6 @@ CREATE TABLE public.notification_templates (
|
||||
CONSTRAINT notification_templates_meta_status_check CHECK ((meta_status = ANY (ARRAY['draft'::text, 'pending_approval'::text, 'approved'::text, 'rejected'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.notification_templates OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notifications; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.notifications (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -210,58 +263,22 @@ CREATE TABLE public.notifications (
|
||||
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.email_layout_config (
|
||||
CREATE TABLE public.twilio_subaccount_usage (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
header_config jsonb DEFAULT '{"layout": null, "content": "", "enabled": false}'::jsonb NOT NULL,
|
||||
footer_config jsonb DEFAULT '{"layout": null, "content": "", "enabled": false}'::jsonb 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,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
CONSTRAINT twilio_subaccount_usage_period_check CHECK ((period_end >= period_start))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.email_layout_config OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: email_templates_global; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.email_templates_global (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
key text NOT NULL,
|
||||
domain text NOT NULL,
|
||||
channel text DEFAULT 'email'::text NOT NULL,
|
||||
subject text NOT NULL,
|
||||
body_html text NOT NULL,
|
||||
body_text text,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
variables jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.email_templates_global OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: email_templates_tenant; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.email_templates_tenant (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid,
|
||||
template_key text NOT NULL,
|
||||
subject text,
|
||||
body_html text,
|
||||
body_text text,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
synced_version integer,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
-- Tables: Documentos
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
|
||||
-- Total: 6
|
||||
|
||||
CREATE TABLE public.document_access_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
documento_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
acao text NOT NULL,
|
||||
user_id uuid,
|
||||
ip inet,
|
||||
user_agent text,
|
||||
acessado_em timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dal_acao_check CHECK ((acao = ANY (ARRAY['visualizou'::text, 'baixou'::text, 'imprimiu'::text, 'compartilhou'::text, 'assinou'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.document_generated (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
template_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
dados_preenchidos jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
pdf_path text NOT NULL,
|
||||
storage_bucket text DEFAULT 'generated-docs'::text NOT NULL,
|
||||
documento_id uuid,
|
||||
gerado_por uuid NOT NULL,
|
||||
gerado_em timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.document_share_links (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
documento_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
token text DEFAULT encode(extensions.gen_random_bytes(32), 'hex'::text) NOT NULL,
|
||||
expira_em timestamp with time zone NOT NULL,
|
||||
usos_max smallint DEFAULT 5 NOT NULL,
|
||||
usos smallint DEFAULT 0 NOT NULL,
|
||||
criado_por uuid NOT NULL,
|
||||
criado_em timestamp with time zone DEFAULT now(),
|
||||
ativo boolean DEFAULT true NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.document_signatures (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
documento_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
signatario_tipo text NOT NULL,
|
||||
signatario_id uuid,
|
||||
signatario_nome text,
|
||||
signatario_email text,
|
||||
ordem smallint DEFAULT 1 NOT NULL,
|
||||
status text DEFAULT 'pendente'::text NOT NULL,
|
||||
ip inet,
|
||||
user_agent text,
|
||||
assinado_em timestamp with time zone,
|
||||
hash_documento text,
|
||||
criado_em timestamp with time zone DEFAULT now(),
|
||||
atualizado_em timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT ds_signatario_tipo_check CHECK ((signatario_tipo = ANY (ARRAY['paciente'::text, 'responsavel_legal'::text, 'terapeuta'::text]))),
|
||||
CONSTRAINT ds_status_check CHECK ((status = ANY (ARRAY['pendente'::text, 'enviado'::text, 'assinado'::text, 'recusado'::text, 'expirado'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.document_templates (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
owner_id uuid,
|
||||
nome_template text NOT NULL,
|
||||
tipo text DEFAULT 'outro'::text NOT NULL,
|
||||
descricao text,
|
||||
corpo_html text DEFAULT ''::text NOT NULL,
|
||||
cabecalho_html text,
|
||||
rodape_html text,
|
||||
variaveis text[] DEFAULT '{}'::text[],
|
||||
logo_url text,
|
||||
is_global boolean DEFAULT false NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT dt_tipo_check CHECK ((tipo = ANY (ARRAY['declaracao_comparecimento'::text, 'atestado_psicologico'::text, 'relatorio_acompanhamento'::text, 'recibo_pagamento'::text, 'termo_consentimento'::text, 'encaminhamento'::text, 'contrato_servicos'::text, 'tcle'::text, 'autorizacao_menor'::text, 'laudo_psicologico'::text, 'parecer_psicologico'::text, 'termo_sigilo'::text, 'declaracao_inicio_tratamento'::text, 'termo_alta'::text, 'tcle_online'::text, 'outro'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.documents (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
bucket_path text NOT NULL,
|
||||
storage_bucket text DEFAULT 'documents'::text NOT NULL,
|
||||
nome_original text NOT NULL,
|
||||
mime_type text,
|
||||
tamanho_bytes bigint,
|
||||
tipo_documento text DEFAULT 'outro'::text NOT NULL,
|
||||
categoria text,
|
||||
descricao text,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
agenda_evento_id uuid,
|
||||
session_note_id uuid,
|
||||
visibilidade text DEFAULT 'privado'::text NOT NULL,
|
||||
compartilhado_portal boolean DEFAULT false NOT NULL,
|
||||
compartilhado_supervisor boolean DEFAULT false NOT NULL,
|
||||
compartilhado_em timestamp with time zone,
|
||||
expira_compartilhamento timestamp with time zone,
|
||||
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
|
||||
status_revisao text DEFAULT 'aprovado'::text,
|
||||
revisado_por uuid,
|
||||
revisado_em timestamp with time zone,
|
||||
uploaded_by uuid NOT NULL,
|
||||
uploaded_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
deleted_by uuid,
|
||||
retencao_ate timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT documents_status_revisao_check CHECK ((status_revisao = ANY (ARRAY['pendente'::text, 'aprovado'::text, 'rejeitado'::text]))),
|
||||
CONSTRAINT documents_tipo_check CHECK ((tipo_documento = ANY (ARRAY['laudo'::text, 'receita'::text, 'exame'::text, 'termo_assinado'::text, 'relatorio_externo'::text, 'identidade'::text, 'convenio'::text, 'declaracao'::text, 'atestado'::text, 'recibo'::text, 'outro'::text]))),
|
||||
CONSTRAINT documents_visibilidade_check CHECK ((visibilidade = ANY (ARRAY['privado'::text, 'compartilhado_supervisor'::text, 'compartilhado_portal'::text])))
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Tables: Estrutura / Calendário
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE TABLE public.feriados (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
owner_id uuid,
|
||||
tipo text DEFAULT 'municipal'::text NOT NULL,
|
||||
nome text NOT NULL,
|
||||
data date NOT NULL,
|
||||
cidade text,
|
||||
estado text,
|
||||
observacao text,
|
||||
bloqueia_sessoes boolean DEFAULT false NOT NULL,
|
||||
criado_em timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT feriados_tipo_check CHECK ((tipo = ANY (ARRAY['municipal'::text, 'personalizado'::text])))
|
||||
);
|
||||
+88
-87
@@ -1,10 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Financeiro
|
||||
-- =============================================================================
|
||||
-- financial_records, financial_categories, financial_exceptions,
|
||||
-- payment_settings, professional_pricing, therapist_payouts,
|
||||
-- therapist_payout_records, services, insurance_plans, insurance_plan_services
|
||||
-- =============================================================================
|
||||
-- Tables: Financeiro
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE TABLE public.financial_records (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -44,7 +40,27 @@ CREATE TABLE public.financial_records (
|
||||
CONSTRAINT financial_records_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'partial'::text, 'overdue'::text, 'cancelled'::text, 'refunded'::text])))
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE public.therapist_payouts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
period_end date NOT NULL,
|
||||
total_sessions integer DEFAULT 0 NOT NULL,
|
||||
gross_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
clinic_fee_total numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
net_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT therapist_payouts_clinic_fee_total_check CHECK ((clinic_fee_total >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_gross_amount_check CHECK ((gross_amount >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_net_amount_check CHECK ((net_amount >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_period_chk CHECK ((period_end >= period_start)),
|
||||
CONSTRAINT therapist_payouts_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'cancelled'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.financial_categories (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -57,13 +73,6 @@ CREATE TABLE public.financial_categories (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.financial_categories OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: financial_exceptions; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.financial_exceptions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid,
|
||||
@@ -79,8 +88,6 @@ CREATE TABLE public.financial_exceptions (
|
||||
CONSTRAINT financial_exceptions_type_chk CHECK ((exception_type = ANY (ARRAY['patient_no_show'::text, 'patient_cancellation'::text, 'professional_cancellation'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.payment_settings (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -106,8 +113,6 @@ CREATE TABLE public.payment_settings (
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.professional_pricing (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -119,81 +124,77 @@ CREATE TABLE public.professional_pricing (
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.therapist_payouts (
|
||||
CREATE TABLE public.recurrence_exceptions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
recurrence_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
period_end date NOT NULL,
|
||||
total_sessions integer DEFAULT 0 NOT NULL,
|
||||
gross_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
clinic_fee_total numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
net_amount numeric(10,2) DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT therapist_payouts_clinic_fee_total_check CHECK ((clinic_fee_total >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_gross_amount_check CHECK ((gross_amount >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_net_amount_check CHECK ((net_amount >= (0)::numeric)),
|
||||
CONSTRAINT therapist_payouts_period_chk CHECK ((period_end >= period_start)),
|
||||
CONSTRAINT therapist_payouts_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'cancelled'::text])))
|
||||
original_date date NOT NULL,
|
||||
type public.recurrence_exception_type NOT NULL,
|
||||
new_date date,
|
||||
new_start_time time without time zone,
|
||||
new_end_time time without time zone,
|
||||
modalidade text,
|
||||
observacoes text,
|
||||
titulo_custom text,
|
||||
extra_fields jsonb,
|
||||
reason text,
|
||||
agenda_evento_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.recurrence_rule_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
rule_id uuid NOT NULL,
|
||||
service_id uuid NOT NULL,
|
||||
quantity integer DEFAULT 1 NOT NULL,
|
||||
unit_price numeric(10,2) NOT NULL,
|
||||
discount_pct numeric(5,2) DEFAULT 0,
|
||||
discount_flat numeric(10,2) DEFAULT 0,
|
||||
final_price numeric(10,2) NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT recurrence_rule_services_disc_flat_chk CHECK ((discount_flat >= (0)::numeric)),
|
||||
CONSTRAINT recurrence_rule_services_disc_pct_chk CHECK (((discount_pct >= (0)::numeric) AND (discount_pct <= (100)::numeric))),
|
||||
CONSTRAINT recurrence_rule_services_final_price_chk CHECK ((final_price >= (0)::numeric)),
|
||||
CONSTRAINT recurrence_rule_services_quantity_chk CHECK ((quantity > 0))
|
||||
);
|
||||
|
||||
CREATE TABLE public.recurrence_rules (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
therapist_id uuid,
|
||||
patient_id uuid,
|
||||
determined_commitment_id uuid,
|
||||
type public.recurrence_type DEFAULT 'weekly'::public.recurrence_type NOT NULL,
|
||||
"interval" smallint DEFAULT 1 NOT NULL,
|
||||
weekdays smallint[] DEFAULT '{}'::smallint[] NOT NULL,
|
||||
start_time time without time zone NOT NULL,
|
||||
end_time time without time zone NOT NULL,
|
||||
timezone text DEFAULT 'America/Sao_Paulo'::text NOT NULL,
|
||||
duration_min smallint DEFAULT 50 NOT NULL,
|
||||
start_date date NOT NULL,
|
||||
end_date date,
|
||||
max_occurrences integer,
|
||||
open_ended boolean DEFAULT true NOT NULL,
|
||||
modalidade text DEFAULT 'presencial'::text,
|
||||
titulo_custom text,
|
||||
observacoes text,
|
||||
extra_fields jsonb,
|
||||
status text DEFAULT 'ativo'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
price numeric(10,2),
|
||||
insurance_plan_id uuid,
|
||||
insurance_guide_number text,
|
||||
insurance_value numeric(10,2),
|
||||
insurance_plan_service_id uuid,
|
||||
CONSTRAINT recurrence_rules_dates_chk CHECK (((end_date IS NULL) OR (end_date >= start_date))),
|
||||
CONSTRAINT recurrence_rules_interval_chk CHECK (("interval" >= 1)),
|
||||
CONSTRAINT recurrence_rules_status_check CHECK ((status = ANY (ARRAY['ativo'::text, 'pausado'::text, 'cancelado'::text]))),
|
||||
CONSTRAINT recurrence_rules_times_chk CHECK ((end_time > start_time))
|
||||
);
|
||||
|
||||
CREATE TABLE public.therapist_payout_records (
|
||||
payout_id uuid NOT NULL,
|
||||
financial_record_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
price numeric(10,2) NOT NULL,
|
||||
duration_min integer,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.insurance_plan_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
insurance_plan_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
value numeric(10,2) NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.insurance_plan_services OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: insurance_plans; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.insurance_plans (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
notes text,
|
||||
default_value numeric(10,2),
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Infraestrutura (realtime, storage, supabase_functions)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE _realtime.extensions (
|
||||
id uuid NOT NULL,
|
||||
type text,
|
||||
settings jsonb,
|
||||
tenant_external_id text,
|
||||
inserted_at timestamp(0) without time zone NOT NULL,
|
||||
updated_at timestamp(0) without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE _realtime.extensions OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: schema_migrations; Type: TABLE; Schema: _realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE _realtime.schema_migrations (
|
||||
version bigint NOT NULL,
|
||||
inserted_at timestamp(0) without time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE _realtime.schema_migrations OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: tenants; Type: TABLE; Schema: _realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE _realtime.tenants (
|
||||
id uuid NOT NULL,
|
||||
name text,
|
||||
external_id text,
|
||||
jwt_secret text,
|
||||
max_concurrent_users integer DEFAULT 200 NOT NULL,
|
||||
inserted_at timestamp(0) without time zone NOT NULL,
|
||||
updated_at timestamp(0) without time zone NOT NULL,
|
||||
max_events_per_second integer DEFAULT 100 NOT NULL,
|
||||
postgres_cdc_default text DEFAULT 'postgres_cdc_rls'::text,
|
||||
max_bytes_per_second integer DEFAULT 100000 NOT NULL,
|
||||
max_channels_per_client integer DEFAULT 100 NOT NULL,
|
||||
max_joins_per_second integer DEFAULT 500 NOT NULL,
|
||||
suspend boolean DEFAULT false,
|
||||
jwt_jwks jsonb,
|
||||
notify_private_alpha boolean DEFAULT false,
|
||||
private_only boolean DEFAULT false NOT NULL,
|
||||
migrations_ran integer DEFAULT 0,
|
||||
broadcast_adapter character varying(255) DEFAULT 'gen_rpc'::character varying,
|
||||
max_presence_events_per_second integer DEFAULT 1000,
|
||||
max_payload_size_in_kb integer DEFAULT 3000,
|
||||
CONSTRAINT jwt_secret_or_jwt_jwks_required CHECK (((jwt_secret IS NOT NULL) OR (jwt_jwks IS NOT NULL)))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE _realtime.tenants OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: audit_log_entries; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE TABLE realtime.messages (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
)
|
||||
PARTITION BY RANGE (inserted_at);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages OWNER TO supabase_realtime_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_20; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.messages_2026_03_20 (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages_2026_03_20 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_21; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.messages_2026_03_21 (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages_2026_03_21 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_22; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.messages_2026_03_22 (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages_2026_03_22 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_23; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.messages_2026_03_23 (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages_2026_03_23 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_24; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.messages_2026_03_24 (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages_2026_03_24 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_25; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.messages_2026_03_25 (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages_2026_03_25 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_26; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.messages_2026_03_26 (
|
||||
topic text NOT NULL,
|
||||
extension text NOT NULL,
|
||||
payload jsonb,
|
||||
event text,
|
||||
private boolean DEFAULT false,
|
||||
updated_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.messages_2026_03_26 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: schema_migrations; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.schema_migrations (
|
||||
version bigint NOT NULL,
|
||||
inserted_at timestamp(0) without time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.schema_migrations OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: subscription; Type: TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE realtime.subscription (
|
||||
id bigint NOT NULL,
|
||||
subscription_id uuid NOT NULL,
|
||||
entity regclass NOT NULL,
|
||||
filters realtime.user_defined_filter[] DEFAULT '{}'::realtime.user_defined_filter[] NOT NULL,
|
||||
claims jsonb NOT NULL,
|
||||
claims_role regrole GENERATED ALWAYS AS (realtime.to_regrole((claims ->> 'role'::text))) STORED NOT NULL,
|
||||
created_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE realtime.subscription OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: subscription_id_seq; Type: SEQUENCE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER TABLE realtime.subscription ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY (
|
||||
SEQUENCE NAME realtime.subscription_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: buckets; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE TABLE storage.buckets (
|
||||
id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
owner uuid,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
public boolean DEFAULT false,
|
||||
avif_autodetection boolean DEFAULT false,
|
||||
file_size_limit bigint,
|
||||
allowed_mime_types text[],
|
||||
owner_id text,
|
||||
type storage.buckettype DEFAULT 'STANDARD'::storage.buckettype NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.buckets OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: COLUMN buckets.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN storage.buckets.owner IS 'Field is deprecated, use owner_id instead';
|
||||
|
||||
|
||||
--
|
||||
-- Name: buckets_analytics; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.buckets_analytics (
|
||||
name text NOT NULL,
|
||||
type storage.buckettype DEFAULT 'ANALYTICS'::storage.buckettype NOT NULL,
|
||||
format text DEFAULT 'ICEBERG'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
deleted_at timestamp with time zone
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.buckets_analytics OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: buckets_vectors; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.buckets_vectors (
|
||||
id text NOT NULL,
|
||||
type storage.buckettype DEFAULT 'VECTOR'::storage.buckettype NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.buckets_vectors OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: iceberg_namespaces; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.iceberg_namespaces (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
bucket_name text NOT NULL,
|
||||
name text NOT NULL COLLATE pg_catalog."C",
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
catalog_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.iceberg_namespaces OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: iceberg_tables; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.iceberg_tables (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
namespace_id uuid NOT NULL,
|
||||
bucket_name text NOT NULL,
|
||||
name text NOT NULL COLLATE pg_catalog."C",
|
||||
location text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
remote_table_id text,
|
||||
shard_key text,
|
||||
shard_id text,
|
||||
catalog_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.iceberg_tables OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: migrations; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.migrations (
|
||||
id integer NOT NULL,
|
||||
name character varying(100) NOT NULL,
|
||||
hash character varying(40) NOT NULL,
|
||||
executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.migrations OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: objects; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.objects (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
bucket_id text,
|
||||
name text,
|
||||
owner uuid,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
last_accessed_at timestamp with time zone DEFAULT now(),
|
||||
metadata jsonb,
|
||||
path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/'::text)) STORED,
|
||||
version text,
|
||||
owner_id text,
|
||||
user_metadata jsonb
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.objects OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: COLUMN objects.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN storage.objects.owner IS 'Field is deprecated, use owner_id instead';
|
||||
|
||||
|
||||
--
|
||||
-- Name: s3_multipart_uploads; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.s3_multipart_uploads (
|
||||
id text NOT NULL,
|
||||
in_progress_size bigint DEFAULT 0 NOT NULL,
|
||||
upload_signature text NOT NULL,
|
||||
bucket_id text NOT NULL,
|
||||
key text NOT NULL COLLATE pg_catalog."C",
|
||||
version text NOT NULL,
|
||||
owner_id text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
user_metadata jsonb
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.s3_multipart_uploads OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: s3_multipart_uploads_parts; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.s3_multipart_uploads_parts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
upload_id text NOT NULL,
|
||||
size bigint DEFAULT 0 NOT NULL,
|
||||
part_number integer NOT NULL,
|
||||
bucket_id text NOT NULL,
|
||||
key text NOT NULL COLLATE pg_catalog."C",
|
||||
etag text NOT NULL,
|
||||
owner_id text,
|
||||
version text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE storage.s3_multipart_uploads_parts OWNER TO supabase_storage_admin;
|
||||
|
||||
--
|
||||
-- Name: vector_indexes; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TABLE storage.vector_indexes (
|
||||
id text DEFAULT gen_random_uuid() NOT NULL,
|
||||
name text NOT NULL COLLATE pg_catalog."C",
|
||||
bucket_id text NOT NULL,
|
||||
data_type text NOT NULL,
|
||||
dimension integer NOT NULL,
|
||||
distance_metric text NOT NULL,
|
||||
metadata_configuration jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE supabase_functions.hooks (
|
||||
id bigint NOT NULL,
|
||||
hook_table_id integer NOT NULL,
|
||||
hook_name text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
request_id bigint
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE supabase_functions.hooks OWNER TO supabase_functions_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE hooks; Type: COMMENT; Schema: supabase_functions; Owner: supabase_functions_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: hooks_id_seq; Type: SEQUENCE; Schema: supabase_functions; Owner: supabase_functions_admin
|
||||
--
|
||||
|
||||
CREATE SEQUENCE supabase_functions.hooks_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
ALTER SEQUENCE supabase_functions.hooks_id_seq OWNER TO supabase_functions_admin;
|
||||
|
||||
--
|
||||
-- Name: hooks_id_seq; Type: SEQUENCE OWNED BY; Schema: supabase_functions; Owner: supabase_functions_admin
|
||||
--
|
||||
|
||||
ALTER SEQUENCE supabase_functions.hooks_id_seq OWNED BY supabase_functions.hooks.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: migrations; Type: TABLE; Schema: supabase_functions; Owner: supabase_functions_admin
|
||||
--
|
||||
|
||||
CREATE TABLE supabase_functions.migrations (
|
||||
version text NOT NULL,
|
||||
inserted_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE supabase_functions.migrations OWNER TO supabase_functions_admin;
|
||||
|
||||
--
|
||||
-- Name: messages_2026_03_20; Type: TABLE ATTACH; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Tables: outros
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE TABLE public._db_migrations (
|
||||
id integer NOT NULL,
|
||||
filename text NOT NULL,
|
||||
hash text NOT NULL,
|
||||
category text DEFAULT 'migration'::text NOT NULL,
|
||||
applied_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
+199
-133
@@ -1,10 +1,177 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Pacientes
|
||||
-- =============================================================================
|
||||
-- patients, patient_groups, patient_group_patient, patient_tags,
|
||||
-- patient_patient_tag, patient_intake_requests, patient_invites,
|
||||
-- patient_discounts
|
||||
-- =============================================================================
|
||||
-- Tables: Pacientes
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
|
||||
-- Total: 12
|
||||
|
||||
CREATE TABLE public.patient_contacts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
nome text NOT NULL,
|
||||
tipo text NOT NULL,
|
||||
relacao text,
|
||||
telefone text,
|
||||
email text,
|
||||
cpf text,
|
||||
especialidade text,
|
||||
registro_profissional text,
|
||||
is_primario boolean DEFAULT false NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT patient_contacts_tipo_check CHECK ((tipo = ANY (ARRAY['emergencia'::text, 'responsavel_legal'::text, 'profissional_saude'::text, 'outro'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_discounts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
discount_pct numeric(5,2) DEFAULT 0,
|
||||
discount_flat numeric(10,2) DEFAULT 0,
|
||||
reason text,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
active_from timestamp with time zone DEFAULT now(),
|
||||
active_to timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_group_patient (
|
||||
patient_group_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_groups (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
nome text NOT NULL,
|
||||
descricao text,
|
||||
cor text,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
is_system boolean DEFAULT false NOT NULL,
|
||||
owner_id uuid DEFAULT auth.uid() NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
therapist_id uuid,
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_intake_requests (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
token text NOT NULL,
|
||||
consent boolean DEFAULT false NOT NULL,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
converted_patient_id uuid,
|
||||
rejected_reason text,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
cpf text,
|
||||
rg text,
|
||||
cep text,
|
||||
nome_completo text,
|
||||
email_principal text,
|
||||
telefone text,
|
||||
pais text,
|
||||
cidade text,
|
||||
estado text,
|
||||
endereco text,
|
||||
numero text,
|
||||
bairro text,
|
||||
complemento text,
|
||||
data_nascimento date,
|
||||
naturalidade text,
|
||||
genero text,
|
||||
estado_civil text,
|
||||
onde_nos_conheceu text,
|
||||
encaminhado_por text,
|
||||
observacoes text,
|
||||
notas_internas text,
|
||||
email_alternativo text,
|
||||
telefone_alternativo text,
|
||||
profissao text,
|
||||
escolaridade text,
|
||||
nacionalidade text,
|
||||
avatar_url text,
|
||||
tenant_id uuid,
|
||||
CONSTRAINT chk_intakes_status CHECK ((status = ANY (ARRAY['new'::text, 'converted'::text, 'rejected'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_invites (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
token text NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
expires_at timestamp with time zone,
|
||||
max_uses integer,
|
||||
uses integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
tenant_id uuid
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_patient_tag (
|
||||
owner_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
tag_id uuid NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_status_history (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
status_anterior text,
|
||||
status_novo text NOT NULL,
|
||||
motivo text,
|
||||
encaminhado_para text,
|
||||
data_saida date,
|
||||
alterado_por uuid,
|
||||
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT psh_status_novo_check CHECK ((status_novo = ANY (ARRAY['Ativo'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_support_contacts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
nome text,
|
||||
relacao text,
|
||||
tipo text,
|
||||
telefone text,
|
||||
email text,
|
||||
is_primario boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_tags (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
nome text NOT NULL,
|
||||
cor text,
|
||||
is_padrao boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone,
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_timeline (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
evento_tipo text NOT NULL,
|
||||
titulo text NOT NULL,
|
||||
descricao text,
|
||||
icone_cor text DEFAULT 'gray'::text,
|
||||
link_ref_tipo text,
|
||||
link_ref_id uuid,
|
||||
gerado_por uuid,
|
||||
ocorrido_em timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT pt_evento_tipo_check CHECK ((evento_tipo = ANY (ARRAY['primeira_sessao'::text, 'sessao_realizada'::text, 'sessao_cancelada'::text, 'falta'::text, 'status_alterado'::text, 'risco_sinalizado'::text, 'risco_removido'::text, 'documento_assinado'::text, 'documento_adicionado'::text, 'escala_respondida'::text, 'escala_enviada'::text, 'pagamento_vencido'::text, 'pagamento_recebido'::text, 'tarefa_combinada'::text, 'contato_adicionado'::text, 'prontuario_editado'::text, 'nota_adicionada'::text, 'manual'::text]))),
|
||||
CONSTRAINT pt_icone_cor_check CHECK ((icone_cor = ANY (ARRAY['green'::text, 'blue'::text, 'amber'::text, 'red'::text, 'gray'::text, 'purple'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.patients (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -54,132 +221,31 @@ CREATE TABLE public.patients (
|
||||
user_id uuid,
|
||||
patient_scope text DEFAULT 'clinic'::text NOT NULL,
|
||||
therapist_member_id uuid,
|
||||
nome_social text,
|
||||
pronomes text,
|
||||
etnia text,
|
||||
religiao text,
|
||||
faixa_renda text,
|
||||
canal_preferido text DEFAULT 'whatsapp'::text,
|
||||
horario_contato_inicio time without time zone DEFAULT '08:00:00'::time without time zone,
|
||||
horario_contato_fim time without time zone DEFAULT '20:00:00'::time without time zone,
|
||||
idioma text DEFAULT 'pt-BR'::text,
|
||||
origem text,
|
||||
metodo_pagamento_preferido text,
|
||||
motivo_saida text,
|
||||
data_saida date,
|
||||
encaminhado_para text,
|
||||
risco_elevado boolean DEFAULT false NOT NULL,
|
||||
risco_nota text,
|
||||
risco_sinalizado_em timestamp with time zone,
|
||||
risco_sinalizado_por uuid,
|
||||
horario_contato text,
|
||||
convenio text,
|
||||
convenio_id uuid,
|
||||
CONSTRAINT cpf_responsavel_format_check CHECK (((cpf_responsavel IS NULL) OR (cpf_responsavel ~ '^\d{11}$'::text))),
|
||||
CONSTRAINT patients_cpf_format_check CHECK (((cpf IS NULL) OR (cpf ~ '^\d{11}$'::text))),
|
||||
CONSTRAINT patients_patient_scope_check CHECK ((patient_scope = ANY (ARRAY['clinic'::text, 'therapist'::text]))),
|
||||
CONSTRAINT patients_status_check CHECK ((status = ANY (ARRAY['Ativo'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text]))),
|
||||
CONSTRAINT patients_therapist_scope_consistency CHECK ((((patient_scope = 'clinic'::text) AND (therapist_member_id IS NULL)) OR ((patient_scope = 'therapist'::text) AND (therapist_member_id IS NOT NULL))))
|
||||
CONSTRAINT patients_faixa_renda_check CHECK (((faixa_renda IS NULL) OR (faixa_renda = ANY (ARRAY['ate_1sm'::text, '1_3sm'::text, '3_6sm'::text, '6_10sm'::text, 'acima_10sm'::text, 'nao_informado'::text])))),
|
||||
CONSTRAINT patients_metodo_pagamento_check CHECK (((metodo_pagamento_preferido IS NULL) OR (metodo_pagamento_preferido = ANY (ARRAY['pix'::text, 'cartao'::text, 'dinheiro'::text, 'deposito'::text, 'convenio'::text])))),
|
||||
CONSTRAINT patients_risco_consistency_check CHECK (((risco_elevado = false) OR ((risco_elevado = true) AND (risco_nota IS NOT NULL) AND (risco_sinalizado_por IS NOT NULL)))),
|
||||
CONSTRAINT patients_status_check CHECK ((status = ANY (ARRAY['Ativo'::text, 'Em espera'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.patient_groups (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
nome text NOT NULL,
|
||||
descricao text,
|
||||
cor text,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
is_system boolean DEFAULT false NOT NULL,
|
||||
owner_id uuid DEFAULT auth.uid() NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
therapist_id uuid,
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.patient_group_patient (
|
||||
patient_group_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.patient_tags (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
nome text NOT NULL,
|
||||
cor text,
|
||||
is_padrao boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone,
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.patient_patient_tag (
|
||||
owner_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
tag_id uuid NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
tenant_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.patient_intake_requests (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
token text NOT NULL,
|
||||
consent boolean DEFAULT false NOT NULL,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
converted_patient_id uuid,
|
||||
rejected_reason text,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
cpf text,
|
||||
rg text,
|
||||
cep text,
|
||||
nome_completo text,
|
||||
email_principal text,
|
||||
telefone text,
|
||||
pais text,
|
||||
cidade text,
|
||||
estado text,
|
||||
endereco text,
|
||||
numero text,
|
||||
bairro text,
|
||||
complemento text,
|
||||
data_nascimento date,
|
||||
naturalidade text,
|
||||
genero text,
|
||||
estado_civil text,
|
||||
onde_nos_conheceu text,
|
||||
encaminhado_por text,
|
||||
observacoes text,
|
||||
notas_internas text,
|
||||
email_alternativo text,
|
||||
telefone_alternativo text,
|
||||
profissao text,
|
||||
escolaridade text,
|
||||
nacionalidade text,
|
||||
avatar_url text,
|
||||
tenant_id uuid,
|
||||
CONSTRAINT chk_intakes_status CHECK ((status = ANY (ARRAY['new'::text, 'converted'::text, 'rejected'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.patient_invites (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
token text NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
expires_at timestamp with time zone,
|
||||
max_uses integer,
|
||||
uses integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
tenant_id uuid
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.patient_discounts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
discount_pct numeric(5,2) DEFAULT 0,
|
||||
discount_flat numeric(10,2) DEFAULT 0,
|
||||
reason text,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
active_from timestamp with time zone DEFAULT now(),
|
||||
active_to timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — SaaS Admin, FAQ, Docs, UI
|
||||
-- =============================================================================
|
||||
-- saas_docs, saas_doc_votos, saas_faq, saas_faq_itens,
|
||||
-- feriados, global_notices, login_carousel_slides, notice_dismissals,
|
||||
-- support_sessions
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE public.saas_doc_votos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
doc_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
util boolean NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.saas_doc_votos OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: saas_docs; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.saas_docs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
titulo text NOT NULL,
|
||||
conteudo text DEFAULT ''::text NOT NULL,
|
||||
medias jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
tipo_acesso text DEFAULT 'usuario'::text NOT NULL,
|
||||
pagina_path text NOT NULL,
|
||||
docs_relacionados uuid[] DEFAULT '{}'::uuid[] NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
categoria text,
|
||||
exibir_no_faq boolean DEFAULT false NOT NULL,
|
||||
votos_util integer DEFAULT 0 NOT NULL,
|
||||
votos_nao_util integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT saas_docs_tipo_acesso_check CHECK ((tipo_acesso = ANY (ARRAY['admin'::text, 'usuario'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.saas_docs OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: COLUMN saas_docs.categoria; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.saas_docs.categoria IS 'Agrupa docs no portal FAQ (ex: Conta, Agenda, Pagamentos)';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN saas_docs.exibir_no_faq; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.saas_docs.exibir_no_faq IS 'Se true, a doc e seus itens FAQ aparecem no portal de FAQ';
|
||||
|
||||
|
||||
--
|
||||
-- Name: saas_faq; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.saas_faq (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
pergunta text NOT NULL,
|
||||
categoria text,
|
||||
publico boolean DEFAULT false NOT NULL,
|
||||
votos integer DEFAULT 0 NOT NULL,
|
||||
titulo text,
|
||||
conteudo text,
|
||||
tipo_acesso text DEFAULT 'usuario'::text NOT NULL,
|
||||
pagina_path text NOT NULL,
|
||||
pagina_label text,
|
||||
medias jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
faqs_relacionados uuid[] DEFAULT '{}'::uuid[] NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.saas_faq OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: saas_faq_itens; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.saas_faq_itens (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
doc_id uuid NOT NULL,
|
||||
pergunta text NOT NULL,
|
||||
resposta text,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.saas_faq_itens OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE saas_faq_itens; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE public.saas_faq_itens IS 'Pares pergunta/resposta vinculados a um documento de ajuda';
|
||||
|
||||
|
||||
--
|
||||
-- Name: services; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE TABLE public.feriados (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
owner_id uuid,
|
||||
tipo text DEFAULT 'municipal'::text NOT NULL,
|
||||
nome text NOT NULL,
|
||||
data date NOT NULL,
|
||||
cidade text,
|
||||
estado text,
|
||||
observacao text,
|
||||
bloqueia_sessoes boolean DEFAULT false NOT NULL,
|
||||
criado_em timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT feriados_tipo_check CHECK ((tipo = ANY (ARRAY['municipal'::text, 'personalizado'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.global_notices (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
title text,
|
||||
message text DEFAULT ''::text NOT NULL,
|
||||
variant text DEFAULT 'info'::text NOT NULL,
|
||||
roles text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
contexts text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
starts_at timestamp with time zone,
|
||||
ends_at timestamp with time zone,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
priority integer DEFAULT 0 NOT NULL,
|
||||
dismissible boolean DEFAULT true NOT NULL,
|
||||
persist_dismiss boolean DEFAULT true NOT NULL,
|
||||
dismiss_scope text DEFAULT 'device'::text NOT NULL,
|
||||
show_once boolean DEFAULT false NOT NULL,
|
||||
max_views integer,
|
||||
cooldown_minutes integer,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
action_type text DEFAULT 'none'::text NOT NULL,
|
||||
action_label text,
|
||||
action_url text,
|
||||
action_route text,
|
||||
views_count integer DEFAULT 0 NOT NULL,
|
||||
clicks_count integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid,
|
||||
content_align text DEFAULT 'left'::text NOT NULL,
|
||||
link_target text DEFAULT '_blank'::text NOT NULL,
|
||||
CONSTRAINT global_notices_action_type_check CHECK ((action_type = ANY (ARRAY['none'::text, 'internal'::text, 'external'::text]))),
|
||||
CONSTRAINT global_notices_content_align_check CHECK ((content_align = ANY (ARRAY['left'::text, 'center'::text, 'right'::text, 'justify'::text]))),
|
||||
CONSTRAINT global_notices_dismiss_scope_check CHECK ((dismiss_scope = ANY (ARRAY['session'::text, 'device'::text, 'user'::text]))),
|
||||
CONSTRAINT global_notices_link_target_check CHECK ((link_target = ANY (ARRAY['_blank'::text, '_self'::text, '_parent'::text, '_top'::text]))),
|
||||
CONSTRAINT global_notices_variant_check CHECK ((variant = ANY (ARRAY['info'::text, 'success'::text, 'warning'::text, 'error'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.login_carousel_slides (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
icon text DEFAULT 'pi-star'::text NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.notice_dismissals (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
notice_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
dismissed_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.support_sessions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
admin_id uuid NOT NULL,
|
||||
token text DEFAULT encode(extensions.gen_random_bytes(32), 'hex'::text) NOT NULL,
|
||||
expires_at timestamp with time zone DEFAULT (now() + '01:00:00'::interval) NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
+166
-281
@@ -1,115 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Plans, Billing, Subscriptions
|
||||
-- =============================================================================
|
||||
-- plans, plan_prices, plan_features, plan_public, plan_public_bullets,
|
||||
-- features, entitlements_invalidation, subscriptions, subscription_events,
|
||||
-- subscription_intents_personal, subscription_intents_tenant,
|
||||
-- subscription_intents_legacy, billing_contracts,
|
||||
-- addon_credits, addon_products, addon_transactions,
|
||||
-- modules, module_features, tenant_modules
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE public.plans (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
key text NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
price_cents integer DEFAULT 0 NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text NOT NULL,
|
||||
billing_interval text DEFAULT 'month'::text NOT NULL,
|
||||
target text,
|
||||
max_supervisees integer,
|
||||
CONSTRAINT plans_target_check CHECK ((target = ANY (ARRAY['patient'::text, 'therapist'::text, 'clinic'::text, 'supervisor'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.plan_prices (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text NOT NULL,
|
||||
"interval" text NOT NULL,
|
||||
amount_cents integer NOT NULL,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
active_from timestamp with time zone DEFAULT now() NOT NULL,
|
||||
active_to timestamp with time zone,
|
||||
source text DEFAULT 'manual'::text NOT NULL,
|
||||
provider text,
|
||||
provider_price_id text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT plan_prices_amount_cents_check CHECK ((amount_cents >= 0)),
|
||||
CONSTRAINT plan_prices_interval_check CHECK (("interval" = ANY (ARRAY['month'::text, 'year'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.plan_features (
|
||||
plan_id uuid NOT NULL,
|
||||
feature_id uuid NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
limits jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.plan_public (
|
||||
plan_id uuid NOT NULL,
|
||||
public_name text DEFAULT ''::text NOT NULL,
|
||||
public_description text DEFAULT ''::text NOT NULL,
|
||||
badge text,
|
||||
is_featured boolean DEFAULT false NOT NULL,
|
||||
is_visible boolean DEFAULT true NOT NULL,
|
||||
sort_order integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.plan_public_bullets (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
text text NOT NULL,
|
||||
sort_order integer DEFAULT 0 NOT NULL,
|
||||
highlight boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.features (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
key text NOT NULL,
|
||||
description text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
descricao text DEFAULT ''::text NOT NULL,
|
||||
name text DEFAULT ''::text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.features OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: COLUMN features.descricao; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON COLUMN public.features.descricao IS 'Descrição humana da feature (exibição no admin e documentação).';
|
||||
|
||||
|
||||
--
|
||||
-- Name: feriados; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE TABLE public.entitlements_invalidation (
|
||||
owner_id uuid NOT NULL,
|
||||
changed_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
-- Tables: SaaS / Planos
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.227Z
|
||||
-- Total: 18
|
||||
|
||||
CREATE TABLE public.subscriptions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -142,92 +33,6 @@ CREATE TABLE public.subscriptions (
|
||||
CONSTRAINT subscriptions_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'active'::text, 'past_due'::text, 'suspended'::text, 'cancelled'::text, 'expired'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.subscription_events (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
subscription_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
old_plan_id uuid,
|
||||
new_plan_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid,
|
||||
source text DEFAULT 'admin_ui'::text,
|
||||
reason text,
|
||||
metadata jsonb,
|
||||
owner_type text NOT NULL,
|
||||
owner_ref uuid NOT NULL,
|
||||
CONSTRAINT subscription_events_owner_ref_consistency_chk CHECK ((owner_id = owner_ref)),
|
||||
CONSTRAINT subscription_events_owner_type_chk CHECK (((owner_type IS NULL) OR (owner_type = ANY (ARRAY['clinic'::text, 'therapist'::text]))))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.subscription_intents_personal (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
created_by_user_id uuid,
|
||||
email text NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
plan_key text,
|
||||
"interval" text,
|
||||
amount_cents integer,
|
||||
currency text,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
source text DEFAULT 'manual'::text NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
subscription_id uuid,
|
||||
CONSTRAINT sint_personal_interval_check CHECK ((("interval" IS NULL) OR ("interval" = ANY (ARRAY['month'::text, 'year'::text])))),
|
||||
CONSTRAINT sint_personal_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.subscription_intents_tenant (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
created_by_user_id uuid,
|
||||
email text NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
plan_key text,
|
||||
"interval" text,
|
||||
amount_cents integer,
|
||||
currency text,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
source text DEFAULT 'manual'::text NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
tenant_id uuid NOT NULL,
|
||||
subscription_id uuid,
|
||||
CONSTRAINT sint_tenant_interval_check CHECK ((("interval" IS NULL) OR ("interval" = ANY (ARRAY['month'::text, 'year'::text])))),
|
||||
CONSTRAINT sint_tenant_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.subscription_intents_legacy (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid,
|
||||
email text,
|
||||
plan_key text NOT NULL,
|
||||
"interval" text NOT NULL,
|
||||
amount_cents integer NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text NOT NULL,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
source text DEFAULT 'landing'::text NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
tenant_id uuid NOT NULL,
|
||||
created_by_user_id uuid,
|
||||
CONSTRAINT subscription_intents_interval_check CHECK (("interval" = ANY (ARRAY['month'::text, 'year'::text]))),
|
||||
CONSTRAINT subscription_intents_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.billing_contracts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -250,90 +55,28 @@ CREATE TABLE public.billing_contracts (
|
||||
CONSTRAINT billing_contracts_type_chk CHECK ((type = ANY (ARRAY['per_session'::text, 'package'::text, 'subscription'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.addon_credits (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid,
|
||||
addon_type text NOT NULL,
|
||||
balance integer DEFAULT 0 NOT NULL,
|
||||
total_purchased integer DEFAULT 0 NOT NULL,
|
||||
total_consumed integer DEFAULT 0 NOT NULL,
|
||||
low_balance_threshold integer DEFAULT 10,
|
||||
low_balance_notified boolean DEFAULT false,
|
||||
daily_limit integer,
|
||||
hourly_limit integer,
|
||||
daily_used integer DEFAULT 0,
|
||||
hourly_used integer DEFAULT 0,
|
||||
daily_reset_at timestamp with time zone,
|
||||
hourly_reset_at timestamp with time zone,
|
||||
from_number_override text,
|
||||
expires_at timestamp with time zone,
|
||||
is_active boolean DEFAULT true,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
CREATE TABLE public.entitlements_invalidation (
|
||||
owner_id uuid NOT NULL,
|
||||
changed_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.addon_products (
|
||||
CREATE TABLE public.features (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
slug text NOT NULL,
|
||||
name text NOT NULL,
|
||||
key text NOT NULL,
|
||||
description text,
|
||||
addon_type text NOT NULL,
|
||||
icon text DEFAULT 'pi pi-box'::text,
|
||||
credits_amount integer DEFAULT 0,
|
||||
price_cents integer DEFAULT 0 NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text,
|
||||
is_active boolean DEFAULT true,
|
||||
is_visible boolean DEFAULT true,
|
||||
sort_order integer DEFAULT 0,
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
deleted_at timestamp with time zone
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
descricao text DEFAULT ''::text NOT NULL,
|
||||
name text DEFAULT ''::text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.addon_transactions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid,
|
||||
addon_type text NOT NULL,
|
||||
type text NOT NULL,
|
||||
amount integer NOT NULL,
|
||||
balance_before integer DEFAULT 0 NOT NULL,
|
||||
balance_after integer DEFAULT 0 NOT NULL,
|
||||
product_id uuid,
|
||||
queue_id uuid,
|
||||
description text,
|
||||
admin_user_id uuid,
|
||||
payment_method text,
|
||||
payment_reference text,
|
||||
price_cents integer,
|
||||
currency text DEFAULT 'BRL'::text,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
metadata jsonb DEFAULT '{}'::jsonb
|
||||
CREATE TABLE public.module_features (
|
||||
module_id uuid NOT NULL,
|
||||
feature_id uuid NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
limits jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.addon_transactions OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: TABLE addon_transactions; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON TABLE public.addon_transactions IS 'Histórico de todas as transações de créditos: compras, consumo, ajustes, reembolsos.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_bloqueios; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE TABLE public.modules (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
key text NOT NULL,
|
||||
@@ -343,18 +86,14 @@ CREATE TABLE public.modules (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.module_features (
|
||||
module_id uuid NOT NULL,
|
||||
CREATE TABLE public.plan_features (
|
||||
plan_id uuid NOT NULL,
|
||||
feature_id uuid NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
limits jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.tenant_modules (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
@@ -367,5 +106,151 @@ CREATE TABLE public.tenant_modules (
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.plan_prices (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text NOT NULL,
|
||||
"interval" text NOT NULL,
|
||||
amount_cents integer NOT NULL,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
active_from timestamp with time zone DEFAULT now() NOT NULL,
|
||||
active_to timestamp with time zone,
|
||||
source text DEFAULT 'manual'::text NOT NULL,
|
||||
provider text,
|
||||
provider_price_id text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT plan_prices_amount_cents_check CHECK ((amount_cents >= 0)),
|
||||
CONSTRAINT plan_prices_interval_check CHECK (("interval" = ANY (ARRAY['month'::text, 'year'::text])))
|
||||
);
|
||||
|
||||
ALTER TABLE public.tenant_modules OWNER TO supabase_admin;
|
||||
CREATE TABLE public.plan_public (
|
||||
plan_id uuid NOT NULL,
|
||||
public_name text DEFAULT ''::text NOT NULL,
|
||||
public_description text DEFAULT ''::text NOT NULL,
|
||||
badge text,
|
||||
is_featured boolean DEFAULT false NOT NULL,
|
||||
is_visible boolean DEFAULT true NOT NULL,
|
||||
sort_order integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.plan_public_bullets (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
text text NOT NULL,
|
||||
sort_order integer DEFAULT 0 NOT NULL,
|
||||
highlight boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.plans (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
key text NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
price_cents integer DEFAULT 0 NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text NOT NULL,
|
||||
billing_interval text DEFAULT 'month'::text NOT NULL,
|
||||
target text,
|
||||
max_supervisees integer,
|
||||
CONSTRAINT plans_target_check CHECK ((target = ANY (ARRAY['patient'::text, 'therapist'::text, 'clinic'::text, 'supervisor'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.subscription_events (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
subscription_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
old_plan_id uuid,
|
||||
new_plan_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid,
|
||||
source text DEFAULT 'admin_ui'::text,
|
||||
reason text,
|
||||
metadata jsonb,
|
||||
owner_type text NOT NULL,
|
||||
owner_ref uuid NOT NULL,
|
||||
CONSTRAINT subscription_events_owner_ref_consistency_chk CHECK ((owner_id = owner_ref)),
|
||||
CONSTRAINT subscription_events_owner_type_chk CHECK (((owner_type IS NULL) OR (owner_type = ANY (ARRAY['clinic'::text, 'therapist'::text]))))
|
||||
);
|
||||
|
||||
CREATE TABLE public.subscription_intents_personal (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
created_by_user_id uuid,
|
||||
email text NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
plan_key text,
|
||||
"interval" text,
|
||||
amount_cents integer,
|
||||
currency text,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
source text DEFAULT 'manual'::text NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
subscription_id uuid,
|
||||
CONSTRAINT sint_personal_interval_check CHECK ((("interval" IS NULL) OR ("interval" = ANY (ARRAY['month'::text, 'year'::text])))),
|
||||
CONSTRAINT sint_personal_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.subscription_intents_tenant (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
created_by_user_id uuid,
|
||||
email text NOT NULL,
|
||||
plan_id uuid NOT NULL,
|
||||
plan_key text,
|
||||
"interval" text,
|
||||
amount_cents integer,
|
||||
currency text,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
source text DEFAULT 'manual'::text NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
tenant_id uuid NOT NULL,
|
||||
subscription_id uuid,
|
||||
CONSTRAINT sint_tenant_interval_check CHECK ((("interval" IS NULL) OR ("interval" = ANY (ARRAY['month'::text, 'year'::text])))),
|
||||
CONSTRAINT sint_tenant_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.subscription_intents_legacy (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid,
|
||||
email text,
|
||||
plan_key text NOT NULL,
|
||||
"interval" text NOT NULL,
|
||||
amount_cents integer NOT NULL,
|
||||
currency text DEFAULT 'BRL'::text NOT NULL,
|
||||
status text DEFAULT 'new'::text NOT NULL,
|
||||
source text DEFAULT 'landing'::text NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
paid_at timestamp with time zone,
|
||||
tenant_id uuid NOT NULL,
|
||||
created_by_user_id uuid,
|
||||
CONSTRAINT subscription_intents_interval_check CHECK (("interval" = ANY (ARRAY['month'::text, 'year'::text]))),
|
||||
CONSTRAINT subscription_intents_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.tenant_feature_exceptions_log (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
feature_key text NOT NULL,
|
||||
enabled boolean NOT NULL,
|
||||
reason text,
|
||||
created_by uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.tenant_features (
|
||||
tenant_id uuid NOT NULL,
|
||||
feature_key text NOT NULL,
|
||||
enabled boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
+84
-46
@@ -1,42 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Compromissos Determinados
|
||||
-- =============================================================================
|
||||
-- determined_commitments, determined_commitment_fields,
|
||||
-- commitment_services, commitment_time_logs
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE public.determined_commitments (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
created_by uuid,
|
||||
is_native boolean DEFAULT false NOT NULL,
|
||||
native_key text,
|
||||
is_locked boolean DEFAULT false NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
bg_color text,
|
||||
text_color text
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.determined_commitment_fields (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
commitment_id uuid NOT NULL,
|
||||
key text NOT NULL,
|
||||
label text NOT NULL,
|
||||
field_type public.determined_field_type DEFAULT 'text'::public.determined_field_type NOT NULL,
|
||||
required boolean DEFAULT false NOT NULL,
|
||||
sort_order integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
-- Tables: Serviços / Prontuários
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
|
||||
-- Total: 8
|
||||
|
||||
CREATE TABLE public.commitment_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -54,13 +18,6 @@ CREATE TABLE public.commitment_services (
|
||||
CONSTRAINT commitment_services_quantity_chk CHECK ((quantity > 0))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.commitment_services OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: commitment_time_logs; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.commitment_time_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
@@ -74,4 +31,85 @@ CREATE TABLE public.commitment_time_logs (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.determined_commitment_fields (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
commitment_id uuid NOT NULL,
|
||||
key text NOT NULL,
|
||||
label text NOT NULL,
|
||||
field_type public.determined_field_type DEFAULT 'text'::public.determined_field_type NOT NULL,
|
||||
required boolean DEFAULT false NOT NULL,
|
||||
sort_order integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.determined_commitments (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
created_by uuid,
|
||||
is_native boolean DEFAULT false NOT NULL,
|
||||
native_key text,
|
||||
is_locked boolean DEFAULT false NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
bg_color text,
|
||||
text_color text
|
||||
);
|
||||
|
||||
CREATE TABLE public.insurance_plan_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
insurance_plan_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
value numeric(10,2) NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.insurance_plans (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
notes text,
|
||||
default_value numeric(10,2),
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.medicos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
nome text NOT NULL,
|
||||
crm text,
|
||||
especialidade text,
|
||||
telefone_profissional text,
|
||||
telefone_pessoal text,
|
||||
email text,
|
||||
clinica text,
|
||||
cidade text,
|
||||
estado text DEFAULT 'SP'::text,
|
||||
observacoes text,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE public.services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
price numeric(10,2) NOT NULL,
|
||||
duration_min integer,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
+92
-125
@@ -1,51 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Tables — Core
|
||||
-- =============================================================================
|
||||
-- profiles, tenants, tenant_members, tenant_invites, tenant_features,
|
||||
-- tenant_feature_exceptions_log, saas_admins, owner_users, user_settings,
|
||||
-- company_profiles, dev_user_credentials
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE public.profiles (
|
||||
id uuid NOT NULL,
|
||||
role text DEFAULT 'tenant_member'::text NOT NULL,
|
||||
full_name text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
avatar_url text,
|
||||
phone text,
|
||||
bio text,
|
||||
language text DEFAULT 'pt-BR'::text NOT NULL,
|
||||
timezone text DEFAULT 'America/Sao_Paulo'::text NOT NULL,
|
||||
notify_system_email boolean DEFAULT true NOT NULL,
|
||||
notify_reminders boolean DEFAULT true NOT NULL,
|
||||
notify_news boolean DEFAULT false NOT NULL,
|
||||
account_type text DEFAULT 'free'::text NOT NULL,
|
||||
platform_roles text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
nickname text,
|
||||
work_description text,
|
||||
work_description_other text,
|
||||
site_url text,
|
||||
social_instagram text,
|
||||
social_youtube text,
|
||||
social_facebook text,
|
||||
social_x text,
|
||||
social_custom jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
CONSTRAINT profiles_account_type_check CHECK ((account_type = ANY (ARRAY['free'::text, 'patient'::text, 'therapist'::text, 'clinic'::text]))),
|
||||
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['saas_admin'::text, 'tenant_member'::text, 'portal_user'::text, 'patient'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.tenants (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
name text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
kind text DEFAULT 'saas'::text NOT NULL,
|
||||
CONSTRAINT tenants_kind_check CHECK ((kind = ANY (ARRAY['therapist'::text, 'clinic_coworking'::text, 'clinic_reception'::text, 'clinic_full'::text, 'clinic'::text, 'saas'::text, 'supervisor'::text])))
|
||||
);
|
||||
|
||||
|
||||
-- Tables: Tenants / Multi-tenant
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE TABLE public.tenant_members (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -56,81 +11,6 @@ CREATE TABLE public.tenant_members (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.tenant_invites (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL,
|
||||
token uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
invited_by uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone DEFAULT (now() + '7 days'::interval) NOT NULL,
|
||||
accepted_at timestamp with time zone,
|
||||
accepted_by uuid,
|
||||
revoked_at timestamp with time zone,
|
||||
revoked_by uuid,
|
||||
CONSTRAINT tenant_invites_role_check CHECK ((role = ANY (ARRAY['therapist'::text, 'secretary'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.tenant_features (
|
||||
tenant_id uuid NOT NULL,
|
||||
feature_key text NOT NULL,
|
||||
enabled boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.tenant_feature_exceptions_log (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
feature_key text NOT NULL,
|
||||
enabled boolean NOT NULL,
|
||||
reason text,
|
||||
created_by uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.saas_admins (
|
||||
user_id uuid NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.owner_users (
|
||||
owner_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
role text DEFAULT 'admin'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.user_settings (
|
||||
user_id uuid NOT NULL,
|
||||
theme_mode text DEFAULT 'dark'::text NOT NULL,
|
||||
preset text DEFAULT 'Aura'::text NOT NULL,
|
||||
primary_color text DEFAULT 'noir'::text NOT NULL,
|
||||
surface_color text DEFAULT 'slate'::text NOT NULL,
|
||||
menu_mode text DEFAULT 'static'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
layout_variant text DEFAULT 'classic'::text NOT NULL,
|
||||
CONSTRAINT user_settings_layout_variant_check CHECK ((layout_variant = ANY (ARRAY['classic'::text, 'rail'::text]))),
|
||||
CONSTRAINT user_settings_menu_mode_check CHECK ((menu_mode = ANY (ARRAY['static'::text, 'overlay'::text]))),
|
||||
CONSTRAINT user_settings_theme_mode_check CHECK ((theme_mode = ANY (ARRAY['light'::text, 'dark'::text])))
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.company_profiles (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
@@ -156,8 +36,6 @@ CREATE TABLE public.company_profiles (
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE public.dev_user_credentials (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid,
|
||||
@@ -168,4 +46,93 @@ CREATE TABLE public.dev_user_credentials (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.owner_users (
|
||||
owner_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
role text DEFAULT 'admin'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.profiles (
|
||||
id uuid NOT NULL,
|
||||
role text DEFAULT 'tenant_member'::text NOT NULL,
|
||||
full_name text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
avatar_url text,
|
||||
phone text,
|
||||
bio text,
|
||||
language text DEFAULT 'pt-BR'::text NOT NULL,
|
||||
timezone text DEFAULT 'America/Sao_Paulo'::text NOT NULL,
|
||||
notify_system_email boolean DEFAULT true NOT NULL,
|
||||
notify_reminders boolean DEFAULT true NOT NULL,
|
||||
notify_news boolean DEFAULT false NOT NULL,
|
||||
account_type text DEFAULT 'free'::text NOT NULL,
|
||||
platform_roles text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
nickname text,
|
||||
work_description text,
|
||||
work_description_other text,
|
||||
site_url text,
|
||||
social_instagram text,
|
||||
social_youtube text,
|
||||
social_facebook text,
|
||||
social_x text,
|
||||
social_custom jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
tenant_id uuid,
|
||||
CONSTRAINT profiles_account_type_check CHECK ((account_type = ANY (ARRAY['free'::text, 'patient'::text, 'therapist'::text, 'clinic'::text]))),
|
||||
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['saas_admin'::text, 'tenant_member'::text, 'portal_user'::text, 'patient'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_admins (
|
||||
user_id uuid NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.support_sessions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
admin_id uuid NOT NULL,
|
||||
token text DEFAULT encode(extensions.gen_random_bytes(32), 'hex'::text) NOT NULL,
|
||||
expires_at timestamp with time zone DEFAULT (now() + '01:00:00'::interval) NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.tenant_invites (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
email text NOT NULL,
|
||||
role text NOT NULL,
|
||||
token uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
invited_by uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone DEFAULT (now() + '7 days'::interval) NOT NULL,
|
||||
accepted_at timestamp with time zone,
|
||||
accepted_by uuid,
|
||||
revoked_at timestamp with time zone,
|
||||
revoked_by uuid,
|
||||
CONSTRAINT tenant_invites_role_check CHECK ((role = ANY (ARRAY['therapist'::text, 'secretary'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.tenants (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
name text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
kind text DEFAULT 'saas'::text NOT NULL,
|
||||
papel_timbrado jsonb DEFAULT '{"footer": {"slots": {"left": null, "right": null, "center": {"type": "custom-text", "content": ""}}, "height": 40, "preset": "text-center", "divider": {"show": true, "color": "#cccccc", "style": "solid"}, "enabled": true, "showPageNumber": false}, "header": {"slots": {"left": {"size": "medium", "type": "logo"}, "right": {"type": "institution-data", "fields": ["nome", "cnpj", "endereco_linha"]}, "center": null}, "height": 80, "preset": "logo-left-text-right", "divider": {"show": true, "color": "#cccccc", "style": "solid"}, "enabled": true}, "margins": {"top": 20, "left": 25, "right": 25, "bottom": 20}}'::jsonb,
|
||||
CONSTRAINT tenants_kind_check CHECK ((kind = ANY (ARRAY['therapist'::text, 'clinic_coworking'::text, 'clinic_reception'::text, 'clinic_full'::text, 'clinic'::text, 'saas'::text, 'supervisor'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.user_settings (
|
||||
user_id uuid NOT NULL,
|
||||
theme_mode text DEFAULT 'dark'::text NOT NULL,
|
||||
preset text DEFAULT 'Aura'::text NOT NULL,
|
||||
primary_color text DEFAULT 'noir'::text NOT NULL,
|
||||
surface_color text DEFAULT 'slate'::text NOT NULL,
|
||||
menu_mode text DEFAULT 'static'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
layout_variant text DEFAULT 'classic'::text NOT NULL,
|
||||
CONSTRAINT user_settings_layout_variant_check CHECK ((layout_variant = ANY (ARRAY['classic'::text, 'rail'::text]))),
|
||||
CONSTRAINT user_settings_menu_mode_check CHECK ((menu_mode = ANY (ARRAY['static'::text, 'overlay'::text]))),
|
||||
CONSTRAINT user_settings_theme_mode_check CHECK ((theme_mode = ANY (ARRAY['light'::text, 'dark'::text])))
|
||||
);
|
||||
@@ -1,28 +1,10 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Views
|
||||
-- =============================================================================
|
||||
-- current_tenant_id, owner_feature_entitlements, subscription_intents,
|
||||
-- v_auth_users_public, v_cashflow_projection, v_commitment_totals,
|
||||
-- v_patient_groups_with_counts, v_plan_active_prices, v_public_pricing,
|
||||
-- v_subscription_feature_mismatch, v_subscription_health, v_subscription_health_v2,
|
||||
-- v_tag_patient_counts, v_tenant_active_subscription, v_tenant_entitlements,
|
||||
-- v_tenant_entitlements_full, v_tenant_entitlements_json,
|
||||
-- v_tenant_feature_exceptions, v_tenant_feature_mismatch,
|
||||
-- v_tenant_members_with_profiles, v_tenant_people, v_tenant_staff,
|
||||
-- v_user_active_subscription, v_user_entitlements
|
||||
-- =============================================================================
|
||||
-- Views
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.233Z
|
||||
-- Total: 27
|
||||
|
||||
CREATE VIEW public.current_tenant_id AS
|
||||
SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting;
|
||||
|
||||
|
||||
ALTER VIEW public.current_tenant_id OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: determined_commitment_fields; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE VIEW public.owner_feature_entitlements AS
|
||||
WITH base AS (
|
||||
SELECT s.user_id AS owner_id,
|
||||
@@ -51,14 +33,6 @@ CREATE VIEW public.owner_feature_entitlements AS
|
||||
FROM base
|
||||
GROUP BY owner_id, feature_key;
|
||||
|
||||
|
||||
ALTER VIEW public.owner_feature_entitlements OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: owner_users; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE VIEW public.subscription_intents AS
|
||||
SELECT t.id,
|
||||
t.user_id,
|
||||
@@ -98,14 +72,6 @@ UNION ALL
|
||||
'therapist'::text AS plan_target
|
||||
FROM public.subscription_intents_personal p;
|
||||
|
||||
|
||||
ALTER VIEW public.subscription_intents OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: subscription_intents_legacy; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
|
||||
CREATE VIEW public.v_auth_users_public AS
|
||||
SELECT id AS user_id,
|
||||
email,
|
||||
@@ -113,13 +79,6 @@ CREATE VIEW public.v_auth_users_public AS
|
||||
last_sign_in_at
|
||||
FROM auth.users u;
|
||||
|
||||
|
||||
ALTER VIEW public.v_auth_users_public OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_cashflow_projection; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_cashflow_projection WITH (security_invoker='on') AS
|
||||
SELECT gs.mes,
|
||||
to_char(gs.mes, 'YYYY-MM'::text) AS mes_label,
|
||||
@@ -136,20 +95,6 @@ CREATE VIEW public.v_cashflow_projection WITH (security_invoker='on') AS
|
||||
GROUP BY gs.mes
|
||||
ORDER BY gs.mes;
|
||||
|
||||
|
||||
ALTER VIEW public.v_cashflow_projection OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: VIEW v_cashflow_projection; Type: COMMENT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
COMMENT ON VIEW public.v_cashflow_projection IS 'Fluxo de caixa projetado: próximos 6 meses com totais de pending+overdue por due_date. Usa security_invoker=on — filtra automaticamente pelo auth.uid() via RLS de financial_records.';
|
||||
|
||||
|
||||
--
|
||||
-- Name: v_commitment_totals; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_commitment_totals AS
|
||||
SELECT c.tenant_id,
|
||||
c.id AS commitment_id,
|
||||
@@ -158,12 +103,76 @@ CREATE VIEW public.v_commitment_totals AS
|
||||
LEFT JOIN public.commitment_time_logs l ON ((l.commitment_id = c.id)))
|
||||
GROUP BY c.tenant_id, c.id;
|
||||
|
||||
|
||||
ALTER VIEW public.v_commitment_totals OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_patient_groups_with_counts; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE VIEW public.v_patient_engajamento WITH (security_invoker='on') AS
|
||||
WITH sessoes AS (
|
||||
SELECT ae.patient_id,
|
||||
ae.tenant_id,
|
||||
count(*) FILTER (WHERE (ae.status = 'realizado'::public.status_evento_agenda)) AS total_realizadas,
|
||||
count(*) FILTER (WHERE (ae.status = ANY (ARRAY['realizado'::public.status_evento_agenda, 'cancelado'::public.status_evento_agenda, 'faltou'::public.status_evento_agenda]))) AS total_marcadas,
|
||||
count(*) FILTER (WHERE (ae.status = 'faltou'::public.status_evento_agenda)) AS total_faltas,
|
||||
max(ae.inicio_em) FILTER (WHERE (ae.status = 'realizado'::public.status_evento_agenda)) AS ultima_sessao_em,
|
||||
min(ae.inicio_em) FILTER (WHERE (ae.status = 'realizado'::public.status_evento_agenda)) AS primeira_sessao_em,
|
||||
count(*) FILTER (WHERE ((ae.status = 'realizado'::public.status_evento_agenda) AND (ae.inicio_em >= (now() - '30 days'::interval)))) AS sessoes_ultimo_mes
|
||||
FROM public.agenda_eventos ae
|
||||
WHERE (ae.patient_id IS NOT NULL)
|
||||
GROUP BY ae.patient_id, ae.tenant_id
|
||||
), financeiro AS (
|
||||
SELECT fr.patient_id,
|
||||
fr.tenant_id,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE (fr.status = 'paid'::text)), (0)::numeric) AS total_pago,
|
||||
COALESCE(avg(fr.final_amount) FILTER (WHERE (fr.status = 'paid'::text)), (0)::numeric) AS ticket_medio,
|
||||
count(*) FILTER (WHERE ((fr.status = ANY (ARRAY['pending'::text, 'overdue'::text])) AND (fr.due_date < now()))) AS cobr_vencidas,
|
||||
count(*) FILTER (WHERE (fr.status = ANY (ARRAY['pending'::text, 'overdue'::text]))) AS cobr_pendentes,
|
||||
count(*) FILTER (WHERE ((fr.type = 'receita'::public.financial_record_type) AND (fr.status = 'paid'::text))) AS cobr_pagas
|
||||
FROM public.financial_records fr
|
||||
WHERE ((fr.patient_id IS NOT NULL) AND (fr.deleted_at IS NULL))
|
||||
GROUP BY fr.patient_id, fr.tenant_id
|
||||
)
|
||||
SELECT p.id AS patient_id,
|
||||
p.tenant_id,
|
||||
p.nome_completo,
|
||||
p.status,
|
||||
p.risco_elevado,
|
||||
COALESCE(s.total_realizadas, (0)::bigint) AS total_sessoes,
|
||||
COALESCE(s.sessoes_ultimo_mes, (0)::bigint) AS sessoes_ultimo_mes,
|
||||
s.primeira_sessao_em,
|
||||
s.ultima_sessao_em,
|
||||
(EXTRACT(day FROM (now() - s.ultima_sessao_em)))::integer AS dias_sem_sessao,
|
||||
CASE
|
||||
WHEN (COALESCE(s.total_marcadas, (0)::bigint) = 0) THEN NULL::numeric
|
||||
ELSE round((((s.total_realizadas)::numeric / (s.total_marcadas)::numeric) * (100)::numeric), 1)
|
||||
END AS taxa_comparecimento,
|
||||
COALESCE(f.total_pago, (0)::numeric) AS ltv_total,
|
||||
round(COALESCE(f.ticket_medio, (0)::numeric), 2) AS ticket_medio,
|
||||
COALESCE(f.cobr_vencidas, (0)::bigint) AS cobr_vencidas,
|
||||
COALESCE(f.cobr_pagas, (0)::bigint) AS cobr_pagas,
|
||||
CASE
|
||||
WHEN (COALESCE((f.cobr_pagas + f.cobr_vencidas), (0)::bigint) = 0) THEN NULL::numeric
|
||||
ELSE round((((f.cobr_pagas)::numeric / ((f.cobr_pagas + f.cobr_vencidas))::numeric) * (100)::numeric), 1)
|
||||
END AS taxa_pagamentos_dia,
|
||||
round(LEAST((100)::numeric, COALESCE(((
|
||||
CASE
|
||||
WHEN (COALESCE(s.total_marcadas, (0)::bigint) = 0) THEN (50)::numeric
|
||||
ELSE LEAST((50)::numeric, (((s.total_realizadas)::numeric / (s.total_marcadas)::numeric) * (50)::numeric))
|
||||
END +
|
||||
CASE
|
||||
WHEN (COALESCE((f.cobr_pagas + f.cobr_vencidas), (0)::bigint) = 0) THEN (30)::numeric
|
||||
ELSE LEAST((30)::numeric, (((f.cobr_pagas)::numeric / ((f.cobr_pagas + f.cobr_vencidas))::numeric) * (30)::numeric))
|
||||
END) + (
|
||||
CASE
|
||||
WHEN (s.ultima_sessao_em IS NULL) THEN 0
|
||||
WHEN (EXTRACT(day FROM (now() - s.ultima_sessao_em)) <= (14)::numeric) THEN 20
|
||||
WHEN (EXTRACT(day FROM (now() - s.ultima_sessao_em)) <= (30)::numeric) THEN 15
|
||||
WHEN (EXTRACT(day FROM (now() - s.ultima_sessao_em)) <= (60)::numeric) THEN 8
|
||||
ELSE 0
|
||||
END)::numeric), (0)::numeric)), 0) AS engajamento_score,
|
||||
CASE
|
||||
WHEN (s.primeira_sessao_em IS NULL) THEN NULL::integer
|
||||
ELSE (EXTRACT(day FROM (now() - s.primeira_sessao_em)))::integer
|
||||
END AS duracao_tratamento_dias
|
||||
FROM ((public.patients p
|
||||
LEFT JOIN sessoes s ON (((s.patient_id = p.id) AND (s.tenant_id = p.tenant_id))))
|
||||
LEFT JOIN financeiro f ON (((f.patient_id = p.id) AND (f.tenant_id = p.tenant_id))));
|
||||
|
||||
CREATE VIEW public.v_patient_groups_with_counts AS
|
||||
SELECT pg.id,
|
||||
@@ -179,12 +188,27 @@ CREATE VIEW public.v_patient_groups_with_counts AS
|
||||
LEFT JOIN public.patient_group_patient pgp ON ((pgp.patient_group_id = pg.id)))
|
||||
GROUP BY pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at;
|
||||
|
||||
|
||||
ALTER VIEW public.v_patient_groups_with_counts OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_plan_active_prices; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE VIEW public.v_patients_risco WITH (security_invoker='on') AS
|
||||
SELECT p.id,
|
||||
p.tenant_id,
|
||||
p.nome_completo,
|
||||
p.status,
|
||||
p.risco_elevado,
|
||||
p.risco_nota,
|
||||
p.risco_sinalizado_em,
|
||||
e.dias_sem_sessao,
|
||||
e.engajamento_score,
|
||||
e.taxa_comparecimento,
|
||||
CASE
|
||||
WHEN p.risco_elevado THEN 'risco_sinalizado'::text
|
||||
WHEN ((COALESCE(e.dias_sem_sessao, 999) > 30) AND (p.status = 'Ativo'::text)) THEN 'sem_sessao_30d'::text
|
||||
WHEN (COALESCE(e.taxa_comparecimento, (100)::numeric) < (60)::numeric) THEN 'baixo_comparecimento'::text
|
||||
WHEN (COALESCE(e.cobr_vencidas, (0)::bigint) > 0) THEN 'cobranca_vencida'::text
|
||||
ELSE 'ok'::text
|
||||
END AS alerta_tipo
|
||||
FROM (public.patients p
|
||||
JOIN public.v_patient_engajamento e ON ((e.patient_id = p.id)))
|
||||
WHERE ((p.status = 'Ativo'::text) AND ((p.risco_elevado = true) OR (COALESCE(e.dias_sem_sessao, 999) > 30) OR (COALESCE(e.taxa_comparecimento, (100)::numeric) < (60)::numeric) OR (COALESCE(e.cobr_vencidas, (0)::bigint) > 0)));
|
||||
|
||||
CREATE VIEW public.v_plan_active_prices AS
|
||||
SELECT plan_id,
|
||||
@@ -211,13 +235,6 @@ CREATE VIEW public.v_plan_active_prices AS
|
||||
FROM public.plan_prices
|
||||
GROUP BY plan_id;
|
||||
|
||||
|
||||
ALTER VIEW public.v_plan_active_prices OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_public_pricing; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_public_pricing AS
|
||||
SELECT p.id AS plan_id,
|
||||
p.key AS plan_key,
|
||||
@@ -241,13 +258,6 @@ CREATE VIEW public.v_public_pricing AS
|
||||
LEFT JOIN public.v_plan_active_prices ap ON ((ap.plan_id = p.id)))
|
||||
ORDER BY COALESCE(pp.sort_order, 0), p.key;
|
||||
|
||||
|
||||
ALTER VIEW public.v_public_pricing OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_subscription_feature_mismatch; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_subscription_feature_mismatch AS
|
||||
WITH expected AS (
|
||||
SELECT s.user_id AS owner_id,
|
||||
@@ -272,13 +282,6 @@ CREATE VIEW public.v_subscription_feature_mismatch AS
|
||||
FULL JOIN actual ON (((expected.owner_id = actual.owner_id) AND (expected.feature_key = actual.feature_key))))
|
||||
WHERE ((expected.feature_key IS NULL) OR (actual.feature_key IS NULL));
|
||||
|
||||
|
||||
ALTER VIEW public.v_subscription_feature_mismatch OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_subscription_health; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_subscription_health AS
|
||||
SELECT s.id AS subscription_id,
|
||||
s.user_id AS owner_id,
|
||||
@@ -303,13 +306,6 @@ CREATE VIEW public.v_subscription_health AS
|
||||
FROM (public.subscriptions s
|
||||
LEFT JOIN public.plans p ON ((p.id = s.plan_id)));
|
||||
|
||||
|
||||
ALTER VIEW public.v_subscription_health OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_subscription_health_v2; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_subscription_health_v2 AS
|
||||
SELECT s.id AS subscription_id,
|
||||
s.user_id AS owner_id,
|
||||
@@ -334,13 +330,6 @@ CREATE VIEW public.v_subscription_health_v2 AS
|
||||
FROM (public.subscriptions s
|
||||
LEFT JOIN public.plans p ON ((p.id = s.plan_id)));
|
||||
|
||||
|
||||
ALTER VIEW public.v_subscription_health_v2 OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tag_patient_counts; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tag_patient_counts AS
|
||||
SELECT t.id,
|
||||
t.owner_id,
|
||||
@@ -355,13 +344,6 @@ CREATE VIEW public.v_tag_patient_counts AS
|
||||
LEFT JOIN public.patient_patient_tag ppt ON (((ppt.tag_id = t.id) AND (ppt.owner_id = t.owner_id))))
|
||||
GROUP BY t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at;
|
||||
|
||||
|
||||
ALTER VIEW public.v_tag_patient_counts OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_active_subscription; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_active_subscription AS
|
||||
SELECT DISTINCT ON (tenant_id) tenant_id,
|
||||
plan_id,
|
||||
@@ -375,13 +357,6 @@ CREATE VIEW public.v_tenant_active_subscription AS
|
||||
WHERE ((tenant_id IS NOT NULL) AND (status = 'active'::text) AND ((current_period_end IS NULL) OR (current_period_end > now())))
|
||||
ORDER BY tenant_id, created_at DESC;
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_active_subscription OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_entitlements; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_entitlements AS
|
||||
SELECT a.tenant_id,
|
||||
f.key AS feature_key,
|
||||
@@ -390,13 +365,6 @@ CREATE VIEW public.v_tenant_entitlements AS
|
||||
JOIN public.plan_features pf ON (((pf.plan_id = a.plan_id) AND (pf.enabled = true))))
|
||||
JOIN public.features f ON ((f.id = pf.feature_id)));
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_entitlements OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_entitlements_full; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_entitlements_full AS
|
||||
SELECT a.tenant_id,
|
||||
f.key AS feature_key,
|
||||
@@ -409,13 +377,6 @@ CREATE VIEW public.v_tenant_entitlements_full AS
|
||||
JOIN public.features f ON ((f.id = pf.feature_id)))
|
||||
JOIN public.plans p ON ((p.id = a.plan_id)));
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_entitlements_full OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_entitlements_json; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_entitlements_json AS
|
||||
SELECT tenant_id,
|
||||
max(plan_key) AS plan_key,
|
||||
@@ -423,13 +384,6 @@ CREATE VIEW public.v_tenant_entitlements_json AS
|
||||
FROM public.v_tenant_entitlements_full
|
||||
GROUP BY tenant_id;
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_entitlements_json OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_feature_exceptions; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_feature_exceptions AS
|
||||
SELECT tf.tenant_id,
|
||||
a.plan_key,
|
||||
@@ -440,13 +394,6 @@ CREATE VIEW public.v_tenant_feature_exceptions AS
|
||||
LEFT JOIN public.v_tenant_entitlements_full v ON (((v.tenant_id = tf.tenant_id) AND (v.feature_key = tf.feature_key))))
|
||||
WHERE ((tf.enabled = true) AND (COALESCE(v.allowed, false) = false));
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_feature_exceptions OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_feature_mismatch; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_feature_mismatch AS
|
||||
WITH plan_allowed AS (
|
||||
SELECT v.tenant_id,
|
||||
@@ -469,13 +416,6 @@ CREATE VIEW public.v_tenant_feature_mismatch AS
|
||||
LEFT JOIN plan_allowed p ON (((p.tenant_id = o.tenant_id) AND (p.feature_key = o.feature_key))))
|
||||
WHERE ((o.enabled = true) AND (COALESCE(p.allowed, false) = false));
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_feature_mismatch OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_members_with_profiles; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_members_with_profiles AS
|
||||
SELECT tm.id AS tenant_member_id,
|
||||
tm.tenant_id,
|
||||
@@ -489,13 +429,6 @@ CREATE VIEW public.v_tenant_members_with_profiles AS
|
||||
LEFT JOIN public.profiles p ON ((p.id = tm.user_id)))
|
||||
LEFT JOIN auth.users au ON ((au.id = tm.user_id)));
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_members_with_profiles OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_people; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_people AS
|
||||
SELECT 'member'::text AS type,
|
||||
m.tenant_id,
|
||||
@@ -519,13 +452,6 @@ UNION ALL
|
||||
FROM public.tenant_invites i
|
||||
WHERE ((i.accepted_at IS NULL) AND (i.revoked_at IS NULL));
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_people OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_tenant_staff; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_tenant_staff AS
|
||||
SELECT ('m_'::text || (tm.id)::text) AS row_id,
|
||||
tm.tenant_id,
|
||||
@@ -552,12 +478,31 @@ UNION ALL
|
||||
FROM public.tenant_invites ti
|
||||
WHERE ((ti.accepted_at IS NULL) AND (ti.revoked_at IS NULL) AND (ti.expires_at > now()));
|
||||
|
||||
|
||||
ALTER VIEW public.v_tenant_staff OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_user_active_subscription; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE 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,
|
||||
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)::numeric) AS current_month_cost_usd,
|
||||
COALESCE(u.cost_brl, (0)::numeric) AS current_month_cost_brl,
|
||||
COALESCE(u.revenue_brl, (0)::numeric) AS current_month_revenue_brl,
|
||||
COALESCE(u.margin_brl, (0)::numeric) 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'::text, (CURRENT_DATE)::timestamp with time zone))::date))))
|
||||
WHERE ((nc.channel = 'whatsapp'::text) AND (nc.provider = 'twilio'::text) AND (nc.deleted_at IS NULL));
|
||||
|
||||
CREATE VIEW public.v_user_active_subscription AS
|
||||
SELECT DISTINCT ON (user_id) user_id,
|
||||
@@ -572,13 +517,6 @@ CREATE VIEW public.v_user_active_subscription AS
|
||||
WHERE ((tenant_id IS NULL) AND (user_id IS NOT NULL) AND (status = 'active'::text) AND ((current_period_end IS NULL) OR (current_period_end > now())))
|
||||
ORDER BY user_id, created_at DESC;
|
||||
|
||||
|
||||
ALTER VIEW public.v_user_active_subscription OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: v_user_entitlements; Type: VIEW; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE VIEW public.v_user_entitlements AS
|
||||
SELECT a.user_id,
|
||||
f.key AS feature_key,
|
||||
@@ -586,11 +524,3 @@ CREATE VIEW public.v_user_entitlements AS
|
||||
FROM ((public.v_user_active_subscription a
|
||||
JOIN public.plan_features pf ON (((pf.plan_id = a.plan_id) AND (pf.enabled = true))))
|
||||
JOIN public.features f ON ((f.id = pf.feature_id)));
|
||||
|
||||
|
||||
ALTER VIEW public.v_user_entitlements OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: messages; Type: TABLE; Schema: realtime; Owner: supabase_realtime_admin
|
||||
--
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,481 +1,163 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Triggers
|
||||
-- =============================================================================
|
||||
-- Triggers
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.238Z
|
||||
-- Total: 80
|
||||
|
||||
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||
|
||||
|
||||
--
|
||||
-- Name: users trg_seed_patient_groups; Type: TRIGGER; Schema: auth; Owner: supabase_auth_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_seed_patient_groups AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.on_new_user_seed_patient_groups();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_bloqueios agenda_bloqueios_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER agenda_bloqueios_updated_at BEFORE UPDATE ON public.agenda_bloqueios FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agendador_configuracoes agendador_slug_trigger; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER agendador_slug_trigger BEFORE INSERT OR UPDATE ON public.agendador_configuracoes FOR EACH ROW EXECUTE FUNCTION public.agendador_gerar_slug();
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenant_members prevent_saas_membership_trigger; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER prevent_saas_membership_trigger BEFORE INSERT ON public.tenant_members FOR EACH ROW EXECUTE FUNCTION public.prevent_saas_membership();
|
||||
|
||||
|
||||
--
|
||||
-- Name: insurance_plan_services set_insurance_plan_services_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER set_insurance_plan_services_updated_at BEFORE UPDATE ON public.insurance_plan_services FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_settings t_user_settings_set_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER t_user_settings_set_updated_at BEFORE UPDATE ON public.user_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_configuracoes tg_agenda_configuracoes_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER tg_agenda_configuracoes_updated_at BEFORE UPDATE ON public.agenda_configuracoes FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos tg_agenda_eventos_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER tg_agenda_eventos_updated_at BEFORE UPDATE ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_excecoes tg_agenda_excecoes_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER tg_agenda_excecoes_updated_at BEFORE UPDATE ON public.agenda_excecoes FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_regras_semanais tg_agenda_regras_semanais_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER tg_agenda_regras_semanais_updated_at BEFORE UPDATE ON public.agenda_regras_semanais FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: recurrence_rules tg_recurrence_rules_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER tg_recurrence_rules_updated_at BEFORE UPDATE ON public.recurrence_rules FOR EACH ROW EXECUTE FUNCTION public.set_updated_at_recurrence();
|
||||
|
||||
|
||||
--
|
||||
-- Name: plan_public tr_plan_public_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER tr_plan_public_updated_at BEFORE UPDATE ON public.plan_public FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: profiles trg_account_type_immutable; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_account_type_immutable BEFORE UPDATE OF account_type ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.guard_account_type_immutable();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_configuracoes trg_agenda_cfg_sync; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_agenda_cfg_sync BEFORE INSERT OR UPDATE ON public.agenda_configuracoes FOR EACH ROW EXECUTE FUNCTION public.agenda_cfg_sync();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos trg_agenda_eventos_busy_mirror_del; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_agenda_eventos_busy_mirror_del AFTER DELETE ON public.agenda_eventos FOR EACH ROW WHEN (((old.mirror_of_event_id IS NULL) AND (old.tenant_id = old.owner_id))) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos trg_agenda_eventos_busy_mirror_ins; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_agenda_eventos_busy_mirror_ins AFTER INSERT ON public.agenda_eventos FOR EACH ROW WHEN (((new.mirror_of_event_id IS NULL) AND (new.tenant_id = new.owner_id) AND (new.visibility_scope = ANY (ARRAY['busy_only'::text, 'private'::text])))) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos trg_agenda_eventos_busy_mirror_upd; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_agenda_eventos_busy_mirror_upd AFTER UPDATE ON public.agenda_eventos FOR EACH ROW WHEN (((new.mirror_of_event_id IS NULL) AND (new.tenant_id = new.owner_id) AND ((new.visibility_scope IS DISTINCT FROM old.visibility_scope) OR (new.inicio_em IS DISTINCT FROM old.inicio_em) OR (new.fim_em IS DISTINCT FROM old.fim_em) OR (new.owner_id IS DISTINCT FROM old.owner_id) OR (new.tenant_id IS DISTINCT FROM old.tenant_id)))) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_regras_semanais trg_agenda_regras_semanais_no_overlap; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_agenda_regras_semanais_no_overlap BEFORE INSERT OR UPDATE ON public.agenda_regras_semanais FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos trg_auto_financial_from_session; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_auto_financial_from_session AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session();
|
||||
|
||||
|
||||
--
|
||||
-- Name: notification_preferences trg_cancel_notifs_on_opt_out; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_cancel_notifs_on_opt_out AFTER UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos trg_cancel_notifs_on_session_cancel; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_cancel_notifs_on_session_cancel AFTER UPDATE ON public.agenda_eventos FOR EACH ROW WHEN ((new.status IS DISTINCT FROM old.status)) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel();
|
||||
|
||||
|
||||
--
|
||||
-- Name: company_profiles trg_company_profiles_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_company_profiles_updated_at BEFORE UPDATE ON public.company_profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: determined_commitment_fields trg_determined_commitment_fields_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_determined_commitment_fields_updated_at BEFORE UPDATE ON public.determined_commitment_fields FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: determined_commitments trg_determined_commitments_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_determined_commitments_updated_at BEFORE UPDATE ON public.determined_commitments FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_documents_timeline_insert AFTER INSERT ON public.documents FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert();
|
||||
|
||||
--
|
||||
-- Name: email_layout_config trg_email_layout_config_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE TRIGGER trg_documents_updated_at BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_ds_timeline AFTER UPDATE ON public.document_signatures FOR EACH ROW EXECUTE FUNCTION public.fn_document_signature_timeline();
|
||||
|
||||
CREATE TRIGGER trg_ds_updated_at BEFORE UPDATE ON public.document_signatures FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_dt_updated_at BEFORE UPDATE ON public.document_templates FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_email_layout_config_updated_at BEFORE UPDATE ON public.email_layout_config FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: email_templates_global trg_email_templates_global_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_email_templates_global_updated_at BEFORE UPDATE ON public.email_templates_global FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: email_templates_tenant trg_email_templates_tenant_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_email_templates_tenant_updated_at BEFORE UPDATE ON public.email_templates_tenant FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: financial_exceptions trg_financial_exceptions_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_financial_exceptions_updated_at BEFORE UPDATE ON public.financial_exceptions FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: financial_records trg_financial_records_auto_overdue; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_financial_records_auto_overdue BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue();
|
||||
|
||||
|
||||
--
|
||||
-- Name: financial_records trg_financial_records_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_financial_records_updated_at BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_notices trg_global_notices_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_global_notices_updated_at BEFORE UPDATE ON public.global_notices FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: insurance_plans trg_insurance_plans_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_insurance_plans_updated_at BEFORE UPDATE ON public.insurance_plans FOR EACH ROW EXECUTE FUNCTION public.set_insurance_plans_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: plans trg_no_change_core_plan_key; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE TRIGGER trg_medicos_updated_at BEFORE UPDATE ON public.medicos FOR EACH ROW EXECUTE FUNCTION public.set_medicos_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_no_change_core_plan_key BEFORE UPDATE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_change_core_plan_key();
|
||||
|
||||
|
||||
--
|
||||
-- Name: plans trg_no_change_plan_target; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_no_change_plan_target BEFORE UPDATE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_change_plan_target();
|
||||
|
||||
|
||||
--
|
||||
-- Name: plans trg_no_delete_core_plans; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_no_delete_core_plans BEFORE DELETE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_delete_core_plans();
|
||||
|
||||
|
||||
--
|
||||
-- Name: notification_channels trg_notification_channels_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notification_channels_updated_at BEFORE UPDATE ON public.notification_channels FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: notification_logs trg_notification_logs_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notification_logs_updated_at BEFORE UPDATE ON public.notification_logs FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: notification_preferences trg_notification_preferences_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notification_preferences_updated_at BEFORE UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: notification_queue trg_notification_queue_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notification_queue_updated_at BEFORE UPDATE ON public.notification_queue FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: notification_schedules trg_notification_schedules_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notification_schedules_updated_at BEFORE UPDATE ON public.notification_schedules FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: notification_templates trg_notification_templates_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notification_templates_updated_at BEFORE UPDATE ON public.notification_templates FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_intake_requests trg_notify_on_intake; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notify_on_intake AFTER INSERT ON public.patient_intake_requests FOR EACH ROW EXECUTE FUNCTION public.notify_on_intake();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agendador_solicitacoes trg_notify_on_scheduling; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notify_on_scheduling AFTER INSERT ON public.agendador_solicitacoes FOR EACH ROW EXECUTE FUNCTION public.notify_on_scheduling();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos trg_notify_on_session_status; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notify_on_session_status AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status();
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenant_members trg_patient_cannot_own_tenant; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_patient_cannot_own_tenant BEFORE INSERT OR UPDATE ON public.tenant_members FOR EACH ROW EXECUTE FUNCTION public.guard_patient_cannot_own_tenant();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_groups trg_patient_groups_set_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE TRIGGER trg_patient_contacts_updated_at BEFORE UPDATE ON public.patient_contacts FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_patient_groups_set_updated_at BEFORE UPDATE ON public.patient_groups FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_intake_requests trg_patient_intake_requests_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_patient_intake_requests_updated_at BEFORE UPDATE ON public.patient_intake_requests FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_patient_risco_timeline AFTER UPDATE OF risco_elevado ON public.patients FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline();
|
||||
|
||||
--
|
||||
-- Name: patient_tags trg_patient_tags_set_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE TRIGGER trg_patient_status_history AFTER INSERT OR UPDATE OF status ON public.patients FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history();
|
||||
|
||||
CREATE TRIGGER trg_patient_status_timeline AFTER INSERT OR UPDATE OF status ON public.patients FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline();
|
||||
|
||||
CREATE TRIGGER trg_patient_tags_set_updated_at BEFORE UPDATE ON public.patient_tags FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patients trg_patients_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_patients_updated_at BEFORE UPDATE ON public.patients FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patients trg_patients_validate_members; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_patients_validate_members BEFORE INSERT OR UPDATE OF tenant_id, responsible_member_id, patient_scope, therapist_member_id ON public.patients FOR EACH ROW EXECUTE FUNCTION public.patients_validate_member_consistency();
|
||||
|
||||
|
||||
--
|
||||
-- Name: payment_settings trg_payment_settings_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_payment_settings_updated_at BEFORE UPDATE ON public.payment_settings FOR EACH ROW EXECUTE FUNCTION public.update_payment_settings_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_groups trg_prevent_promoting_to_system; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_prevent_promoting_to_system BEFORE UPDATE ON public.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_promoting_to_system();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_groups trg_prevent_system_group_changes; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_prevent_system_group_changes BEFORE DELETE OR UPDATE ON public.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_system_group_changes();
|
||||
|
||||
|
||||
--
|
||||
-- Name: professional_pricing trg_professional_pricing_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_professional_pricing_updated_at BEFORE UPDATE ON public.professional_pricing FOR EACH ROW EXECUTE FUNCTION public.update_professional_pricing_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: profiles trg_profiles_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_profiles_updated_at BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: services trg_services_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
CREATE TRIGGER trg_psc_updated_at BEFORE UPDATE ON public.patient_support_contacts FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_services_updated_at BEFORE UPDATE ON public.services FOR EACH ROW EXECUTE FUNCTION public.set_services_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: subscription_intents trg_subscription_intents_view_insert; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_subscription_intents_view_insert INSTEAD OF INSERT ON public.subscription_intents FOR EACH ROW EXECUTE FUNCTION public.subscription_intents_view_insert();
|
||||
|
||||
|
||||
--
|
||||
-- Name: subscriptions trg_subscriptions_validate_scope; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_subscriptions_validate_scope BEFORE INSERT OR UPDATE ON public.subscriptions FOR EACH ROW EXECUTE FUNCTION public.subscriptions_validate_scope();
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenant_features trg_tenant_features_guard_with_plan; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_tenant_features_guard_with_plan BEFORE INSERT OR UPDATE ON public.tenant_features FOR EACH ROW EXECUTE FUNCTION public.tenant_features_guard_with_plan();
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenant_features trg_tenant_features_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_tenant_features_updated_at BEFORE UPDATE ON public.tenant_features FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenants trg_tenant_kind_immutable; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_tenant_kind_immutable BEFORE UPDATE OF kind ON public.tenants FOR EACH ROW EXECUTE FUNCTION public.guard_tenant_kind_immutable();
|
||||
|
||||
|
||||
--
|
||||
-- Name: therapist_payouts trg_therapist_payouts_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_therapist_payouts_updated_at BEFORE UPDATE ON public.therapist_payouts FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_settings trg_user_settings_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_user_settings_updated_at BEFORE UPDATE ON public.user_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
--
|
||||
-- Name: subscription tr_check_filters; Type: TRIGGER; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters();
|
||||
|
||||
|
||||
--
|
||||
-- Name: buckets enforce_bucket_name_length_trigger; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER enforce_bucket_name_length_trigger BEFORE INSERT OR UPDATE OF name ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.enforce_bucket_name_length();
|
||||
|
||||
|
||||
--
|
||||
-- Name: buckets protect_buckets_delete; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER protect_buckets_delete BEFORE DELETE ON storage.buckets FOR EACH STATEMENT EXECUTE FUNCTION storage.protect_delete();
|
||||
|
||||
|
||||
--
|
||||
-- Name: objects protect_objects_delete; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER protect_objects_delete BEFORE DELETE ON storage.objects FOR EACH STATEMENT EXECUTE FUNCTION storage.protect_delete();
|
||||
|
||||
|
||||
--
|
||||
-- Name: objects update_objects_updated_at; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER update_objects_updated_at BEFORE UPDATE ON storage.objects FOR EACH ROW EXECUTE FUNCTION storage.update_updated_at_column();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
-- =============================================================================
|
||||
-- Seed 030 — dev_roadmap_phases + dev_roadmap_items
|
||||
-- Importa Fase 1 / 2 / 3 do development/04-roadmap/ROADMAP.md
|
||||
-- =============================================================================
|
||||
-- ATENÇÃO: este seed faz TRUNCATE RESTART IDENTITY CASCADE.
|
||||
-- Re-rodá-lo **apaga** edições feitas na UI (roadmap). Rode só no 1º setup ou
|
||||
-- depois de confirmar que quer redefinir.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
TRUNCATE TABLE public.dev_roadmap_items RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE public.dev_roadmap_phases RESTART IDENTITY CASCADE;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Fases
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO public.dev_roadmap_phases (numero, nome, objetivo, timeline_sugerida, criterio_saida, status, ordem) VALUES
|
||||
(1, 'MVP Launch',
|
||||
'Ter um produto cobrável, confiável, completo o suficiente pra um terapeuta solo trocar Psicomanager/PsicoPlanner pelo AgenciaPsi.',
|
||||
'4-6 semanas',
|
||||
'5 usuários pagantes em produção sem churn por "falta de feature básica".',
|
||||
'em_andamento', 1),
|
||||
(2, 'Paridade Competitiva',
|
||||
'Qualquer usuário avaliando AgenciaPsi × Psicomanager × PsicoPlanner deve ver paridade ou mais de features. Nenhum "ah, mas lá tem X" válido.',
|
||||
'2-3 meses após Fase 1',
|
||||
'Feature checklist empatada com top-3 concorrentes nichados.',
|
||||
'planejada', 2),
|
||||
(3, 'Diferenciação IA-first',
|
||||
'Ter 2-3 features que nenhum concorrente BR tem em paridade. Virar marketing: "o sistema com IA pra psicólogos".',
|
||||
'3-6 meses após Fase 2',
|
||||
'Justificar tier premium 2x mais caro que o básico.',
|
||||
'planejada', 3);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- FASE 1 — MVP Launch (18 items)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- 1.1 Monetização (bloqueador)
|
||||
INSERT INTO public.dev_roadmap_items (phase_id, numero, bloco, feature, descricao, esforco, prioridade, status, ordem) VALUES
|
||||
(1, 1, 'Monetização', 'Integração com gateway de pagamento',
|
||||
'Asaas ou Iugu (PIX + cartão + boleto nativos). Escolher 1. Stripe só se for internacional depois.',
|
||||
'L', 'bloqueador', 'pendente', 1),
|
||||
(1, 2, 'Monetização', 'Cartão on file (tokenização)',
|
||||
'Desdobra do gateway — salvar cartão do paciente pra cobranças recorrentes.',
|
||||
'M', 'bloqueador', 'pendente', 2),
|
||||
(1, 3, 'Monetização', 'Auto-billing recorrente',
|
||||
'Trigger: sessão realizada → gera fatura → cobra automaticamente no cartão on file.',
|
||||
'M', 'bloqueador', 'pendente', 3),
|
||||
(1, 4, 'Monetização', 'Cobrança das próprias assinaturas SaaS',
|
||||
'Tenants pagam pelo plano via gateway. Aproveita estrutura de subscriptions que já existe.',
|
||||
'M', 'bloqueador', 'pendente', 4),
|
||||
|
||||
-- 1.2 Compliance básico BR
|
||||
(1, 5, 'Compliance BR', 'Tipo de registro profissional',
|
||||
'Campo obrigatório no cadastro (CRP, CRM, CRFa, RMS…). Aparece em recibos/laudos.',
|
||||
'S', 'bloqueador', 'pendente', 5),
|
||||
(1, 6, 'Compliance BR', 'Biblioteca de consent forms editáveis',
|
||||
'Templates pré-prontos: TCLE, Telehealth, LGPD, Gravação, TCLE menores. Profissional customiza.',
|
||||
'M', 'bloqueador', 'pendente', 6),
|
||||
(1, 7, 'Compliance BR', 'Assinatura eletrônica pelo paciente no portal',
|
||||
'Simples — IP + timestamp. Não precisa ICP-Brasil nesta fase.',
|
||||
'M', 'bloqueador', 'pendente', 7),
|
||||
(1, 8, 'Compliance BR', 'Nome social no cadastro',
|
||||
'Além do nome de registro. CFP exige. Aparece em todas as telas voltadas ao paciente.',
|
||||
'S', 'bloqueador', 'pendente', 8),
|
||||
(1, 9, 'Compliance BR', 'Especialidades no cadastro do profissional',
|
||||
'Lista pré-definida + "outra". DB: tabela specialties + FK em profiles.',
|
||||
'S', 'bloqueador', 'pendente', 9),
|
||||
|
||||
-- 1.3 UX mínima esperada
|
||||
(1, 10, 'UX mínima', 'Busca global no topbar',
|
||||
'Paciente, email, telefone. Autocomplete com debounce + highlight.',
|
||||
'S', 'alta', 'pendente', 10),
|
||||
(1, 11, 'UX mínima', 'Recently viewed (últimos 5 pacientes)',
|
||||
'Por usuário. localStorage ou tabela user_recent_access.',
|
||||
'S', 'alta', 'pendente', 11),
|
||||
(1, 12, 'UX mínima', 'Papel timbrado',
|
||||
'Portar do UniaoApp (já existe noutro projeto) — só adaptar pro AgenciaPsi.',
|
||||
'M', 'alta', 'pendente', 12),
|
||||
(1, 13, 'UX mínima', 'Relatórios com export PDF/Excel',
|
||||
'Fechar gaps que o MVP assessment apontou. Estrutura já existe.',
|
||||
'M', 'alta', 'pendente', 13),
|
||||
|
||||
-- 1.4 Fiscal mínimo
|
||||
(1, 14, 'Fiscal mínimo', 'Recibo profissional (PDF)',
|
||||
'Gerado com dados CFP (nome, CRP, especialidade, tenant). Já tem base de documentos — formalizar template.',
|
||||
'S', 'alta', 'pendente', 14),
|
||||
(1, 15, 'Fiscal mínimo', 'NFS-e emissão',
|
||||
'Integração Focus NF-e ou NFS-e direto da prefeitura. Pode empurrar pra 1.5 se apertar prazo. Diferencia de Psicomanager.',
|
||||
'L', 'alta', 'pendente', 15),
|
||||
|
||||
-- 1.5 Qualidade pra lançar
|
||||
(1, 16, 'Qualidade', 'Testes E2E dos fluxos críticos',
|
||||
'Playwright ou Cypress. Prioridade: cadastro, login, agendamento, cobrança, prontuário.',
|
||||
'L', 'alta', 'pendente', 16),
|
||||
(1, 17, 'Qualidade', 'Responsividade mobile validada',
|
||||
'Terapeuta consulta agenda no celular. Auditoria de breakpoints + fix.',
|
||||
'M', 'alta', 'pendente', 17),
|
||||
(1, 18, 'Qualidade', 'Monitoramento de produção',
|
||||
'Sentry + Supabase dashboards. Detectar bugs antes do usuário reclamar.',
|
||||
'S', 'alta', 'pendente', 18);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- FASE 2 — Paridade Competitiva (16 items)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- 2.1 Comunicação / Engajamento
|
||||
INSERT INTO public.dev_roadmap_items (phase_id, numero, bloco, feature, descricao, esforco, prioridade, status, ordem) VALUES
|
||||
(2, 19, 'Comunicação / Engajamento', 'Agenda diária automática 7h via WhatsApp',
|
||||
'Cron job + template. Feature signature do PsicoPlanner.',
|
||||
'S', 'alta', 'pendente', 1),
|
||||
(2, 20, 'Comunicação / Engajamento', 'Confirmação de presença pelo paciente',
|
||||
'Parser de resposta WhatsApp/SMS ("SIM") → atualiza status da sessão.',
|
||||
'M', 'alta', 'pendente', 2),
|
||||
(2, 21, 'Comunicação / Engajamento', 'Rastreamento de engajamento em tempo real',
|
||||
'Webhooks da Evolution API → dashboard (recebeu/leu/respondeu).',
|
||||
'M', 'alta', 'pendente', 3),
|
||||
(2, 22, 'Comunicação / Engajamento', 'Envio de prescrição/documento via WhatsApp direto',
|
||||
'Botão "Enviar via WA" gerando link temporário pro documento.',
|
||||
'S', 'alta', 'pendente', 4),
|
||||
|
||||
-- 2.2 Prontuário
|
||||
(2, 23, 'Prontuário', 'Templates de nota (SOAP / DAP / BIRP / CFP)',
|
||||
'Biblioteca com templates selecionáveis ao criar evolução.',
|
||||
'M', 'alta', 'pendente', 5),
|
||||
(2, 24, 'Prontuário', 'Biblioteca de instrumentos de avaliação',
|
||||
'GAD-7, PHQ-9, BDI, BAI, DASS-21, SRQ-20 com scoring automático + gráfico de evolução.',
|
||||
'L', 'alta', 'pendente', 6),
|
||||
(2, 25, 'Prontuário', 'Histórico em gráfico/tabela',
|
||||
'Evolução de escalas ao longo do tempo. Chart.js já no projeto.',
|
||||
'M', 'alta', 'pendente', 7),
|
||||
(2, 26, 'Prontuário', 'Versionamento de notas',
|
||||
'Auditoria de alterações + diff visual.',
|
||||
'M', 'media', 'pendente', 8),
|
||||
|
||||
-- 2.3 Intake / Onboarding
|
||||
(2, 27, 'Intake / Onboarding', 'Pacote de intake pré-1ª-sessão',
|
||||
'Formulários + anamnese + consent forms enviados ao paciente antes da 1ª sessão. Fluxo: terapeuta monta pacote → paciente recebe link → preenche → assina → terapeuta vê tudo.',
|
||||
'M', 'media', 'pendente', 9),
|
||||
(2, 28, 'Intake / Onboarding', 'Upload de arquivo pelo paciente',
|
||||
'Exames, relatórios externos. Storage bucket já configurado.',
|
||||
'S', 'media', 'pendente', 10),
|
||||
|
||||
-- 2.4 Agenda / Integrações
|
||||
(2, 29, 'Agenda / Integrações', 'Google Calendar 2-way sync',
|
||||
'API OAuth + conflict resolution. Feature mais pedida.',
|
||||
'L', 'media', 'pendente', 11),
|
||||
(2, 30, 'Agenda / Integrações', 'iCal feed (Apple Calendar / Outlook)',
|
||||
'Endpoint que serve .ics. Read-only.',
|
||||
'S', 'media', 'pendente', 12),
|
||||
|
||||
-- 2.5 Fiscal avançado BR
|
||||
(2, 31, 'Fiscal avançado BR', 'Assinatura digital ICP-Brasil',
|
||||
'Laudos com validade jurídica. Integração com ValidCertificadora ou similar.',
|
||||
'L', 'media', 'pendente', 13),
|
||||
(2, 32, 'Fiscal avançado BR', 'Faturamento TISS básico',
|
||||
'Pra clínicas que têm convênio. Nichado — só se tier clínica/rede for prioridade. Recomendado NÃO fazer nesta fase.',
|
||||
'XL', 'media', 'pendente', 14),
|
||||
|
||||
-- 2.6 Marketing / Presença
|
||||
(2, 33, 'Marketing / Presença', 'Perfil público do terapeuta',
|
||||
'Página /p/<slug> com bio, horários, agendamento. Já tem agendador público — só enriquecer.',
|
||||
'M', 'media', 'pendente', 15),
|
||||
(2, 34, 'Marketing / Presença', 'SEO básico',
|
||||
'schema.org/MedicalBusiness + meta tags. Melhora descoberta orgânica.',
|
||||
'S', 'media', 'pendente', 16);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- FASE 3 — Diferenciação IA-first (14 items)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- 3.1 IA
|
||||
INSERT INTO public.dev_roadmap_items (phase_id, numero, bloco, feature, descricao, esforco, prioridade, status, ordem) VALUES
|
||||
(3, 35, 'IA', 'Bot WhatsApp que agenda sozinho',
|
||||
'Equivalente Amélia Agendamento da Amplimed. Stack: Evolution API + LLM + RAG na disponibilidade da agenda. ROI: 24/7 sem recepcionista.',
|
||||
'XL', 'diferencial', 'pendente', 1),
|
||||
(3, 36, 'IA', 'Transcrição de sessão áudio→texto',
|
||||
'Equivalente AI Scribe do Jane / Amélia Transcrição. Whisper API ou Deepgram. Paciente consente. Transcrição vira rascunho de nota.',
|
||||
'L', 'diferencial', 'pendente', 2),
|
||||
(3, 37, 'IA', 'Copilot no prontuário',
|
||||
'Resumir histórico, sugerir diagnóstico diferencial, busca semântica em notas anteriores. RAG em cima das notas do paciente.',
|
||||
'L', 'diferencial', 'pendente', 3),
|
||||
(3, 38, 'IA', 'Gerador de documentos com compliance CFP',
|
||||
'Equivalente PsiAssist do PsicoPlanner. LLM com system prompt CFP + templates.',
|
||||
'M', 'diferencial', 'pendente', 4),
|
||||
|
||||
-- 3.2 Teleconsulta nativa
|
||||
(3, 39, 'Teleconsulta', 'Vídeo nativo integrado',
|
||||
'Daily.co ou Jitsi Meet. Sala gerada por consulta + link no lembrete.',
|
||||
'L', 'diferencial', 'pendente', 5),
|
||||
(3, 40, 'Teleconsulta', 'Sala de espera virtual',
|
||||
'Profissional admite paciente quando estiver pronto.',
|
||||
'M', 'diferencial', 'pendente', 6),
|
||||
(3, 41, 'Teleconsulta', 'Whiteboard digital + screen share',
|
||||
'Daily.co tem nativo. Útil pra TCC visual e crianças.',
|
||||
'M', 'diferencial', 'pendente', 7),
|
||||
|
||||
-- 3.3 Rede / Multi-unidade
|
||||
(3, 42, 'Rede / Multi-unidade', 'Multi-unidade / filiais com Main Office',
|
||||
'Tabela clinic_units + FK em consultas/profissionais. Só se posicionar pra tier enterprise.',
|
||||
'L', 'diferencial', 'pendente', 8),
|
||||
(3, 43, 'Rede / Multi-unidade', 'Salas e equipamentos como recursos',
|
||||
'Estilo Jane App. Evita double-booking.',
|
||||
'M', 'diferencial', 'pendente', 9),
|
||||
(3, 44, 'Rede / Multi-unidade', 'CRM de leads',
|
||||
'Captura de landing → funil → matching com terapeuta. Aproveita perfil público da Fase 2.',
|
||||
'L', 'diferencial', 'pendente', 10),
|
||||
(3, 45, 'Rede / Multi-unidade', 'BI avançado',
|
||||
'MRR, cohort retention, LTV por terapeuta. Dashboards dedicados.',
|
||||
'M', 'diferencial', 'pendente', 11),
|
||||
|
||||
-- 3.4 UX premium / diferenciação fina
|
||||
(3, 46, 'UX premium', 'Website builder pra clínica',
|
||||
'Estilo Jane — sem precisar de Wix/WordPress. Grande mas ROI de marketing enorme.',
|
||||
'XL', 'diferencial', 'pendente', 12),
|
||||
(3, 47, 'UX premium', 'App mobile (PWA otimizado)',
|
||||
'Paciente instala no celular. Vue já dá PWA grátis, só polir.',
|
||||
'M', 'diferencial', 'pendente', 13),
|
||||
(3, 48, 'UX premium', 'Migração assistida de Psicomanager',
|
||||
'Importador de CSV/API. Feature de venda: "deixa o outro, a gente migra".',
|
||||
'M', 'diferencial', 'pendente', 14);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Log
|
||||
INSERT INTO public.dev_generation_log (tipo, comando, sucesso, metadata)
|
||||
VALUES ('seed', 'seed_030_dev_phases_items.sql', true,
|
||||
jsonb_build_object('phases', 3, 'items', 48, 'source', 'development/04-roadmap/ROADMAP.md'));
|
||||
@@ -0,0 +1,142 @@
|
||||
-- =============================================================================
|
||||
-- Seed 031 — dev_auditoria_items
|
||||
-- Importa bugs/débitos técnicos do development/02-auditoria/AUDITORIA.md
|
||||
-- =============================================================================
|
||||
-- ATENÇÃO: TRUNCATE RESTART IDENTITY — re-rodar apaga edições na UI.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
TRUNCATE TABLE public.dev_auditoria_items RESTART IDENTITY CASCADE;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Bugs e dívidas técnicas identificadas em 2026-03-11 (Sessões 1-4)
|
||||
-- Fonte: AUDITORIA.md
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO public.dev_auditoria_items
|
||||
(categoria, titulo, descricao_problema, solucao, severidade, status, resolvido_em, sessao_resolucao, arquivo_afetado, tags)
|
||||
VALUES
|
||||
|
||||
-- Bugs Críticos
|
||||
('Bug crítico',
|
||||
'useRecurrence.js — variável occurrenceCount não declarada',
|
||||
'Branches custom_weekdays, monthly e yearly usavam occurrenceCount sem declará-la → ReferenceError em runtime. Nenhum dos três contava ocorrências anteriores ao range, então max_occurrences nunca funcionava corretamente.',
|
||||
'Cada branch ganhou let occurrenceCount = 0 + fase de pré-contagem de ruleStart até effStart.',
|
||||
'critico', 'resolvido', '2026-03-11', 'Sessão 2 — 2026-03-11',
|
||||
'src/features/agenda/composables/useRecurrence.js',
|
||||
ARRAY['agenda','recorrencia','runtime-error']),
|
||||
|
||||
('Bug crítico',
|
||||
'Exceção de remarcação fora do range não aparece',
|
||||
'loadExceptions só buscava original_date no range. Se original_date estivesse fora mas new_date caísse dentro, a sessão remarcada não aparecia.',
|
||||
'loadExceptions: duas queries paralelas — q1 (original_date no range) + q2 (reschedule com new_date no range). Mescladas e deduplicadas por id. expandRules post-pass: itera exceções não consumidas, injeta inbound reschedules.',
|
||||
'alto', 'resolvido', '2026-03-11', 'Sessão 3 — 2026-03-11',
|
||||
'src/features/agenda/composables/useRecurrence.js',
|
||||
ARRAY['agenda','recorrencia','edge-case']),
|
||||
|
||||
-- Segurança
|
||||
('Segurança',
|
||||
'SQL dumps no repositório',
|
||||
'Dumps com dados sensíveis versionados no git.',
|
||||
'Removidos do tracking + adicionados ao .gitignore. Backups ficam em database-novo/backups/ (também gitignored).',
|
||||
'critico', 'resolvido', '2026-03-11', 'Sessão 1 — 2026-03-11',
|
||||
'.gitignore',
|
||||
ARRAY['segurança','git','lgpd']),
|
||||
|
||||
('Segurança',
|
||||
'useAgendaEvents — sem tenant_id em nenhuma operação',
|
||||
'Todas as operações CRUD de eventos faltavam filtro tenant_id → risco de vazamento cross-tenant.',
|
||||
'Adicionado filtro tenant_id em select/insert/update/delete + validação na composable.',
|
||||
'critico', 'resolvido', '2026-03-11', 'Sessão 1 — 2026-03-11',
|
||||
'src/features/agenda/composables/useAgendaEvents.js',
|
||||
ARRAY['multi-tenant','rls','segurança']),
|
||||
|
||||
('Segurança',
|
||||
'loadRules em useRecurrence sem filtro tenant_id',
|
||||
'Regras de recorrência carregavam sem filtrar por tenant → possível leak.',
|
||||
'Filtro tenant_id adicionado em loadRules.',
|
||||
'critico', 'resolvido', '2026-03-11', 'Sessão 1 — 2026-03-11',
|
||||
'src/features/agenda/composables/useRecurrence.js',
|
||||
ARRAY['multi-tenant','segurança']),
|
||||
|
||||
('Segurança',
|
||||
'console.log expõe dados de pacientes no browser',
|
||||
'Logs com PII (nome, CPF, email, telefone) sendo enviados ao console.',
|
||||
'Removidos console.log sensíveis. Logs restantes filtrados via wrapper que só loga em dev mode.',
|
||||
'alto', 'resolvido', '2026-03-11', 'Sessão 1 — 2026-03-11',
|
||||
'multiple',
|
||||
ARRAY['segurança','lgpd','logs']),
|
||||
|
||||
-- Arquitetura / Performance
|
||||
('Arquitetura',
|
||||
'window.__guardsBound / window.__supabaseAuthListenerBound',
|
||||
'Uso de flags globais no window para evitar bind duplicado de listeners. Anti-pattern — vazamento de escopo + difícil debug.',
|
||||
NULL,
|
||||
'medio', 'aberto', NULL, NULL,
|
||||
'src/router/guards.js',
|
||||
ARRAY['arquitetura','global-state','refactor']),
|
||||
|
||||
('Arquitetura',
|
||||
'globalRole do profiles sem cache no guard',
|
||||
'Guard fazia fetch do profile a cada navegação — N queries desnecessárias.',
|
||||
'Cache de globalRole no store com invalidation via Supabase auth state changes.',
|
||||
'medio', 'resolvido', '2026-03-11', 'Sessão 2 — 2026-03-11',
|
||||
'src/router/guards.js',
|
||||
ARRAY['performance','cache']),
|
||||
|
||||
('Arquitetura',
|
||||
'Dois composables para a mesma entidade',
|
||||
'Duplicação: useAgendaEvents e outro composable fazendo a mesma coisa.',
|
||||
'Consolidado em um único useAgendaEvents. Outro removido.',
|
||||
'medio', 'resolvido', '2026-03-11', 'Sessão 2 — 2026-03-11',
|
||||
'src/features/agenda/composables/',
|
||||
ARRAY['refactor','duplicação']),
|
||||
|
||||
('Arquitetura',
|
||||
'Dois mappers para agenda',
|
||||
'Duplicação: agendaMapper e outro mapper convertendo mesma estrutura.',
|
||||
'Unificados em um único agendaMapper canônico.',
|
||||
'medio', 'resolvido', '2026-03-11', 'Sessão 2 — 2026-03-11',
|
||||
'src/features/agenda/mappers/',
|
||||
ARRAY['refactor','duplicação']),
|
||||
|
||||
('Performance',
|
||||
'N+1 Query — migração paciente_id → patient_id',
|
||||
'Queries em N+1 pattern durante transição de naming. Cada agenda_evento fazia query separada pra paciente.',
|
||||
'Migrados todos os queries pra usar JOIN em patient_id. Migration de schema unificou naming.',
|
||||
'alto', 'resolvido', '2026-03-11', 'Sessão 4 — 2026-03-11',
|
||||
'multiple',
|
||||
ARRAY['performance','query','n+1']),
|
||||
|
||||
-- Build / Produção
|
||||
('Build',
|
||||
'Template Sakai removido — bundle de produção',
|
||||
'Bundle de produção carregando código do template Sakai que não era usado. Peso desnecessário.',
|
||||
'Cleanup do template, tree-shake manual dos componentes não usados.',
|
||||
'medio', 'resolvido', '2026-03-11', 'Sessão 2 — 2026-03-11',
|
||||
'vite.config.mjs',
|
||||
ARRAY['build','bundle-size']),
|
||||
|
||||
('Dívida técnica',
|
||||
'Arquivos obsoletos no projeto',
|
||||
'Vários arquivos .vue/.js deprecated ou não importados ainda no repo, confundindo navegação.',
|
||||
'Parcial — alguns removidos, outros ainda a mapear.',
|
||||
'baixo', 'em_analise', NULL, NULL,
|
||||
'multiple',
|
||||
ARRAY['cleanup','dívida-técnica']),
|
||||
|
||||
('Produção',
|
||||
'Logs excessivos em produção',
|
||||
'Muitos console.log/console.trace rodando em prod degradando performance.',
|
||||
'Removidos console.trace em router.push e queries Supabase. Logs restantes condicionais a DEV mode.',
|
||||
'medio', 'resolvido', '2026-03-11', 'Sessão 4 — 2026-03-11',
|
||||
'multiple',
|
||||
ARRAY['performance','logs','produção']);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Log
|
||||
INSERT INTO public.dev_generation_log (tipo, comando, sucesso, metadata)
|
||||
VALUES ('seed', 'seed_031_dev_auditoria.sql', true,
|
||||
jsonb_build_object('items', 14, 'abertos', 2, 'resolvidos', 12, 'source', 'development/02-auditoria/AUDITORIA.md'));
|
||||
@@ -0,0 +1,271 @@
|
||||
-- =============================================================================
|
||||
-- Seed 032 — dev_competitors + dev_competitor_features + dev_comparison_matrix
|
||||
-- Importa benchmark do development/03-concorrentes/concorrentes.md
|
||||
-- =============================================================================
|
||||
-- ATENÇÃO: TRUNCATE RESTART IDENTITY CASCADE — re-rodar apaga edições na UI.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
TRUNCATE TABLE public.dev_comparison_competitor_status RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE public.dev_comparison_matrix RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE public.dev_competitor_features RESTART IDENTITY CASCADE;
|
||||
TRUNCATE TABLE public.dev_competitors RESTART IDENTITY CASCADE;
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. dev_competitors — 7 players
|
||||
-- =============================================================================
|
||||
INSERT INTO public.dev_competitors
|
||||
(slug, nome, pais, foco, pricing, posicionamento, url, ultima_pesquisa, ordem)
|
||||
VALUES
|
||||
('simplepractice', 'SimplePractice', 'EUA',
|
||||
'Mental health EHR',
|
||||
'USD ~29-99/mês por profissional (Essential, Plus). 30-day ou 7-day trial.',
|
||||
'"Salesforce da saúde mental" — líder EUA com 20M+ clientes e 250k+ practitioners. HIPAA+HITRUST+PCI+BAA.',
|
||||
'https://www.simplepractice.com',
|
||||
'2026-04-17', 1),
|
||||
|
||||
('psicomanager', 'Psicomanager', 'BR',
|
||||
'Psicologia-first',
|
||||
'R$ ~50-150/mês/profissional (confirmar)',
|
||||
'O "padrão" do mercado brasileiro pra psicólogos. Site é SPA — info via exploração manual.',
|
||||
'https://psicomanager.com.br',
|
||||
NULL, 2),
|
||||
|
||||
('psicoplanner', 'PsicoPlanner', 'BR',
|
||||
'Psicologia-first',
|
||||
'Individual R$ 59/mês · Plus R$ 79 · Duo R$ 99 · Clínicas R$ 395 (até 5 profissionais)',
|
||||
'"Psicólogo que odeia planilha" — simples, WhatsApp-first, IA nativa (PsiAssist com compliance CFP).',
|
||||
'https://psicoplanner.com.br',
|
||||
'2026-04-17', 3),
|
||||
|
||||
('iclinic', 'iClinic', 'BR',
|
||||
'Multispecialidade',
|
||||
'A confirmar',
|
||||
'EHR completo pro consultório/clínica médica BR. Psicólogos usam bastante apesar de não ser foco.',
|
||||
'https://iclinic.com.br',
|
||||
'2026-04-17', 4),
|
||||
|
||||
('amplimed', 'Amplimed', 'BR',
|
||||
'Multispecialidade com IA',
|
||||
'A confirmar',
|
||||
'"IA que trabalha pela sua clínica" — suite Amélia (agendamento 24/7, transcrição, copilot) + TISS automatizado + NFS-e + ICP-Brasil.',
|
||||
'https://www.amplimed.com.br',
|
||||
'2026-04-17', 5),
|
||||
|
||||
('ninsaude', 'Ninsaúde', 'BR',
|
||||
'ERP clínico',
|
||||
'A confirmar',
|
||||
'ERP clínico completo (várias especialidades) — mais robusto/caro. Produtos satélites: CRM, Safe, Sign.',
|
||||
'https://ninsaude.com',
|
||||
'2026-04-17', 6),
|
||||
|
||||
('jane-app', 'Jane App', 'CA',
|
||||
'Practice management premium',
|
||||
'A confirmar (mercados CA, US, UK)',
|
||||
'"O app mais amado pelos profissionais de saúde" — referência internacional de UX. AI Scribe + Jane Payments (online+POS físico) + HIPAA/PIPEDA/GDPR/SOC-2.',
|
||||
'https://jane.app',
|
||||
'2026-04-17', 7);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. dev_competitor_features — features por concorrente
|
||||
-- =============================================================================
|
||||
|
||||
-- SimplePractice (fetched)
|
||||
INSERT INTO public.dev_competitor_features (competitor_id, categoria, nome, descricao, fonte, fonte_url, data_fonte, destaque) VALUES
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Compliance', 'HIPAA + HITRUST + PCI + BAA', 'Certificações de segurança top-tier', 'fetched', 'https://www.simplepractice.com/features/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Telehealth', 'Vídeo nativo integrado', 'Launch sessions direto do calendário, sem extra login', 'fetched', 'https://www.simplepractice.com/features/telehealth/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Telehealth', 'Sala de espera virtual', 'Admit client quando pronto', 'fetched', 'https://www.simplepractice.com/features/telehealth/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Telehealth', 'Digital whiteboard', 'Quadro branco colaborativo na sessão', 'fetched', 'https://www.simplepractice.com/features/telehealth/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Telehealth', 'Video em grupo (até 15)', 'Sessões de grupo/terapia familiar', 'fetched', 'https://www.simplepractice.com/features/telehealth/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Telehealth', 'Screen sharing + blurred background', 'Compartilhamento de tela e fundo desfocado', 'fetched', 'https://www.simplepractice.com/features/telehealth/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Portal Paciente', 'Self-scheduling', 'Paciente solicita/cancela/reagenda', 'fetched', 'https://www.simplepractice.com/features/client-portal/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Portal Paciente', 'Paperless intake forms', 'Formulários de entrada preenchidos online', 'fetched', 'https://www.simplepractice.com/features/client-portal/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Portal Paciente', 'Questionnaires (measures)', 'Instrumentos de medição (GAD-7, PHQ-9, etc)', 'fetched', 'https://www.simplepractice.com/features/client-portal/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Portal Paciente', 'Portal em espanhol', 'Multi-idioma pro LATAM', 'fetched', 'https://www.simplepractice.com/features/client-portal/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Portal Paciente', 'Secure messaging', 'Chat seguro terapeuta↔cliente', 'fetched', 'https://www.simplepractice.com/features/client-portal/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Portal Paciente', 'Invoice payment pelo portal', 'Paciente paga fatura diretamente', 'fetched', 'https://www.simplepractice.com/features/client-portal/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Documentação', 'Biblioteca GAD-7/PHQ-9/BDI', 'Instrumentos validados com scoring', 'publico', NULL, NULL, true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Documentação', 'Consent forms assinados', 'Biblioteca editável + assinatura eletrônica via portal', 'observacao', NULL, '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Billing', 'AutoPay + Superbills', 'Cobrança automática + recibo detalhado', 'publico', NULL, NULL, false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Onboarding', 'Credentialing grátis', 'Auxílio com credenciamento junto a seguradoras', 'fetched', 'https://www.simplepractice.com/features/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='simplepractice'), 'Onboarding', 'Switching Assistance', 'Suporte de migração de outra plataforma', 'fetched', 'https://www.simplepractice.com/features/', '2026-04-17', false);
|
||||
|
||||
-- Psicomanager (publico only - site SPA)
|
||||
INSERT INTO public.dev_competitor_features (competitor_id, categoria, nome, descricao, fonte, destaque) VALUES
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Agenda', 'Agenda online/presencial + recorrências', 'Agenda nativa multi-profissional', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Prontuário', 'Prontuário eletrônico', 'Anamnese + evolução + sessões', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Financeiro', 'Cobranças via PIX/cartão', 'Gateway a confirmar (Asaas? Iugu?)', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Comunicação', 'Lembretes Email/SMS/WhatsApp', 'Sistema de notificações', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Fiscal', 'Recibo / NFSe', 'Emissão de nota fiscal de serviço', 'publico', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Telehealth', 'Teleconsulta integrada', 'Próprio ou via parceiro (confirmar)', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Portal Paciente', 'Portal do Paciente', 'Visão das sessões/faturas', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Documentação', 'Documentos / atestados', 'Laudos, declarações', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Compliance', 'LGPD compliance', 'Consentimento de dados', 'publico', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicomanager'), 'Multi-profissional', 'Gestão de clínica multi-pro', 'Multi-terapeuta na mesma instância', 'publico', false);
|
||||
|
||||
-- PsicoPlanner (fetched)
|
||||
INSERT INTO public.dev_competitor_features (competitor_id, categoria, nome, descricao, fonte, fonte_url, data_fonte, destaque) VALUES
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'Comunicação', 'Lembretes WhatsApp ilimitados', 'Personalizáveis por profissional', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'Comunicação', 'Agenda diária automática 7h via WhatsApp', 'Push automático do cronograma do dia — diferencial', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'Comunicação', 'Rastreamento engajamento tempo real', 'Recebeu/leu/respondeu', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'Telehealth', 'Sala de vídeo integrada', 'Nativa no app', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'Prontuário', 'Prontuários + anamnese customizáveis', 'Adaptável à metodologia do profissional', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'Agenda', 'Autoagendamento por link', 'Paciente escolhe horário', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'Financeiro', 'Gestão financeira visual', 'Pagamentos, recebidos, pendentes', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='psicoplanner'), 'IA', 'PsiAssist AI com compliance CFP', 'Gera relatórios/documentos/resumos com regras CFP', 'fetched', 'https://psicoplanner.com.br/', '2026-04-17', true);
|
||||
|
||||
-- iClinic (fetched)
|
||||
INSERT INTO public.dev_competitor_features (competitor_id, categoria, nome, descricao, fonte, fonte_url, data_fonte, destaque) VALUES
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Prontuário', 'Histórico de valores (gráfico/tabela)', 'Evolução de dados do paciente', 'fetched', 'https://iclinic.com.br/funcionalidades/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Prontuário', 'Campos personalizados por especialidade', 'Adaptável a qualquer medicina', 'fetched', 'https://iclinic.com.br/funcionalidades/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Prontuário', 'Assinatura digital com validade jurídica', 'Documentação assinada juridicamente', 'fetched', 'https://iclinic.com.br/funcionalidades/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Prontuário', 'CID-10 integrado', 'Consulta de códigos internacionais', 'fetched', 'https://iclinic.com.br/funcionalidades/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Prescrição', 'Prescrição eletrônica com envio WhatsApp', 'Base de medicamentos + envio direto', 'fetched', 'https://iclinic.com.br/funcionalidades/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Documentos', 'Modelos com campos automáticos', 'Preenchimento automático de dados do paciente', 'fetched', 'https://iclinic.com.br/funcionalidades/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Marketing', 'Marketing Médico (email campaigns)', 'Otimização de experiência do paciente', 'fetched', 'https://iclinic.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='iclinic'), 'Operação', 'Módulo Recepcionista', 'Fluxo de atendimento', 'fetched', 'https://iclinic.com.br/', '2026-04-17', false);
|
||||
|
||||
-- Amplimed (fetched) — AI-heavy
|
||||
INSERT INTO public.dev_competitor_features (competitor_id, categoria, nome, descricao, fonte, fonte_url, data_fonte, destaque) VALUES
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'IA', 'Amélia Agendamento (bot WhatsApp 24/7)', 'Atende e responde pacientes via WhatsApp com sincronização automática da agenda', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'IA', 'Amélia Transcrição (áudio→texto)', 'Converte áudio em texto e preenche prontuário automaticamente', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'IA', 'Amélia Copilot (prontuário)', 'Localiza, resume e organiza info do paciente', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'Agenda', 'Agenda inteligente (redução 38% ausências)', 'ML pra otimização', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'Fiscal', 'Faturamento TISS (99% menos glosas)', 'Padrão automático de validação', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'Fiscal', 'NFS-e emissão integrada', 'Simplificada ao faturamento', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'Fiscal', 'Certificado Digital ICP-Brasil', 'Assinatura com validade jurídica', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'Telehealth', 'Teleconsulta E2E + prescrição digital', 'Criptografia ponta a ponta', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'Comunicação', 'WhatsApp Connect + SMS', 'Mensagens personalizadas automáticas', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='amplimed'), 'Operação', 'Painel de chamados (TV recepção)', 'Fluxo organizado com exibição pública', 'fetched', 'https://www.amplimed.com.br/', '2026-04-17', false);
|
||||
|
||||
-- Ninsaúde (fetched)
|
||||
INSERT INTO public.dev_competitor_features (competitor_id, categoria, nome, descricao, fonte, fonte_url, data_fonte, destaque) VALUES
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'Prontuário', 'Prontuário Eletrônico', 'Registro digital estruturado', 'fetched', 'https://ninsaude.com/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'Financeiro', 'Faturamento de Convênios', 'Integração com planos de saúde', 'fetched', 'https://ninsaude.com/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'Operação', 'Controle de Estoque', 'Medicamentos e materiais', 'fetched', 'https://ninsaude.com/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'CRM', 'Ninsaúde CRM', 'Gestão de leads e funil', 'fetched', 'https://ninsaude.com/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'Segurança', 'Ninsaúde Safe', 'Produto satélite de segurança', 'fetched', 'https://ninsaude.com/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'Documentos', 'Ninsaúde Sign', 'Assinatura digital', 'fetched', 'https://ninsaude.com/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'Cadastro', 'Nome Social', 'Respeita identidade de gênero', 'fetched', 'https://ninsaude.com/', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'BI', 'Análise Inteligente', 'Relatórios e insights da operação', 'fetched', 'https://ninsaude.com/', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='ninsaude'), 'Retenção', 'Retenção de Pacientes automatizada', 'Estratégias automatizadas', 'fetched', 'https://ninsaude.com/', '2026-04-17', false);
|
||||
|
||||
-- Jane App (fetched)
|
||||
INSERT INTO public.dev_competitor_features (competitor_id, categoria, nome, descricao, fonte, fonte_url, data_fonte, destaque) VALUES
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Agenda', 'Online Booking com branding', 'Site visita → consulta marcada 24/7', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Agenda', 'Staff Scheduling multi-location', 'Serviços, salas, recursos, waitlist na mesma visão', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Documentação', 'Template library (SOAP, forms, surveys)', 'Customizável por tipo de clínica', 'fetched', 'https://jane.app/features', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'IA', 'AI Scribe (grava e gera nota)', 'Record/dictate e rascunho em minutos', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Telehealth', 'Telehealth até 12 clientes', 'Video HIPAA-compliant', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Pagamentos', 'Jane Payments (online + terminal POS)', 'PCI-compliant', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Fiscal', 'Insurance eligibility + claims (CA/US/UK)', 'Multi-região', 'fetched', 'https://jane.app/features', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'BI', 'Real-time dashboards', 'Métricas de saúde do negócio', 'fetched', 'https://jane.app/features', '2026-04-17', false),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Compliance', 'HIPAA + PIPEDA + GDPR + SOC-2', 'Certificações multi-região', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Marketing', 'Website Builder com IA', 'Cria site da clínica auto-sincronizado', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Onboarding', 'Migração de dados grátis', 'Feature de venda', 'fetched', 'https://jane.app/features', '2026-04-17', true),
|
||||
((SELECT id FROM public.dev_competitors WHERE slug='jane-app'), 'Suporte', 'Suporte ilimitado phone/email/chat', 'Premium', 'fetched', 'https://jane.app/features', '2026-04-17', false);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. dev_comparison_matrix — AgenciaPsi × features esperadas do mercado
|
||||
-- =============================================================================
|
||||
INSERT INTO public.dev_comparison_matrix (dominio, feature, nosso_status, nossa_nota, importancia, ordem) VALUES
|
||||
-- Cadastro/Clientes
|
||||
('Pacientes', 'Cadastro de pacientes', 'tem', 'Completo', 'alta', 1),
|
||||
('Pacientes', 'Grupos / Tags', 'tem', NULL, 'media', 2),
|
||||
('Pacientes', 'Busca global no topbar', 'gap', 'Todos os concorrentes têm — quick win', 'alta', 3),
|
||||
('Pacientes', 'Recently viewed (últimos acessados)', 'gap', 'Quick win de UX', 'media', 4),
|
||||
('Pacientes', 'Merge de duplicatas', 'gap', NULL, 'baixa', 5),
|
||||
('Pacientes', 'Nome social', 'a_definir', 'Validar se já temos — CFP/LGPD exige', 'alta', 6),
|
||||
|
||||
-- Agenda
|
||||
('Agenda', 'Agenda / Calendário', 'tem', 'FullCalendar completo', 'alta', 10),
|
||||
('Agenda', 'Recorrências', 'tem', 'useRecurrence composable', 'alta', 11),
|
||||
('Agenda', 'Agendamento online público', 'tem', NULL, 'alta', 12),
|
||||
('Agenda', 'Google Calendar 2-way sync', 'gap', 'Fase 2', 'alta', 13),
|
||||
('Agenda', 'iCal feed', 'gap', 'Fase 2 - quick win', 'media', 14),
|
||||
('Agenda', 'Agenda diária 7h WhatsApp automática', 'gap', 'Diferencial PsicoPlanner - Fase 2', 'alta', 15),
|
||||
|
||||
-- Teleconsulta
|
||||
('Teleconsulta', 'Vídeo nativo integrado', 'gap', 'Fase 3 - aposta diferenciação', 'alta', 20),
|
||||
('Teleconsulta', 'Sala de espera virtual', 'gap', 'Fase 3', 'media', 21),
|
||||
('Teleconsulta', 'Screen sharing + whiteboard', 'gap', 'SimplePractice benchmark', 'media', 22),
|
||||
('Teleconsulta', 'Video em grupo (5+ pessoas)', 'gap', 'SP faz até 15, Jane até 12', 'media', 23),
|
||||
|
||||
-- Prontuário
|
||||
('Prontuário', 'Prontuário eletrônico', 'tem', 'Completo', 'alta', 30),
|
||||
('Prontuário', 'Templates de nota (SOAP/DAP/BIRP)', 'a_definir', 'Validar - Fase 2', 'alta', 31),
|
||||
('Prontuário', 'Versionamento de notas', 'a_definir', 'Validar - Fase 2', 'media', 32),
|
||||
('Prontuário', 'Biblioteca de avaliações (GAD-7/PHQ-9)', 'gap', 'Diferencial forte BR - Fase 2', 'alta', 33),
|
||||
('Prontuário', 'Histórico em gráfico (evolução)', 'a_definir', 'Validar - Fase 2', 'media', 34),
|
||||
|
||||
-- Compliance / Legal
|
||||
('Compliance', 'Consent forms editáveis (TCLE etc)', 'gap', 'Bloqueador MVP - Fase 1', 'alta', 40),
|
||||
('Compliance', 'Assinatura eletrônica paciente', 'gap', 'Fase 1', 'alta', 41),
|
||||
('Compliance', 'Assinatura digital ICP-Brasil', 'gap', 'Fase 2', 'media', 42),
|
||||
('Compliance', 'Papel timbrado', 'gap', 'Portar do UniaoApp - Fase 1', 'alta', 43),
|
||||
('Compliance', 'Tipo de registro (CRP/CRM)', 'gap', 'Bloqueador MVP - Fase 1', 'alta', 44),
|
||||
('Compliance', 'Especialidades no cadastro', 'gap', 'Bloqueador MVP - Fase 1', 'alta', 45),
|
||||
|
||||
-- Intake / Onboarding
|
||||
('Intake', 'Pacote de intake pré-1ª-sessão', 'parcial', 'Documentos existem, pacote estruturado não', 'media', 50),
|
||||
('Intake', 'Upload de arquivo pelo paciente', 'a_definir', 'Validar no portal', 'media', 51),
|
||||
|
||||
-- Financeiro
|
||||
('Financeiro', 'Lançamentos financeiros', 'tem', 'Completo', 'alta', 60),
|
||||
('Financeiro', 'Gateway de pagamento (Stripe/PIX)', 'gap', 'BLOQUEADOR MVP - Fase 1', 'alta', 61),
|
||||
('Financeiro', 'Cartão on file', 'gap', 'Bloqueador MVP - Fase 1', 'alta', 62),
|
||||
('Financeiro', 'Auto-billing recorrente', 'parcial', 'Recorrência de consulta sim, cobrança não', 'alta', 63),
|
||||
('Financeiro', 'Superbill / recibo detalhado', 'parcial', 'Recibo existe, formato detalhado a validar', 'media', 64),
|
||||
('Financeiro', 'NFS-e emissão', 'gap', 'Fase 1 (preferível) ou 2', 'alta', 65),
|
||||
('Financeiro', 'Faturamento TISS', 'gap', 'Nichado - Fase 2+ se for enterprise', 'baixa', 66),
|
||||
|
||||
-- Comunicação
|
||||
('Comunicação', 'Lembretes Email/SMS/WhatsApp', 'tem', 'Completo', 'alta', 70),
|
||||
('Comunicação', 'Confirmação do paciente ("SIM")', 'a_definir', 'Validar no sistema - Fase 2', 'media', 71),
|
||||
('Comunicação', 'Rastreamento engajamento tempo real', 'gap', 'Diferencial PsicoPlanner - Fase 2', 'media', 72),
|
||||
|
||||
-- Portal Paciente
|
||||
('Portal Paciente', 'Portal do paciente autenticado', 'parcial', 'Existe mas limitado - expandir Fase 2', 'alta', 80),
|
||||
('Portal Paciente', 'Self-scheduling no portal', 'parcial', 'Agendador público existe, portal autenticado não', 'media', 81),
|
||||
('Portal Paciente', 'Push notifications (portal)', 'gap', NULL, 'baixa', 82),
|
||||
('Portal Paciente', 'Portal multi-idioma (ES)', 'gap', 'Pensar pra LATAM', 'baixa', 83),
|
||||
('Portal Paciente', 'Paciente paga fatura no portal', 'gap', 'Depende do gateway (Fase 1)', 'alta', 84),
|
||||
('Portal Paciente', 'App mobile paciente', 'gap', 'PWA pode resolver - Fase 3', 'media', 85),
|
||||
|
||||
-- Analytics
|
||||
('Analytics', 'Dashboard com KPIs', 'tem', 'Existe mas pode ampliar', 'alta', 90),
|
||||
('Analytics', 'Relatórios com export PDF/Excel', 'parcial', 'Estrutura existe, fechar na Fase 1', 'alta', 91),
|
||||
('Analytics', 'BI avançado (MRR/cohort/LTV)', 'gap', 'Fase 3', 'baixa', 92),
|
||||
|
||||
-- Supervisão
|
||||
('Supervisão', 'Sala de Supervisão', 'parcial', 'Estrutura existe, features avançadas não', 'media', 100),
|
||||
('Supervisão', 'Co-assinatura de supervisor em notas', 'gap', 'Fase 2+', 'media', 101),
|
||||
|
||||
-- Infra / Multi-tenant
|
||||
('Infra', 'Multi-tenant SaaS', 'tem', 'RLS por tenant_id em todas tabelas', 'alta', 110),
|
||||
('Infra', 'Multi-unidade / filiais', 'gap', 'Fase 3 se for enterprise', 'baixa', 111),
|
||||
('Infra', 'Compliance LGPD', 'parcial', 'RLS + logs, faltam políticas formais', 'alta', 112),
|
||||
|
||||
-- Marketing
|
||||
('Marketing', 'Perfil público do terapeuta', 'gap', 'Fase 2', 'media', 120),
|
||||
('Marketing', 'SEO básico', 'gap', 'Fase 2', 'baixa', 121),
|
||||
('Marketing', 'Website builder', 'gap', 'Fase 3 - Jane benchmark', 'baixa', 122),
|
||||
|
||||
-- IA (tendência 2026)
|
||||
('IA', 'Bot WhatsApp que agenda sozinho', 'gap', 'Diferencial Fase 3 - Amplimed benchmark', 'alta', 130),
|
||||
('IA', 'Transcrição áudio→texto', 'gap', 'Diferencial Fase 3 - Jane/Amplimed', 'alta', 131),
|
||||
('IA', 'Copilot no prontuário', 'gap', 'Diferencial Fase 3 - Amplimed', 'alta', 132),
|
||||
('IA', 'Gerador de documentos (compliance CFP)', 'gap', 'Diferencial Fase 3 - PsicoPlanner benchmark', 'alta', 133);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Log
|
||||
INSERT INTO public.dev_generation_log (tipo, comando, sucesso, metadata)
|
||||
VALUES ('seed', 'seed_032_dev_competitors.sql', true,
|
||||
jsonb_build_object(
|
||||
'competitors', 7,
|
||||
'features', (SELECT count(*) FROM public.dev_competitor_features),
|
||||
'comparison_rows', (SELECT count(*) FROM public.dev_comparison_matrix),
|
||||
'source', 'development/03-concorrentes/concorrentes.md'
|
||||
));
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// AgenciaPsi — RPC integration tests (T#8)
|
||||
// =============================================================================
|
||||
// Executa cenários SQL via `docker exec` no container do Postgres.
|
||||
// Cada cenário roda em transação isolada (BEGIN ... ROLLBACK), zero side effect.
|
||||
//
|
||||
// Uso: node database-novo/tests/run.cjs
|
||||
//
|
||||
// Estrutura de cada caso:
|
||||
// { name, sub: 'as user|as saas|anon', sql, expect: { ok|errorIncludes, jsonHas } }
|
||||
// =============================================================================
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const CONFIG = JSON.parse(require('fs').readFileSync(path.join(__dirname, '..', 'db.config.json'), 'utf8'));
|
||||
const CONTAINER = CONFIG.container;
|
||||
const DB = CONFIG.database;
|
||||
const USER = CONFIG.user;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
function runSql(sql) {
|
||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -v ON_ERROR_STOP=1 -t -A`;
|
||||
try {
|
||||
const out = execSync(cmd, { input: sql, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return { ok: true, out: String(out).trim(), err: null };
|
||||
} catch (e) {
|
||||
const stderr = String(e?.stderr || e?.message || '').trim();
|
||||
return { ok: false, out: null, err: stderr };
|
||||
}
|
||||
}
|
||||
|
||||
function asUser(uid) {
|
||||
return `SET LOCAL ROLE authenticated; SELECT set_config('request.jwt.claim.sub', '${uid}', true);`;
|
||||
}
|
||||
|
||||
// IDs do seed que sabemos existir (db.config.json + seeds)
|
||||
const SAAS_ADMIN_UID = 'aaaaaaaa-0006-0006-0006-000000000006';
|
||||
const TENANT_ADMIN_UID = 'aaaaaaaa-0005-0005-0005-000000000005'; // owner de Clínica Bem Estar
|
||||
const TENANT_BEM_ESTAR = 'bbbbbbbb-0005-0005-0005-000000000005';
|
||||
const PATIENT_UID = 'aaaaaaaa-0001-0001-0001-000000000001';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Cases
|
||||
// -----------------------------------------------------------------------------
|
||||
const cases = [
|
||||
// ────── set_tenant_feature_exception ──────
|
||||
{
|
||||
name: 'set_tenant_feature_exception: anônimo é rejeitado',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'patients', true, NULL);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'Não autenticado' }
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: tenant_admin tenta override+ fora do plano → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(TENANT_ADMIN_UID)}
|
||||
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'documents.signatures', true, 'tentativa');
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'saas_admin' }
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: saas_admin sem reason em exceção → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'documents.signatures', true, NULL);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'reason' }
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: saas_admin com reason curto (<4 chars) — RPC aceita, frontend valida',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT (public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'documents.signatures', true, 'PIX'))->>'is_exception';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: 'true' } // is_exception=true
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: tenant_admin desliga feature DO plano → permitido',
|
||||
sql: `BEGIN;
|
||||
${asUser(TENANT_ADMIN_UID)}
|
||||
SELECT (public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'patients', false, 'pref'))->>'plan_allows';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: 'true' }
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: feature_key inválida (uppercase) → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'PATIENTS', false, NULL);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'formato inválido' }
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: feature_key desconhecida → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.set_tenant_feature_exception('${TENANT_BEM_ESTAR}'::uuid, 'unknown_feature', false, NULL);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'desconhecida' }
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: tenant inexistente → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.set_tenant_feature_exception('99999999-9999-9999-9999-999999999999'::uuid, 'patients', false, NULL);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'tenant não encontrado' }
|
||||
},
|
||||
{
|
||||
name: 'set_tenant_feature_exception: trigger guard ainda bloqueia INSERT direto fora do plano',
|
||||
sql: `BEGIN;
|
||||
INSERT INTO tenant_features (tenant_id, feature_key, enabled)
|
||||
VALUES ('${TENANT_BEM_ESTAR}', 'documents.signatures', true);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'não permitida pelo plano' }
|
||||
},
|
||||
|
||||
// ────── delete_plan_safe ──────
|
||||
{
|
||||
name: 'delete_plan_safe: anônimo é rejeitado',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SELECT public.delete_plan_safe((SELECT id FROM plans WHERE key='clinic_free'));
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'Não autenticado' }
|
||||
},
|
||||
{
|
||||
name: 'delete_plan_safe: tenant_admin não-saas é rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(TENANT_ADMIN_UID)}
|
||||
SELECT public.delete_plan_safe((SELECT id FROM plans WHERE key='clinic_free'));
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'saas_admin' }
|
||||
},
|
||||
{
|
||||
name: 'delete_plan_safe: bloqueia delete de plano com subscriptions ativas',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.delete_plan_safe((SELECT id FROM plans WHERE key='clinic_free'));
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'assinatura' }
|
||||
},
|
||||
{
|
||||
name: 'delete_plan_safe: plan_id null → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.delete_plan_safe(NULL);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'plan_id obrigatório' }
|
||||
},
|
||||
{
|
||||
name: 'delete_plan_safe: plano inexistente → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.delete_plan_safe('99999999-9999-9999-9999-999999999999'::uuid);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'plano não encontrado' }
|
||||
},
|
||||
|
||||
// ────── create_patient_intake_request_v2 ──────
|
||||
{
|
||||
name: 'create_patient_intake_request_v2: A#20 — anon NÃO chama mais o RPC direto',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE anon;
|
||||
SELECT public.create_patient_intake_request_v2('token-inexistente'::text, '{}'::jsonb, NULL::text);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'permission denied' }
|
||||
},
|
||||
{
|
||||
name: 'create_patient_intake_request_v2: token inválido (via authenticated) → Token inválido',
|
||||
sql: `BEGIN;
|
||||
${asUser(TENANT_ADMIN_UID)}
|
||||
SELECT public.create_patient_intake_request_v2('token-inexistente'::text, '{}'::jsonb, NULL::text);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'Token inválido' }
|
||||
},
|
||||
{
|
||||
name: 'create_patient_intake_request_v2: payload sem nome_completo → rejeitado',
|
||||
sql: `BEGIN;
|
||||
-- desativa invites pré-existentes desse owner (constraint one_active_per_owner)
|
||||
UPDATE patient_invites SET active=false WHERE owner_id='${TENANT_ADMIN_UID}' AND active=true;
|
||||
WITH inv AS (
|
||||
INSERT INTO patient_invites (token, owner_id, tenant_id, active)
|
||||
VALUES ('test-token-' || md5(random()::text), '${TENANT_ADMIN_UID}', '${TENANT_BEM_ESTAR}', true)
|
||||
RETURNING token
|
||||
)
|
||||
SELECT public.create_patient_intake_request_v2(
|
||||
(SELECT token FROM inv),
|
||||
jsonb_build_object('email_principal','test@x.com','telefone','11999999999','consent',true),
|
||||
NULL::text
|
||||
);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'Nome' }
|
||||
},
|
||||
|
||||
// ────── features.is_active (V#40 sanity) ──────
|
||||
{
|
||||
name: 'features.is_active existe e default true',
|
||||
sql: `SELECT column_default FROM information_schema.columns WHERE table_name='features' AND column_name='is_active';`,
|
||||
expect: { ok: true, jsonHas: 'true' }
|
||||
},
|
||||
|
||||
// ────── A#20 rev2: defesa em camadas ──────
|
||||
{
|
||||
name: 'check_rate_limit: IP novo → allowed=true',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE service_role;
|
||||
SELECT (public.check_rate_limit('test-hash-novo-' || gen_random_uuid()::text, 'patient_intake'))->>'allowed';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: 'true' }
|
||||
},
|
||||
{
|
||||
name: 'record_submission_attempt: 3 falhas seguidas → marca requires_captcha',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE service_role;
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-fail-3', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-fail-3', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-fail-3', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT (public.check_rate_limit('h-fail-3', 'patient_intake'))->>'requires_captcha';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: 'true' }
|
||||
},
|
||||
{
|
||||
name: 'record_submission_attempt: > max_attempts → bloqueia (allowed=false)',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE service_role;
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT public.record_submission_attempt('patient_intake', 'h-block', false, 'rpc', 'X', 'Y', NULL, NULL);
|
||||
SELECT (public.check_rate_limit('h-block', 'patient_intake'))->>'allowed';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: 'false' }
|
||||
},
|
||||
{
|
||||
name: 'generate_math_challenge: cria id + question',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE service_role;
|
||||
SELECT (public.generate_math_challenge())->>'question';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: 'Quanto' }
|
||||
},
|
||||
{
|
||||
name: 'verify_math_challenge: id desconhecido → false',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE service_role;
|
||||
SELECT public.verify_math_challenge('00000000-0000-0000-0000-000000000000'::uuid, 1)::text;
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: 'f' }
|
||||
},
|
||||
{
|
||||
name: 'check_rate_limit: anon não pode chamar (só service_role)',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE anon;
|
||||
SELECT public.check_rate_limit('h', 'patient_intake');
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'permission denied' }
|
||||
},
|
||||
{
|
||||
name: 'saas_security_config singleton existe com defaults',
|
||||
sql: `SELECT honeypot_enabled::text || ',' || rate_limit_enabled::text FROM saas_security_config WHERE id=true;`,
|
||||
expect: { ok: true, jsonHas: 'true,true' }
|
||||
},
|
||||
|
||||
// ────── saas_twilio_config + RPCs ──────
|
||||
{
|
||||
name: 'get_twilio_config: anon NÃO pode chamar',
|
||||
sql: `BEGIN;
|
||||
SET LOCAL ROLE anon;
|
||||
SELECT public.get_twilio_config();
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'permission denied' }
|
||||
},
|
||||
{
|
||||
name: 'get_twilio_config: authenticated não-saas → "Sem permissão"',
|
||||
sql: `BEGIN;
|
||||
${asUser(TENANT_ADMIN_UID)}
|
||||
SELECT public.get_twilio_config();
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'Sem permissão' }
|
||||
},
|
||||
{
|
||||
name: 'get_twilio_config: saas_admin retorna defaults',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT (public.get_twilio_config())->>'usd_brl_rate';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: '5.5' }
|
||||
},
|
||||
{
|
||||
name: 'update_twilio_config: tenant_admin é rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(TENANT_ADMIN_UID)}
|
||||
SELECT public.update_twilio_config(p_usd_brl_rate := 6);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'saas_admin' }
|
||||
},
|
||||
{
|
||||
name: 'update_twilio_config: SID inválido (sem AC) → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.update_twilio_config(p_account_sid := 'XX123');
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'account_sid inválido' }
|
||||
},
|
||||
{
|
||||
name: 'update_twilio_config: webhook sem http(s) → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.update_twilio_config(p_whatsapp_webhook_url := 'ftp://x.com');
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'webhook' }
|
||||
},
|
||||
{
|
||||
name: 'update_twilio_config: rate fora da faixa → rejeitado',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT public.update_twilio_config(p_usd_brl_rate := 200);
|
||||
ROLLBACK;`,
|
||||
expect: { errorIncludes: 'usd_brl_rate' }
|
||||
},
|
||||
{
|
||||
name: 'update_twilio_config: saas_admin com payload válido → ok',
|
||||
sql: `BEGIN;
|
||||
${asUser(SAAS_ADMIN_UID)}
|
||||
SELECT (public.update_twilio_config(
|
||||
p_account_sid := 'ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
p_usd_brl_rate := 5.85,
|
||||
p_margin_multiplier := 1.5
|
||||
))->>'usd_brl_rate';
|
||||
ROLLBACK;`,
|
||||
expect: { ok: true, jsonHas: '5.85' }
|
||||
}
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Runner
|
||||
// -----------------------------------------------------------------------------
|
||||
function color(s, c) {
|
||||
const map = { red: 31, green: 32, yellow: 33, gray: 90 };
|
||||
return `\x1b[${map[c] || 0}m${s}\x1b[0m`;
|
||||
}
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const failures = [];
|
||||
|
||||
console.log(color('▶ AgenciaPsi RPC integration tests', 'gray'));
|
||||
console.log(color('───────────────────────────────────', 'gray'));
|
||||
|
||||
for (const c of cases) {
|
||||
process.stdout.write(` ${c.name} ... `);
|
||||
const r = runSql(c.sql);
|
||||
|
||||
let ok = true;
|
||||
let why = '';
|
||||
|
||||
if (c.expect.errorIncludes) {
|
||||
if (r.ok) {
|
||||
ok = false;
|
||||
why = `esperava erro com "${c.expect.errorIncludes}" mas SQL passou. saída=${r.out}`;
|
||||
} else if (!r.err.includes(c.expect.errorIncludes)) {
|
||||
ok = false;
|
||||
why = `erro não contém "${c.expect.errorIncludes}". stderr=${r.err.slice(0, 200)}`;
|
||||
}
|
||||
} else {
|
||||
if (!r.ok) {
|
||||
ok = false;
|
||||
why = `esperava sucesso mas falhou. stderr=${r.err.slice(0, 300)}`;
|
||||
} else if (c.expect.jsonHas && !r.out.includes(c.expect.jsonHas)) {
|
||||
ok = false;
|
||||
why = `saída não contém "${c.expect.jsonHas}". out=${r.out}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(color('✓', 'green'));
|
||||
} else {
|
||||
fail++;
|
||||
failures.push({ name: c.name, why });
|
||||
console.log(color('✗', 'red'));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(color('───────────────────────────────────', 'gray'));
|
||||
console.log(`${color(pass + ' passed', 'green')}, ${fail > 0 ? color(fail + ' failed', 'red') : color('0 failed', 'gray')}`);
|
||||
|
||||
if (failures.length) {
|
||||
console.log('\n' + color('FAILURES:', 'red'));
|
||||
for (const f of failures) {
|
||||
console.log(` ${color('×', 'red')} ${f.name}`);
|
||||
console.log(` ${color(f.why, 'yellow')}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,223 @@
|
||||
# AgenciaPsi — Estrutura Atual
|
||||
|
||||
> Snapshot do que já está construído no sistema hoje (2026-04-17).
|
||||
> Inventário extraído dos menus de cada perfil de usuário.
|
||||
|
||||
**Legenda:** `[ok]` pronto · `[~]` parcial (estrutura existe, refinar) · `[--]` placeholder/vazio
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
AgenciaPsi (v5.0.0)
|
||||
│
|
||||
├── SaaS Admin (operação da plataforma)
|
||||
│ │
|
||||
│ ├── Início
|
||||
│ │ └── Dashboard [ok] /saas
|
||||
│ │
|
||||
│ ├── Planos
|
||||
│ │ ├── Planos e Preços [ok] /saas/plans
|
||||
│ │ ├── Vitrine Pública [ok] /saas/plans-public
|
||||
│ │ ├── Recursos (features) [ok] /saas/features
|
||||
│ │ ├── Controle de Recursos [ok] /saas/plan-features
|
||||
│ │ └── Limites por Plano [ok] /saas/plan-limits
|
||||
│ │
|
||||
│ ├── Assinaturas
|
||||
│ │ ├── Listagem [ok] /saas/subscriptions
|
||||
│ │ ├── Intenções [ok] /saas/subscription-intents
|
||||
│ │ ├── Histórico (eventos) [ok] /saas/subscription-events
|
||||
│ │ └── Saúde das Assinaturas [ok] /saas/subscription-health
|
||||
│ │
|
||||
│ ├── Operações
|
||||
│ │ ├── Clínicas (Tenants) [ok] /saas/tenants
|
||||
│ │ ├── Feriados [ok] /saas/feriados
|
||||
│ │ └── Suporte Técnico [ok] /saas/support
|
||||
│ │
|
||||
│ ├── Canais de Comunicação
|
||||
│ │ ├── WhatsApp (Evolution API) [ok] /saas/whatsapp
|
||||
│ │ ├── WhatsApp Twilio (Subcontas) [ok] /saas/twilio-whatsapp
|
||||
│ │ ├── Templates WhatsApp/SMS [ok] /saas/notification-templates
|
||||
│ │ └── Add-ons / Créditos SMS [ok] /saas/addons
|
||||
│ │
|
||||
│ └── Conteúdo
|
||||
│ ├── Documentação [ok] /saas/docs
|
||||
│ ├── FAQ [ok] /saas/faq
|
||||
│ ├── Carrossel de Login [ok] /saas/login-carousel
|
||||
│ ├── Avisos Globais [ok] /saas/global-notices
|
||||
│ ├── Templates de E-mail [ok] /saas/email-templates
|
||||
│ └── Templates de Documentos [ok] /saas/document-templates
|
||||
│
|
||||
├── Clínica Admin (gestão da clínica)
|
||||
│ │
|
||||
│ ├── Início
|
||||
│ │ ├── Dashboard da Clínica [ok] /admin
|
||||
│ │ ├── Agenda da Clínica [ok] /admin/agenda/clinica
|
||||
│ │ ├── Recorrências [ok] /admin/agenda/recorrencias
|
||||
│ │ └── Compromissos [ok] /admin/agenda/compromissos
|
||||
│ │
|
||||
│ ├── Pacientes
|
||||
│ │ ├── Lista de Pacientes [ok] /admin/pacientes
|
||||
│ │ ├── Grupos [ok] /admin/pacientes/grupos
|
||||
│ │ ├── Tags [ok] /admin/pacientes/tags
|
||||
│ │ ├── Médicos & Referências [ok] /admin/pacientes/medicos
|
||||
│ │ ├── Link Externo (cadastro) [ok] /admin/pacientes/link-externo
|
||||
│ │ ├── Cadastros Recebidos [ok] /admin/pacientes/cadastro/recebidos
|
||||
│ │ └── Templates de Documentos [ok] /admin/documents/templates
|
||||
│ │
|
||||
│ ├── Gestão
|
||||
│ │ ├── Profissionais [ok] /admin/clinic/professionals
|
||||
│ │ ├── Tipos de Clínicas (features ativas) [ok] /admin/clinic/features
|
||||
│ │ └── Meu Plano [ok] /admin/meu-plano
|
||||
│ │
|
||||
│ ├── Financeiro
|
||||
│ │ └── Cobranças [ok] /admin/financeiro
|
||||
│ │
|
||||
│ └── Sistema
|
||||
│ ├── Meu Perfil [ok] /account/profile
|
||||
│ ├── Meu Negócio [ok] /account/negocio
|
||||
│ ├── Segurança [ok] /admin/settings/security
|
||||
│ ├── Agendamento Online (PRO) [ok] /admin/online-scheduling
|
||||
│ └── Agendamentos Recebidos [ok] /admin/agendamentos-recebidos
|
||||
│
|
||||
├── Terapeuta (dia-a-dia do profissional)
|
||||
│ │
|
||||
│ ├── Início
|
||||
│ │ └── Dashboard [ok] /therapist
|
||||
│ │
|
||||
│ ├── Agenda
|
||||
│ │ ├── Agenda [ok] /therapist/agenda
|
||||
│ │ ├── Recorrências [ok] /therapist/agenda/recorrencias
|
||||
│ │ └── Compromissos [ok] /therapist/agenda/compromissos
|
||||
│ │
|
||||
│ ├── Pacientes
|
||||
│ │ ├── Meus Pacientes [ok] /therapist/patients
|
||||
│ │ ├── Grupos de Pacientes [ok] /therapist/patients/grupos
|
||||
│ │ ├── Tags [ok] /therapist/patients/tags
|
||||
│ │ ├── Médicos & Referências [ok] /therapist/patients/medicos
|
||||
│ │ ├── Documentos [ok] /therapist/documents
|
||||
│ │ ├── Templates de Documentos [ok] /therapist/documents/templates
|
||||
│ │ ├── Meu Link de Cadastro [ok] /therapist/patients/link-externo
|
||||
│ │ └── Cadastros Recebidos [ok] /therapist/patients/cadastro/recebidos
|
||||
│ │
|
||||
│ ├── Agendamento Online
|
||||
│ │ ├── Configurar Página Pública [ok] /therapist/online-scheduling
|
||||
│ │ └── Agendamentos Recebidos [ok] /therapist/agendamentos-recebidos
|
||||
│ │
|
||||
│ ├── Financeiro
|
||||
│ │ ├── Cobranças [ok] /therapist/financeiro
|
||||
│ │ └── Lançamentos [ok] /therapist/financeiro/lancamentos
|
||||
│ │
|
||||
│ ├── Relatórios
|
||||
│ │ └── Relatórios [~] /therapist/relatorios (export PDF/Excel a validar)
|
||||
│ │
|
||||
│ └── Conta
|
||||
│ ├── Meu Plano [ok] /therapist/meu-plano
|
||||
│ ├── Meu Perfil [ok] /account/profile
|
||||
│ ├── Meu Negócio [ok] /account/negocio
|
||||
│ └── Segurança [ok] /account/security
|
||||
│
|
||||
├── Supervisor (supervisão de casos)
|
||||
│ │
|
||||
│ ├── Início
|
||||
│ │ └── Dashboard [ok] /supervisor
|
||||
│ │
|
||||
│ ├── Supervisão
|
||||
│ │ └── Sala de Supervisão [~] /supervisor/sala (features avançadas a confirmar)
|
||||
│ │
|
||||
│ └── Conta
|
||||
│ ├── Meu Plano [ok] /supervisor/meu-plano
|
||||
│ ├── Meu Perfil [ok] /account/profile
|
||||
│ └── Segurança [ok] /account/security
|
||||
│
|
||||
├── Portal do Paciente (portal web para o paciente)
|
||||
│ │
|
||||
│ ├── Início
|
||||
│ │ └── Dashboard [ok] /portal
|
||||
│ │
|
||||
│ ├── Minhas Sessões
|
||||
│ │ └── Sessões [ok] /portal/sessoes
|
||||
│ │
|
||||
│ └── Conta
|
||||
│ ├── Meu Plano [ok] /portal/meu-plano
|
||||
│ ├── Minha Conta [ok] /account/profile
|
||||
│ └── Segurança [ok] /account/security
|
||||
│
|
||||
└── Editor de Conteúdo (criação de cursos — roadmap futuro)
|
||||
│
|
||||
├── Início
|
||||
│ └── Dashboard [ok] /editor
|
||||
│
|
||||
├── Conteúdo
|
||||
│ ├── Cursos [--] /editor/cursos (placeholder)
|
||||
│ ├── Módulos [--] /editor/modulos (placeholder)
|
||||
│ └── Publicados [--] /editor/publicados (placeholder)
|
||||
│
|
||||
└── Conta
|
||||
├── Meu Plano [ok] /editor/meu-plano
|
||||
├── Meu Perfil [ok] /account/profile
|
||||
└── Segurança [ok] /account/security
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack / Infraestrutura (suporte)
|
||||
|
||||
```
|
||||
Infraestrutura
|
||||
│
|
||||
├── Banco & Backend
|
||||
│ ├── Supabase (Postgres + Auth + Storage + Realtime + Edge Functions) [ok]
|
||||
│ ├── PostgreSQL 15 (container Docker local + cloud) [ok]
|
||||
│ └── Docker Compose (supabase + evolution-api) [ok]
|
||||
│
|
||||
├── Comunicação
|
||||
│ ├── Evolution API (WhatsApp) [ok]
|
||||
│ ├── Twilio (SMS / Voz / WhatsApp Business) [ok]
|
||||
│ ├── Mailpit (SMTP dev local) [ok]
|
||||
│ └── SMTP produção (envio real de e-mails) [--] pendente
|
||||
│
|
||||
├── Geração de Documentos
|
||||
│ ├── pdfmake + html2pdf + jsPDF [ok]
|
||||
│ ├── Jodit (editor rico) [ok]
|
||||
│ └── html2canvas-pro [ok]
|
||||
│
|
||||
├── Frontend
|
||||
│ ├── Vue 3 + Composition API [ok]
|
||||
│ ├── Vite 5 + Brotli/Gzip [ok]
|
||||
│ ├── PrimeVue 4 (tema Sakai) [ok]
|
||||
│ ├── Tailwind v4 [ok]
|
||||
│ ├── Vue Router (guards por role/tenant) [ok]
|
||||
│ ├── Pinia (state) [ok]
|
||||
│ ├── FullCalendar 6 (agenda) [ok]
|
||||
│ └── Chart.js 3 (dashboards) [ok]
|
||||
│
|
||||
└── Dev / Tooling
|
||||
├── Supabase CLI [ok]
|
||||
├── db.cjs (setup/backup/restore/migrate/verify) [ok]
|
||||
├── generate-dashboard.cjs (mapa do banco) [ok]
|
||||
├── Vitest 4 [~] base pronta, cobertura a expandir
|
||||
└── ESLint + Prettier [ok]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resumo numérico
|
||||
|
||||
- **6 perfis** (roles)
|
||||
- **27 grupos** de navegação
|
||||
- **79 features** ativas no menu
|
||||
- **73** `[ok]` prontas
|
||||
- **2** `[~]` parciais
|
||||
- **4** `[--]` placeholders
|
||||
- **97 tabelas** no banco (11 domínios)
|
||||
- **27 views**
|
||||
- **165 funções SQL**
|
||||
|
||||
---
|
||||
|
||||
## Próximos passos (a definir nesta manhã)
|
||||
|
||||
- [ ] Mapear módulos que **deveriam existir** mas ainda não estão (comparando com Psicomanager / SimplePractice / iClinic)
|
||||
- [ ] Organizar o roadmap em **fases** (MVP → v2 → v3)
|
||||
- [ ] Identificar dependências entre módulos (ex: pagamento integrado antes de faturamento automático)
|
||||
- [ ] Decidir cortes (o que fica pra pós-MVP)
|
||||
@@ -0,0 +1,741 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mapa do Sistema · AgenciaPsi</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/primeicons@7.0.0/primeicons.css">
|
||||
<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;
|
||||
--bg4: #1e2740;
|
||||
--border: #1e2740;
|
||||
--border2: #2a3552;
|
||||
--text: #e2e8f8;
|
||||
--text2: #94a3b8;
|
||||
--text3: #5a6782;
|
||||
--accent: #6366f1;
|
||||
--ok: #22c55e;
|
||||
--warn: #eab308;
|
||||
--pend: #f87171;
|
||||
|
||||
/* role colors */
|
||||
--saas: #4f8cff;
|
||||
--clinic: #38bdf8;
|
||||
--therapist: #6366f1;
|
||||
--supervisor: #a78bfa;
|
||||
--portal: #ec4899;
|
||||
--editor: #fb923c;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ============ TOPBAR ============ */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
display: flex; align-items: center; gap: 20px;
|
||||
padding: 0 28px; height: 60px;
|
||||
background: rgba(11,13,18,.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 16px; letter-spacing: -.3px; }
|
||||
.brand span { color: var(--accent); }
|
||||
.brand small { display: block; font-size: 10px; font-weight: 400; color: var(--text3); font-family: 'IBM Plex Mono', monospace; margin-top: -2px; }
|
||||
.search {
|
||||
flex: 1; max-width: 360px;
|
||||
background: var(--bg3); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 7px 14px;
|
||||
color: var(--text); font-size: 13px; outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.search:focus { border-color: var(--accent); }
|
||||
.search::placeholder { color: var(--text3); }
|
||||
|
||||
.legend { display: flex; align-items: center; gap: 14px; margin-left: auto; font-size: 12px; color: var(--text2); }
|
||||
.legend .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
||||
.dot-done { background: var(--ok); }
|
||||
.dot-partial { background: var(--warn); }
|
||||
.dot-missing { background: var(--pend); }
|
||||
|
||||
.stats-pill { display: flex; gap: 10px; }
|
||||
.stats-pill .p {
|
||||
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;
|
||||
}
|
||||
.stats-pill .p strong { color: var(--text); font-size: 13px; }
|
||||
|
||||
/* ============ HERO ============ */
|
||||
.hero {
|
||||
padding: 40px 28px 20px;
|
||||
text-align: center;
|
||||
background: radial-gradient(ellipse at center top, rgba(99,102,241,.12), transparent 60%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hero h1 { font-size: 28px; font-weight: 700; letter-spacing: -.5px; margin-bottom: 8px; }
|
||||
.hero h1 span { color: var(--accent); }
|
||||
.hero p { font-size: 14px; color: var(--text2); max-width: 640px; margin: 0 auto; line-height: 1.55; }
|
||||
|
||||
.hub {
|
||||
display: inline-block; margin: 24px auto 8px;
|
||||
padding: 18px 32px;
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 40px rgba(99,102,241,.35), 0 0 0 4px rgba(99,102,241,.15);
|
||||
font-size: 17px; font-weight: 700; letter-spacing: -.2px;
|
||||
}
|
||||
.hub .tag {
|
||||
display: inline-block; margin-right: 10px;
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 1px;
|
||||
background: rgba(255,255,255,.2); padding: 3px 8px; border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.hero-totals {
|
||||
display: flex; justify-content: center; gap: 20px;
|
||||
margin-top: 20px;
|
||||
font-size: 13px; color: var(--text2);
|
||||
}
|
||||
.hero-totals b { color: var(--text); }
|
||||
|
||||
/* ============ GRID ============ */
|
||||
.map {
|
||||
padding: 30px 28px 60px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(440px, 1fr));
|
||||
gap: 22px;
|
||||
max-width: 1900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ============ ROLE CARD ============ */
|
||||
.role {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: border-color .2s;
|
||||
position: relative;
|
||||
}
|
||||
.role::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px;
|
||||
background: var(--rc);
|
||||
}
|
||||
.role:hover { border-color: var(--border2); }
|
||||
|
||||
.role-head {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 18px 20px 14px;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.role-icon {
|
||||
width: 42px; height: 42px; border-radius: 10px;
|
||||
display: grid; place-items: center;
|
||||
background: color-mix(in srgb, var(--rc) 18%, transparent);
|
||||
color: var(--rc);
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.role-meta { flex: 1; min-width: 0; }
|
||||
.role-name { font-size: 15px; font-weight: 700; letter-spacing: -.2px; }
|
||||
.role-sub { font-size: 11px; color: var(--text3); font-family: 'IBM Plex Mono', monospace; margin-top: 2px; }
|
||||
.role-counter {
|
||||
display: flex; gap: 6px; font-size: 10px; font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.role-counter span {
|
||||
padding: 3px 7px; border-radius: 10px;
|
||||
background: var(--bg4); color: var(--text2);
|
||||
}
|
||||
.role-counter span.done { background: rgba(34,197,94,.15); color: var(--ok); }
|
||||
.role-counter span.partial { background: rgba(234,179,8,.15); color: var(--warn); }
|
||||
.role-counter span.missing { background: rgba(248,113,113,.15); color: var(--pend); }
|
||||
.role-tg {
|
||||
color: var(--text3); font-size: 11px;
|
||||
transition: transform .2s;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.role-tg.open { transform: rotate(180deg); }
|
||||
|
||||
.role-body {
|
||||
display: none;
|
||||
padding: 0 20px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.role-body.open { display: block; }
|
||||
|
||||
/* ============ GROUPS ============ */
|
||||
.group {
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
border-left: 2px dashed color-mix(in srgb, var(--rc) 40%, transparent);
|
||||
}
|
||||
.group::before {
|
||||
content: ''; position: absolute; left: -6px; top: 6px;
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--rc);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--rc) 15%, var(--bg2));
|
||||
}
|
||||
.group-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 1px;
|
||||
color: color-mix(in srgb, var(--rc) 70%, var(--text));
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.group-head i { font-size: 13px; }
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 8px 11px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
.item:hover {
|
||||
background: var(--bg4);
|
||||
border-color: var(--border2);
|
||||
transform: translateX(1px);
|
||||
}
|
||||
.item.hl {
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--bg3));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.item.dim { opacity: .25; }
|
||||
.item i.pi { font-size: 12px; color: var(--text3); flex-shrink: 0; }
|
||||
.item .lbl { flex: 1; min-width: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.item .status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============ PANEL (detail) ============ */
|
||||
.panel {
|
||||
position: fixed; top: 60px; right: 0; bottom: 0;
|
||||
width: 380px; max-width: 92vw;
|
||||
background: var(--bg2);
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 24px 22px;
|
||||
transform: translateX(100%);
|
||||
transition: transform .2s ease-out;
|
||||
overflow-y: auto;
|
||||
z-index: 40;
|
||||
}
|
||||
.panel.open { transform: translateX(0); }
|
||||
.panel-close {
|
||||
position: absolute; top: 14px; right: 16px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--text2); font-size: 18px;
|
||||
width: 32px; height: 32px; border-radius: 6px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.panel-close:hover { background: var(--bg4); color: var(--text); }
|
||||
|
||||
.panel-role {
|
||||
display: inline-block;
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 1px;
|
||||
color: var(--rc); background: color-mix(in srgb, var(--rc) 15%, transparent);
|
||||
padding: 3px 9px; border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.panel-title {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
font-size: 18px; font-weight: 700; letter-spacing: -.2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.panel-title i {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
display: grid; place-items: center;
|
||||
background: color-mix(in srgb, var(--rc) 18%, transparent);
|
||||
color: var(--rc); font-size: 16px;
|
||||
}
|
||||
.panel-group {
|
||||
font-size: 12px; color: var(--text2); margin-bottom: 20px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
.panel-section { margin-top: 18px; }
|
||||
.panel-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text3); margin-bottom: 6px; }
|
||||
.panel-value { font-size: 13px; color: var(--text); line-height: 1.55; }
|
||||
.panel-route {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
background: var(--bg3); border: 1px solid var(--border);
|
||||
padding: 8px 11px; border-radius: 6px;
|
||||
font-size: 12px; word-break: break-all;
|
||||
}
|
||||
.panel-status {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 4px 10px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-status.done { background: rgba(34,197,94,.15); color: var(--ok); }
|
||||
.panel-status.partial { background: rgba(234,179,8,.15); color: var(--warn); }
|
||||
.panel-status.missing { background: rgba(248,113,113,.15); color: var(--pend); }
|
||||
|
||||
.panel-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
background: var(--accent); color: white;
|
||||
padding: 9px 16px; border-radius: 7px;
|
||||
font-size: 12px; font-weight: 600; text-decoration: none;
|
||||
transition: opacity .15s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.panel-btn:hover { opacity: .9; }
|
||||
|
||||
/* ============ SEARCH no results ============ */
|
||||
.empty { text-align: center; padding: 40px; color: var(--text3); font-size: 13px; }
|
||||
|
||||
/* ============ RESPONSIVE ============ */
|
||||
@media (max-width: 860px) {
|
||||
.hero h1 { font-size: 22px; }
|
||||
.hub { font-size: 14px; padding: 14px 22px; }
|
||||
.legend { display: none; }
|
||||
.stats-pill { display: none; }
|
||||
.map { grid-template-columns: 1fr; padding: 20px 16px 40px; }
|
||||
.panel { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand">Mapa do Sistema<small>AgenciaPsi · v5.0.0</small></div>
|
||||
<input type="search" class="search" id="search" placeholder="Buscar feature, rota ou grupo..." autocomplete="off">
|
||||
<div class="stats-pill" id="stats"></div>
|
||||
<div class="legend">
|
||||
<span><i class="dot dot-done"></i>Pronto</span>
|
||||
<span><i class="dot dot-partial"></i>Parcial</span>
|
||||
<span><i class="dot dot-missing"></i>Faltando</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<h1>Mapa do <span>Sistema</span></h1>
|
||||
<p>Tudo que o AgenciaPsi entrega hoje, agrupado por perfil de usuário. Clique em qualquer feature pra ver detalhes — rota, status e notas. Use a busca no topo pra filtrar.</p>
|
||||
<div class="hub"><span class="tag">SaaS</span>AgenciaPsi</div>
|
||||
<div class="hero-totals" id="heroTotals"></div>
|
||||
</section>
|
||||
|
||||
<main class="map" id="map"></main>
|
||||
|
||||
<aside class="panel" id="panel">
|
||||
<button class="panel-close" onclick="closePanel()" aria-label="Fechar">✕</button>
|
||||
<div id="panelContent"></div>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
// ======================================================================
|
||||
// DADOS - inventário extraído dos menus de cada role
|
||||
// ======================================================================
|
||||
const DATA = {
|
||||
"roles": {
|
||||
"saas": {
|
||||
"label": "SaaS Admin",
|
||||
"icon": "pi-shield",
|
||||
"color": "#4f8cff",
|
||||
"description": "Operação da plataforma — planos, assinaturas, clínicas, canais, conteúdo",
|
||||
"groups": [
|
||||
{ "label": "Início", "icon": "pi-chart-bar", "items": [
|
||||
{ "label": "Dashboard", "icon": "pi-chart-bar", "route": "/saas", "status": "done" }
|
||||
]},
|
||||
{ "label": "Planos", "icon": "pi-list", "items": [
|
||||
{ "label": "Planos e Preços", "icon": "pi-list", "route": "/saas/plans", "status": "done" },
|
||||
{ "label": "Vitrine Pública", "icon": "pi-megaphone", "route": "/saas/plans-public", "status": "done" },
|
||||
{ "label": "Recursos", "icon": "pi-bolt", "route": "/saas/features", "status": "done" },
|
||||
{ "label": "Controle de Recursos", "icon": "pi-th-large", "route": "/saas/plan-features", "status": "done" },
|
||||
{ "label": "Limites por Plano", "icon": "pi-sliders-h", "route": "/saas/plan-limits", "status": "done" }
|
||||
]},
|
||||
{ "label": "Assinaturas", "icon": "pi-credit-card", "items": [
|
||||
{ "label": "Listagem", "icon": "pi-list", "route": "/saas/subscriptions", "status": "done" },
|
||||
{ "label": "Intenções", "icon": "pi-inbox", "route": "/saas/subscription-intents", "status": "done" },
|
||||
{ "label": "Histórico", "icon": "pi-history", "route": "/saas/subscription-events", "status": "done" },
|
||||
{ "label": "Saúde das Assinaturas", "icon": "pi-shield", "route": "/saas/subscription-health", "status": "done" }
|
||||
]},
|
||||
{ "label": "Operações", "icon": "pi-users", "items": [
|
||||
{ "label": "Clínicas (Tenants)", "icon": "pi-users", "route": "/saas/tenants", "status": "done" },
|
||||
{ "label": "Feriados", "icon": "pi-star", "route": "/saas/feriados", "status": "done" },
|
||||
{ "label": "Suporte Técnico", "icon": "pi-headphones", "route": "/saas/support", "status": "done" }
|
||||
]},
|
||||
{ "label": "Canais de Comunicação", "icon": "pi-whatsapp", "items": [
|
||||
{ "label": "WhatsApp (Evolution)", "icon": "pi-whatsapp", "route": "/saas/whatsapp", "status": "done" },
|
||||
{ "label": "WhatsApp Twilio", "icon": "pi-whatsapp", "route": "/saas/twilio-whatsapp", "status": "done" },
|
||||
{ "label": "Templates WA/SMS", "icon": "pi-comment", "route": "/saas/notification-templates", "status": "done" },
|
||||
{ "label": "Add-ons / Créditos", "icon": "pi-box", "route": "/saas/addons", "status": "done" }
|
||||
]},
|
||||
{ "label": "Conteúdo", "icon": "pi-book", "items": [
|
||||
{ "label": "Documentação", "icon": "pi-question-circle", "route": "/saas/docs", "status": "done" },
|
||||
{ "label": "FAQ", "icon": "pi-comments", "route": "/saas/faq", "status": "done" },
|
||||
{ "label": "Carrossel Login", "icon": "pi-images", "route": "/saas/login-carousel", "status": "done" },
|
||||
{ "label": "Avisos Globais", "icon": "pi-megaphone", "route": "/saas/global-notices", "status": "done" },
|
||||
{ "label": "Templates E-mail", "icon": "pi-envelope", "route": "/saas/email-templates", "status": "done" },
|
||||
{ "label": "Templates Documentos", "icon": "pi-file-edit", "route": "/saas/document-templates", "status": "done" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
"clinic": {
|
||||
"label": "Clínica Admin",
|
||||
"icon": "pi-building",
|
||||
"color": "#38bdf8",
|
||||
"description": "Gestão da clínica — agenda, pacientes, profissionais, financeiro",
|
||||
"groups": [
|
||||
{ "label": "Início", "icon": "pi-home", "items": [
|
||||
{ "label": "Dashboard", "icon": "pi-home", "route": "/admin", "status": "done" },
|
||||
{ "label": "Agenda da Clínica", "icon": "pi-calendar", "route": "/admin/agenda/clinica", "status": "done" },
|
||||
{ "label": "Recorrências", "icon": "pi-refresh", "route": "/admin/agenda/recorrencias", "status": "done" },
|
||||
{ "label": "Compromissos", "icon": "pi-clock", "route": "/admin/agenda/compromissos", "status": "done" }
|
||||
]},
|
||||
{ "label": "Pacientes", "icon": "pi-users", "items": [
|
||||
{ "label": "Lista de Pacientes", "icon": "pi-users", "route": "/admin/pacientes", "status": "done" },
|
||||
{ "label": "Grupos", "icon": "pi-sitemap", "route": "/admin/pacientes/grupos", "status": "done" },
|
||||
{ "label": "Tags", "icon": "pi-tags", "route": "/admin/pacientes/tags", "status": "done" },
|
||||
{ "label": "Médicos & Referências", "icon": "pi-heart", "route": "/admin/pacientes/medicos", "status": "done" },
|
||||
{ "label": "Link Externo", "icon": "pi-link", "route": "/admin/pacientes/link-externo", "status": "done" },
|
||||
{ "label": "Cadastros recebidos", "icon": "pi-inbox", "route": "/admin/pacientes/cadastro/recebidos", "status": "done" },
|
||||
{ "label": "Templates Documentos", "icon": "pi-file-edit", "route": "/admin/documents/templates", "status": "done" }
|
||||
]},
|
||||
{ "label": "Gestão", "icon": "pi-id-card", "items": [
|
||||
{ "label": "Profissionais", "icon": "pi-id-card", "route": "/admin/clinic/professionals", "status": "done" },
|
||||
{ "label": "Tipos de Clínicas", "icon": "pi-sliders-h", "route": "/admin/clinic/features", "status": "done" },
|
||||
{ "label": "Meu plano", "icon": "pi-credit-card", "route": "/admin/meu-plano", "status": "done" }
|
||||
]},
|
||||
{ "label": "Financeiro", "icon": "pi-wallet", "items": [
|
||||
{ "label": "Cobranças", "icon": "pi-wallet", "route": "/admin/financeiro", "status": "done" }
|
||||
]},
|
||||
{ "label": "Sistema", "icon": "pi-cog", "items": [
|
||||
{ "label": "Meu Perfil", "icon": "pi-user", "route": "/account/profile", "status": "done" },
|
||||
{ "label": "Meu Negócio", "icon": "pi-building", "route": "/account/negocio", "status": "done" },
|
||||
{ "label": "Segurança", "icon": "pi-shield", "route": "/admin/settings/security", "status": "done" },
|
||||
{ "label": "Agendamento Online (PRO)", "icon": "pi-calendar-plus", "route": "/admin/online-scheduling", "status": "done" },
|
||||
{ "label": "Agendamentos Recebidos", "icon": "pi-inbox", "route": "/admin/agendamentos-recebidos", "status": "done" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
"therapist": {
|
||||
"label": "Terapeuta",
|
||||
"icon": "pi-user",
|
||||
"color": "#6366f1",
|
||||
"description": "Dia-a-dia do profissional — agenda, prontuários, documentos, cobrança",
|
||||
"groups": [
|
||||
{ "label": "Início", "icon": "pi-home", "items": [
|
||||
{ "label": "Dashboard", "icon": "pi-home", "route": "/therapist", "status": "done" }
|
||||
]},
|
||||
{ "label": "Agenda", "icon": "pi-calendar", "items": [
|
||||
{ "label": "Agenda", "icon": "pi-calendar", "route": "/therapist/agenda", "status": "done" },
|
||||
{ "label": "Recorrências", "icon": "pi-refresh", "route": "/therapist/agenda/recorrencias", "status": "done" },
|
||||
{ "label": "Compromissos", "icon": "pi-clock", "route": "/therapist/agenda/compromissos", "status": "done" }
|
||||
]},
|
||||
{ "label": "Pacientes", "icon": "pi-list", "items": [
|
||||
{ "label": "Meus pacientes", "icon": "pi-list", "route": "/therapist/patients", "status": "done" },
|
||||
{ "label": "Grupos de pacientes", "icon": "pi-users", "route": "/therapist/patients/grupos", "status": "done" },
|
||||
{ "label": "Tags", "icon": "pi-tags", "route": "/therapist/patients/tags", "status": "done" },
|
||||
{ "label": "Médicos & Referências", "icon": "pi-heart", "route": "/therapist/patients/medicos", "status": "done" },
|
||||
{ "label": "Documentos", "icon": "pi-file", "route": "/therapist/documents", "status": "done" },
|
||||
{ "label": "Templates Documentos", "icon": "pi-file-edit", "route": "/therapist/documents/templates", "status": "done" },
|
||||
{ "label": "Meu link de cadastro", "icon": "pi-link", "route": "/therapist/patients/link-externo", "status": "done" },
|
||||
{ "label": "Cadastros recebidos", "icon": "pi-inbox", "route": "/therapist/patients/cadastro/recebidos", "status": "done" }
|
||||
]},
|
||||
{ "label": "Agendamento Online", "icon": "pi-globe", "items": [
|
||||
{ "label": "Configurar página", "icon": "pi-globe", "route": "/therapist/online-scheduling", "status": "done" },
|
||||
{ "label": "Agendamentos Recebidos", "icon": "pi-inbox", "route": "/therapist/agendamentos-recebidos", "status": "done" }
|
||||
]},
|
||||
{ "label": "Financeiro", "icon": "pi-wallet", "items": [
|
||||
{ "label": "Cobranças", "icon": "pi-wallet", "route": "/therapist/financeiro", "status": "done" },
|
||||
{ "label": "Lançamentos", "icon": "pi-list", "route": "/therapist/financeiro/lancamentos", "status": "done" }
|
||||
]},
|
||||
{ "label": "Relatórios", "icon": "pi-chart-bar", "items": [
|
||||
{ "label": "Relatórios", "icon": "pi-chart-bar", "route": "/therapist/relatorios", "status": "partial", "notes": "Página existe, mas export PDF/Excel pode não estar 100% — validar." }
|
||||
]},
|
||||
{ "label": "Conta", "icon": "pi-user", "items": [
|
||||
{ "label": "Meu plano", "icon": "pi-credit-card", "route": "/therapist/meu-plano", "status": "done" },
|
||||
{ "label": "Meu Perfil", "icon": "pi-user", "route": "/account/profile", "status": "done" },
|
||||
{ "label": "Meu Negócio", "icon": "pi-building", "route": "/account/negocio", "status": "done" },
|
||||
{ "label": "Segurança", "icon": "pi-shield", "route": "/account/security", "status": "done" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
"supervisor": {
|
||||
"label": "Supervisor",
|
||||
"icon": "pi-users",
|
||||
"color": "#a78bfa",
|
||||
"description": "Supervisão de casos e grupos",
|
||||
"groups": [
|
||||
{ "label": "Início", "icon": "pi-home", "items": [
|
||||
{ "label": "Dashboard", "icon": "pi-home", "route": "/supervisor", "status": "done" }
|
||||
]},
|
||||
{ "label": "Supervisão", "icon": "pi-users", "items": [
|
||||
{ "label": "Sala de Supervisão", "icon": "pi-users", "route": "/supervisor/sala", "status": "partial", "notes": "Estrutura base pronta — features avançadas (gravação, anotações colaborativas) a confirmar." }
|
||||
]},
|
||||
{ "label": "Conta", "icon": "pi-user", "items": [
|
||||
{ "label": "Meu plano", "icon": "pi-credit-card", "route": "/supervisor/meu-plano", "status": "done" },
|
||||
{ "label": "Meu Perfil", "icon": "pi-user", "route": "/account/profile", "status": "done" },
|
||||
{ "label": "Segurança", "icon": "pi-shield", "route": "/account/security", "status": "done" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
"portal": {
|
||||
"label": "Portal do Paciente",
|
||||
"icon": "pi-heart",
|
||||
"color": "#ec4899",
|
||||
"description": "Portal web pro paciente — ver sessões, histórico, dados",
|
||||
"groups": [
|
||||
{ "label": "Início", "icon": "pi-home", "items": [
|
||||
{ "label": "Dashboard", "icon": "pi-home", "route": "/portal", "status": "done" }
|
||||
]},
|
||||
{ "label": "Minhas sessões", "icon": "pi-calendar", "items": [
|
||||
{ "label": "Sessões", "icon": "pi-calendar", "route": "/portal/sessoes", "status": "done" }
|
||||
]},
|
||||
{ "label": "Conta", "icon": "pi-user", "items": [
|
||||
{ "label": "Meu plano", "icon": "pi-credit-card", "route": "/portal/meu-plano", "status": "done" },
|
||||
{ "label": "Minha Conta", "icon": "pi-user", "route": "/account/profile", "status": "done" },
|
||||
{ "label": "Segurança", "icon": "pi-shield", "route": "/account/security", "status": "done" }
|
||||
]}
|
||||
]
|
||||
},
|
||||
"editor": {
|
||||
"label": "Editor de Conteúdo",
|
||||
"icon": "pi-book",
|
||||
"color": "#fb923c",
|
||||
"description": "Criação de cursos e conteúdo (roadmap futuro)",
|
||||
"groups": [
|
||||
{ "label": "Início", "icon": "pi-home", "items": [
|
||||
{ "label": "Dashboard", "icon": "pi-home", "route": "/editor", "status": "done" }
|
||||
]},
|
||||
{ "label": "Conteúdo", "icon": "pi-book", "items": [
|
||||
{ "label": "Cursos", "icon": "pi-book", "route": "/editor/cursos", "status": "partial", "notes": "Placeholder / estrutura inicial — biblioteca de cursos ainda não está plena." },
|
||||
{ "label": "Módulos", "icon": "pi-th-large", "route": "/editor/modulos", "status": "partial", "notes": "Placeholder / estrutura inicial." },
|
||||
{ "label": "Publicados", "icon": "pi-check-circle", "route": "/editor/publicados", "status": "partial", "notes": "Placeholder / estrutura inicial." }
|
||||
]},
|
||||
{ "label": "Conta", "icon": "pi-user", "items": [
|
||||
{ "label": "Meu plano", "icon": "pi-credit-card", "route": "/editor/meu-plano", "status": "done" },
|
||||
{ "label": "Meu Perfil", "icon": "pi-user", "route": "/account/profile", "status": "done" },
|
||||
{ "label": "Segurança", "icon": "pi-shield", "route": "/account/security", "status": "done" }
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ======================================================================
|
||||
// RENDER
|
||||
// ======================================================================
|
||||
const STATUS_LABEL = { done: 'Pronto', partial: 'Parcial', missing: 'Faltando' };
|
||||
let currentQuery = '';
|
||||
|
||||
function countItems(role) {
|
||||
const total = role.groups.reduce((s, g) => s + g.items.length, 0);
|
||||
const done = role.groups.reduce((s, g) => s + g.items.filter(i => i.status === 'done').length, 0);
|
||||
const partial = role.groups.reduce((s, g) => s + g.items.filter(i => i.status === 'partial').length, 0);
|
||||
const missing = role.groups.reduce((s, g) => s + g.items.filter(i => i.status === 'missing').length, 0);
|
||||
return { total, done, partial, missing };
|
||||
}
|
||||
|
||||
function matchesQuery(item, group, role) {
|
||||
if (!currentQuery) return true;
|
||||
const q = currentQuery.toLowerCase();
|
||||
return (
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
(item.route || '').toLowerCase().includes(q) ||
|
||||
group.label.toLowerCase().includes(q) ||
|
||||
role.label.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
const map = document.getElementById('map');
|
||||
let html = '';
|
||||
let totalMatch = 0;
|
||||
|
||||
for (const [key, role] of Object.entries(DATA.roles)) {
|
||||
const counts = countItems(role);
|
||||
let visibleGroups = '';
|
||||
let groupsWithMatches = 0;
|
||||
|
||||
for (const group of role.groups) {
|
||||
const itemsHtml = group.items.map(item => {
|
||||
const match = matchesQuery(item, group, role);
|
||||
if (match) totalMatch++;
|
||||
const dim = currentQuery && !match ? ' dim' : '';
|
||||
const hl = currentQuery && match ? ' hl' : '';
|
||||
const statusColor = { done: 'var(--ok)', partial: 'var(--warn)', missing: 'var(--pend)' }[item.status] || 'var(--text3)';
|
||||
return `<div class="item${dim}${hl}" onclick="openPanel('${key}','${escapeAttr(group.label)}','${escapeAttr(item.label)}')">
|
||||
<i class="pi ${item.icon}"></i>
|
||||
<span class="lbl">${escapeHtml(item.label)}</span>
|
||||
<span class="status-dot" style="background:${statusColor}"></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const groupMatched = group.items.some(i => matchesQuery(i, group, role)) || matchesQuery({label:'',route:''}, group, role);
|
||||
if (groupMatched) groupsWithMatches++;
|
||||
|
||||
visibleGroups += `
|
||||
<div class="group">
|
||||
<div class="group-head"><i class="pi ${group.icon}"></i>${escapeHtml(group.label)}</div>
|
||||
<div class="items">${itemsHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Decide expansion default: expanded if no search (so user sees all), expanded if search matches any
|
||||
const expanded = !currentQuery || groupsWithMatches > 0;
|
||||
|
||||
html += `
|
||||
<section class="role" style="--rc:${role.color}">
|
||||
<div class="role-head" onclick="toggleRole('${key}')">
|
||||
<div class="role-icon"><i class="pi ${role.icon}"></i></div>
|
||||
<div class="role-meta">
|
||||
<div class="role-name">${escapeHtml(role.label)}</div>
|
||||
<div class="role-sub">${escapeHtml(role.description)}</div>
|
||||
</div>
|
||||
<div class="role-counter">
|
||||
<span class="done">${counts.done}</span>
|
||||
${counts.partial ? `<span class="partial">${counts.partial}</span>` : ''}
|
||||
${counts.missing ? `<span class="missing">${counts.missing}</span>` : ''}
|
||||
</div>
|
||||
<span class="role-tg ${expanded ? 'open' : ''}" id="tg-${key}">▼</span>
|
||||
</div>
|
||||
<div class="role-body ${expanded ? 'open' : ''}" id="body-${key}">
|
||||
${visibleGroups}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
map.innerHTML = html;
|
||||
renderStats(totalMatch);
|
||||
}
|
||||
|
||||
function renderStats(visibleCount) {
|
||||
let totalItems = 0, totalDone = 0, totalPartial = 0, totalMissing = 0;
|
||||
for (const role of Object.values(DATA.roles)) {
|
||||
const c = countItems(role);
|
||||
totalItems += c.total; totalDone += c.done; totalPartial += c.partial; totalMissing += c.missing;
|
||||
}
|
||||
const totalRoles = Object.keys(DATA.roles).length;
|
||||
const totalGroups = Object.values(DATA.roles).reduce((s, r) => s + r.groups.length, 0);
|
||||
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="p"><strong>${totalRoles}</strong> perfis</div>
|
||||
<div class="p"><strong>${totalGroups}</strong> grupos</div>
|
||||
<div class="p"><strong>${totalItems}</strong> features</div>`;
|
||||
|
||||
document.getElementById('heroTotals').innerHTML = `
|
||||
<span><b>${totalRoles}</b> perfis</span>
|
||||
<span>·</span>
|
||||
<span><b>${totalGroups}</b> grupos</span>
|
||||
<span>·</span>
|
||||
<span><b>${totalItems}</b> features</span>
|
||||
<span>·</span>
|
||||
<span style="color:var(--ok)"><b>${totalDone}</b> prontas</span>
|
||||
${totalPartial ? `<span>·</span><span style="color:var(--warn)"><b>${totalPartial}</b> parciais</span>` : ''}
|
||||
${totalMissing ? `<span>·</span><span style="color:var(--pend)"><b>${totalMissing}</b> faltando</span>` : ''}`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
function escapeAttr(s) {
|
||||
return String(s || '').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// INTERACTIONS
|
||||
// ======================================================================
|
||||
function toggleRole(key) {
|
||||
const body = document.getElementById('body-' + key);
|
||||
const tg = document.getElementById('tg-' + key);
|
||||
body.classList.toggle('open');
|
||||
tg.classList.toggle('open');
|
||||
}
|
||||
|
||||
function openPanel(roleKey, groupLabel, itemLabel) {
|
||||
const role = DATA.roles[roleKey];
|
||||
const group = role.groups.find(g => g.label === groupLabel);
|
||||
const item = group.items.find(i => i.label === itemLabel);
|
||||
|
||||
const statusLabel = STATUS_LABEL[item.status];
|
||||
const panel = document.getElementById('panel');
|
||||
const content = document.getElementById('panelContent');
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="panel-role" style="--rc:${role.color};color:${role.color};background:color-mix(in srgb, ${role.color} 15%, transparent)">${escapeHtml(role.label)}</div>
|
||||
<div class="panel-title">
|
||||
<i class="pi ${item.icon}" style="--rc:${role.color};background:color-mix(in srgb, ${role.color} 18%, transparent);color:${role.color}"></i>
|
||||
<span>${escapeHtml(item.label)}</span>
|
||||
</div>
|
||||
<div class="panel-group">${escapeHtml(group.label)}</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="panel-label">Status</div>
|
||||
<div class="panel-status ${item.status}">
|
||||
<span class="status-dot" style="width:8px;height:8px;border-radius:50%;background:currentColor"></span>
|
||||
${statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="panel-label">Rota</div>
|
||||
<div class="panel-route">${escapeHtml(item.route || '-')}</div>
|
||||
</div>
|
||||
|
||||
${item.notes ? `
|
||||
<div class="panel-section">
|
||||
<div class="panel-label">Notas</div>
|
||||
<div class="panel-value">${escapeHtml(item.notes)}</div>
|
||||
</div>` : ''}
|
||||
|
||||
${item.route ? `<a class="panel-btn" href="http://localhost:5173${item.route}" target="_blank" rel="noopener">
|
||||
<i class="pi pi-external-link"></i> Abrir no dev server
|
||||
</a>` : ''}
|
||||
`;
|
||||
panel.classList.add('open');
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
document.getElementById('panel').classList.remove('open');
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// SEARCH
|
||||
// ======================================================================
|
||||
let searchTimer;
|
||||
document.getElementById('search').addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
currentQuery = e.target.value.trim();
|
||||
renderMap();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Close panel on ESC
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closePanel();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// INIT
|
||||
// ======================================================================
|
||||
renderMap();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,740 @@
|
||||
# Concorrentes — Análise Comparativa
|
||||
|
||||
> Benchmark de referência pro **AgenciaPsi**. Objetivo: mapear features dos principais players, confrontar com o que já temos e identificar gaps de roadmap.
|
||||
|
||||
## Como usar este documento
|
||||
Cada produto tem sua seção com módulos organizados por domínio. Tags de fonte:
|
||||
- `[observação 2026-04-17]` — observado direto pelo usuário navegando no produto
|
||||
- `[fetched 2026-04-17]` — extraído via WebFetch das páginas oficiais
|
||||
- `[público]` — inferido de conhecimento público (marketing/demos/reviews)
|
||||
- `[?]` — ainda não verificado
|
||||
|
||||
## Legenda de status (vs AgenciaPsi)
|
||||
- `[tem]` — AgenciaPsi já tem esta feature
|
||||
- `[parcial]` — tem estrutura mas não está completo
|
||||
- `[gap]` — AgenciaPsi **não** tem → candidato a roadmap
|
||||
- `[?]` — a investigar (no concorrente ou no AgenciaPsi)
|
||||
|
||||
---
|
||||
|
||||
## 1. SimplePractice (EUA)
|
||||
|
||||
### Resumo executivo
|
||||
- **País:** EUA · **Idioma:** inglês (+ Client Portal em espanhol) `[fetched 2026-04-17]`
|
||||
- **Escala:** **20M+ clientes** e **250k+ practitioners** na plataforma `[fetched: /pricing/]`
|
||||
- **Pricing:** USD ~29–99/mês por profissional (Essential, Plus, e um terceiro tier) · **30-day** ou **7-day trial** `[fetched: /pricing/]`
|
||||
- **Público-alvo:** terapeutas, psicólogos, counselors, psiquiatras — solo e pequenas clínicas (até ~10 profissionais) `[público]`
|
||||
- **Diferencial:** maturidade em telehealth HIPAA, claims de seguro (insurance billing), consent forms assinados eletronicamente, biblioteca de instrumentos de avaliação `[público]`
|
||||
- **Compliance:** HIPAA + HITRUST + PCI + BAA `[fetched: /features/]`
|
||||
- **Posicionamento:** "o Salesforce da saúde mental" no mercado americano `[público]`
|
||||
|
||||
### Categorias top-level confirmadas `[fetched: /features/]`
|
||||
- **Admin & Practice Management** — Reports Access, AutoPay Enrollment, Practitioner Profiles, Permissions Management
|
||||
- **Client Care & Documentation** — Secure Messaging, Client Scoring/Assessment Tools, Prescription/Medication Renewal Requests
|
||||
- **Client-Facing Services** — Client Portal, Telehealth
|
||||
- **Compliance & Security** — HIPAA, HITRUST, PCI, BAA
|
||||
- **Additional Services** — Credentialing Services (grátis, ofertado), Switching Assistance (ajuda na migração de outra plataforma)
|
||||
|
||||
### Navegação principal observada
|
||||
`[observação 2026-04-17]`
|
||||
Menus top-level vistos no app:
|
||||
- **Clients** (Clientes)
|
||||
- **Billing / Payments** (Pagamentos)
|
||||
- **Insurance** (Convênios EUA)
|
||||
- **Analytics**
|
||||
- **Activities** (Atividades)
|
||||
- **Supervision**
|
||||
- **Settings**
|
||||
- **Reminders**
|
||||
- **Requests**
|
||||
- **Marketing**
|
||||
- **Recently Viewed** — atalho pros últimos clientes acessados
|
||||
- **Busca global** no topbar por clientes
|
||||
|
||||
### Módulos
|
||||
|
||||
#### 1.1 Clientes (Clients)
|
||||
- Cadastro completo do paciente — `[tem]`
|
||||
- **Recently Viewed** (últimos clientes acessados) — `[gap]`
|
||||
- **Busca global** no topbar — `[?]` investigar no AgenciaPsi
|
||||
- Insurance info no perfil — `[parcial]` (temos convênios cadastrados, mas não ligados ao perfil do paciente desse jeito)
|
||||
- Emergency contacts — `[?]`
|
||||
- Tags / grupos — `[tem]`
|
||||
- Merge de clientes duplicados — `[gap]`
|
||||
|
||||
#### 1.2 Settings → Clinical Info
|
||||
`[observação 2026-04-17]`
|
||||
> Estou vendo a tela de Clinical Info. Para os EUA, tem NPI Number, Taxonomy Code e Specialty. Nós já temos um cadastro de clínica completo para o Brasil. Acho que poderíamos colocar o campo **Especialidades** também, listar um monte e, se o usuário não encontrar, cadastrar o seu.
|
||||
|
||||
**Propostas derivadas:**
|
||||
- Campo **Especialidades** da clínica/profissional com lista pré-definida + "outro" — `[gap]`
|
||||
- Equivalente BR do NPI/Taxonomy seria: **número de registro** (CRP, CRM, etc) e **área de atuação** — talvez já exista em user_settings/profile, a confirmar — `[?]`
|
||||
|
||||
`[observação 2026-04-17]`
|
||||
> Tem um campo **Licenses Type** onde o usuário seleciona se é Médico, PhD, etc. (Não sei se isso seria aqui.)
|
||||
|
||||
**Proposta derivada:** campo **Tipo de registro profissional** (CRP, CRM, CRFa, RMS, etc) no cadastro do terapeuta — útil pra aparecer em recibos/laudos — `[gap]`
|
||||
|
||||
#### 1.3 Settings → Practice Details
|
||||
`[observação 2026-04-17]`
|
||||
> Cadastra a localização da clínica e digamos filiais (no caso de gestão de clínicas). É possível definir qual é o **Main Office**.
|
||||
|
||||
**Proposta derivada:** **multi-unidade/filiais** com marcação de "unidade principal" — `[gap]`
|
||||
- Ao criar consulta, escolher em qual unidade ocorre
|
||||
- Faturamento pode ser consolidado ou por unidade
|
||||
- Cada unidade tem endereço, telefone, papel timbrado próprio (opcional)
|
||||
|
||||
#### 1.4 Client Care → Template Library
|
||||
`[observação 2026-04-17]`
|
||||
> Variados tipos de arquivos prontos pra personalizar. Ex: **GAD-7** (Generalized Anxiety Disorder), **PHQ-9** (Patient Health Questionnaire), **COVID-19 Pre-Appointment Screening Questionnaire**.
|
||||
|
||||
**Proposta derivada (alto valor):** **Biblioteca de instrumentos de avaliação psicológica** com scoring automático — `[gap]`
|
||||
- GAD-7 (ansiedade generalizada)
|
||||
- PHQ-9 (depressão)
|
||||
- BDI (Inventário Beck)
|
||||
- BAI (Beck ansiedade)
|
||||
- PCL-5 (PTSD)
|
||||
- AUDIT (uso de álcool)
|
||||
- DASS-21 (depressão/ansiedade/estresse)
|
||||
- SRQ-20 (MS/BR)
|
||||
- Escala de qualidade de vida (WHOQOL)
|
||||
|
||||
> **Diferencial possível pro BR:** curar uma biblioteca com instrumentos validados em português (muitos psicólogos ainda aplicam em papel).
|
||||
|
||||
#### 1.5 Client Care → Shareable Documents
|
||||
`[observação 2026-04-17]`
|
||||
> Manage default intake documents and upload files.
|
||||
|
||||
**Interpretação:** documentos que vão com o paciente durante onboarding/intake (antes da 1ª sessão).
|
||||
- AgenciaPsi já tem módulo de Documentos — `[tem]`
|
||||
- Mas "enviar pacote de intake" estruturado antes da 1ª sessão — `[parcial / ?]`
|
||||
- Upload de arquivo pelo paciente (anexos, exames) — `[?]`
|
||||
|
||||
#### 1.6 Consent Forms
|
||||
`[observação 2026-04-17]`
|
||||
> Read and signed by clients via the client portal. Legal disclaimer: consent forms are for reference only. It's your responsibility to customize them and ensure they meet the legal requirements of your state.
|
||||
>
|
||||
> Formulários default observados:
|
||||
> - Consent for Telehealth Consultation
|
||||
> - Credit Card Authorization
|
||||
> - Notice of Privacy Practices
|
||||
> - Informed Consent for Psychotherapy
|
||||
> - Practice Policies
|
||||
> - Release Forms
|
||||
> - Consent to Record Audio
|
||||
> - (+ New Consent Form)
|
||||
>
|
||||
> O usuário consegue adicionar, editar um novo e visualizar.
|
||||
|
||||
**Proposta derivada:** **biblioteca de termos de consentimento** editáveis + assinatura eletrônica no portal — `[gap]`
|
||||
|
||||
Equivalentes BR a disponibilizar:
|
||||
- **TCLE** — Termo de Consentimento Livre e Esclarecido (CFP exige)
|
||||
- **Termo de Atendimento Online** — atendimento remoto (Resolução CFP 11/2018)
|
||||
- **Política de Privacidade LGPD** — tratamento de dados sensíveis de saúde
|
||||
- **Autorização de Cobrança** (cartão recorrente)
|
||||
- **Termo de Gravação** (se for gravar sessão)
|
||||
- **Termo de Sigilo** em caso de supervisão/atendimento em grupo
|
||||
- **Autorização de uso de imagem** (se aplicável)
|
||||
- **+ Novo termo personalizado** (botão)
|
||||
|
||||
#### 1.7 Papel Timbrado
|
||||
`[observação 2026-04-17]`
|
||||
> Precisa criar a parte de papel timbrado, assim como fizemos no outro projeto. Quando chegar a hora, pode me perguntar.
|
||||
|
||||
**Status:** `[gap]` — reaproveitar a implementação do outro projeto (UniaoApp). Quando chegar a hora, me pergunta pra eu buscar lá e portar.
|
||||
|
||||
#### 1.8 Outros módulos públicos do SimplePractice `[público]`
|
||||
|
||||
> Essas features são conhecidas publicamente (pricing pages / demos). A confirmar navegando.
|
||||
|
||||
##### Client Portal (Portal do Paciente) `[fetched: /features/client-portal/]`
|
||||
Portal do cliente completo, HIPAA-compliant. Extraído da página oficial:
|
||||
- **Self-scheduling** — cliente solicita / cancela / reagenda consultas — `[tem parcial]` (agendador público existe mas não é portal autenticado)
|
||||
- **Paperless intake forms** — cliente preenche formulários antes da 1ª sessão — `[gap]`
|
||||
- **Questionnaires** — instrumentos de medição/progresso (GAD-7, PHQ-9 etc) — `[gap]`
|
||||
- **Secure messaging** — chat seguro terapeuta ↔ cliente — `[gap]`
|
||||
- **Invoice payment** — paciente paga direto pelo portal — `[gap]`
|
||||
- **Push notifications** — notif antes da consulta — `[gap]` (temos email/SMS/WhatsApp, push seria diferencial)
|
||||
- **Appointment reminders** — lembretes programados — `[tem]`
|
||||
- **Portal em espanhol** — multi-idioma — `[gap]` (pensar se vale traduzir pra ES pro LATAM)
|
||||
- **Administrative controls** — clínica decide quais features o paciente enxerga — `[parcial]`
|
||||
- **HIPAA-compliant** — `[parcial]` (LGPD equivalente no BR)
|
||||
|
||||
##### Telehealth `[fetched: /features/telehealth/]`
|
||||
SP tem um **módulo de telehealth próprio**, totalmente integrado ao EHR. Confirmado na página oficial:
|
||||
- **Vídeo nativo** — "launch sessions directly from your calendar — no extra login required" — `[gap]`
|
||||
- **Sala de espera virtual** — "admit clients when you're ready" — `[gap]`
|
||||
- **Screen sharing** embutido — `[gap]`
|
||||
- **Chat seguro** durante a sessão — `[gap]`
|
||||
- **Digital whiteboard** (quadro branco digital) — `[gap]` · bom pra sessões com crianças / TCC visual
|
||||
- **Video em grupo** — até 15 clientes por sessão — `[gap]` · suporta terapia de grupo
|
||||
- **Timer de sessão** embutido + **fundos desfocados (blurred backgrounds)** — `[gap]`
|
||||
- **Lembretes automáticos** SMS/email — `[tem parcial]` (temos lembretes; falta o link da sala)
|
||||
- **HIPAA + HITRUST + PCI** compliance — `[tem parcial]` (temos LGPD compliance mas não certificação formal)
|
||||
- **Integração nativa com o EHR** — notas da sessão + billing automáticos — `[gap]`
|
||||
- **Apps iOS / Android** — `[gap]` (AgenciaPsi é só web)
|
||||
|
||||
##### Documentation / Notes de sessão
|
||||
- Templates de notas pré-prontos (**SOAP**, **BIRP**, **DAP**) — `[?]`
|
||||
- Progress notes com versionamento — `[parcial]` (temos Prontuário, validar versionamento)
|
||||
- Co-assinatura de supervisor em notas de supervisionando — `[gap]`
|
||||
|
||||
##### Billing
|
||||
- **Credit card on file** (cartão salvo) — `[gap]`
|
||||
- **Auto-billing** / cobrança recorrente automática — `[parcial]` (temos recorrências de consulta, não de cobrança)
|
||||
- **Superbill** (recibo detalhado com CID, CPT codes equivalente) — `[gap]`
|
||||
- **AR reports** (contas a receber, aging) — `[?]`
|
||||
|
||||
##### Insurance (EUA) — equivalente BR: **Convênios**
|
||||
- Eligibility check automático — `[gap]` (BR seria checagem TISS mas raramente integra)
|
||||
- Claims submission eletrônico (837) — `[gap]`
|
||||
- ERA (Electronic Remittance Advice) — `[gap]`
|
||||
|
||||
##### Reminders (Lembretes)
|
||||
- Email / SMS / Voice — `[tem]` (temos Email + SMS + WhatsApp)
|
||||
- Confirmação pelo paciente (responder "SIM") — `[?]`
|
||||
- Configuração de quanto tempo antes — `[tem]`
|
||||
- Templates customizáveis — `[tem]`
|
||||
|
||||
##### Marketing
|
||||
- Perfil público / diretório — `[gap]` (similar a Doctoralia)
|
||||
- Website/landing de divulgação — `[gap]`
|
||||
- SEO básico — `[gap]`
|
||||
|
||||
##### Analytics / Relatórios
|
||||
- Receita (MRR, por período, por profissional) — `[parcial]` (dashboard tem alguns KPIs, não sei se tudo)
|
||||
- Sessões realizadas (por tipo, por profissional, por convênio) — `[?]`
|
||||
- Clientes ativos / novos / churn — `[?]`
|
||||
- Export PDF/Excel — `[parcial]`
|
||||
|
||||
##### Supervisão
|
||||
- Compartilhar notas com supervisor — `[parcial]` (Sala de Supervisão existe, features a confirmar)
|
||||
- Co-assinar antes de fechar nota — `[gap]`
|
||||
|
||||
##### Sync & Integrações
|
||||
- **Google Calendar** 2-way sync — `[gap]` (já listado no assessment como pós-MVP)
|
||||
- **Apple Calendar** — `[gap]`
|
||||
- **Stripe** / gateway de pagamento — `[gap]` (crítico)
|
||||
|
||||
##### Tasks / To-do
|
||||
- Lista de tarefas por cliente — `[?]`
|
||||
- Task follow-ups (ex: "ligar em 30 dias") — `[gap]`
|
||||
|
||||
---
|
||||
|
||||
## 2. Psicomanager (Brasil)
|
||||
|
||||
### Resumo executivo `[público]`
|
||||
- **País:** Brasil · **Pricing:** R$ ~50–150/mês por profissional (varia por plano e módulos contratados) — **confirmar**
|
||||
- **Público-alvo:** psicólogos solo e clínicas no BR
|
||||
- **Diferencial:** nicho-específico em psicologia no BR, familiaridade com CFP/LGPD, integração com WhatsApp/PIX
|
||||
- **Posicionamento:** o "padrão" do mercado brasileiro pra psicólogos
|
||||
|
||||
### Módulos conhecidos publicamente `[público / ?]`
|
||||
|
||||
> **Tentativa de WebFetch em 2026-04-17:** `psicomanager.com.br/`, `/planos/`, `/funcionalidades/`, `/blog/`, `/agenda-online/`, `/prontuario-eletronico/` — **todas retornaram SPA vazia ou 404**. O site do Psicomanager é single-page app renderizado via JS, o que torna scraping impossível sem browser headless.
|
||||
>
|
||||
> **Caminhos alternativos pra validar:**
|
||||
> 1. Você abrir o site logado e me mandar screenshots/texto das principais telas
|
||||
> 2. Criar um trial e navegar junto comigo
|
||||
> 3. Buscar reviews detalhados em sites tipo **B2BStack**, **Reclame Aqui**, **SoftwareSuggest BR**
|
||||
> 4. Buscar por posts do blog deles via Google (`site:psicomanager.com.br`)
|
||||
>
|
||||
> A lista abaixo é conhecimento de mercado. Priorizar validação antes de tomar decisões de roadmap baseadas nela.
|
||||
|
||||
- **Agenda** — online, presencial, bloqueios, recorrências
|
||||
- **Prontuário Eletrônico** — anamnese, evolução, sessões
|
||||
- **Cadastro de Pacientes** — multi-paciente, anamnese inicial
|
||||
- **Financeiro** — cobranças, PIX, cartão (Asaas? Iugu? a confirmar)
|
||||
- **Lembretes** — Email/SMS/WhatsApp
|
||||
- **Recibo / NFSe** — emissão de nota fiscal de serviço
|
||||
- **Teleconsulta** — integrada ao sistema (próprio ou externo? a confirmar)
|
||||
- **Anamnese e Evolução** — templates customizáveis
|
||||
- **Portal do Paciente** — visão do paciente das sessões/faturas
|
||||
- **Documentos** — atestados, laudos, declarações
|
||||
- **Relatórios** — produção, financeiro, agenda
|
||||
- **LGPD** — compliance, consentimento de dados
|
||||
- **Multi-profissional** — gestão de clínica com múltiplos psicólogos
|
||||
- **Assinatura eletrônica** — documentos assinados pelo paciente `[?]`
|
||||
- **App mobile** pro paciente/profissional `[?]`
|
||||
|
||||
### Observações durante exploração
|
||||
> *Em branco — preencher navegando no Psicomanager.*
|
||||
|
||||
---
|
||||
|
||||
## 3. PsicoPlanner (Brasil) — concorrente direto `[fetched 2026-04-17]`
|
||||
|
||||
### Resumo executivo
|
||||
- **País:** Brasil · **Nicho:** psicologia-first
|
||||
- **Preços (publicados):**
|
||||
- **Individual:** R$ 59/mês (1 acesso)
|
||||
- **Plus:** R$ 79/mês (1 profissional + 1 recepcionista)
|
||||
- **Duo:** R$ 99/mês (2 logins independentes)
|
||||
- **Clínicas:** R$ 395/mês (até 5 profissionais)
|
||||
- **Posicionamento:** "psicólogo que odeia planilha" — simples, WhatsApp-first, IA
|
||||
|
||||
### Features confirmadas
|
||||
- **Lembretes WhatsApp** ilimitados e personalizáveis (confirmações, lembretes, avisos de pagamento)
|
||||
- **Sala de vídeo integrada** (teleconsulta com confidencialidade dentro do app)
|
||||
- **Prontuários e anamnese customizáveis** por profissional
|
||||
- **Autoagendamento** via link compartilhável (paciente escolhe horário)
|
||||
- **Agenda diária por WhatsApp às 7h** (push automático do cronograma do dia) — `[gap]` diferencial forte
|
||||
- **Gestão financeira** visual (pagamentos, recebidos, pendentes)
|
||||
- **PsiAssist AI** — gera relatórios, documentos, resumos e conteúdo com **compliance CFP**
|
||||
- **Rastreamento de engajamento em tempo real** — ver se paciente recebeu, leu e respondeu
|
||||
|
||||
### Destaques comparativos
|
||||
| Feature | PsicoPlanner | AgenciaPsi |
|
||||
|---|---|---|
|
||||
| WhatsApp nativo com dashboard engajamento | ✅ | `[parcial]` (enviamos, mas tracking?) |
|
||||
| Agenda diária automática 7AM via WA | ✅ | `[gap]` |
|
||||
| IA com compliance CFP | ✅ (PsiAssist) | `[gap]` |
|
||||
| Preço entrada baixo | R$ 59 solo | (definir pricing) |
|
||||
|
||||
---
|
||||
|
||||
## 4. iClinic (Brasil) — multispecialidade popular `[fetched 2026-04-17]`
|
||||
|
||||
### Resumo executivo
|
||||
- **País:** Brasil · **Nicho:** multispecialidade (psicólogos usam bastante apesar de não ser foco)
|
||||
- **Posicionamento:** "o EHR completo pro consultório/clínica médica no BR"
|
||||
|
||||
### Módulos confirmados
|
||||
|
||||
#### Prontuário Eletrônico
|
||||
- Histórico de valores em tabela/gráfico
|
||||
- Campos personalizados por especialidade
|
||||
- Anexo de arquivos centralizado
|
||||
- **Assinatura Digital** com validade jurídica
|
||||
- Consulta CID-10 integrada
|
||||
- Compartilhamento de prontuário entre profissionais
|
||||
- **Controle de vacinas** (rastreamento por idade) — menos relevante pra psico
|
||||
- Tags de lembrete
|
||||
- Comparação de imagens (evolução do tratamento)
|
||||
|
||||
#### Prescrição Eletrônica
|
||||
- Base de medicamentos atualizada
|
||||
- Modelos personalizados
|
||||
- **Envio por WhatsApp** direto ao paciente
|
||||
|
||||
#### Emissão de Documentos
|
||||
- Modelos com campos automáticos
|
||||
- Preenchimento automático de dados do paciente
|
||||
|
||||
#### Ferramentas Clínicas
|
||||
- Calculadoras: IMC, curva de crescimento, IG, DPP
|
||||
- Integração com Glic (diabetes)
|
||||
|
||||
#### Outros módulos mencionados
|
||||
- Agenda Médica · Teleconsulta · Agendamento Online · Marketing Médico (email campaigns) · Gestão Financeira · Recepcionista
|
||||
|
||||
### Destaques comparativos
|
||||
| Feature | iClinic | AgenciaPsi |
|
||||
|---|---|---|
|
||||
| Assinatura digital com validade jurídica | ✅ | `[gap]` |
|
||||
| CID-10 integrado | ✅ | `[?]` |
|
||||
| Prescrição eletrônica com WhatsApp | ✅ | `[gap]` |
|
||||
| Email campaigns (marketing) | ✅ | `[gap]` |
|
||||
| Histórico em gráfico/tabela evolutiva | ✅ | `[?]` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Amplimed (Brasil) — AI-first `[fetched 2026-04-17]`
|
||||
|
||||
### Resumo executivo
|
||||
- **País:** Brasil · **Nicho:** multispecialidade com **forte aposta em IA**
|
||||
- **Posicionamento:** "IA que trabalha pela sua clínica"
|
||||
|
||||
### Módulos confirmados
|
||||
|
||||
#### 🤖 Amélia (IA proprietária — 3 produtos)
|
||||
- **Amélia Agendamento** — bot virtual que atende e responde pacientes via WhatsApp **24/7** com sincronização automática da agenda
|
||||
- **Amélia Transcrição** — converte áudio em texto e preenche prontuário automaticamente
|
||||
- **Amélia Copilot** — localiza, resume e organiza info do paciente + pesquisas médicas
|
||||
|
||||
#### Gestão
|
||||
- Gestão Clínica com redução de até **38% de ausências** via agenda inteligente
|
||||
- Prontuário Eletrônico estruturado + compartilhamento seguro
|
||||
- Agenda de Procedimentos (cirurgias, exames) com controle de conflitos
|
||||
|
||||
#### Agendamento & Comunicação
|
||||
- Agendamento Online 24/7 com integração a prontuário + pagamento
|
||||
- SMS (redução de faltas)
|
||||
- **WhatsApp Connect** — mensagens personalizadas + lembretes
|
||||
|
||||
#### Financeiro
|
||||
- **Faturamento TISS** — "99% menos glosas" via padrão automático — `[gap]` (psico usa pouco TISS, mas é diferencial enorme pra clínica grande)
|
||||
- **NFS-e** emissão simplificada integrada
|
||||
- Dashboards de indicadores
|
||||
|
||||
#### Outros
|
||||
- Teleconsulta com criptografia E2E + prescrição digital
|
||||
- Estoque com alertas de validade
|
||||
- **Painel de chamados** com TV na recepção
|
||||
- **Certificado Digital ICP-Brasil** embutido
|
||||
|
||||
### Destaques comparativos (e FORTES alertas)
|
||||
| Feature | Amplimed | AgenciaPsi |
|
||||
|---|---|---|
|
||||
| Bot WhatsApp 24/7 que agenda sozinho | ✅ Amélia | `[gap]` 🔥 |
|
||||
| Transcrição de sessão via áudio | ✅ Amélia | `[gap]` 🔥 |
|
||||
| Copilot de IA no prontuário | ✅ Amélia | `[gap]` 🔥 |
|
||||
| NFS-e integrado | ✅ | `[gap]` |
|
||||
| Faturamento TISS com validação automática | ✅ | `[gap]` |
|
||||
| Certificado digital ICP-Brasil | ✅ | `[gap]` |
|
||||
|
||||
> **Nota:** AI-heavy é a principal tendência BR em 2026. Amplimed e Psicomanager (prov.) vão nessa direção. Pode ser **o diferencial** do AgenciaPsi se investir.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ninsaúde (Brasil) — ERP clínico `[fetched 2026-04-17]`
|
||||
|
||||
### Resumo executivo
|
||||
- **País:** Brasil · **Nicho:** ERP clínico completo (várias especialidades) — menos focado em psico
|
||||
- **Posicionamento:** "ERP da clínica" — mais robusto, mais caro
|
||||
|
||||
### Módulos confirmados
|
||||
- Prontuário Eletrônico
|
||||
- Sistema de Atendimento (fluxo)
|
||||
- Gerenciamento Financeiro
|
||||
- **Faturamento de Convênios**
|
||||
- Controle de Estoque
|
||||
- Engajamento dos Pacientes (retenção)
|
||||
- Engajamento da Equipe (motivação interna)
|
||||
- Análise Inteligente (BI)
|
||||
- Retenção de Pacientes
|
||||
|
||||
### Produtos satélites
|
||||
- **Ninsaúde CRM** — gestão de relacionamento
|
||||
- **Ninsaúde Safe** — segurança de dados
|
||||
- **Ninsaúde Sign** — assinatura digital
|
||||
- Integração QR Code
|
||||
- **Nome Social** — respeita identidade de gênero no cadastro
|
||||
|
||||
### Destaques comparativos
|
||||
| Feature | Ninsaúde | AgenciaPsi |
|
||||
|---|---|---|
|
||||
| CRM interno (leads, funil) | ✅ | `[gap]` |
|
||||
| Nome social | ✅ | `[?]` (validar se temos) |
|
||||
| Retenção automatizada (CRM) | ✅ | `[gap]` |
|
||||
| BI avançado | ✅ | `[parcial]` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Jane App (Canadá) — referência internacional de UX `[fetched 2026-04-17]`
|
||||
|
||||
### Resumo executivo
|
||||
- **País:** Canadá · **Idioma:** inglês · **Mercados:** CA, US, UK
|
||||
- **Posicionamento:** "o app mais amado pelos profissionais de saúde" — UX premium
|
||||
|
||||
### Módulos confirmados
|
||||
|
||||
#### Cliente & Agendamento
|
||||
- **Online Booking** com branding customizável da clínica + 24/7
|
||||
- **Staff & Appointment Scheduling** multi-location (sala, recurso, waitlist, pagamento — tudo na mesma visão)
|
||||
|
||||
#### Documentação
|
||||
- **Charting & Documentation** — biblioteca de templates, SOAP notes, forms, surveys
|
||||
- **AI Scribe** — grava ou dicta a sessão e gera nota em minutos (integrado)
|
||||
|
||||
#### Comunicação
|
||||
- **Telehealth** HIPAA-compliant — até **12 clientes** por sessão (solo + grupo/family)
|
||||
|
||||
#### Financeiro
|
||||
- **Jane Payments** — online + terminal físico, PCI-compliant
|
||||
- Billing & Insurance multi-região (CA, US, UK)
|
||||
- Invoicing de serviços, memberships e produtos
|
||||
|
||||
#### BI
|
||||
- Real-time dashboards de saúde do negócio
|
||||
- Relatórios customizáveis
|
||||
|
||||
#### Infra & Support
|
||||
- Compliance: HIPAA + PIPEDA + GDPR + SOC-2 + 256-bit encryption
|
||||
- Integrações com apps third-party
|
||||
- **Website Builder com IA** — cria site da clínica auto-sincronizado com staff/branding
|
||||
- Suporte ilimitado (phone/email/chat)
|
||||
- **Migração de dados grátis**
|
||||
|
||||
### Destaques comparativos
|
||||
| Feature | Jane App | AgenciaPsi |
|
||||
|---|---|---|
|
||||
| AI Scribe (grava e gera nota) | ✅ | `[gap]` 🔥 |
|
||||
| Terminal físico de pagamento | ✅ | `[gap]` (POS integrado) |
|
||||
| Multi-location + resources (sala/equipamento) | ✅ | `[parcial]` |
|
||||
| Website builder integrado | ✅ | `[gap]` |
|
||||
| Cert SOC-2 | ✅ | `[gap]` |
|
||||
| Migração grátis de outra plataforma | ✅ | `[gap]` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Comparação rápida (quadro)
|
||||
|
||||
> Linhas com `*` têm fonte `[fetched]` confirmada do SimplePractice. Demais são `[público]`/`[?]`.
|
||||
|
||||
| Domínio | AgenciaPsi | SimplePractice | Psicomanager |
|
||||
|---|---|---|---|
|
||||
| Cadastro de pacientes | `[tem]` | `[tem]` | `[tem]` |
|
||||
| Grupos / Tags | `[tem]` | `[tem]` | `[?]` |
|
||||
| **Busca global topbar** | `[?]` | `[tem]` | `[?]` |
|
||||
| **Recently viewed** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Merge de duplicatas** | `[gap]` | `[tem]` | `[?]` |
|
||||
| Agenda | `[tem]` | `[tem]` | `[tem]` |
|
||||
| Agendamento online público | `[tem]` | `[tem]` | `[?]` |
|
||||
| Recorrências | `[tem]` | `[tem]` | `[?]` |
|
||||
| **Teleconsulta integrada** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **Sala de espera virtual** | `[gap]` | `[tem]` * | `[gap]` |
|
||||
| **Screen sharing na sessão** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **Digital whiteboard** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **Video em grupo (até 15)** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **Blurred background (vídeo)** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| Prontuário / Notas | `[tem]` | `[tem]` | `[tem]` |
|
||||
| **Templates de nota (SOAP/BIRP/DAP)** | `[?]` | `[tem]` | `[?]` |
|
||||
| **Versionamento de nota** | `[?]` | `[tem]` | `[?]` |
|
||||
| **Biblioteca de avaliações (GAD-7/PHQ-9)** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Consent forms assinados** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Assinatura eletrônica paciente** | `[?]` | `[tem]` | `[?]` |
|
||||
| **Papel timbrado** | `[gap]` | `[tem]` | `[tem]` |
|
||||
| **Intake / pacote onboarding** | `[parcial]` | `[tem]` | `[?]` |
|
||||
| **Upload de arquivo pelo paciente** | `[?]` | `[tem]` | `[?]` |
|
||||
| Cobrança / financeiro | `[tem]` | `[tem]` | `[tem]` |
|
||||
| **Gateway de pagamento (Stripe/PIX)** | `[gap]` | `[tem]` | `[tem]` |
|
||||
| **Cartão on file** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Auto-billing recorrente** | `[parcial]` | `[tem]` | `[?]` |
|
||||
| **Superbill / recibo detalhado** | `[parcial]` | `[tem]` | `[?]` |
|
||||
| **NFSe emissão** | `[?]` | N/A (EUA) | `[tem]` |
|
||||
| Convênios / Insurance | `[parcial]` | `[tem]` | `[?]` |
|
||||
| **Claims eletrônico (TISS/837)** | `[gap]` | `[tem]` | `[gap]` |
|
||||
| Lembretes Email/SMS/WA | `[tem]` | `[tem]` | `[tem]` |
|
||||
| **Confirmação pelo paciente** | `[?]` | `[tem]` | `[?]` |
|
||||
| Portal do paciente | `[parcial]` | `[tem]` * | `[tem]` |
|
||||
| **Push notifications (portal)** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **Portal multi-idioma (ES)** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **Paciente paga fatura no portal** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **App mobile paciente** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| Analytics / Relatórios | `[parcial]` | `[tem]` | `[tem]` |
|
||||
| **Export PDF/Excel** | `[parcial]` | `[tem]` | `[?]` |
|
||||
| Supervisão | `[parcial]` | `[tem]` | `[?]` |
|
||||
| **Co-assinatura supervisor** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Marketing / perfil público** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Google Calendar sync** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Multi-unidade / filiais** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Especialidades (cadastro)** | `[gap]` | `[tem]` | `[?]` |
|
||||
| **Tipo de registro (CRP/CRM)** | `[?]` | `[tem]` | `[tem]` |
|
||||
| **Tasks / To-do por cliente** | `[gap]` | `[?]` | `[?]` |
|
||||
| **E-prescribing** | N/A | `[tem]` | N/A |
|
||||
| Multi-tenant SaaS | `[tem]` | `[tem]` | `[?]` |
|
||||
| RLS por tenant | `[tem]` | N/A | `[?]` |
|
||||
| **Credentialing grátis** (EUA) | N/A | `[tem]` * | N/A |
|
||||
| **Migração assistida de outra plataforma** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **HITRUST cert** | `[gap]` | `[tem]` * | `[?]` |
|
||||
| **Compliance HIPAA/PCI** | N/A (LGPD) | `[tem]` * | N/A |
|
||||
|
||||
---
|
||||
|
||||
## 9. Quem faz melhor — diferenciais por player
|
||||
|
||||
Resumo punchy: cada concorrente tem um ponto onde está **claramente na frente** do que AgenciaPsi entrega hoje. Mapa pra você pensar em "copiar o melhor de cada um":
|
||||
|
||||
### 🤖 Inteligência Artificial
|
||||
- **Amplimed — Amélia Agendamento** 🥇 · bot 24/7 que agenda pelo WhatsApp sozinho
|
||||
- **Amplimed — Amélia Transcrição** · áudio→texto no prontuário
|
||||
- **Amplimed — Amélia Copilot** · resumo/pesquisa inteligente no prontuário
|
||||
- **Jane App — AI Scribe** · grava sessão e gera nota em minutos
|
||||
- **PsicoPlanner — PsiAssist** · gera relatórios/documentos com **compliance CFP** (diferencial BR)
|
||||
|
||||
> Conclusão: IA é **a tendência dominante em 2026**. AgenciaPsi tem `[gap]` nos 3 casos (agendador, transcrição, resumo). Virar IA-first pode ser o **diferencial de posicionamento**.
|
||||
|
||||
### 💬 WhatsApp / Engajamento
|
||||
- **PsicoPlanner** 🥇 · agenda diária às 7h automática, rastreamento de leitura/resposta em tempo real
|
||||
- **Amplimed** · WhatsApp Connect + Amélia como bot de atendimento
|
||||
- **iClinic** · envio de prescrição direto pelo WhatsApp
|
||||
|
||||
### 💰 Faturamento / Fiscal BR
|
||||
- **Amplimed** 🥇 · "99% menos glosas" no TISS + NFS-e integrada + ICP-Brasil
|
||||
- **iClinic** · assinatura digital com validade jurídica embutida
|
||||
- **Ninsaúde Sign** · assinatura digital como produto satélite
|
||||
|
||||
### 🎥 Teleconsulta
|
||||
- **SimplePractice** 🥇 · vídeo grupo (15 pessoas), whiteboard digital, blurred BG, screen share
|
||||
- **Jane App** · telehealth multi-cliente (até 12)
|
||||
- **PsicoPlanner** · sala de vídeo integrada (solo, mas nativa)
|
||||
- **Amplimed** · teleconsulta E2E + prescrição digital
|
||||
|
||||
### 📋 Prontuário / Documentação
|
||||
- **SimplePractice** 🥇 · biblioteca GAD-7/PHQ-9/BDI + consent forms assinados + Spanish portal
|
||||
- **Jane App** · template library + SOAP/notes + AI Scribe
|
||||
- **iClinic** · histórico em gráfico/tabela + CID-10 + vacinação
|
||||
|
||||
### 💳 Pagamentos
|
||||
- **Jane App** 🥇 · Jane Payments online + **terminal físico POS**
|
||||
- **SimplePractice** · AutoPay + Superbills
|
||||
- **Amplimed** · integrado ao prontuário
|
||||
|
||||
### 🌐 Portal Paciente
|
||||
- **SimplePractice** 🥇 · portal em EN+ES, self-scheduling, intake, questionnaires, invoice payment, messaging seguro
|
||||
- **Jane App** · online booking + website builder com IA
|
||||
|
||||
### 🏢 Multi-unidade / Rede
|
||||
- **Jane App** 🥇 · multi-location + sala/equipamento como resources
|
||||
- **SimplePractice** · Practice Details com Main Office definido
|
||||
|
||||
### 🔒 Compliance & Segurança
|
||||
- **Jane App** 🥇 · HIPAA + PIPEDA + GDPR + SOC-2 + 256-bit
|
||||
- **SimplePractice** · HIPAA + HITRUST + PCI + BAA
|
||||
- **Ninsaúde Safe** · produto de segurança dedicado
|
||||
|
||||
### 🎁 Onboarding / Migração
|
||||
- **Jane App** 🥇 · migração de dados **grátis**
|
||||
- **SimplePractice** · "Switching Assistance" + Credentialing grátis
|
||||
|
||||
### 🎯 Retenção / CRM
|
||||
- **Ninsaúde CRM** 🥇 · produto dedicado
|
||||
- **Amplimed** · redução 38% ausências via agenda inteligente
|
||||
- **iClinic** · marketing médico (email campaigns)
|
||||
|
||||
---
|
||||
|
||||
## 10. Gaps priorizados (primeira leitura)
|
||||
|
||||
Agrupando os `[gap]` acima por tema e impacto-no-usuário:
|
||||
|
||||
### 🔴 Críticos pro MVP (quick wins, dependência fraca)
|
||||
1. **Especialidades** (lista + "outro") no cadastro da clínica/profissional
|
||||
2. **Tipo de registro** (CRP/CRM) no cadastro do profissional
|
||||
3. **Busca global de clientes** no topbar
|
||||
4. **Recently viewed** (últimos pacientes acessados)
|
||||
5. **Papel timbrado** (portar do UniaoApp quando chegar a hora)
|
||||
|
||||
### 🟡 Alto valor, esforço médio
|
||||
6. **Biblioteca de instrumentos de avaliação** (GAD-7, PHQ-9, BDI, BAI, DASS-21) com scoring automático — **diferencial forte no BR**
|
||||
7. **Biblioteca de termos de consentimento** (TCLE, Telehealth, LGPD) — editáveis
|
||||
8. **Assinatura eletrônica** no portal do paciente
|
||||
9. **Templates de nota de sessão** (SOAP, DAP, BIRP ou equivalente BR)
|
||||
10. **Auto-billing recorrente** (cobrança automática baseada em agenda)
|
||||
|
||||
### 🔵 Grandes (esforço alto, dependências)
|
||||
11. **Gateway de pagamento integrado** (Stripe/Asaas/Iugu) — destrava cartão on file, auto-billing, superbill
|
||||
12. **Multi-unidade / filiais** com unidade principal
|
||||
13. **Teleconsulta integrada** (video próprio ou Daily/Jitsi)
|
||||
14. **Google Calendar 2-way sync**
|
||||
15. **Co-assinatura de supervisor**
|
||||
16. **Claims eletrônico TISS** (convênios BR — mercado nichado mas alto valor pra clínicas grandes)
|
||||
|
||||
### ⚪ Diferenciação / marketing
|
||||
17. **Perfil público / diretório de profissionais**
|
||||
18. **App mobile** (PWA talvez seja suficiente)
|
||||
19. **NFSe automatizada** (se for posicionar contra Amplimed/iClinic)
|
||||
20. **Website builder** pra clínica (Jane App faz com IA)
|
||||
21. **Terminal POS físico** pra pagamento presencial
|
||||
|
||||
### 🤖 IA (novo balde — tendência forte 2026)
|
||||
22. **Bot WhatsApp que agenda sozinho** — equivalente Amélia Agendamento da Amplimed (ROI claro: 24/7 sem recepcionista)
|
||||
23. **Transcrição de sessão áudio→texto** — equivalente Amélia Transcrição ou AI Scribe do Jane
|
||||
24. **Copilot no prontuário** — resumo da história clínica do paciente + busca semântica
|
||||
25. **Gerador de documentos** (relatórios, laudos) com **compliance CFP** — equivalente PsiAssist do PsicoPlanner
|
||||
|
||||
### 🧾 Fiscal / Jurídico BR (diferencial local)
|
||||
26. **Assinatura digital ICP-Brasil** (Amplimed, iClinic têm)
|
||||
27. **NFS-e integrada** com dados da consulta
|
||||
28. **Faturamento TISS automatizado** (nichado mas essencial pra clínicas grandes)
|
||||
29. **Nome social** no cadastro do paciente (Ninsaúde tem) — LGPD + CFP
|
||||
|
||||
---
|
||||
|
||||
## 11. Próximos passos
|
||||
|
||||
### Feito em 2026-04-17
|
||||
- [x] Estrutura do doc criada e notas do usuário organizadas
|
||||
- [x] **Primeira rodada de WebFetch** (SP + Psicomanager)
|
||||
- ✅ SP `/features/` — categorias top-level + compliance
|
||||
- ✅ SP `/features/telehealth/` — módulo completo extraído
|
||||
- ✅ SP `/features/client-portal/` — módulo completo extraído
|
||||
- ✅ SP `/pricing/` — escala (20M clientes, 250k practitioners) + trials
|
||||
- ❌ SP `/features/scheduling|billing|documentation/` — 404 (URLs mudaram)
|
||||
- ❌ Psicomanager — site é SPA JS, scraping não retorna conteúdo
|
||||
- [x] **Segunda rodada — varredura de concorrentes BR + internacional**
|
||||
- ✅ **iClinic** (`/funcionalidades/` + `/`) — Prontuário + Prescrição + Documentos + Ferramentas
|
||||
- ✅ **Amplimed** (`/`) — **AI-first** (Amélia Agendamento/Transcrição/Copilot) + TISS + NFS-e + ICP
|
||||
- ✅ **PsicoPlanner** (`/`) — concorrente direto: WhatsApp + PsiAssist (IA com CFP) + preços
|
||||
- ✅ **Jane App** (`/features`) — referência internacional de UX + AI Scribe + Jane Payments
|
||||
- ✅ **Ninsaúde** (`/`) — ERP clínico + CRM + Safe + Sign + Nome Social
|
||||
- ❌ Feegow — SSL handshake falhou
|
||||
- ❌ Mapps (mapps.com.br, mappsbr.com.br) — DNS não resolve
|
||||
- ❌ TheraNest (`/features/`) — SSL handshake falhou
|
||||
- ❌ Doctoralia (`/empresa/produtos`) — 404
|
||||
- [x] Seções 3-7 adicionadas ao doc com features extraídas
|
||||
- [x] Seção 9 "Quem faz melhor" criada — diferenciais por player
|
||||
- [x] Gaps priorizados expandidos (+12 itens novos, com balde 🤖 IA e 🧾 Fiscal BR)
|
||||
|
||||
### Pendente
|
||||
- [ ] **Validar este doc** — você lê e marca o que está certo/errado/incompleto
|
||||
- [ ] **SimplePractice restante** (alta prioridade): Documentation/Notes, Billing & Insurance, Analytics, Supervision
|
||||
- Estratégia: você navega logado/trial e me manda prints — eu transcrevo pro doc
|
||||
- [ ] **Psicomanager completo** — impossível via WebFetch, precisa de:
|
||||
- Prints das telas principais, ou
|
||||
- Reviews em B2BStack / Capterra BR / SoftwareSuggest, ou
|
||||
- Trial pra navegar junto
|
||||
- [ ] **Decidir fases** — transformar a lista de gaps em roadmap v1/v2/v3
|
||||
- [ ] **Atualizar `mapa-sistema.html`** marcando os itens `missing` conforme formos confirmando gaps
|
||||
|
||||
---
|
||||
|
||||
## Rascunho original do usuário (17/04/2026)
|
||||
|
||||
> *Preservado aqui pra não perder nenhuma observação. Tudo acima foi organizado a partir destas notas.*
|
||||
|
||||
```
|
||||
SIMPLE PRATICTICE
|
||||
CLIENTES
|
||||
PAGAMENTOS
|
||||
INSURENCE
|
||||
ANALYTICS
|
||||
ATIVIDADES
|
||||
SUPERVISION
|
||||
SETTINGS
|
||||
REMINDERS
|
||||
REQUESTS
|
||||
MARKETING
|
||||
RECENTLY VIEWS
|
||||
BUSCA NO TOPBAR POR CLIENTES
|
||||
|
||||
ESTOU VENDO A TELA DE CLINICAL INFO. PARA OS EUA, TEM NPI NUMBER, TAXONOMY CODE E SPECIALITY,
|
||||
NOS JA TEMOS UM CADASTRO DE CLINICA COMPLETO PARA O BRASIL. ACHO QUE PODERIAMOS COLOCAR O CAMPO
|
||||
ESPECIALIDADES TBM, LISTAR UM MONTE E SE O USUARIO NAO ENCONTRAR, CADASTRAR O SEU.
|
||||
|
||||
TBM TEM UM CAMPO LICENSES TYPE, ONDE O USUARIO SELECIONA SE É MEDICO, PHD, ...
|
||||
(NÃO SEI SE ISSO SERIA AKI).
|
||||
|
||||
AI TEM UMA SESSAO CHAMADA PRACTICE DETAILS ONDE CADASTRA A LOCALIZACAO DA CLINICA
|
||||
E DIGAMOS FILIAIS (NO CASO DE GESTAO DE CLINICAS). É POSSIVEL DEFINIR QUAL É O MAIN OFFICE.
|
||||
|
||||
EM OUTRA ABA ACHAMADA CLIENT CARE, TEM O TEMPLATE LIBRARY, QUE TEM VARIADOS TIPOS
|
||||
DE ARQUIVOS PRONTOS PARA PERSONALIZAR. E INLUSIVE MONTAR O TEMPLATE.
|
||||
EX. GAD-7 (GENERALIZED ANXIETY DISORDER), PHQ-9 (PATIENT HEALTH QUESTIONAIRE),
|
||||
COVID-19 PRE-APPOINTMENT SCREENING QUESTIONAIRE.
|
||||
|
||||
TBM TEM UMA OUTRA SESSAO CHAMADA SHARABLE DOCUMENTS: A SESSAO DIZ
|
||||
MANAGE DEFAULT INTAKE DOCUMENTS AND UPLOADS FILES.
|
||||
|
||||
CONSENT FORMS: READ AN SIGNED BY CLIENTS VIA THA CLIENT PORTAL
|
||||
LEGAL DISCLAIMER: CONSENT FORMS ARE FOR REFERENCE ONLY.
|
||||
ITS YOUR RESPONSIBILITY TO CUSTOMIZE THEM AND ENSURE THEY MEET
|
||||
THE LEGAL REQUERIMENTS OF YOUR STATE.
|
||||
|
||||
- CONSENT FOR TELEHEALTH CONSULTATION
|
||||
- CREDIT CARD AUTORIZATION
|
||||
- NOTICE OF PRIVACY PRATICES
|
||||
- INFORMED CONSENT FOR PSYCOTHERAPY
|
||||
- PRACTICE POLICIES
|
||||
- RELEASE FORMS
|
||||
- CONSENT TO RECORD AUDIO
|
||||
- + NEW CONSENT FORM
|
||||
|
||||
O USUARIO CONSEGUE ADICIONAR, EDITAR UM NOVO E VISUALIZAR.
|
||||
|
||||
PRECISA CRIAR A PARTE DE PAPEL TIMBRADO, ASSIM COMO FIZEMOS NO OUTRO PROJETO,
|
||||
QUANDO CHEGAR A HORA VC PODE ME PERGUNTAR.
|
||||
```
|
||||
@@ -0,0 +1,278 @@
|
||||
# AgenciaPsi — Roadmap em Fases
|
||||
|
||||
> Proposta derivada de `ESTRUTURA.md` (o que já existe) + `concorrentes.md` (o que o mercado tem).
|
||||
> Data: 2026-04-17
|
||||
> **Decisões estratégicas** abertas pra validação estão marcadas com ⚠️
|
||||
|
||||
---
|
||||
|
||||
## Posicionamento recomendado (proposta)
|
||||
|
||||
Olhando o estado atual e o terreno competitivo, recomendo:
|
||||
|
||||
> **"Psicomanager moderno + IA"** — competir diretamente no nicho de psicologia BR (onde já temos 75-80% pronto), ganhar por **UX moderna** (Vue 3 / PrimeVue / dark mode) e **IA nativa** (onde Psicomanager/PsicoPlanner ainda estão correndo atrás). Pricing entry baixo (tipo PsicoPlanner R$59-79/mês solo) + tier clínica pra monetizar rede.
|
||||
|
||||
**Por quê esse corte:**
|
||||
- 🎯 **Foco:** psico-first (e não generalista como iClinic/Amplimed) — o diferencial é conhecer profundamente o fluxo CFP
|
||||
- 🎯 **Competitividade:** IA é o campo aberto — Amplimed (Amélia) e Jane (AI Scribe) já estão lá; ainda dá pra chegar
|
||||
- 🎯 **Custo:** solo-dev → priorizar features de **alto ROI por esforço**; deixar TISS/hospital-grade pra depois
|
||||
- 🎯 **UX:** PrimeVue/Tailwind dá dignidade visual que Psicomanager ainda não tem
|
||||
|
||||
⚠️ **Decisão aberta:** se você quer competir por preço-baixo (PsicoPlanner) ou por valor-alto (Jane App), o roadmap muda. Supus mid-market aqui.
|
||||
|
||||
---
|
||||
|
||||
## Legenda de esforço
|
||||
- **S** — <1 semana (quick win)
|
||||
- **M** — 1-3 semanas
|
||||
- **L** — 3-8 semanas
|
||||
- **XL** — 2+ meses (feature grande, múltiplas deps)
|
||||
|
||||
## Legenda de prioridade
|
||||
- 🔴 **Bloqueador** — sem isso, não rola lançar a fase
|
||||
- 🟠 **Alta** — cria valor imediato pro usuário, reduz churn
|
||||
- 🟡 **Média** — completa a paridade, não é urgente
|
||||
- 🔵 **Diferencial** — aposta de posicionamento
|
||||
|
||||
---
|
||||
|
||||
# 🚀 FASE 1 — MVP Launch
|
||||
|
||||
**Objetivo:** ter um produto cobrável, confiável, completo o suficiente pra um terapeuta solo trocar Psicomanager/PsicoPlanner pelo AgenciaPsi.
|
||||
**Timeline sugerida:** 4-6 semanas
|
||||
**Critério de saída:** 5 usuários pagantes em produção sem churn por "falta de feature básica".
|
||||
|
||||
## 1.1 Monetização (bloqueador total) 🔴
|
||||
|
||||
Você **não consegue cobrar** os próprios clientes nem as assinaturas SaaS sem isso.
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 1 | **Integração com gateway de pagamento** (Asaas ou Iugu recomendado pro BR — PIX + cartão + boleto nativos) | L | Asaas é o mais barato pra começar; Iugu tem API melhor. Escolha 1. Stripe só se for internacional. |
|
||||
| 2 | **Cartão on file** (tokenização via gateway) | M | Desdobra de #1 |
|
||||
| 3 | **Auto-billing recorrente** (baseado na agenda) | M | Trigger: sessão realizada → gera fatura → cobra automaticamente |
|
||||
| 4 | **Cobrança das próprias assinaturas SaaS** (tenants pagam pelo plano) | M | Aproveita estrutura de `subscriptions` que já existe |
|
||||
|
||||
## 1.2 Compliance básico BR 🔴
|
||||
|
||||
Não dá pra lançar sem isso ou você pega processo em 6 meses.
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 5 | **Tipo de registro profissional** (CRP, CRM, CRFa, RMS…) — campo obrigatório no cadastro | S | Aparece em recibos/laudos |
|
||||
| 6 | **Biblioteca de consent forms editáveis** (TCLE, Telehealth, LGPD, Gravação, TCLE menores) | M | Templates pré-prontos; profissional customiza |
|
||||
| 7 | **Assinatura eletrônica pelo paciente no portal** (simples, com IP+timestamp) | M | Não precisa ICP-Brasil nessa fase |
|
||||
| 8 | **Nome social** no cadastro (além do nome de registro) — CFP exige | S | Campo adicional; aparece em todas as telas voltadas ao paciente |
|
||||
| 9 | **Especialidades** no cadastro do profissional (lista + "outra") | S | DB: tabela `specialties` + FK em `profiles` |
|
||||
|
||||
## 1.3 UX mínima esperada 🟠
|
||||
|
||||
Todo concorrente tem — usuário novo estranha se não tiver.
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 10 | **Busca global no topbar** (paciente, email, telefone) | S | Autocomplete com debounce + highlight |
|
||||
| 11 | **Recently viewed** (últimos 5 pacientes acessados, por usuário) | S | localStorage ou tabela `user_recent_access` |
|
||||
| 12 | **Papel timbrado** (portar do UniaoApp como você mencionou) | M | Já existe noutro projeto — só adaptar |
|
||||
| 13 | **Relatórios com export PDF/Excel** (já tem estrutura, fechar) | M | Fechar os gaps que o MVP assessment apontou |
|
||||
|
||||
## 1.4 Fiscal mínimo 🟠
|
||||
|
||||
Pra o terapeuta conseguir operar.
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 14 | **Recibo profissional** (PDF gerado, com dados CFP) | S | Já tem base de documentos — só formalizar template |
|
||||
| 15 | **NFS-e emissão** (integração Focus NF-e ou NFS-e direto da prefeitura) | L | Pode ficar pra 1.5 se apertar prazo; faz diferença pro profissional se diferenciar |
|
||||
|
||||
## 1.5 Qualidade pra lançar 🟠
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 16 | **Testes E2E dos fluxos críticos** (cadastro, login, agendamento, cobrança, prontuário) | L | Playwright ou Cypress; prioridade: compra + agendamento |
|
||||
| 17 | **Responsividade mobile validada** (terapeuta consulta agenda no celular) | M | Auditoria + fix de breakpoints |
|
||||
| 18 | **Monitoramento de produção** (Sentry + Supabase dashboards) | S | Detectar bugs antes do usuário reclamar |
|
||||
|
||||
### 📦 Entregas da Fase 1
|
||||
- **Produto cobrável** (gateway integrado)
|
||||
- **Legal safe** (consent forms, nome social, especialidades)
|
||||
- **UX polida** (busca, papel timbrado, relatórios)
|
||||
- **Testado** (E2E crítico + mobile)
|
||||
|
||||
### ⚠️ Decisões abertas
|
||||
- **Gateway:** Asaas (barato) vs Iugu (melhor DX) vs Stripe (internacional depois) — recomendo **Asaas** pra começar rápido
|
||||
- **NFS-e:** incluir na 1.4 ou empurrar pra 2? Recomendo **incluir** — é quick win e diferencia de Psicomanager
|
||||
- **Pricing:** qual é o seu valor? (R$59 solo como PsicoPlanner? R$99? R$149?) — afeta quanto time você tem pra chegar nas próximas fases
|
||||
|
||||
---
|
||||
|
||||
# 🏗️ FASE 2 — Paridade Competitiva
|
||||
|
||||
**Objetivo:** qualquer usuário avaliando AgenciaPsi × Psicomanager × PsicoPlanner deve ver **paridade ou mais** de features. Nenhum "ah, mas lá tem X" válido.
|
||||
**Timeline sugerida:** 2-3 meses após Fase 1
|
||||
**Critério de saída:** feature checklist empatada com top-3 concorrentes nichados.
|
||||
|
||||
## 2.1 Comunicação / Engajamento (onde PsicoPlanner ganha hoje) 🟠
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 19 | **Agenda diária automática 7h via WhatsApp** | S | Cron job + template; feature signature do PsicoPlanner |
|
||||
| 20 | **Confirmação de presença pelo paciente** ("responder SIM") | M | Parser de resposta WhatsApp/SMS → atualiza status |
|
||||
| 21 | **Rastreamento de engajamento em tempo real** (recebeu/leu/respondeu) | M | Webhooks da Evolution API + dashboard no topbar |
|
||||
| 22 | **Envio de prescrição/documento via WhatsApp direto** | S | Botão "Enviar via WA" já gerando link temporário |
|
||||
|
||||
## 2.2 Prontuário (onde iClinic ganha hoje) 🟠
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 23 | **Templates de nota** (SOAP / DAP / BIRP / evolução livre CFP-style) | M | Biblioteca com templates selecionáveis |
|
||||
| 24 | **Biblioteca de instrumentos de avaliação** (GAD-7, PHQ-9, BDI, BAI, DASS-21, SRQ-20) com scoring automático | L | Cada escala tem perguntas + score calculado + gráfico de evolução |
|
||||
| 25 | **Histórico em gráfico/tabela** (evolução de escalas ao longo do tempo) | M | Chart.js já no projeto |
|
||||
| 26 | **Versionamento de notas** (auditoria de alterações) | M | Log de alterações + diff visual |
|
||||
|
||||
## 2.3 Intake / Onboarding do paciente 🟡
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 27 | **Pacote de intake** (formulários + anamnese + consent forms enviados pré-1ª-sessão) | M | Fluxo: terapeuta monta pacote → paciente recebe link → preenche → assina → terapeuta vê tudo |
|
||||
| 28 | **Upload de arquivo pelo paciente** (exames, relatórios externos) | S | Storage bucket já configurado |
|
||||
|
||||
## 2.4 Agenda / Integrações 🟡
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 29 | **Google Calendar 2-way sync** | L | API OAuth + conflict resolution |
|
||||
| 30 | **iCal feed** (leitura por Apple Calendar / Outlook) | S | Endpoint que serve .ics |
|
||||
|
||||
## 2.5 Fiscal avançado BR (onde Amplimed/iClinic ganham) 🟡
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 31 | **Assinatura digital ICP-Brasil** (laudos com validade jurídica) | L | Integração com ValidCertificadora ou similar |
|
||||
| 32 | **Faturamento TISS básico** (pra clínicas que têm convênio) | XL | Nichado — só se você quiser ir pra tier clínica/rede |
|
||||
|
||||
## 2.6 Marketing / Presença 🟡
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 33 | **Perfil público do terapeuta** (página /p/<slug> com bio, horários, agendamento) | M | Já tem agendador público — só enriquecer |
|
||||
| 34 | **SEO básico** (schema.org/MedicalBusiness + meta tags) | S | Melhora descoberta orgânica |
|
||||
|
||||
### 📦 Entregas da Fase 2
|
||||
- Paridade completa com Psicomanager/PsicoPlanner/iClinic
|
||||
- **Sem furo comparativo** em review de feature
|
||||
- Começo de presença pública (perfil + SEO)
|
||||
|
||||
### ⚠️ Decisões abertas
|
||||
- **TISS (item 32):** grande esforço por nichado — só faz sentido se você for atrás de mercado de clínica com convênio. **Recomendo não fazer** nesta fase.
|
||||
- **ICP-Brasil (item 31):** pesa? Depende do público. Terapeuta solo raramente precisa. Clínica com laudo pericial, sim. **Recomendo empurrar pra Fase 3** se o MVP foi pra solo.
|
||||
|
||||
---
|
||||
|
||||
# 🧠 FASE 3 — Diferenciação (IA-first)
|
||||
|
||||
**Objetivo:** ter 2-3 features que **nenhum concorrente BR tem em paridade**. Vira marketing: "o sistema com IA pra psicólogos".
|
||||
**Timeline sugerida:** 3-6 meses após Fase 2
|
||||
**Critério de saída:** você consegue justificar um **tier premium 2x mais caro** que o básico.
|
||||
|
||||
## 3.1 IA — Onde compensa correr 🔵
|
||||
|
||||
O time de IA geral (Claude, GPT) tá commoditizado — vantagem vai pra quem **integra bem** ao workflow do usuário.
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 35 | **Bot WhatsApp que agenda sozinho** (equivalente Amélia Agendamento) | XL | Stack: Evolution API + LLM + RAG na disponibilidade da agenda. ROI claro: **24/7 sem recepcionista** |
|
||||
| 36 | **Transcrição de sessão áudio→texto** (equivalente AI Scribe do Jane + Amélia Transcrição) | L | Whisper API local ou Deepgram; paciente consente; transcrição vira rascunho de nota |
|
||||
| 37 | **Copilot no prontuário** (resumir histórico, sugerir diagnóstico diferencial baseado em notas anteriores, buscar semântica) | L | RAG em cima das notas do paciente |
|
||||
| 38 | **Gerador de documentos com compliance CFP** (equivalente PsiAssist) | M | LLM com system prompt de CFP + templates |
|
||||
|
||||
## 3.2 Teleconsulta nativa 🔵
|
||||
|
||||
Se o público-alvo inclui teleatendimento (grande chance pós-pandemia), isso é essencial.
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 39 | **Vídeo nativo integrado** (Daily.co ou Jitsi Meet) | L | Sala gerada por consulta + link no lembrete |
|
||||
| 40 | **Sala de espera virtual** | M | Profissional admite paciente |
|
||||
| 41 | **Whiteboard digital + screen share** | M | Daily.co tem nativo |
|
||||
|
||||
## 3.3 Rede / Multi-unidade (se posicionar pra clínicas) 🔵
|
||||
|
||||
Só fazer se posicionar pra tier **enterprise**. Solo-therapist não precisa.
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 42 | **Multi-unidade / filiais** com Main Office | L | Tabela `clinic_units` + FK em consultas/profissionais |
|
||||
| 43 | **Salas e equipamentos como recursos** (estilo Jane App) | M | Evita double-booking |
|
||||
| 44 | **CRM de leads** (captura de landing → funil → matching com terapeuta) | L | Aproveita o perfil público da Fase 2 |
|
||||
| 45 | **BI avançado** (MRR, cohort retention, LTV por terapeuta) | M | Dashboards dedicados |
|
||||
|
||||
## 3.4 UX premium / diferenciação fina 🔵
|
||||
|
||||
| # | Feature | Esforço | Notas |
|
||||
|---|---|---|---|
|
||||
| 46 | **Website builder pra clínica** (estilo Jane) — sem precisar de Wix/WordPress | XL | Grande mas ROI de marketing enorme |
|
||||
| 47 | **App mobile (PWA otimizado)** — paciente instala no celular | M | Vue já dá PWA grátis, só polir |
|
||||
| 48 | **Migração assistida de Psicomanager** (importador de CSV/API) | M | Feature de venda: "deixa o outro, a gente migra" |
|
||||
|
||||
### 📦 Entregas da Fase 3
|
||||
- **IA-nativa** posicionamento (bot WhatsApp, transcrição, copilot) — Amplimed/Jane nível
|
||||
- **Teleconsulta própria** (se optar)
|
||||
- Opcional: tier **enterprise** com multi-unidade
|
||||
- **Website builder** como moat de marketing
|
||||
|
||||
### ⚠️ Decisões abertas
|
||||
- **Foco único ou múltiplas apostas?** Recomendo: **só IA (3.1)** no primeiro semestre da Fase 3. Teleconsulta e Multi-unidade podem esperar Fase 4.
|
||||
- **Custo de IA:** transcrição (Whisper) + LLM (Claude/GPT) tem custo por uso — precisa embutir no pricing ou cobrar add-on
|
||||
|
||||
---
|
||||
|
||||
# ❌ O que ficou FORA do roadmap (propositalmente)
|
||||
|
||||
Decisões de "não fazer" são tão importantes quanto as de fazer:
|
||||
|
||||
- **Prescrição eletrônica de medicamentos** — só psiquiatras usam; mercado nichado demais
|
||||
- **Marketplace de terapeutas público** (tipo Doctoralia) — é um negócio completamente diferente; seu negócio é gestão, não captação
|
||||
- **E-prescribing com farmácia integrada** — idem, só pra psiquiatra
|
||||
- **Controle de estoque** (Ninsaúde/iClinic têm) — irrelevante pra psicólogo
|
||||
- **Integração com laboratórios** — irrelevante
|
||||
- **Controle de vacinas** (iClinic tem) — irrelevante
|
||||
- **Multi-idioma** (SimplePractice tem espanhol) — empurrar pra Fase 4+ quando for pensar LATAM
|
||||
- **Certificação HIPAA/HITRUST** — caríssima; LGPD suficiente pra BR
|
||||
- **App iOS/Android nativo** — PWA resolve 90% do caso; nativo só se houver demanda explícita
|
||||
|
||||
---
|
||||
|
||||
# 📊 Resumo em tabela
|
||||
|
||||
| Fase | Foco | Duração | Features | Esforço total |
|
||||
|---|---|---|---|---|
|
||||
| **1 — MVP** | Cobrável + legal safe + UX básica | 4-6 semanas | 18 itens | ~3M de backlog |
|
||||
| **2 — Paridade** | Empatar com competição BR | 2-3 meses | 16 itens | ~4L de backlog |
|
||||
| **3 — Diferenciação IA** | Virar "o SaaS com IA pra psico" | 3-6 meses | 14 itens | ~5XL de backlog |
|
||||
|
||||
---
|
||||
|
||||
# 🎯 Decisões estratégicas pendentes (pra você)
|
||||
|
||||
Antes de começar, definir:
|
||||
|
||||
1. **Pricing** — R$59, R$99 ou R$149 solo? Afeta quanto tempo você tem pra chegar na Fase 2.
|
||||
2. **Tier clínica** — vai existir desde o MVP ou só na Fase 3? Afeta item 32 (TISS) e 42 (multi-unidade).
|
||||
3. **Gateway de pagamento** — Asaas (barato) vs Iugu (melhor DX)? Escolher agora.
|
||||
4. **IA própria ou terceirizada?** — chamar API da Anthropic/OpenAI (rápido, custo variável) ou rodar modelos open-source localmente (lento, custo fixo)? **Recomendo API** pra começar.
|
||||
5. **Foco na Fase 3** — IA-first, teleconsulta-first, enterprise-first? Você só tem tempo pra **uma** dessas apostas no primeiro semestre.
|
||||
6. **Data de lançamento do MVP** — se a meta é 2026-06-01, Fase 1 tem que terminar mid-maio. Planejar capacity.
|
||||
|
||||
---
|
||||
|
||||
# 📎 Referências cruzadas
|
||||
|
||||
- **`ESTRUTURA.md`** — o que já existe no sistema (79 features construídas)
|
||||
- **`concorrentes.md`** — benchmark de 7 players (SP, Psicomanager, iClinic, Amplimed, PsicoPlanner, Ninsaúde, Jane)
|
||||
- **`mapa-sistema.html`** — visualização interativa do que está feito
|
||||
- **`memory/project_mvp_assessment.md`** (25/03/2026) — gaps críticos MVP-assessment original
|
||||
|
||||
---
|
||||
|
||||
**Próximo passo sugerido:** você lê, contesta o que discorda, responde as 6 decisões pendentes lá em cima, e a gente passa pra **backlog executável** da Fase 1 (breakdown task-por-task do que você vai fazer esta semana / próxima).
|
||||
@@ -0,0 +1,33 @@
|
||||
# Database — ferramentas
|
||||
|
||||
As ferramentas de banco vivem em [`../../database-novo/`](../../database-novo/) na raiz do projeto. Esta pasta existe apenas como **index** de referência pra navegação.
|
||||
|
||||
## Scripts principais
|
||||
|
||||
### `database-novo/db.cjs`
|
||||
CLI completa do banco. Comandos:
|
||||
- `setup` — instalação do zero (schema + fixes + seeds + migrations) + auto-backup + verify
|
||||
- `backup` — exporta `schema.sql`, `data.sql`, `full_dump.sql`, `supabase_restore.sql` (restauração completa)
|
||||
- `restore [data]` — restaura do backup mais recente (ou de uma data específica)
|
||||
- `migrate` — aplica migrations pendentes com auto-backup antes
|
||||
- `seed [grupo]` — roda seeds (`all` / `users` / `system` / `test_data`)
|
||||
- `status` — estado do banco + contagens em tabelas-chave
|
||||
- `diff` — compara schema atual vs último backup
|
||||
- `reset` — dropa schema public e reinstala tudo (com safety backup)
|
||||
- `verify` — checa integridade (tabelas e views definidas em `db.config.json`)
|
||||
- `schema-export` — gera `schema/00_full`…`10_grants/` granulares
|
||||
- `dashboard` — gera `agenciapsi-db-dashboard.html` interativo
|
||||
|
||||
### `database-novo/generate-dashboard.cjs`
|
||||
Gera dashboard HTML do banco lendo schema do backup mais recente. Lê config de `db.config.json` (domínios, cores, infraestrutura).
|
||||
|
||||
## Config
|
||||
|
||||
`database-novo/db.config.json` — domínios (11), cores, infraestrutura (6 grupos × 23 itens), contagens esperadas pra verify.
|
||||
|
||||
## Na UI
|
||||
|
||||
A página `/saas/desenvolvimento > Banco de Dados` vai:
|
||||
- Exibir os comandos com botão "copiar" (executar no terminal)
|
||||
- Mostrar status dos últimos runs (lido de `dev_generation_log`)
|
||||
- Link pro dashboard HTML gerado (abre em nova aba)
|
||||
@@ -0,0 +1,170 @@
|
||||
# Development — Pendências
|
||||
|
||||
> Registro vivo do que ficou pra próximas etapas da área de desenvolvimento.
|
||||
> **Nada aqui pode se perder** — cada item vira task quando for tratado.
|
||||
|
||||
**Última atualização:** 2026-04-17
|
||||
**Responsável:** Leonardo (user) · **Implementação:** Claude (dev sessions)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Entregue até agora
|
||||
|
||||
### Parte A — Banco + estrutura (2026-04-17)
|
||||
- [x] Pasta `development/` criada com estrutura hierárquica (`01-visao-geral`…`05-database`)
|
||||
- [x] 6 arquivos `.md`/`.txt`/`.html` movidos da raiz pra subpastas temáticas
|
||||
- [x] `development/README.md` de navegação
|
||||
- [x] Migration `20260417000001_dev_tables.sql` aplicada com 8 tabelas (`dev_*`) + RLS via `is_saas_admin()`
|
||||
- [x] 3 seeds executados:
|
||||
- `seed_030_dev_phases_items.sql` → 3 fases + 48 items
|
||||
- `seed_031_dev_auditoria.sql` → 14 bugs/débitos (12 resolvidos + 2 abertos)
|
||||
- `seed_032_dev_competitors.sql` → 7 concorrentes + 74 features + 60 linhas de matriz
|
||||
|
||||
### Parte B — UI read-only (2026-04-17)
|
||||
- [x] Rota `/saas/desenvolvimento` registrada em `routes.saas.js`
|
||||
- [x] Item de menu "Desenvolvimento > Área de Dev" em `saas.menu.js`
|
||||
- [x] `SaasDevelopmentPage.vue` com TabView de 7 abas
|
||||
- [x] 7 sub-componentes (read-only):
|
||||
- `DevOverviewTab.vue` — stats + progresso das fases + últimas gerações
|
||||
- `DevRoadmapTab.vue` — fases, items, filtros por status/prioridade/bloco
|
||||
- `DevEstruturaTab.vue` — ESTRUTURA.md embutido + iframe do mapa-sistema.html
|
||||
- `DevAuditoriaTab.vue` — bugs/débitos expansíveis, filtros por status/severidade
|
||||
- `DevCompetitorsTab.vue` — concorrentes + features agrupadas por categoria + matriz de gaps
|
||||
- `DevDatabaseTab.vue` — comandos do db.cjs com copy-to-clipboard + log de execuções
|
||||
- `DevExportTab.vue` — stubs de "Gerar ROADMAP.md/AUDITORIA.md/concorrentes.md/ESTRUTURA.md" (desabilitados, mensagem "Pendente — Parte C")
|
||||
|
||||
---
|
||||
|
||||
## 🔨 Parte C — Edição + integração CLI
|
||||
|
||||
### C.1 Edição inline na UI ✅ **CONCLUÍDA (2026-04-17)**
|
||||
|
||||
Banco agora é source-of-truth. Implementado:
|
||||
|
||||
**Infra reutilizável:**
|
||||
- [x] `components/DevDrawer.vue` — drawer lateral com footer (save/cancel/delete)
|
||||
- [x] `components/DevField.vue` — wrapper de label/input padronizado
|
||||
- [x] `composables/useDraggableList.js` — drag-drop HTML5 nativo (sem deps)
|
||||
- [x] Migration `20260417000002_dev_tables_ordem.sql` — coluna `ordem` em `dev_auditoria_items` e `dev_competitor_features`
|
||||
|
||||
**Roadmap (`DevRoadmapTab.vue`)**
|
||||
- [x] Drawer pra editar item (todos os campos)
|
||||
- [x] Drag & drop pra reordenar items dentro de um bloco (via `BlocoItems.vue`)
|
||||
- [x] Mover item entre fases (via select no drawer)
|
||||
- [x] Criar novo item
|
||||
- [x] Excluir item (com confirm)
|
||||
- [x] Criar nova fase (botão "+" no seletor)
|
||||
- [x] Editar fase (nome, objetivo, timeline, status, datas)
|
||||
- [x] Excluir fase (cascade nos items)
|
||||
|
||||
**Auditoria (`DevAuditoriaTab.vue`)**
|
||||
- [x] Drawer com todos os campos (título, categoria, severidade, status, problema, solução, arquivo, tags, datas)
|
||||
- [x] Criar/editar/excluir
|
||||
- [x] Drag-reorder (desativado quando filtro ativo)
|
||||
|
||||
**Concorrentes (`DevCompetitorsTab.vue`)**
|
||||
- [x] CRUD de concorrente (slug, nome, país, foco, pricing, posicionamento, url, notas, ativo)
|
||||
- [x] CRUD de feature por concorrente (categoria, nome, descrição, fonte, url, destaque)
|
||||
- [x] CRUD de linha da matriz (domínio, feature, status, nota, importância)
|
||||
- [x] **Edição inline de status na matriz** (dropdown colorido)
|
||||
|
||||
**Pendências menores que ficaram:**
|
||||
- [ ] Drag-reorder no overview das fases
|
||||
- [ ] Drag-reorder na matriz de comparação
|
||||
- [ ] Drag-reorder nas features dentro de um concorrente
|
||||
- [ ] Drag-reorder do topo da lista de concorrentes
|
||||
- [ ] Autosave nos drawers (hoje é "clique em Salvar")
|
||||
- [ ] Undo recente
|
||||
- [ ] Keyboard shortcuts (`/` busca, `n` novo, `Esc` fecha)
|
||||
- [ ] Mudança rápida de status direto no badge do card (sem abrir drawer)
|
||||
- [ ] Marcar fase como concluída automaticamente quando 100% items concluídos
|
||||
- [ ] Quick filters no overview (só bloqueadores, só Fase 1, etc.)
|
||||
|
||||
### C.2 Integração CLI local (opcional — usuário disse "copy-to-clipboard por ora")
|
||||
|
||||
Hoje todos os comandos do `DevDatabaseTab.vue` são copy-to-clipboard. Na próxima onda (se for quiser):
|
||||
|
||||
- [ ] Pequeno server Node em `development/cli-server/server.cjs`
|
||||
- Escuta em `127.0.0.1:3456` (localhost-only)
|
||||
- Whitelist de comandos: `backup`, `dashboard`, `schema-export`, `status`, `verify`, `migrate`, `seed`, `diff`
|
||||
- Endpoints: `POST /run/:cmd` com SSE pra streaming do stdout
|
||||
- Escreve em `dev_generation_log` o resultado
|
||||
- [ ] Script `npm run dev:cli` pra iniciar o server
|
||||
- [ ] Indicador na UI (verde/vermelho) mostrando se server está online
|
||||
- [ ] Botões "Executar agora" ao lado de cada comando (alternativo ao copy)
|
||||
|
||||
### C.3 Export banco → .md (botões da aba "Exportar")
|
||||
|
||||
- [ ] Edge Function `generate-roadmap-md` que lê `dev_roadmap_phases` + `dev_roadmap_items` e retorna markdown
|
||||
- [ ] Edge Function `generate-auditoria-md` análogo
|
||||
- [ ] Edge Function `generate-concorrentes-md` análogo
|
||||
- [ ] Na UI: botão clica → fetch da edge function → `<a download>` do resultado
|
||||
- [ ] OU: endpoint no CLI server escreve direto em `development/04-roadmap/ROADMAP.md` (se CLI server existir)
|
||||
- [ ] Versionar os arquivos gerados no git (script "publish": gera tudo + `git add development/`)
|
||||
|
||||
### C.4 Pesquisa automática de concorrentes
|
||||
|
||||
O usuário **disse "não sei"** sobre essa feature. Quando for decidir:
|
||||
|
||||
- [ ] Edge Function `fetch-competitor-page` — recebe URL, faz `fetch()` do HTML, retorna raw
|
||||
- [ ] UI: botão "Pesquisar este concorrente" abre modal com:
|
||||
- Input de URL
|
||||
- Preview do HTML capturado
|
||||
- Campo de texto livre pra colar observações
|
||||
- Opção: "extrair com IA" (chama Anthropic API com system prompt pra listar features)
|
||||
- [ ] Features extraídas entram numa fila de revisão (usuário aprova → salva em `dev_competitor_features`)
|
||||
- [ ] Histórico: `dev_competitor_features` ganha campos `aprovado_em`, `aprovado_por`, `revisao_status`
|
||||
|
||||
### C.5 Melhorias secundárias
|
||||
|
||||
- [ ] **Autosave** nos drawers de edição (debounce 1s)
|
||||
- [ ] **Toast de feedback** em toda mutação
|
||||
- [ ] **Undo recente** (Ctrl+Z nos últimos 5 minutos via revision log)
|
||||
- [ ] **Keyboard shortcuts** — `/` foca busca, `n` novo item, `Esc` fecha drawer
|
||||
- [ ] **Dark mode** — se o app tem, garantir compatibilidade (surface tokens estão OK mas validar)
|
||||
- [ ] **Responsividade mobile** — tabs scroll horizontal já tratado, mas drawers precisam virar modals full-screen em mobile
|
||||
- [ ] **Paginação/virtualização** nas listas grandes (quando matriz passar de 100 linhas)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Itens órfãos / decisões estratégicas
|
||||
|
||||
### Decisões **abertas** (do `ROADMAP.md`)
|
||||
Repetidas aqui pra não perder:
|
||||
|
||||
1. **Pricing** — R$ 59, R$ 99 ou R$ 149 solo? Afeta quanto tempo você tem pra Fase 2.
|
||||
2. **Tier clínica** — existe desde o MVP ou só na Fase 3? Afeta TISS (item 32) e multi-unidade (42).
|
||||
3. **Gateway de pagamento** — Asaas (barato) vs Iugu (melhor DX)? Escolher agora.
|
||||
4. **IA própria ou terceirizada?** — Claude/OpenAI via API (rápido, custo variável) vs open-source local (lento, custo fixo)? Recomendo API pra começar.
|
||||
5. **Foco da Fase 3** — IA-first, teleconsulta-first ou enterprise-first? Capacity só dá pra uma aposta no primeiro semestre.
|
||||
6. **Data de lançamento MVP** — se meta é 2026-06-01, Fase 1 termina mid-maio.
|
||||
|
||||
### Itens técnicos **abertos** do `AUDITORIA.md`
|
||||
Ainda não resolvidos (já no banco como `dev_auditoria_items` com status `aberto` / `em_analise`):
|
||||
|
||||
- `window.__guardsBound / window.__supabaseAuthListenerBound` — flags globais no window (anti-pattern)
|
||||
- Arquivos obsoletos no projeto (cleanup parcial — a finalizar)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ordem sugerida pra Parte C
|
||||
|
||||
Se quiser atacar **agora**:
|
||||
|
||||
1. **C.1 roadmap** — drawer de edição (mais valor imediato — marcar items concluídos)
|
||||
2. **C.1 auditoria** — mesmo padrão do roadmap
|
||||
3. **C.3 export** — ROADMAP.md e AUDITORIA.md (para versionar o que você editou)
|
||||
4. **C.1 concorrentes** — edição completa
|
||||
5. **C.2 CLI server** — quando cansar de copy-paste
|
||||
6. **C.4 pesquisa auto** — se decidir que vale
|
||||
|
||||
---
|
||||
|
||||
## 📁 Referências
|
||||
|
||||
- Migração: `database-novo/migrations/20260417000001_dev_tables.sql`
|
||||
- Seeds: `database-novo/seeds/seed_030_dev_phases_items.sql`, `seed_031_dev_auditoria.sql`, `seed_032_dev_competitors.sql`
|
||||
- Frontend: `src/views/pages/saas/development/`
|
||||
- Rota: `src/router/routes.saas.js` (path `desenvolvimento`)
|
||||
- Menu: `src/navigation/menus/saas.menu.js` (grupo "Desenvolvimento")
|
||||
- Docs fonte: `development/04-roadmap/ROADMAP.md` · `development/02-auditoria/AUDITORIA.md` · `development/03-concorrentes/concorrentes.md`
|
||||
@@ -0,0 +1,53 @@
|
||||
# Development — AgenciaPsi
|
||||
|
||||
> Área de trabalho de desenvolvimento. Centraliza documentação interna, análise competitiva, roadmap e ferramentas de banco de dados.
|
||||
>
|
||||
> Este diretório é a **contraparte em arquivos** da página `/saas/desenvolvimento` no sistema. Dados estruturados (roadmap, auditoria, concorrentes) ficam no banco (`dev_*` tables); os `.md` aqui são **snapshots exportáveis** a partir do banco ou leitura humana da situação atual.
|
||||
|
||||
## Navegação
|
||||
|
||||
### [`01-visao-geral/`](./01-visao-geral/)
|
||||
Snapshot do sistema hoje.
|
||||
- [`ESTRUTURA.md`](./01-visao-geral/ESTRUTURA.md) — tree-view dos 6 perfis × ~79 features ativas
|
||||
- [`mapa-sistema.html`](./01-visao-geral/mapa-sistema.html) — visualização interativa (abrir no navegador)
|
||||
- [`estrutura.txt`](./01-visao-geral/estrutura.txt) — snapshot antigo da árvore de arquivos (histórico)
|
||||
|
||||
### [`02-auditoria/`](./02-auditoria/)
|
||||
Bugs, dívidas técnicas e decisões arquiteturais.
|
||||
- [`AUDITORIA.md`](./02-auditoria/AUDITORIA.md) — auditoria técnica com status `[RESOLVIDO]` / `[ABERTO]`
|
||||
|
||||
### [`03-concorrentes/`](./03-concorrentes/)
|
||||
Benchmark competitivo.
|
||||
- [`concorrentes.md`](./03-concorrentes/concorrentes.md) — 7 players analisados (SimplePractice, Psicomanager, PsicoPlanner, iClinic, Amplimed, Ninsaúde, Jane App) + tabela comparativa + diferenciais por player
|
||||
|
||||
### [`04-roadmap/`](./04-roadmap/)
|
||||
Fases de evolução do produto.
|
||||
- [`ROADMAP.md`](./04-roadmap/ROADMAP.md) — Fase 1 (MVP Launch) · Fase 2 (Paridade) · Fase 3 (Diferenciação IA) · decisões estratégicas abertas
|
||||
|
||||
### [`05-database/`](./05-database/)
|
||||
Ferramentas de banco (read-only aqui — os scripts vivem em `database-novo/` na raiz do projeto).
|
||||
|
||||
## Fluxo de trabalho
|
||||
|
||||
1. **Edição primária na UI** — a página `/saas/desenvolvimento` lê/escreve diretamente nas tabelas `dev_*`
|
||||
2. **Export pra git** — botões "Gerar {ROADMAP, concorrentes, ESTRUTURA}.md" serializam o banco pros arquivos aqui (pra versionar)
|
||||
3. **Import inicial** (uma vez) — seeds em `database-novo/seeds/seed_030_*_dev.sql` populam o banco a partir dos `.md` atuais
|
||||
|
||||
## Tabelas no banco (schema `public`, prefixo `dev_`)
|
||||
|
||||
Criadas pela migration `20260417000001_dev_tables.sql`. Todas com RLS restrita a `saas_admins`.
|
||||
|
||||
| Tabela | Pra quê |
|
||||
|---|---|
|
||||
| `dev_roadmap_phases` | Fases (1/2/3) com status, datas, objetivo |
|
||||
| `dev_roadmap_items` | Itens das fases (prioridade, esforço, status, notas) |
|
||||
| `dev_auditoria_items` | Bugs/débitos técnicos (severidade, status, solução) |
|
||||
| `dev_competitors` | Concorrentes (pricing, URL, última pesquisa) |
|
||||
| `dev_competitor_features` | Features deles (categoria, nome, fonte) |
|
||||
| `dev_comparison_matrix` | AgenciaPsi × feature-de-concorrente (nosso status) |
|
||||
| `dev_generation_log` | Histórico de execuções (backup/dashboard/export) |
|
||||
|
||||
## Relacionados (fora de `development/`)
|
||||
|
||||
- [`database-novo/`](../database-novo/) — CLI do banco (db.cjs, generate-dashboard.cjs) e schema/migrations/seeds
|
||||
- [`diagrama-visualizacao-dados.webp`](../diagrama-visualizacao-dados.webp) — referência visual (fica na raiz)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
agenciapsi_app:
|
||||
container_name: agenciapsi_app
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- agenciapsi_node_modules:/app/node_modules
|
||||
ports:
|
||||
- "5173:5173"
|
||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
networks:
|
||||
- agenciapsi_net
|
||||
restart: unless-stopped
|
||||
|
||||
agenciapsi_mysql:
|
||||
container_name: agenciapsi_mysql
|
||||
image: mysql:8.0
|
||||
ports:
|
||||
- "3307:3306"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: agenciapsi123
|
||||
MYSQL_ROOT_HOST: "%"
|
||||
MYSQL_DATABASE: agenciapsi
|
||||
MYSQL_USER: agenciapsi
|
||||
MYSQL_PASSWORD: agenciapsi123
|
||||
volumes:
|
||||
- agenciapsi_mysql_data:/var/lib/mysql
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
networks:
|
||||
- agenciapsi_net
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
agenciapsi_node_modules:
|
||||
agenciapsi_mysql_data:
|
||||
|
||||
networks:
|
||||
agenciapsi_net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,149 @@
|
||||
// =============================================================================
|
||||
// T#10 — Golden path: paciente abre link → preenche → submit → sucesso
|
||||
// =============================================================================
|
||||
// Strategy: intercepta as chamadas pra Supabase Functions/REST e responde
|
||||
// com mocks. Sem precisar de banco real ou edge functions rodando.
|
||||
//
|
||||
// Cobre as camadas de defesa em camadas (A#20 rev2):
|
||||
// - honeypot (campo invisível, garantimos que não preenchemos)
|
||||
// - submit normal funciona
|
||||
// - 403 captcha-required → componente MathCaptchaChallenge aparece
|
||||
// - 429 rate-limited → toast amigável
|
||||
// - token UUID inválido bloqueia a página
|
||||
// =============================================================================
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const VALID_TOKEN = '550e8400-e29b-41d4-a716-446655440000'; // UUID v4 válido (TOKEN_RX bate)
|
||||
const PUBLIC_URL = `/cadastro/paciente?t=${VALID_TOKEN}`;
|
||||
|
||||
const FUNCTIONS_RX = /\/functions\/v1\/submit-patient-intake/;
|
||||
|
||||
// Helper: preenche os 3 campos obrigatórios + consent
|
||||
async function fillRequired(page, { nome, email, telefone }) {
|
||||
await page.locator('#f_nome').fill(nome);
|
||||
await page.locator('#f_email_principal').fill(email);
|
||||
await page.locator('#f_telefone').fill(telefone);
|
||||
// PrimeVue Checkbox é um div com role=checkbox; .check() não funciona, precisa click
|
||||
await page.locator('label[for="ext_consent"]').click();
|
||||
}
|
||||
|
||||
test.describe('Cadastro paciente externo — golden path', () => {
|
||||
test('paciente preenche e submete com sucesso (sem captcha)', async ({ page }) => {
|
||||
await page.route(FUNCTIONS_RX, async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
|
||||
// honeypot NÃO foi preenchido (humano)
|
||||
expect(body?.website).toBeFalsy();
|
||||
expect(body?.token).toBe(VALID_TOKEN);
|
||||
expect(body?.payload?.nome_completo).toBe('Maria da Silva');
|
||||
expect(body?.payload?.consent).toBe(true);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true, intake_id: 'fake-intake-uuid' })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(PUBLIC_URL);
|
||||
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
||||
|
||||
await fillRequired(page, {
|
||||
nome: 'Maria da Silva',
|
||||
email: 'maria@test.com',
|
||||
telefone: '11987654321'
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
|
||||
await expect(page.getByText(/Enviado com sucesso/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('rate limit (429) mostra mensagem amigável de tentativas', async ({ page }) => {
|
||||
await page.route(FUNCTIONS_RX, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 429,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'rate-limited', retry_after_seconds: 600 })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(PUBLIC_URL);
|
||||
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
||||
|
||||
await fillRequired(page, {
|
||||
nome: 'João Bot',
|
||||
email: 'joao@test.com',
|
||||
telefone: '11999999999'
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
|
||||
await expect(page.getByText(/Muitas tentativas/i)).toBeVisible({ timeout: 6_000 });
|
||||
});
|
||||
|
||||
test('captcha-required mostra MathCaptchaChallenge e bloqueia botão até resposta', async ({ page }) => {
|
||||
let firstSubmitCall = true;
|
||||
|
||||
await page.route(FUNCTIONS_RX, async (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes('/captcha-challenge')) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ challenge: { id: 'fake-challenge-id', question: 'Quanto é 2 + 3?' } })
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (firstSubmitCall) {
|
||||
firstSubmitCall = false;
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'captcha-required' })
|
||||
});
|
||||
return;
|
||||
}
|
||||
// segunda call: ok
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true, intake_id: 'after-captcha' })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(PUBLIC_URL);
|
||||
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
||||
|
||||
await fillRequired(page, {
|
||||
nome: 'Ana Pereira',
|
||||
email: 'ana@test.com',
|
||||
telefone: '11955554444'
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Enviar cadastro', exact: true }).click();
|
||||
|
||||
// MathCaptchaChallenge aparece
|
||||
await expect(page.getByText('Quanto é 2 + 3?')).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Botão "Enviar cadastro" do footer fica disabled enquanto sem resposta
|
||||
await expect(page.getByRole('button', { name: 'Enviar cadastro', exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('honeypot field existe no DOM mas está fora da viewport', async ({ page }) => {
|
||||
await page.goto(PUBLIC_URL);
|
||||
await expect(page.getByText('Pré-cadastro do paciente')).toBeVisible();
|
||||
|
||||
const honeypot = page.locator('input#ext_website');
|
||||
await expect(honeypot).toHaveCount(1);
|
||||
await expect(honeypot).not.toBeInViewport();
|
||||
});
|
||||
|
||||
test('token inválido bloqueia toda a página de cadastro', async ({ page }) => {
|
||||
await page.goto('/cadastro/paciente?t=token-invalido');
|
||||
|
||||
// Mensagem de erro aparece
|
||||
await expect(page.getByText(/Link inválido ou ausente/i)).toBeVisible();
|
||||
|
||||
// Form não renderiza (botão Enviar não existe)
|
||||
await expect(page.getByRole('button', { name: 'Enviar cadastro', exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Generated
+94
@@ -36,6 +36,7 @@
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@primevue/auto-import-resolver": "^4.3.1",
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
@@ -1133,6 +1134,21 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -4546,6 +4562,50 @@
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
@@ -7221,6 +7281,15 @@
|
||||
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
|
||||
"dev": true
|
||||
},
|
||||
"@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"playwright": "1.59.1"
|
||||
}
|
||||
},
|
||||
"@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -9541,6 +9610,31 @@
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fsevents": "2.3.2",
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true
|
||||
},
|
||||
"png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:sql": "node database-novo/tests/run.cjs",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"simulate": "node scripts/simulation/simulateUsage.js"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -41,6 +44,7 @@
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@primevue/auto-import-resolver": "^4.3.1",
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// =============================================================================
|
||||
// AgenciaPsi — Playwright config (T#10)
|
||||
// =============================================================================
|
||||
// E2E roda contra dev server local. Specs em e2e/, isoladas dos unit tests.
|
||||
//
|
||||
// Uso:
|
||||
// npm run test:e2e — roda todos
|
||||
// npm run test:e2e:ui — modo UI (debug)
|
||||
// npx playwright test e2e/patient-intake.spec.js
|
||||
// =============================================================================
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [['list']],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
],
|
||||
|
||||
// Sobe o dev server automaticamente se ainda não estiver rodando
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* session.spec.js
|
||||
*
|
||||
* Cobre o módulo de sessão global — foco nas race conditions documentadas
|
||||
* no próprio session.js (singleflight, SIGNED_IN redundante, TOKEN_REFRESHED).
|
||||
*
|
||||
* Mock do supabase: capturamos o callback de onAuthStateChange pra disparar
|
||||
* eventos manualmente e observar o state dos refs reativos.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Captura do callback de onAuthStateChange (setado no listenAuthChanges)
|
||||
let authCallback = null;
|
||||
|
||||
// Mock configurável de getSession (pode trocar em cada teste via mockImplementation)
|
||||
const getSessionMock = vi.fn();
|
||||
const profileSingleMock = vi.fn();
|
||||
const saasMaybeSingleMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => {
|
||||
const from = vi.fn((table) => {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle: table === 'saas_admins' ? saasMaybeSingleMock : profileSingleMock,
|
||||
single: vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: getSessionMock,
|
||||
onAuthStateChange: vi.fn((cb) => {
|
||||
authCallback = cb;
|
||||
return { data: { subscription: { unsubscribe: vi.fn() } } };
|
||||
})
|
||||
},
|
||||
from
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/support/supportLogger', () => ({
|
||||
logAuth: vi.fn(),
|
||||
logError: vi.fn()
|
||||
}));
|
||||
|
||||
// Importa depois dos mocks
|
||||
const session = await import('../session.js');
|
||||
|
||||
beforeEach(() => {
|
||||
// Reseta state dos refs e mocks a cada teste
|
||||
session.sessionUser.value = null;
|
||||
session.sessionRole.value = null;
|
||||
session.sessionIsSaasAdmin.value = false;
|
||||
session.sessionReady.value = false;
|
||||
session.sessionRefreshing.value = false;
|
||||
|
||||
// Desfaz listenAuthChanges de teste anterior pra permitir re-registro
|
||||
session.stopAuthChanges();
|
||||
|
||||
authCallback = null;
|
||||
getSessionMock.mockReset();
|
||||
profileSingleMock.mockReset();
|
||||
saasMaybeSingleMock.mockReset();
|
||||
|
||||
// defaults razoáveis
|
||||
profileSingleMock.mockResolvedValue({ data: { role: 'therapist' }, error: null });
|
||||
saasMaybeSingleMock.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
describe('initSession — boot inicial', () => {
|
||||
it('sem sessão → zera user/role/saasAdmin', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: null }, error: null });
|
||||
|
||||
await session.initSession({ initial: true });
|
||||
|
||||
expect(session.sessionUser.value).toBe(null);
|
||||
expect(session.sessionRole.value).toBe(null);
|
||||
expect(session.sessionIsSaasAdmin.value).toBe(false);
|
||||
expect(session.sessionReady.value).toBe(true);
|
||||
expect(session.sessionRefreshing.value).toBe(false);
|
||||
});
|
||||
|
||||
it('com sessão → hydrata user + busca role', async () => {
|
||||
getSessionMock.mockResolvedValue({
|
||||
data: { session: { user: { id: 'uid-1' } } },
|
||||
error: null
|
||||
});
|
||||
profileSingleMock.mockResolvedValue({ data: { role: 'clinic_admin' }, error: null });
|
||||
|
||||
await session.initSession({ initial: true });
|
||||
|
||||
expect(session.sessionUser.value?.id).toBe('uid-1');
|
||||
expect(session.sessionRole.value).toBe('clinic_admin');
|
||||
expect(session.sessionReady.value).toBe(true);
|
||||
});
|
||||
|
||||
it('erro em getSession → state zerado (não propaga)', async () => {
|
||||
getSessionMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await session.initSession({ initial: true });
|
||||
|
||||
expect(session.sessionUser.value).toBe(null);
|
||||
expect(session.sessionRole.value).toBe(null);
|
||||
expect(session.sessionReady.value).toBe(true); // ainda marca ready pra não travar o guard
|
||||
});
|
||||
|
||||
it('singleflight: 2 chamadas concorrentes fazem apenas 1 getSession', async () => {
|
||||
let resolveGet;
|
||||
getSessionMock.mockImplementation(() => new Promise((resolve) => { resolveGet = resolve; }));
|
||||
|
||||
const p1 = session.initSession({ initial: true });
|
||||
const p2 = session.initSession({ initial: true });
|
||||
|
||||
// segunda chamada deve ter retornado a mesma promise (sem disparar getSession de novo)
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveGet({ data: { session: null }, error: null });
|
||||
await Promise.all([p1, p2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshSession — evita corrida', () => {
|
||||
it('não dispara se já está refreshing', async () => {
|
||||
session.sessionRefreshing.value = true;
|
||||
|
||||
await session.refreshSession();
|
||||
|
||||
expect(getSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sem sessão → não zera state existente (SIGNED_OUT cuida)', async () => {
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
getSessionMock.mockResolvedValue({ data: { session: null }, error: null });
|
||||
|
||||
await session.refreshSession();
|
||||
|
||||
// State preservado — refreshSession não é quem zera (é SIGNED_OUT)
|
||||
expect(session.sessionUser.value?.id).toBe('uid-1');
|
||||
expect(session.sessionRole.value).toBe('therapist');
|
||||
});
|
||||
|
||||
it('mesma sessão consistente → no-op', async () => {
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
getSessionMock.mockResolvedValue({
|
||||
data: { session: { user: { id: 'uid-1' } } },
|
||||
error: null
|
||||
});
|
||||
|
||||
await session.refreshSession();
|
||||
|
||||
// initSession não foi chamado de novo (state já era consistente)
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1); // só o refreshSession próprio
|
||||
});
|
||||
});
|
||||
|
||||
describe('listenAuthChanges — callbacks de auth', () => {
|
||||
it('SIGNED_OUT zera state + chama callback', async () => {
|
||||
const onOut = vi.fn();
|
||||
session.setOnSignedOut(onOut);
|
||||
session.listenAuthChanges();
|
||||
|
||||
// simula state previamente hydratado
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
session.sessionIsSaasAdmin.value = true;
|
||||
session.sessionRefreshing.value = true;
|
||||
|
||||
expect(authCallback).toBeTypeOf('function');
|
||||
await authCallback('SIGNED_OUT', null);
|
||||
|
||||
expect(session.sessionUser.value).toBe(null);
|
||||
expect(session.sessionRole.value).toBe(null);
|
||||
expect(session.sessionIsSaasAdmin.value).toBe(false);
|
||||
expect(session.sessionRefreshing.value).toBe(false);
|
||||
expect(session.sessionReady.value).toBe(true);
|
||||
expect(onOut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIGNED_IN com mesmo user (redundante) é ignorado', async () => {
|
||||
session.listenAuthChanges();
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
session.sessionReady.value = true;
|
||||
|
||||
await authCallback('SIGNED_IN', { user: { id: 'uid-1' } });
|
||||
|
||||
// Não rehidratou — profileSingleMock não foi chamado
|
||||
expect(profileSingleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SIGNED_IN com user diferente → hydrata novo', async () => {
|
||||
session.listenAuthChanges();
|
||||
session.sessionUser.value = { id: 'uid-1' };
|
||||
session.sessionRole.value = 'therapist';
|
||||
session.sessionReady.value = true;
|
||||
|
||||
profileSingleMock.mockResolvedValue({ data: { role: 'clinic_admin' }, error: null });
|
||||
|
||||
await authCallback('SIGNED_IN', { user: { id: 'uid-2' } });
|
||||
|
||||
expect(session.sessionUser.value?.id).toBe('uid-2');
|
||||
expect(session.sessionRole.value).toBe('clinic_admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopAuthChanges — cleanup', () => {
|
||||
it('unsubscribe é chamado', () => {
|
||||
session.listenAuthChanges();
|
||||
session.stopAuthChanges();
|
||||
|
||||
// não deve lançar erro se chamar de novo
|
||||
expect(() => session.stopAuthChanges()).not.toThrow();
|
||||
});
|
||||
});
|
||||
+42
-5
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logAuth, logError } from '@/support/supportLogger';
|
||||
|
||||
/**
|
||||
* ⚠️ IMPORTANTE — ESTABILIDADE DE NAVEGAÇÃO
|
||||
@@ -57,11 +58,44 @@ export function setOnSignedOut(cb) {
|
||||
onSignedOutCallback = typeof cb === 'function' ? cb : null;
|
||||
}
|
||||
|
||||
// V#2: session.js é o único registrante de supabase.auth.onAuthStateChange.
|
||||
// Outros módulos (guards.js, etc.) se inscrevem aqui via onSessionEvent
|
||||
// em vez de registrar listeners próprios.
|
||||
const eventHandlers = {
|
||||
SIGNED_IN: [],
|
||||
SIGNED_OUT: [],
|
||||
TOKEN_REFRESHED: [],
|
||||
USER_UPDATED: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Inscreve handler para um evento de auth processado por listenAuthChanges.
|
||||
* Retorna função pra desregistrar.
|
||||
*
|
||||
* @param {'SIGNED_IN'|'SIGNED_OUT'|'TOKEN_REFRESHED'|'USER_UPDATED'} event
|
||||
* @param {(session: object|null) => void} handler
|
||||
*/
|
||||
export function onSessionEvent(event, handler) {
|
||||
if (!eventHandlers[event] || typeof handler !== 'function') return () => {};
|
||||
eventHandlers[event].push(handler);
|
||||
return () => {
|
||||
eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(event, sess) {
|
||||
const handlers = eventHandlers[event];
|
||||
if (!handlers || !handlers.length) return;
|
||||
for (const h of handlers) {
|
||||
try { h(sess); } catch (e) { logError('session', `${event} handler failed`, e); }
|
||||
}
|
||||
}
|
||||
|
||||
// evita init concorrente
|
||||
let initPromise = null;
|
||||
|
||||
async function fetchRole(userId) {
|
||||
const { data, error } = await supabase.from('profiles').select('role').eq('id', userId).single();
|
||||
const { data, error } = await supabase.from('profiles').select('role').eq('id', userId).maybeSingle();
|
||||
|
||||
if (error) return null;
|
||||
return data?.role || null;
|
||||
@@ -129,7 +163,7 @@ export async function initSession({ initial = false } = {}) {
|
||||
sessionIsSaasAdmin.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[initSession] getSession falhou (tratando como sem sessão):', e);
|
||||
logError('session', 'initSession.getSession falhou (tratando como sem sessão)', e);
|
||||
// não deixa estourar pro router guard
|
||||
sessionUser.value = null;
|
||||
sessionRole.value = null;
|
||||
@@ -173,7 +207,7 @@ export function listenAuthChanges() {
|
||||
if (authSubscription) return;
|
||||
|
||||
const { data } = supabase.auth.onAuthStateChange(async (event, sess) => {
|
||||
console.log('[AUTH EVENT]', event);
|
||||
logAuth('event', { event });
|
||||
|
||||
// ✅ SIGNED_OUT: zera e chama callback
|
||||
if (event === 'SIGNED_OUT') {
|
||||
@@ -183,6 +217,7 @@ export function listenAuthChanges() {
|
||||
sessionRefreshing.value = false;
|
||||
sessionReady.value = true;
|
||||
if (onSignedOutCallback) onSignedOutCallback();
|
||||
dispatch('SIGNED_OUT', sess);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,6 +225,7 @@ export function listenAuthChanges() {
|
||||
if (event === 'SIGNED_IN') {
|
||||
const uid = sess?.user?.id || null;
|
||||
if (uid && sessionReady.value && sessionUser.value?.id === uid && sessionRole.value) {
|
||||
dispatch('SIGNED_IN', sess);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -204,10 +240,11 @@ export function listenAuthChanges() {
|
||||
await hydrateFromSession(sess);
|
||||
sessionReady.value = true;
|
||||
} catch (e) {
|
||||
console.warn('[auth hydrate error]', e);
|
||||
logError('session', 'auth hydrate error', e);
|
||||
} finally {
|
||||
sessionRefreshing.value = false;
|
||||
}
|
||||
dispatch(event === 'TOKEN_REFRESHED' ? 'TOKEN_REFRESHED' : 'SIGNED_IN', sess);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +252,7 @@ export function listenAuthChanges() {
|
||||
try {
|
||||
await refreshSession();
|
||||
} catch (e) {
|
||||
console.error('[refreshSession error]', e);
|
||||
logError('session', 'refreshSession error', e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — MathCaptchaChallenge (A#20 rev2)
|
||||
|--------------------------------------------------------------------------
|
||||
| Componente de captcha matemático invocado SOB DEMANDA quando a edge
|
||||
| function retorna 403 captcha-required ou na primeira tentativa se o IP
|
||||
| já está marcado como suspeito.
|
||||
|
|
||||
| Uso:
|
||||
| <MathCaptchaChallenge
|
||||
| v-model:id="captchaId"
|
||||
| v-model:answer="captchaAnswer"
|
||||
| :function-url="..."
|
||||
| />
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Button from 'primevue/button';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, default: '' },
|
||||
answer: { type: [Number, null], default: null },
|
||||
autoLoad: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:id', 'update:answer']);
|
||||
|
||||
const challenge = ref({ id: '', question: '' });
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const localAnswer = ref(props.answer);
|
||||
|
||||
async function loadChallenge() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data, error: fnErr } = await supabase.functions.invoke('submit-patient-intake/captcha-challenge', {
|
||||
method: 'POST',
|
||||
body: {}
|
||||
});
|
||||
if (fnErr) throw fnErr;
|
||||
const ch = data?.challenge || data;
|
||||
challenge.value = { id: ch?.id || '', question: ch?.question || '' };
|
||||
emit('update:id', challenge.value.id);
|
||||
localAnswer.value = null;
|
||||
emit('update:answer', null);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Não foi possível carregar a verificação.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAnswerChange(v) {
|
||||
localAnswer.value = v;
|
||||
emit('update:answer', v);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoLoad) loadChallenge();
|
||||
});
|
||||
|
||||
defineExpose({ loadChallenge });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-amber-400/30 bg-amber-400/5 p-4">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="text-sm font-semibold text-slate-100">
|
||||
<i class="pi pi-shield mr-2 text-amber-300" />
|
||||
Verificação rápida
|
||||
</div>
|
||||
<Button icon="pi pi-refresh" text size="small" :loading="loading" @click="loadChallenge" v-tooltip.top="'Outra pergunta'" />
|
||||
</div>
|
||||
|
||||
<p v-if="!challenge.question && !loading" class="text-xs text-slate-300">Carregando…</p>
|
||||
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
|
||||
|
||||
<div v-if="challenge.question" class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span class="text-base text-slate-100 font-medium">{{ challenge.question }}</span>
|
||||
<InputNumber
|
||||
:modelValue="localAnswer"
|
||||
@update:modelValue="onAnswerChange"
|
||||
placeholder="?"
|
||||
class="!w-32"
|
||||
:useGrouping="false"
|
||||
inputClass="text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-[11px] text-slate-400">Confirma que você é humano. Sem cookies, sem rastreio externo.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -222,8 +222,8 @@ export function useAgendaFinanceiro() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── remarcar → atualizar due_date da cobrança existente ────────────
|
||||
if (novoStatus === 'remarcar' && evento.billed) {
|
||||
// ── remarcado → atualizar due_date da cobrança existente ────────────
|
||||
if (novoStatus === 'remarcado' && evento.billed) {
|
||||
// due_date mantém a data da sessão original por enquanto
|
||||
// (a nova data virá quando a sessão for reagendada)
|
||||
return { ok: true };
|
||||
|
||||
@@ -295,7 +295,7 @@ watch(
|
||||
async (newVal, oldVal) => {
|
||||
if (_skipStatusWatch.value) return;
|
||||
if (!isEdit.value || !form.value?.id) return;
|
||||
if (newVal !== 'cancelado' && newVal !== 'remarcar') return;
|
||||
if (newVal !== 'cancelado' && newVal !== 'remarcado') return;
|
||||
|
||||
_prevStatus.value = oldVal;
|
||||
|
||||
@@ -1073,7 +1073,7 @@ const statusOptions = [
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' },
|
||||
{ label: 'Remarcar', value: 'remarcar' }
|
||||
{ label: 'Remarcar', value: 'remarcado' }
|
||||
];
|
||||
|
||||
const serieCountByStatus = computed(() => {
|
||||
@@ -1122,7 +1122,7 @@ const statusOptionsFiltered = computed(() => [
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' },
|
||||
{ label: 'Remarcar', value: 'remarcar', disabled: isInativoFutureEdit.value }
|
||||
{ label: 'Remarcar', value: 'remarcado', disabled: isInativoFutureEdit.value }
|
||||
]);
|
||||
function fmtWeekdayShort(iso) {
|
||||
return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3);
|
||||
@@ -1661,7 +1661,7 @@ const googleCalendarUrl = computed(() => {
|
||||
});
|
||||
|
||||
function labelStatusSessao(v) {
|
||||
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado', remarcar: 'Remarcar' };
|
||||
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado', remarcado: 'Remarcado' };
|
||||
return map[v] || '—';
|
||||
}
|
||||
function statusSeverity(v) {
|
||||
@@ -1669,11 +1669,11 @@ function statusSeverity(v) {
|
||||
if (v === 'realizado') return 'success';
|
||||
if (v === 'faltou') return 'warn';
|
||||
if (v === 'cancelado') return 'danger';
|
||||
if (v === 'remarcar') return 'secondary'; // cor real via classe CSS
|
||||
if (v === 'remarcado') return 'secondary'; // cor real via classe CSS
|
||||
return 'secondary';
|
||||
}
|
||||
function statusExtraClass(v) {
|
||||
return v === 'remarcar' ? 'tag-remarcar' : '';
|
||||
return v === 'remarcado' ? 'tag-remarcado' : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1949,7 +1949,7 @@ function statusExtraClass(v) {
|
||||
<span v-if="serieCountByStatus.realizado"> · {{ serieCountByStatus.realizado }} realizadas</span>
|
||||
<span v-if="serieCountByStatus.faltou"> · {{ serieCountByStatus.faltou }} faltaram</span>
|
||||
<span v-if="serieCountByStatus.cancelado"> · {{ serieCountByStatus.cancelado }} canceladas</span>
|
||||
<span v-if="serieCountByStatus.remarcar"> · {{ serieCountByStatus.remarcar }} para remarcar</span>
|
||||
<span v-if="serieCountByStatus.remarcado"> · {{ serieCountByStatus.remarcado }} para remarcar</span>
|
||||
</div>
|
||||
<span v-if="serieLoading" class="ml-auto text-xs opacity-50">Carregando…</span>
|
||||
</div>
|
||||
@@ -2482,8 +2482,8 @@ function statusExtraClass(v) {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── tag: remarcar (roxo — sem severity nativo no PrimeVue) ─ */
|
||||
:deep(.tag-remarcar) {
|
||||
/* ── tag: remarcado (roxo — sem severity nativo no PrimeVue) ─ */
|
||||
:deep(.tag-remarcado) {
|
||||
background: #a855f7 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
@@ -3302,7 +3302,7 @@ function statusExtraClass(v) {
|
||||
.serie-pill--cancelado {
|
||||
border-left-color: var(--surface-border);
|
||||
}
|
||||
.serie-pill--remarcar {
|
||||
.serie-pill--remarcado {
|
||||
border-left-color: var(--orange-400, #fb923c);
|
||||
}
|
||||
.serie-pill__date {
|
||||
|
||||
@@ -207,7 +207,7 @@ async function confirmar() {
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert(rows);
|
||||
if (error) throw error;
|
||||
|
||||
// Marcar sessões existentes como "remarcar"
|
||||
// Marcar sessões existentes como "remarcado"
|
||||
await marcarSessoesParaRemarcar(rows);
|
||||
|
||||
toast.add({
|
||||
@@ -226,10 +226,10 @@ async function confirmar() {
|
||||
}
|
||||
|
||||
async function marcarSessoesParaRemarcar(bloqueios) {
|
||||
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcar'
|
||||
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcado'
|
||||
for (const b of bloqueios) {
|
||||
try {
|
||||
let query = supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`);
|
||||
let query = supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', props.ownerId).eq('tipo', 'sessao').gte('inicio_em', `${b.data_inicio}T00:00:00`).lte('inicio_em', `${b.data_fim}T23:59:59`);
|
||||
|
||||
if (b.hora_inicio && b.hora_fim) {
|
||||
// filtra pela hora aproximada — comparação UTC simplificada
|
||||
|
||||
@@ -156,8 +156,8 @@ async function confirmarBloqueio(feriado) {
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert([row]);
|
||||
if (error) throw error;
|
||||
|
||||
// Marcar sessões existentes no dia como 'remarcar'
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
// Marcar sessões existentes no dia como 'remarcado'
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data]);
|
||||
toast.add({
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* useAgendaEvents.spec.js — T#9
|
||||
*
|
||||
* Wrapper fino do agendaRepository. Cobertura focada nos contratos:
|
||||
* - rows/loading/error reativos
|
||||
* - delega I/O ao repository (nada de fetch direto)
|
||||
* - sem ownerId, loadMyRange é no-op (proteção tenant scoping)
|
||||
* - error é capturado em loadMyRange e zera rows; create/update/remove propagam
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const listMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const updateMock = vi.fn();
|
||||
const removeMock = vi.fn();
|
||||
|
||||
vi.mock('@/features/agenda/services/agendaRepository', () => ({
|
||||
listMyAgendaEvents: (...a) => listMock(...a),
|
||||
createAgendaEvento: (...a) => createMock(...a),
|
||||
updateAgendaEvento: (...a) => updateMock(...a),
|
||||
deleteAgendaEvento: (...a) => removeMock(...a)
|
||||
}));
|
||||
|
||||
const { useAgendaEvents } = await import('../useAgendaEvents.js');
|
||||
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
createMock.mockReset();
|
||||
updateMock.mockReset();
|
||||
removeMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useAgendaEvents — estado inicial', () => {
|
||||
it('rows vazio, loading false, error null', () => {
|
||||
const { rows, loading, error } = useAgendaEvents();
|
||||
expect(rows.value).toEqual([]);
|
||||
expect(loading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMyRange', () => {
|
||||
it('sem ownerId, não chama o repository (no-op de segurança)', async () => {
|
||||
const { rows, loadMyRange } = useAgendaEvents();
|
||||
await loadMyRange('2026-01-01', '2026-01-31', null);
|
||||
await loadMyRange('2026-01-01', '2026-01-31', '');
|
||||
await loadMyRange('2026-01-01', '2026-01-31', undefined);
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
expect(rows.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('chama listMyAgendaEvents com payload correto e popula rows', async () => {
|
||||
listMock.mockResolvedValue([{ id: 'a' }, { id: 'b' }]);
|
||||
const { rows, loading, loadMyRange } = useAgendaEvents();
|
||||
|
||||
const promise = loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
// loading vira true durante a execução
|
||||
expect(loading.value).toBe(true);
|
||||
await promise;
|
||||
|
||||
expect(listMock).toHaveBeenCalledWith({
|
||||
startISO: '2026-01-01',
|
||||
endISO: '2026-01-31',
|
||||
ownerId: 'u-1'
|
||||
});
|
||||
expect(rows.value).toEqual([{ id: 'a' }, { id: 'b' }]);
|
||||
expect(loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('captura erro, zera rows e seta error.message', async () => {
|
||||
listMock.mockRejectedValue(new Error('fetch fail'));
|
||||
const { rows, loading, error, loadMyRange } = useAgendaEvents();
|
||||
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
|
||||
expect(rows.value).toEqual([]);
|
||||
expect(loading.value).toBe(false);
|
||||
expect(error.value).toBe('fetch fail');
|
||||
});
|
||||
|
||||
it('reseta error em call subsequente bem-sucedida', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('first fail')).mockResolvedValueOnce([{ id: 'x' }]);
|
||||
const { error, loadMyRange } = useAgendaEvents();
|
||||
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
expect(error.value).toBe('first fail');
|
||||
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('error sem message vira fallback string', async () => {
|
||||
listMock.mockRejectedValue({}); // sem .message
|
||||
const { error, loadMyRange } = useAgendaEvents();
|
||||
await loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
expect(error.value).toBe('Erro ao carregar eventos');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create / update / remove — delegação pura', () => {
|
||||
it('create encaminha payload e retorna o resultado do repository', async () => {
|
||||
createMock.mockResolvedValue({ id: 'new', titulo: 'X' });
|
||||
const { create } = useAgendaEvents();
|
||||
const result = await create({ titulo: 'X' });
|
||||
expect(createMock).toHaveBeenCalledWith({ titulo: 'X' });
|
||||
expect(result).toEqual({ id: 'new', titulo: 'X' });
|
||||
});
|
||||
|
||||
it('update encaminha id+patch e retorna o resultado', async () => {
|
||||
updateMock.mockResolvedValue({ id: '42', titulo: 'updated' });
|
||||
const { update } = useAgendaEvents();
|
||||
const result = await update('42', { titulo: 'updated' });
|
||||
expect(updateMock).toHaveBeenCalledWith('42', { titulo: 'updated' });
|
||||
expect(result).toEqual({ id: '42', titulo: 'updated' });
|
||||
});
|
||||
|
||||
it('remove encaminha id (sem retorno)', async () => {
|
||||
removeMock.mockResolvedValue(undefined);
|
||||
const { remove } = useAgendaEvents();
|
||||
const result = await remove('99');
|
||||
expect(removeMock).toHaveBeenCalledWith('99');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('create propaga erro (não engole)', async () => {
|
||||
createMock.mockRejectedValue(new Error('insert blocked by RLS'));
|
||||
const { create } = useAgendaEvents();
|
||||
await expect(create({ titulo: 'X' })).rejects.toThrow(/RLS/);
|
||||
});
|
||||
|
||||
it('update propaga erro', async () => {
|
||||
updateMock.mockRejectedValue(new Error('not found'));
|
||||
const { update } = useAgendaEvents();
|
||||
await expect(update('1', { x: 1 })).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('remove propaga erro', async () => {
|
||||
removeMock.mockRejectedValue(new Error('cascade fail'));
|
||||
const { remove } = useAgendaEvents();
|
||||
await expect(remove('1')).rejects.toThrow(/cascade/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isolamento entre instâncias', () => {
|
||||
it('cada useAgendaEvents() retorna refs independentes', async () => {
|
||||
listMock.mockResolvedValue([{ id: 'a' }]);
|
||||
const a = useAgendaEvents();
|
||||
const b = useAgendaEvents();
|
||||
|
||||
await a.loadMyRange('2026-01-01', '2026-01-31', 'u-1');
|
||||
|
||||
expect(a.rows.value).toEqual([{ id: 'a' }]);
|
||||
expect(b.rows.value).toEqual([]); // b não foi tocado
|
||||
});
|
||||
});
|
||||
@@ -16,75 +16,33 @@
|
||||
*/
|
||||
/**
|
||||
* useAgendaEvents.js
|
||||
* src/features/agenda/composables/useAgendaEvents.js
|
||||
*
|
||||
* Gerencia apenas eventos reais (agenda_eventos).
|
||||
* Sessões com recurrence_id são sessões reais de uma série.
|
||||
* Wrapper fino sobre agendaRepository — agrega estado reativo (rows/loading/error)
|
||||
* e delega toda a lógica de I/O ao repository. Mesmo padrão de useAgendaClinicEvents.
|
||||
*
|
||||
* Só gerencia eventos reais (agenda_eventos). Ocorrências virtuais de séries são
|
||||
* responsabilidade do useRecurrence.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||
|
||||
function assertTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar na agenda.');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const BASE_SELECT = `
|
||||
id, owner_id, patient_id, tipo, status,
|
||||
titulo, titulo_custom, observacoes, inicio_em, fim_em,
|
||||
terapeuta_id, tenant_id, visibility_scope,
|
||||
determined_commitment_id, link_online, extra_fields, modalidade,
|
||||
recurrence_id, recurrence_date,
|
||||
mirror_of_event_id, price,
|
||||
insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id,
|
||||
patients!agenda_eventos_patient_id_fkey (
|
||||
id, nome_completo, avatar_url, status
|
||||
),
|
||||
determined_commitments!agenda_eventos_determined_commitment_fk (
|
||||
id, bg_color, text_color
|
||||
)
|
||||
`.trim();
|
||||
import {
|
||||
listMyAgendaEvents,
|
||||
createAgendaEvento,
|
||||
updateAgendaEvento,
|
||||
deleteAgendaEvento
|
||||
} from '@/features/agenda/services/agendaRepository';
|
||||
|
||||
export function useAgendaEvents() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function loadMyRange(start, end, ownerId) {
|
||||
async function loadMyRange(startISO, endISO, ownerId) {
|
||||
if (!ownerId) return;
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select(BASE_SELECT)
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', ownerId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', start)
|
||||
.lte('inicio_em', end)
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
if (err) throw err;
|
||||
rows.value = (data || []).map(flattenRow);
|
||||
rows.value = await listMyAgendaEvents({ startISO, endISO, ownerId });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar eventos';
|
||||
rows.value = [];
|
||||
@@ -93,89 +51,17 @@ export function useAgendaEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um evento injetando tenant_id e owner_id automaticamente.
|
||||
* owner_id é sempre o usuário autenticado — nunca vem do payload externo.
|
||||
* tenant_id vem do tenantStore ativo — nunca do payload externo.
|
||||
*/
|
||||
async function create(payload) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const uid = await getUid();
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...rest } = payload;
|
||||
const safePayload = {
|
||||
...rest,
|
||||
tenant_id: tenantId,
|
||||
owner_id: uid
|
||||
};
|
||||
|
||||
const { data, error: err } = await supabase.from('agenda_eventos').insert([safePayload]).select(BASE_SELECT).single();
|
||||
if (err) throw err;
|
||||
return flattenRow(data);
|
||||
return createAgendaEvento(payload);
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...safePatch } = patch;
|
||||
|
||||
const { data, error: err } = await supabase.from('agenda_eventos').update(safePatch).eq('id', id).eq('tenant_id', tenantId).select(BASE_SELECT).single();
|
||||
if (err) throw err;
|
||||
return flattenRow(data);
|
||||
return updateAgendaEvento(id, patch);
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const { error: err } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
await deleteAgendaEvento(id);
|
||||
}
|
||||
|
||||
async function removeSeriesFrom(recurrenceId, fromDateISO) {
|
||||
if (!recurrenceId) throw new Error('recurrenceId inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const { error: err } = await supabase.from('agenda_eventos').delete().eq('recurrence_id', recurrenceId).eq('tenant_id', tenantId).gte('recurrence_date', fromDateISO);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
async function removeAllSeries(recurrenceId) {
|
||||
if (!recurrenceId) throw new Error('recurrenceId inválido.');
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const { error: err } = await supabase.from('agenda_eventos').delete().eq('recurrence_id', recurrenceId).eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadMyRange, create, update, remove, removeSeriesFrom, removeAllSeries };
|
||||
}
|
||||
|
||||
function flattenRow(r) {
|
||||
if (!r) return r;
|
||||
const patient = r.patients || null;
|
||||
const out = { ...r };
|
||||
delete out.patients;
|
||||
out.paciente_nome = patient?.nome_completo || out.paciente_nome || '';
|
||||
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || '';
|
||||
out.paciente_status = patient?.status || out.paciente_status || '';
|
||||
return out;
|
||||
return { rows, loading, error, loadMyRange, create, update, remove };
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId } from '@/features/agenda/services/_tenantGuards';
|
||||
import { logRecurrence, logError, logPerf } from '@/support/supportLogger';
|
||||
|
||||
// ─── helpers de data ────────────────────────────────────────────────────────
|
||||
@@ -197,6 +199,11 @@ export function generateDates(rule, rangeStart, rangeEnd) {
|
||||
|
||||
// ─── expansão principal ──────────────────────────────────────────────────────
|
||||
|
||||
// Cap defensivo: a agenda real sempre passa ranges mensais/semanais (≤42d).
|
||||
// Range muito grande com muitas regras = milhares de ocorrências no browser.
|
||||
// Não bloqueamos (relatórios legítimos podem precisar), só avisamos.
|
||||
const MAX_RANGE_DAYS = 730; // 2 anos
|
||||
|
||||
/**
|
||||
* Expande regras em ocorrências, aplica exceções.
|
||||
*
|
||||
@@ -207,6 +214,15 @@ export function generateDates(rule, rangeStart, rangeEnd) {
|
||||
* @returns {Array} occurrences — objetos com shape compatível com FullCalendar
|
||||
*/
|
||||
export function expandRules(rules, exceptions, rangeStart, rangeEnd) {
|
||||
const rangeDays = Math.round((rangeEnd.getTime() - rangeStart.getTime()) / 86_400_000);
|
||||
if (rangeDays > MAX_RANGE_DAYS) {
|
||||
logError('useRecurrence', 'expandRules: range grande pode degradar UI', {
|
||||
rangeDays,
|
||||
maxRecommended: MAX_RANGE_DAYS,
|
||||
ruleCount: (rules || []).length
|
||||
});
|
||||
}
|
||||
|
||||
// índice de exceções por regra+data
|
||||
const exMap = new Map();
|
||||
for (const ex of exceptions || []) {
|
||||
@@ -399,6 +415,13 @@ export function useRecurrence() {
|
||||
const exceptions = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
function currentTenantId() {
|
||||
const tid = tenantStore.activeTenantId;
|
||||
assertTenantId(tid);
|
||||
return tid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega regras ativas para um owner no range dado.
|
||||
@@ -493,6 +516,7 @@ export function useRecurrence() {
|
||||
return true;
|
||||
});
|
||||
} catch (e) {
|
||||
logError('useRecurrence', 'loadExceptions ERRO', e);
|
||||
error.value = e?.message || 'Erro ao carregar exceções';
|
||||
exceptions.value = [];
|
||||
}
|
||||
@@ -546,13 +570,16 @@ export function useRecurrence() {
|
||||
// ── CRUD de regras ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cria uma nova regra de recorrência
|
||||
* Cria uma nova regra de recorrência.
|
||||
* tenant_id é injetado do tenantStore se não vier no payload (defesa em profundidade).
|
||||
* @param {Object} rule - campos da tabela recurrence_rules
|
||||
* @returns {Object} regra criada
|
||||
*/
|
||||
async function createRule(rule) {
|
||||
const tenantId = currentTenantId();
|
||||
logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type });
|
||||
const { data, error: err } = await supabase.from('recurrence_rules').insert([rule]).select('*').single();
|
||||
const safeRule = { ...rule, tenant_id: rule?.tenant_id || tenantId };
|
||||
const { data, error: err } = await supabase.from('recurrence_rules').insert([safeRule]).select('*').single();
|
||||
if (err) {
|
||||
logError('useRecurrence', 'createRule ERRO', err);
|
||||
throw err;
|
||||
@@ -562,13 +589,16 @@ export function useRecurrence() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza a regra toda (editar todos)
|
||||
* Atualiza a regra toda (editar todos).
|
||||
* Filtro adicional por tenant_id — defesa em profundidade (RLS cobre, mas reforçamos).
|
||||
*/
|
||||
async function updateRule(id, patch) {
|
||||
const tenantId = currentTenantId();
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.update({ ...patch, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
@@ -576,10 +606,15 @@ export function useRecurrence() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela a série inteira
|
||||
* Cancela a série inteira (filtro por tenant_id — defesa em profundidade).
|
||||
*/
|
||||
async function cancelRule(id) {
|
||||
const { error: err } = await supabase.from('recurrence_rules').update({ status: 'cancelado', updated_at: new Date().toISOString() }).eq('id', id);
|
||||
const tenantId = currentTenantId();
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
@@ -610,19 +645,33 @@ export function useRecurrence() {
|
||||
// ── CRUD de exceções ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cria ou atualiza uma exceção para uma ocorrência específica
|
||||
* Cria ou atualiza uma exceção para uma ocorrência específica.
|
||||
* tenant_id é injetado do tenantStore se não vier no payload.
|
||||
*/
|
||||
async function upsertException(ex) {
|
||||
const { data, error: err } = await supabase.from('recurrence_exceptions').upsert([ex], { onConflict: 'recurrence_id,original_date' }).select('*').single();
|
||||
const tenantId = currentTenantId();
|
||||
const safeEx = { ...ex, tenant_id: ex?.tenant_id || tenantId };
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
.upsert([safeEx], { onConflict: 'recurrence_id,original_date' })
|
||||
.select('*')
|
||||
.single();
|
||||
if (err) throw err;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove uma exceção (restaura a ocorrência ao normal)
|
||||
* Remove uma exceção (restaura a ocorrência ao normal).
|
||||
* Filtro por tenant_id — defesa em profundidade.
|
||||
*/
|
||||
async function deleteException(recurrenceId, originalDate) {
|
||||
const { error: err } = await supabase.from('recurrence_exceptions').delete().eq('recurrence_id', recurrenceId).eq('original_date', originalDate);
|
||||
const tenantId = currentTenantId();
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
.delete()
|
||||
.eq('recurrence_id', recurrenceId)
|
||||
.eq('original_date', originalDate)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -1968,7 +1968,7 @@ async function bloquearFeriadoDoAlerta(feriado) {
|
||||
}
|
||||
]);
|
||||
if (error) throw error;
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', clinicOwnerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
||||
|
||||
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
|
||||
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user