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:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
-53
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,138 @@
# Docker Setup — Projetos Locais
## Tabela Resumo
| Projeto | Container(s) | Porta Host | Rede | Volume(s) |
|---|---|---|---|---|
| **AgenciaPsi** | `agenciapsi_app` | `5173` → Vite dev | `agenciapsi_net` | `agenciapsi_node_modules` |
| | `agenciapsi_mysql` | `3307` → MySQL | `agenciapsi_net` | `agenciapsi_mysql_data` |
| **Evolution API** | `evolution_api` | `8080` → API | `agenciapsi_net` (external) | — |
| | `evolution_db` | interno | `agenciapsi_net` | `evolution_db_data` |
| | `evolution_redis` | interno | `agenciapsi_net` | — |
| | `evolution_mailpit` | `1025` SMTP / `8025` Web | `agenciapsi_net` | — |
| **Supabase AgenciaPsi** | `supabase_*_agenciapsi-primesakai` | `54321` API / `54322` PG / `54323` Studio | — | volumes internos |
| **Sakai-Vue** | `sakaivue_app` | `5174` → Vite dev | `sakaivue_net` | `sakaivue_node_modules` |
| | `sakaivue_mysql` | `3308` → MySQL | `sakaivue_net` | `sakaivue_mysql_data` |
| **Supabase Sakai-Vue** | `supabase_*_sakai-vue` | `54331` API / `54332` PG / `54333` Studio | — | volumes internos |
| **Gisaf Local** | `gisaf_mysql` | `3309` → MySQL | `gisaf_net` | `gisaf_mysql_data` |
## Mapa de Portas
| Porta | Serviço |
|---|---|
| 3307 | AgenciaPsi MySQL |
| 3308 | Sakai-Vue MySQL |
| 3309 | Gisaf MySQL |
| 5173 | AgenciaPsi Vite dev |
| 5174 | Sakai-Vue Vite dev |
| 8080 | Evolution API |
| 1025 | Mailpit SMTP |
| 8025 | Mailpit Web UI |
| 54321 | Supabase AgenciaPsi — Kong (API) |
| 54322 | Supabase AgenciaPsi — PostgreSQL |
| 54323 | Supabase AgenciaPsi — Studio |
| 54327 | Supabase AgenciaPsi — Analytics |
| 54331 | Supabase Sakai-Vue — Kong (API) |
| 54332 | Supabase Sakai-Vue — PostgreSQL |
| 54333 | Supabase Sakai-Vue — Studio |
| 54337 | Supabase Sakai-Vue — Analytics |
## Ordem de Start
```bash
# 1. AgenciaPsi (cria a rede agenciapsi_net)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
docker compose up -d
# 2. Supabase AgenciaPsi (porta 54321)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
npx supabase start
# 3. Evolution API (depende da rede agenciapsi_net)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api"
docker compose up -d
# 4. Sakai-Vue
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
docker compose up -d
# 5. Supabase Sakai-Vue (porta 54331)
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
npx supabase start
# 6. Gisaf Local
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local"
docker compose up -d
```
## Parar tudo
```bash
# Na ordem inversa
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local" && docker compose down
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && npx supabase stop
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && docker compose down
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api" && docker compose down
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && npx supabase stop
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && docker compose down
```
## Caminhos dos docker-compose.yml
| Projeto | Caminho |
|---|---|
| AgenciaPsi | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\docker-compose.yml` |
| Evolution API | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\evolution-api\docker-compose.yml` |
| Sakai-Vue | `D:\leonohama\UniaoApp.com.br\Sistema\sakai-vue\docker-compose.yml` |
| Gisaf Local | `D:\leonohama\UniaoApp.com.br\Gisaf Local\docker-compose.yml` |
## DBeaver — Conexões MySQL
| Conexão | Host | Port | Database | User | Password |
|---|---|---|---|---|---|
| Gisaf | `localhost` | `3309` | `sindsp` | `sindsp` | `marlboro` |
| AgenciaPsi | `localhost` | `3307` | `agenciapsi` | `agenciapsi` | `agenciapsi123` |
| Sakai-Vue | `localhost` | `3308` | `sakaivue` | `sakaivue` | `sakaivue123` |
Para criar cada conexão: **Database → New Database Connection → MySQL → preencher dados → Test Connection → Finish**
## Supabase — Instancias Locais
Cada projeto tem sua propria instancia Supabase (schemas diferentes, nao podem compartilhar).
| Projeto | API URL | Studio | PostgreSQL | Anon Key |
|---|---|---|---|---|
| AgenciaPsi | `http://127.0.0.1:54321` | `http://127.0.0.1:54323` | `127.0.0.1:54322` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
| Sakai-Vue | `http://127.0.0.1:54331` | `http://127.0.0.1:54333` | `127.0.0.1:54332` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
**Resetar banco (aplica migrations + seed):**
```bash
# AgenciaPsi
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
npx supabase db reset
# Sakai-Vue
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
npx supabase db reset
```
### Sakai-Vue — Usuarios de teste
| Email | Senha | Role |
|---|---|---|
| `dev@sistema.com.br` | `Dev@12345` | dev |
| `master@tenant.com.br` | `Master@12345` | master |
| `admin@tenant.com.br` | `Admin@12345` | admin |
| `chefe@tenant.com.br` | `Chefe@12345` | chefe_setor |
| `servidor@tenant.com.br` | `Servidor@12345` | servidor |
| `leitura@tenant.com.br` | `Leitura@12345` | leitura |
## Importar dump SQL no Gisaf
```bash
# Via CLI (já feito)
docker exec -i gisaf_mysql mysql -usindsp -pmarlboro sindsp < "D:/leonohama/UniaoApp.com.br/Gisaf Local/Dump20260330.sql"
```
Ou via DBeaver: conectar no banco `sindsp`**Tools → Execute SQL Script** → selecionar `Dump20260330.sql`
+174
View File
@@ -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.
+237
View File
@@ -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#1V#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#11V#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#22V#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#15A#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#33V#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
View File
@@ -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
`);
};
+314 -2
View File
@@ -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."
}
]
}
}
}
+236 -177
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 em /saas, tenant lockdown, trocaTenantScope, matchesRoles com aliases, cache de globalRole, cache de saasAdmin.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'guards.js tem ~650 linhas e só roda via navegação real. Sem teste unitário → mudanças no guard são de alto risco. Prioridade média para criar (mock do router + pinia).',
ARRAY['unit','auth','router','guard','missing'], 3),
('auth', 'a_escrever',
'session.js — hydrate e race conditions',
'src/app/__tests__/session.spec.js (não existe)',
$$Deveria cobrir: initSession com/sem session, refreshSession que não dispara se refreshing, SIGNED_IN redundante ignorado, SIGNED_OUT zera state, TOKEN_REFRESHED não derruba cache, hydrate preserva user em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Módulo tem histórico de race conditions (comentado no próprio arquivo). Teste unitário daria garantia contra regressão.',
ARRAY['unit','auth','session','race','missing'], 4),
('stores', 'a_escrever',
'tenantStore — singleflight + persist',
'src/stores/__tests__/tenantStore.spec.js (não existe)',
$$Deveria cobrir: loadSessionAndTenant com Promise compartilhada (V#3), ensureLoaded sem setInterval, tenant salvo se pertence ao user, normalizeTenantRole, reset, persistência em localStorage.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'V#3 trocou polling por Promise singleflight — a correção não tem teste que proteja contra regressão.',
ARRAY['unit','store','tenant','missing'], 5),
('utils', 'a_escrever',
'roleNormalizer — saídas esperadas',
'src/utils/__tests__/roleNormalizer.spec.js (não existe)',
$$Fácil de testar função pura, sem IO. Cobre: tenant_admin+therapisttherapist, tenant_admin+clinicclinic_admin, tenant_admin+supervisorsupervisor, tenant_admin sem kindclinic_admin, clinic_adminclinic_admin, pass-through.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Criado em V#4. É função pura — fácil de cobrir em 10min. Baixa prioridade técnica mas alto valor simbólico (garantir que os 2 consumidores — guards.js e tenantStore.js — concordam).',
ARRAY['unit','utils','trivial'], 6),
('pacientes', 'a_escrever',
'Cadastros externos — fluxo do paciente',
'src/features/patients/__tests__/external-intake.spec.js (não existe)',
$$Deveria cobrir: validação client-side (token regex, email, consent), truncation em todos os campos, payload final, não envio de notas_internas, comportamento com token inválido.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Página pública é ponto crítico de segurança. Teste de regressão importante após A#17/A#18/A#21 — garantir que nenhum dos valores "perigosos" voltem a ser enviados.',
ARRAY['unit','pacientes','external','security-regression'], 7),
('database', 'manual',
'RPCs de intake — validação de inputs maliciosos',
'database-novo/tests/test_patient_intake_security.sql (sugerido)',
$$Deveria cobrir: token inválido raise, token desativado raise (A#16), token expirado raise, max_uses raise, uses incrementa após sucesso, consent=false raise, payload com notas_internas é ignorado (A#17), tenant_id é preenchido (A#19), nome > 200 chars raise, email inválido raise, genero fora whitelist vira NULL, data_nascimento futura vira NULL.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Testes SQL diretos via psql. Importantes porque as validações estão dentro do RPC SECURITY DEFINER. Executar antes de cada deploy.',
ARRAY['manual','sql','security','rpc'], 8),
('agenda', 'a_escrever',
'useAgendaEvents — wrapper do repository',
'src/features/agenda/composables/__tests__/useAgendaEvents.spec.js (não existe)',
$$Deveria cobrir: loadMyRange chama listMyAgendaEvents, estado loading/error transições, sem ownerId retorna cedo, rollback em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 2 — agenda',
'Após refactor V#14 o composable virou fino. Teste garante que continue fino.',
ARRAY['unit','agenda','composable','missing'], 9),
('e2e', 'a_escrever',
'Fluxo completo: terapeuta cria link → paciente preenche → terapeuta vê',
'(não existe)',
$$Deveria cobrir o happy path integrado: login terapeuta, gera link via issue_patient_invite, abre /cadastro/paciente em aba anônima, preenche, submit, terapeuta em /therapist/patients/recebidos.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Não há E2E hoje. Playwright ou Cypress valem? Decidir provider. Alta prioridade pra confiança em deploy.',
ARRAY['e2e','critical','missing','decisão-pendente'], 10);
SELECT id, area, categoria, status, total_tests, passing FROM public.dev_test_items ORDER BY ordem;
@@ -0,0 +1,167 @@
-- =============================================================================
-- Migration: 20260418000005_saas_rls_emergency_fix
-- Corrige A#30 (P0) — 7 tabelas SaaS estavam com RLS desabilitado + grants
-- totais pra anon/authenticated/service_role. Qualquer usuário anônimo
-- podia alterar/deletar dados críticos (tenant_features, plan_prices,
-- subscription_intents_personal/tenant, plan_public, ...).
--
-- Estratégia:
-- 1. Habilitar RLS em todas as 7 tabelas
-- 2. REVOKE ALL de anon (nunca deveria ter tido)
-- 3. REVOKE ALL de authenticated (controle passa a ser via policy)
-- 4. Policies explícitas por caso de uso
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. REVOKE grants inseguros
-- -----------------------------------------------------------------------------
REVOKE ALL ON public.tenant_features FROM anon, authenticated;
REVOKE ALL ON public.plan_prices FROM anon, authenticated;
REVOKE ALL ON public.plan_public FROM anon, authenticated;
REVOKE ALL ON public.plan_public_bullets FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_personal FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_tenant FROM anon, authenticated;
REVOKE ALL ON public.tenant_feature_exceptions_log FROM anon, authenticated;
-- Concede o mínimo necessário (controlado por RLS abaixo)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_features TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_prices TO authenticated;
GRANT SELECT ON public.plan_public TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public TO authenticated;
GRANT SELECT ON public.plan_public_bullets TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public_bullets TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_personal TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_tenant TO authenticated;
GRANT SELECT ON public.tenant_feature_exceptions_log TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. HABILITAR RLS em todas
-- -----------------------------------------------------------------------------
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. POLICIES — tenant_features
-- -----------------------------------------------------------------------------
-- SELECT: membros do tenant leem as features do próprio tenant. Saas admin lê tudo.
DROP POLICY IF EXISTS tenant_features_select ON public.tenant_features;
CREATE POLICY tenant_features_select ON public.tenant_features
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
-- WRITE: apenas tenant_admin do próprio tenant OU saas_admin.
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
CREATE POLICY tenant_features_write ON public.tenant_features
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 4. POLICIES — plan_prices (SaaS admin only pra escrita; authenticated lê)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_prices_read ON public.plan_prices;
CREATE POLICY plan_prices_read ON public.plan_prices
FOR SELECT TO authenticated
USING (true); -- preços são públicos pra usuários logados
DROP POLICY IF EXISTS plan_prices_write ON public.plan_prices;
CREATE POLICY plan_prices_write ON public.plan_prices
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 5. POLICIES — plan_public + plan_public_bullets (anon pode ler — landing page)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_public_read_anon ON public.plan_public;
CREATE POLICY plan_public_read_anon ON public.plan_public
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_write ON public.plan_public;
CREATE POLICY plan_public_write ON public.plan_public
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
DROP POLICY IF EXISTS plan_public_bullets_read_anon ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_bullets_write ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 6. POLICIES — subscription_intents_personal + _tenant
-- -----------------------------------------------------------------------------
-- Dono vê o próprio intent; saas admin vê tudo; owner cria/atualiza seus próprios.
DROP POLICY IF EXISTS subscription_intents_personal_owner ON public.subscription_intents_personal;
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal
FOR ALL TO authenticated
USING (user_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (user_id = auth.uid() OR public.is_saas_admin());
DROP POLICY IF EXISTS subscription_intents_tenant_member ON public.subscription_intents_tenant;
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 7. POLICY — tenant_feature_exceptions_log (somente leitura)
-- -----------------------------------------------------------------------------
-- Log de auditoria. Inserts vêm de triggers/funções server-side (SECURITY DEFINER).
DROP POLICY IF EXISTS tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log;
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
COMMENT ON TABLE public.tenant_features IS
'Controle de features por tenant. RLS: member do tenant lê; tenant_admin ou saas_admin escreve. Antes da migration 20260418000005 estava com RLS off + GRANT ALL pra anon (A#30).';
@@ -0,0 +1,214 @@
-- =============================================================================
-- Migration: 20260419000001_tenant_features_b2_governance
-- Resolve V#34 (isEnabled opt-out por padrão) + V#41 (dupla fonte entitlements
-- vs tenant_features) — Opção B2 (plano + override com exceção comercial).
--
-- Mudanças:
-- 1. Trigger tenant_features_guard_with_plan ganha bypass via session flag
-- (current_setting('app.allow_feature_exception')) — só RPC pode setar.
-- 2. Nova RPC set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)
-- SECURITY DEFINER, com regras assimétricas:
-- - p_enabled=false → tenant_admin OU saas_admin (preferência)
-- - p_enabled=true AND plano permite → tenant_admin OU saas_admin
-- - p_enabled=true AND plano NÃO permite → SOMENTE saas_admin + reason obrigatório
-- Toda mudança grava em tenant_feature_exceptions_log.
-- 3. Policy tenant_features_write restringida a saas_admin (writes diretos).
-- Tenant_admin agora muda só via RPC.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. Trigger: bypass controlado por session flag
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.tenant_features_guard_with_plan()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_allowed boolean;
v_bypass text;
BEGIN
-- Só valida quando está habilitando
IF new.enabled IS DISTINCT FROM true THEN
RETURN new;
END IF;
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
-- após validar que o caller é saas_admin com reason.
v_bypass := current_setting('app.allow_feature_exception', true);
IF v_bypass = 'true' THEN
RETURN new;
END IF;
-- Permitido pelo plano do tenant?
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements_full v
WHERE v.tenant_id = new.tenant_id
AND v.feature_key = new.feature_key
AND v.allowed = true
) INTO v_allowed;
IF NOT v_allowed THEN
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
new.feature_key, new.tenant_id
USING ERRCODE = 'P0001';
END IF;
RETURN new;
END;
$$;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. RPC set_tenant_feature_exception
-- (substitui versão anterior que retornava void; retorna jsonb agora)
-- -----------------------------------------------------------------------------
DROP FUNCTION IF EXISTS public.set_tenant_feature_exception(uuid, text, boolean, text);
CREATE OR REPLACE FUNCTION public.set_tenant_feature_exception(
p_tenant_id uuid,
p_feature_key text,
p_enabled boolean,
p_reason text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_is_saas boolean := public.is_saas_admin();
v_is_tenant_adm boolean;
v_plan_allows boolean;
v_feature_key text;
v_reason text;
v_is_exception boolean;
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização (padrão V#31)
-- ───────────────────────────────────────────────────────────────────────
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
END IF;
IF p_enabled IS NULL THEN
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
END IF;
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
IF v_feature_key IS NULL THEN
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
END IF;
IF length(v_feature_key) > 80 THEN
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
END IF;
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
END IF;
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
v_reason := substring(v_reason FROM 1 FOR 500);
END IF;
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Plano permite essa feature?
-- ───────────────────────────────────────────────────────────────────────
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements vte
WHERE vte.tenant_id = p_tenant_id
AND vte.feature_key = v_feature_key
) INTO v_plan_allows;
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
-- ───────────────────────────────────────────────────────────────────────
-- Caller é tenant_admin desse tenant?
-- ───────────────────────────────────────────────────────────────────────
v_is_tenant_adm := EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = p_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
);
-- ───────────────────────────────────────────────────────────────────────
-- Autorização (assimétrica — V#34 Opção B2)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
-- Override positivo fora do plano = exceção comercial
IF NOT v_is_saas THEN
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
END IF;
IF v_reason IS NULL THEN
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
END IF;
ELSE
-- Demais casos: tenant_admin OR saas_admin
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
END IF;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Persistência: bypass controlado do trigger guard quando é exceção
-- (escopo de transação via SET LOCAL — só esta RPC vê)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'true', true);
END IF;
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
ON CONFLICT (tenant_id, feature_key)
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'false', true);
END IF;
INSERT INTO public.tenant_feature_exceptions_log
(tenant_id, feature_key, enabled, reason, created_by)
VALUES
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
RETURN jsonb_build_object(
'tenant_id', p_tenant_id,
'feature_key', v_feature_key,
'enabled', p_enabled,
'plan_allows', v_plan_allows,
'is_exception', v_is_exception,
'reason', v_reason
);
END;
$function$;
REVOKE ALL ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. Policy: writes diretos só via saas_admin
-- (tenant_admin agora muda só via RPC set_tenant_feature_exception)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
DROP POLICY IF EXISTS tenant_features_write_saas_only ON public.tenant_features;
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
@@ -0,0 +1,21 @@
-- =============================================================================
-- Migration: 20260419000002_features_is_active
-- V#40 — features hard-deleted: adiciona is_active para soft-delete.
--
-- Estratégia conservadora:
-- - features.is_active boolean DEFAULT true NOT NULL
-- - SaasFeaturesPage substitui DELETE por UPDATE is_active=false
-- - Views que expõem features para o app (v_tenant_entitlements etc) NÃO são
-- alteradas: features depreciadas ainda servem tenants legados via plan_features
-- enquanto não houver migração explícita
-- - Permite reativar feature acidentalmente deprecada
-- =============================================================================
ALTER TABLE public.features
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
CREATE INDEX IF NOT EXISTS idx_features_is_active
ON public.features (is_active) WHERE is_active = false;
COMMENT ON COLUMN public.features.is_active IS
'V#40: false = feature depreciada, escondida no catálogo SaaS mas continua válida em planos/tenants existentes.';
@@ -0,0 +1,69 @@
-- =============================================================================
-- Migration: 20260419000003_delete_plan_safe
-- V#36 — DELETE de plans sem checagem de assinaturas ativas pode quebrar tenants.
--
-- Cria RPC delete_plan_safe(plan_id) que:
-- - Valida saas_admin
-- - Conta subscriptions ativas (status='active') no plano
-- - Se houver, RAISE EXCEPTION descritivo com a contagem
-- - Se OK, desativa prices ativos e deleta o plano (atomic)
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_plan_safe(
p_plan_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_active_count int;
v_plan_key text;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
END IF;
IF p_plan_id IS NULL THEN
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
END IF;
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
IF v_plan_key IS NULL THEN
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
END IF;
SELECT COUNT(*) INTO v_active_count
FROM public.subscriptions
WHERE plan_id = p_plan_id
AND status = 'active';
IF v_active_count > 0 THEN
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
v_plan_key, v_active_count
USING ERRCODE = 'P0001';
END IF;
-- desativa preços ativos antes de deletar
UPDATE public.plan_prices
SET is_active = false,
active_to = now()
WHERE plan_id = p_plan_id
AND is_active = true;
DELETE FROM public.plans WHERE id = p_plan_id;
RETURN jsonb_build_object(
'deleted', true,
'plan_key', v_plan_key
);
END;
$function$;
REVOKE ALL ON FUNCTION public.delete_plan_safe(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.delete_plan_safe(uuid) TO authenticated;
@@ -0,0 +1,46 @@
-- =============================================================================
-- Migration: 20260419000004_consolidate_policies
-- V#35 — Consolida policies duplicadas em plans, features, plan_features e
-- subscriptions. Remove legado redundante e documenta as que ficam.
--
-- Análise (auditada via pg_policies):
-- • plans/features/plan_features: cada uma tem "read * (auth)" duplicado
-- com "*_read_authenticated" (mesmo USING true). Removidos os legados.
-- • subscriptions:
-- - "subscriptions read own" (USING user_id = auth.uid()) é SUBSET de
-- "subscriptions_read_own" (USING user_id = auth.uid() OR is_saas_admin())
-- - "subscriptions_select_own_personal" (user_id = auth.uid() AND tenant_id IS NULL)
-- é SUBSET de "subscriptions_read_own"
-- - "subscriptions_no_direct_update" (USING false) é no-op em OR com
-- "subscriptions_update_only_saas_admin"
-- Removidas as 3 redundâncias.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Drops dos legados redundantes
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "read plans (auth)" ON public.plans;
DROP POLICY IF EXISTS "read features (auth)" ON public.features;
DROP POLICY IF EXISTS "read plan_features (auth)" ON public.plan_features;
DROP POLICY IF EXISTS "subscriptions read own" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_select_own_personal" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_no_direct_update" ON public.subscriptions;
-- ─────────────────────────────────────────────────────────────────────────
-- COMMENT ON POLICY — documenta escopo das que ficaram
-- -----------------------------------------------------------------------------
COMMENT ON POLICY plans_read_authenticated ON public.plans IS 'Qualquer usuário autenticado lê o catálogo de planos (vitrine, upgrade UI).';
COMMENT ON POLICY plans_write_saas_admin ON public.plans IS 'Somente saas_admin escreve. DELETE deve ser via RPC delete_plan_safe (V#36).';
COMMENT ON POLICY features_read_authenticated ON public.features IS 'Qualquer logado lê o catálogo de features.';
COMMENT ON POLICY features_write_saas_admin ON public.features IS 'Somente saas_admin escreve. DELETE = soft delete via is_active=false (V#40).';
COMMENT ON POLICY plan_features_read_authenticated ON public.plan_features IS 'Qualquer logado lê o vínculo plano↔feature (necessário para entitlements).';
COMMENT ON POLICY plan_features_write_saas_admin ON public.plan_features IS 'Somente saas_admin escreve.';
COMMENT ON POLICY subscriptions_read_own ON public.subscriptions IS 'Dono da assinatura (user_id) ou saas_admin. Cobre o caso pessoal.';
COMMENT ON POLICY subscriptions_select_for_tenant_members ON public.subscriptions IS 'Membros ativos do tenant leem assinaturas do tenant.';
COMMENT ON POLICY "subscriptions: read if linked owner_users" ON public.subscriptions IS 'Caso especial: usuários ligados ao owner via owner_users (terapeutas de uma clínica que precisam ver a assinatura do owner).';
COMMENT ON POLICY subscriptions_insert_own_personal ON public.subscriptions IS 'Usuário cria a própria assinatura pessoal (intent → conversion).';
COMMENT ON POLICY subscriptions_update_only_saas_admin ON public.subscriptions IS 'UPDATE direto somente via saas_admin. Mudanças de tenant devem passar por RPC dedicada.';
@@ -0,0 +1,29 @@
-- =============================================================================
-- Migration: 20260419000005_restrict_intake_rpc
-- A#20 — Restringe create_patient_intake_request_v2 a service_role.
--
-- Antes: anon (e PUBLIC) podia chamar direto. Bot bypassava qualquer
-- proteção do front (Turnstile etc).
-- Agora: edge function `submit-patient-intake` valida CAPTCHA e chama
-- a RPC com service_role. Anon não chama mais a RPC direto.
-- =============================================================================
-- Revoga PUBLIC (DEFAULT) e anon
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) FROM PUBLIC, anon;
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) FROM PUBLIC, anon;
-- Mantém grants explícitos pra authenticated (uso interno futuro) e service_role (edge function)
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) TO authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) TO authenticated, service_role;
-- Mesma proteção para RPC v1 legada (caso ainda exista)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'create_patient_intake_request'
) THEN
EXECUTE 'REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) FROM PUBLIC, anon';
EXECUTE 'GRANT EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) TO authenticated, service_role';
END IF;
END$$;
@@ -0,0 +1,136 @@
-- =============================================================================
-- Migration: 20260419000006_layered_bot_defense
-- A#20 (rev2) — Defesa em camadas self-hosted (substitui Turnstile).
--
-- Camadas:
-- 1. Honeypot field (no front) → invisível, sempre ativo
-- 2. Rate limit por IP no edge → submission_rate_limits
-- 3. Math captcha CONDICIONAL → só se IP teve N falhas recentes
-- 4. Logging em public_submission_attempts (genérico, não só intake)
-- 5. Modo paranoid global → saas_security_config.captcha_required
--
-- Substitui chamadas Turnstile na edge function submit-patient-intake.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. saas_security_config (singleton)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.saas_security_config (
id boolean PRIMARY KEY DEFAULT true,
honeypot_enabled boolean NOT NULL DEFAULT true,
rate_limit_enabled boolean NOT NULL DEFAULT true,
rate_limit_window_min integer NOT NULL DEFAULT 10,
rate_limit_max_attempts integer NOT NULL DEFAULT 5,
captcha_after_failures integer NOT NULL DEFAULT 3,
captcha_required_globally boolean NOT NULL DEFAULT false,
block_duration_min integer NOT NULL DEFAULT 30,
captcha_required_window_min integer NOT NULL DEFAULT 60,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK (id = true)
);
INSERT INTO public.saas_security_config (id) VALUES (true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.saas_security_config FROM anon, authenticated;
GRANT SELECT, UPDATE ON public.saas_security_config TO authenticated;
DROP POLICY IF EXISTS saas_security_config_read ON public.saas_security_config;
CREATE POLICY saas_security_config_read ON public.saas_security_config
FOR SELECT TO authenticated
USING (true); -- qualquer logado pode ler config global (não tem segredo)
DROP POLICY IF EXISTS saas_security_config_write ON public.saas_security_config;
CREATE POLICY saas_security_config_write ON public.saas_security_config
FOR UPDATE TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.saas_security_config IS 'Singleton: configuração global de defesa contra bots em endpoints públicos.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. public_submission_attempts (log genérico)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.public_submission_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text, -- 'honeypot' | 'rate_limit' | 'captcha' | 'rpc' | null
user_agent text,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_psa_endpoint_created ON public.public_submission_attempts (endpoint, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_psa_ip_hash_created ON public.public_submission_attempts (ip_hash, created_at DESC) WHERE ip_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_psa_failed ON public.public_submission_attempts (created_at DESC) WHERE success = false;
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.public_submission_attempts FROM anon, authenticated;
GRANT SELECT ON public.public_submission_attempts TO authenticated;
DROP POLICY IF EXISTS psa_read_saas_admin ON public.public_submission_attempts;
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.public_submission_attempts IS 'Log de tentativas em endpoints públicos (intake, signup, agendador). Escrita apenas via RPC SECURITY DEFINER.';
-- ─────────────────────────────────────────────────────────────────────────
-- 3. submission_rate_limits (estado vigente por IP+endpoint)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
fail_count integer NOT NULL DEFAULT 0,
window_start timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
requires_captcha_until timestamptz,
last_attempt_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (ip_hash, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_srl_blocked_until ON public.submission_rate_limits (blocked_until) WHERE blocked_until IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_srl_endpoint ON public.submission_rate_limits (endpoint, last_attempt_at DESC);
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.submission_rate_limits FROM anon, authenticated;
GRANT SELECT ON public.submission_rate_limits TO authenticated;
DROP POLICY IF EXISTS srl_read_saas_admin ON public.submission_rate_limits;
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.submission_rate_limits IS 'Estado de rate limit por IP+endpoint. Escrita apenas via RPC. SaaS admin lê pra dashboard.';
-- ─────────────────────────────────────────────────────────────────────────
-- 4. math_challenges (TTL 5min, limpa via cron)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.math_challenges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
question text NOT NULL,
answer integer NOT NULL,
used boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + interval '5 minutes')
);
CREATE INDEX IF NOT EXISTS idx_mc_expires ON public.math_challenges (expires_at);
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.math_challenges FROM anon, authenticated;
-- nenhum grant: tabela acessada apenas via RPC SECURITY DEFINER
COMMENT ON TABLE public.math_challenges IS 'Challenges de math captcha. TTL 5min. Escrita/leitura apenas via RPC.';
@@ -0,0 +1,299 @@
-- =============================================================================
-- Migration: 20260419000007_bot_defense_rpcs
-- A#20 (rev2) — RPCs da defesa em camadas:
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
-- • record_submission_attempt — log + atualiza contadores e bloqueios
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
-- • verify_math_challenge — valida {id, answer}, marca used
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- check_rate_limit
-- Lê config + estado atual, decide o que retornar.
-- Se fora da janela atual, "rolha" os contadores (reset).
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.check_rate_limit(
p_ip_hash text,
p_endpoint text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
rl submission_rate_limits%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
v_in_window boolean;
v_requires_captcha boolean := false;
v_blocked_until timestamptz;
v_retry_after_seconds integer := 0;
BEGIN
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND THEN
-- Sem config: fail-open (libera). Logado.
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
END IF;
-- Modo paranoid global: sempre captcha
IF cfg.captcha_required_globally THEN
v_requires_captcha := true;
END IF;
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
IF NOT cfg.rate_limit_enabled THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
);
END IF;
-- Sem ip_hash: libera (não dá pra rastrear)
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', 'no_ip'
);
END IF;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
-- Bloqueio temporário ativo?
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'blocked'
);
END IF;
-- Captcha condicional ativo?
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
v_requires_captcha := true;
END IF;
-- Janela atual ainda válida?
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
v_in_window := FOUND AND rl.window_start >= v_window_start;
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
-- Excedeu — bloqueia
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
UPDATE submission_rate_limits
SET blocked_until = v_blocked_until,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'rate_limit_exceeded'
);
END IF;
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
);
END;
$function$;
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- record_submission_attempt
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
-- requires_captcha_until = now + captcha_required_window_min.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
p_endpoint text,
p_ip_hash text,
p_success boolean,
p_blocked_by text DEFAULT NULL,
p_error_code text DEFAULT NULL,
p_error_msg text DEFAULT NULL,
p_user_agent text DEFAULT NULL,
p_metadata jsonb DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
rl submission_rate_limits%ROWTYPE;
BEGIN
-- Log sempre (mesmo sem ip)
INSERT INTO public_submission_attempts
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
VALUES
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
left(coalesce(p_error_code, ''), 80),
left(coalesce(p_error_msg, ''), 500),
left(coalesce(p_user_agent, ''), 500),
p_metadata);
-- Sem ip ou rate limit desligado: não atualiza contador
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF NOT FOUND THEN
INSERT INTO submission_rate_limits
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
VALUES
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
ELSE
IF rl.window_start < v_window_start THEN
-- Reset janela
UPDATE submission_rate_limits
SET attempt_count = 1,
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
window_start = v_now,
last_attempt_at = v_now,
blocked_until = NULL
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
ELSE
UPDATE submission_rate_limits
SET attempt_count = attempt_count + 1,
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
-- Se atingiu threshold de captcha condicional, marca
IF NOT p_success THEN
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF rl.fail_count >= cfg.captcha_after_failures
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
UPDATE submission_rate_limits
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
END IF;
END IF;
END;
$function$;
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- generate_math_challenge
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
-- Operações: + - * (resultado sempre positivo)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_a integer;
v_b integer;
v_op text;
v_ans integer;
v_q text;
v_id uuid;
BEGIN
v_a := 1 + floor(random() * 9)::int;
v_b := 1 + floor(random() * 9)::int;
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
-- garantir resultado positivo na subtração
IF v_op = '-' AND v_b > v_a THEN
v_a := v_a + v_b;
END IF;
v_ans := CASE v_op
WHEN '+' THEN v_a + v_b
WHEN '-' THEN v_a - v_b
WHEN '*' THEN v_a * v_b
END;
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
INSERT INTO math_challenges (question, answer)
VALUES (v_q, v_ans)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'question', v_q);
END;
$function$;
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- verify_math_challenge
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
p_id uuid,
p_answer integer
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
mc math_challenges%ROWTYPE;
BEGIN
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
RETURN false;
END IF;
UPDATE math_challenges SET used = true WHERE id = p_id;
RETURN mc.answer = p_answer;
END;
$function$;
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- cleanup_expired_math_challenges (chamável via cron)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
RETURNS integer
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
WITH d AS (
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
)
SELECT COUNT(*)::int FROM d;
$function$;
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
@@ -0,0 +1,155 @@
-- =============================================================================
-- Migration: 20260419000008_saas_twilio_config
-- Permite saas_admin editar config Twilio operacional pelo painel, sem redeploy.
--
-- DECISÃO DE SEGURANÇA:
-- • TWILIO_AUTH_TOKEN (secret) NÃO entra na tabela. Continua em env var
-- da Edge Function. Painel apenas exibe se está configurado (best-effort).
-- • TWILIO_ACCOUNT_SID (público no Twilio dashboard, identificador) → DB
-- • TWILIO_WHATSAPP_WEBHOOK (URL) → DB
-- • USD_BRL_RATE / MARGIN_MULTIPLIER (operacional) → DB
--
-- Edge function: lê primeiro do banco; cai pra env vars como fallback se row
-- ainda não foi configurada (back-compat com deploys antigos).
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.saas_twilio_config (
id boolean PRIMARY KEY DEFAULT true,
account_sid text,
whatsapp_webhook_url text,
usd_brl_rate numeric(10,4) NOT NULL DEFAULT 5.5,
margin_multiplier numeric(10,4) NOT NULL DEFAULT 1.4,
notes text,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_twilio_config_singleton CHECK (id = true),
CONSTRAINT saas_twilio_config_rate_chk CHECK (usd_brl_rate > 0 AND usd_brl_rate < 100),
CONSTRAINT saas_twilio_config_mult_chk CHECK (margin_multiplier >= 1 AND margin_multiplier <= 10),
CONSTRAINT saas_twilio_config_sid_chk CHECK (account_sid IS NULL OR account_sid ~ '^AC[a-zA-Z0-9]{32}$'),
CONSTRAINT saas_twilio_config_url_chk CHECK (whatsapp_webhook_url IS NULL OR whatsapp_webhook_url ~ '^https?://')
);
INSERT INTO public.saas_twilio_config (id) VALUES (true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.saas_twilio_config FROM anon, authenticated;
GRANT SELECT ON public.saas_twilio_config TO authenticated;
DROP POLICY IF EXISTS saas_twilio_config_read ON public.saas_twilio_config;
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config
FOR SELECT TO authenticated
USING (public.is_saas_admin()); -- só admin vê config (mesmo sem secret, é dado operacional)
COMMENT ON TABLE public.saas_twilio_config IS
'Config operacional Twilio editável via painel. AUTH_TOKEN continua em env var por segurança.';
-- ─────────────────────────────────────────────────────────────────────────
-- RPC get_twilio_config — retorna config atual (saas_admin OU service_role)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_twilio_config()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_twilio_config%ROWTYPE;
BEGIN
-- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
-- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
END IF;
SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'account_sid', NULL,
'whatsapp_webhook_url', NULL,
'usd_brl_rate', 5.5,
'margin_multiplier', 1.4
);
END IF;
RETURN jsonb_build_object(
'account_sid', cfg.account_sid,
'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
'usd_brl_rate', cfg.usd_brl_rate,
'margin_multiplier', cfg.margin_multiplier,
'notes', cfg.notes,
'updated_at', cfg.updated_at,
'updated_by', cfg.updated_by
);
END;
$function$;
REVOKE ALL ON FUNCTION public.get_twilio_config() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.get_twilio_config() TO authenticated, service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- RPC update_twilio_config — só saas_admin
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.update_twilio_config(
p_account_sid text DEFAULT NULL,
p_whatsapp_webhook_url text DEFAULT NULL,
p_usd_brl_rate numeric DEFAULT NULL,
p_margin_multiplier numeric DEFAULT NULL,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_account_sid text;
v_webhook_url text;
v_notes text;
BEGIN
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
END IF;
-- Sanitização
v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
v_notes := nullif(btrim(coalesce(p_notes, '')), '');
IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
END IF;
IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
END IF;
IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
END IF;
IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
END IF;
IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
v_notes := substring(v_notes FROM 1 FOR 1000);
END IF;
UPDATE saas_twilio_config
SET account_sid = COALESCE(v_account_sid, account_sid),
whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate),
margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier),
notes = COALESCE(v_notes, notes),
updated_at = now(),
updated_by = v_caller
WHERE id = true;
RETURN public.get_twilio_config();
END;
$function$;
REVOKE ALL ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) TO authenticated;
@@ -0,0 +1,34 @@
-- =============================================================================
-- Migration: 20260419000009_patient_session_counts_rpc
-- V#8 — Substitui o .limit(1000) arbitrário em PatientsListPage por RPC
-- agregada que retorna contagens por paciente (sempre atualizada, sem teto).
--
-- Tenant scoping é feito via WHERE tenant_id IN (memberships do caller),
-- consistente com a policy SELECT de agenda_eventos.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_session_counts(
p_patient_ids uuid[]
)
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
ae.patient_id,
COUNT(*)::int AS session_count,
MAX(ae.inicio_em) AS last_session_at
FROM public.agenda_eventos ae
WHERE ae.patient_id = ANY(p_patient_ids)
AND ae.tenant_id IN (
SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
)
GROUP BY ae.patient_id;
$function$;
REVOKE ALL ON FUNCTION public.get_patient_session_counts(uuid[]) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_session_counts(uuid[]) TO authenticated;
@@ -0,0 +1,304 @@
-- =============================================================================
-- Migration: 20260419000010_documents_security_hardening
-- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos
-- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes
-- pra próxima sessão (precisam de design/decisão).
--
-- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}"
-- (storage.foldername(name))[1] = tenant_id
-- =============================================================================
-- Tabelas de documents são owned por supabase_admin
SET LOCAL ROLE supabase_admin;
-- ─────────────────────────────────────────────────────────────────────────
-- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs"
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects;
CREATE POLICY "documents: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "documents: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'documents'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "documents: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects;
CREATE POLICY "generated-docs: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'generated-docs'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#45: documents — policies separadas por cmd
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: owner full access" ON public.documents;
DROP POLICY IF EXISTS "documents: select" ON public.documents;
DROP POLICY IF EXISTS "documents: insert" ON public.documents;
DROP POLICY IF EXISTS "documents: update" ON public.documents;
DROP POLICY IF EXISTS "documents: delete" ON public.documents;
-- SELECT: owner OR tenant_member ativo OR saas_admin
CREATE POLICY "documents: select" ON public.documents
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller
CREATE POLICY "documents: insert" ON public.documents
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- UPDATE: só owner
CREATE POLICY "documents: update" ON public.documents
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
-- DELETE: só owner ou saas_admin
CREATE POLICY "documents: delete" ON public.documents
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#46: document_share_links — RPC validate_share_token + remover SELECT direto
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
sl document_share_links%ROWTYPE;
v_doc documents%ROWTYPE;
v_token text;
BEGIN
v_token := nullif(btrim(coalesce(p_token, '')), '');
IF v_token IS NULL THEN
RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
END IF;
SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF sl.ativo IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- Incrementa uso atomicamente
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
-- Loga acesso (best-effort)
BEGIN
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
FROM documents d WHERE d.id = sl.document_id;
EXCEPTION WHEN OTHERS THEN
-- não derruba a request se log falhar (schema pode variar)
NULL;
END;
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
RETURN jsonb_build_object(
'document_id', sl.document_id,
'bucket', v_doc.storage_bucket,
'bucket_path', v_doc.bucket_path,
'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type,
'tamanho_bytes', v_doc.tamanho_bytes
);
END;
$function$;
REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role;
-- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário)
DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links;
DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links;
CREATE POLICY "dsl: creator full access" ON public.document_share_links
FOR ALL TO authenticated
USING (criado_por = auth.uid() OR public.is_saas_admin())
WITH CHECK (criado_por = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: select" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: update" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures;
CREATE POLICY "ds: select" ON public.document_signatures
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller
-- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row
-- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo)
CREATE POLICY "ds: insert" ON public.document_signatures
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
AND (signatario_id IS NULL OR signatario_id = auth.uid())
);
-- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado')
CREATE POLICY "ds: update" ON public.document_signatures
FOR UPDATE TO authenticated
USING (signatario_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin());
-- DELETE: signatário, saas_admin ou tenant_admin/owner
CREATE POLICY "ds: delete" ON public.document_signatures
FOR DELETE TO authenticated
USING (
signatario_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#48: document_access_logs — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs;
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#49: document_templates — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates;
DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates;
CREATE POLICY "dt: owner can insert" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (
is_global = false
AND owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (is_global = true AND public.is_saas_admin());
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
+3 -53
View File
@@ -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
@@ -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
-776
View File
@@ -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
+3 -88
View File
@@ -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
--
+3 -109
View File
@@ -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
);
@@ -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))
);
-608
View File
@@ -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
);
@@ -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])))
);
@@ -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()
);
-500
View File
@@ -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
--
+11
View File
@@ -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
);
@@ -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
);
@@ -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
);
@@ -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()
);
@@ -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])))
);
+119 -189
View File
@@ -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
+18 -336
View File
@@ -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.
+405
View File
@@ -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);
+223
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
+740
View File
@@ -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 ~2999/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$ ~50150/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.
```
+278
View File
@@ -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).
+33
View File
@@ -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)
+170
View File
@@ -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`
+53
View File
@@ -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

+42
View File
@@ -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
+149
View File
@@ -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);
});
});
+94
View File
@@ -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",
+4
View File
@@ -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",
+43
View File
@@ -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
}
});
+219
View File
@@ -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
View File
@@ -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
| 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>
+2 -2
View File
@@ -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.
*
* 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