Compare commits
26 Commits
676042268b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 037ba3721f | |||
| d6eb992f71 | |||
| 7c20b518d4 | |||
| d088a89fb7 | |||
| 0658e2e9bf | |||
| bfe148ef12 | |||
| 3f1786c9bf | |||
| 53a4980396 | |||
| a89d1f5560 | |||
| 29ed349cf2 | |||
| d6d2fe29d1 | |||
| 66f67cd40f | |||
| 84d65e49c0 | |||
| f66f6f3fde | |||
| ee09b30987 | |||
| 587079e414 | |||
| 06fb369beb | |||
| f4b185ae17 | |||
| f733db8436 | |||
| d58dc21297 | |||
| b1c0cb47c0 | |||
| 89b4ecaba1 | |||
| 6eff67bf22 | |||
| 62e79e243a | |||
| 3a671b1e9e | |||
| b3bb817e3f |
@@ -1,2 +1,4 @@
|
||||
VITE_SUPABASE_URL=http://127.0.0.1:54321
|
||||
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
|
||||
VITE_QA_MODE=true
|
||||
VITE_QA_PASS=123Mudar@
|
||||
|
||||
+15
-2
@@ -6,11 +6,24 @@ coverage
|
||||
.cache
|
||||
.output
|
||||
# .env
|
||||
dist
|
||||
dist/
|
||||
dist-*/
|
||||
.DS_Store
|
||||
.idea
|
||||
.eslintcache
|
||||
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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "src/assets"]
|
||||
path = src/assets
|
||||
url = https://github.com/primefaces/sakai-assets.git
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": [
|
||||
"development"
|
||||
],
|
||||
"hints": {
|
||||
"compat-api/css": [
|
||||
"default",
|
||||
{
|
||||
"ignore": [
|
||||
"background-color: color-mix(in srgb, var(--primary-color) 50%, transparent)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
+124
-39
@@ -1,70 +1,155 @@
|
||||
# Changelog
|
||||
# CHANGELOG — Banco de Dados AgênciaPsi
|
||||
|
||||
## 4.3.0 (2025-02-26)
|
||||
Registro histórico de todas as migrations aplicadas no banco.
|
||||
Formato: data | arquivo | o que mudou | por quê
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
---
|
||||
|
||||
- Update PrimeVue version
|
||||
## [001] — 2026-03-03
|
||||
**Arquivo:** `migration_001.sql`
|
||||
**Seed:** `seed_001.sql`
|
||||
|
||||
## 4.2.0 (2024-12-09)
|
||||
### Contexto
|
||||
O schema original foi construído de forma incremental e acumulou
|
||||
inconsistências no modelo de identidade. Usuários não tinham um
|
||||
tipo de conta definido formalmente, tenants não distinguiam
|
||||
terapeuta de clínica, e não existia suporte a paciente como
|
||||
tipo de conta de plataforma.
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
### O que mudou
|
||||
|
||||
- Refactored dashboard sections to components
|
||||
- Migrate sass from @import to @use
|
||||
#### `profiles`
|
||||
- ✅ Adicionada coluna `account_type text NOT NULL DEFAULT 'free'`
|
||||
- Valores: `free | patient | therapist | clinic`
|
||||
- Imutável após sair de `free` (trigger `trg_account_type_immutable`)
|
||||
- Usuários com role=`patient` migrados para `account_type='patient'`
|
||||
- Usuários com tenant `saas` ativo migrados para `account_type='therapist'`
|
||||
|
||||
## 4.1.0 (2024-07-29)
|
||||
#### `tenants`
|
||||
- ✅ Novos valores aceitos em `kind`:
|
||||
- `therapist` → terapeuta individual (substitui `saas`)
|
||||
- `clinic_coworking` → clínica tipo 1: gestão de salas
|
||||
- `clinic_reception` → clínica tipo 2: secretaria + múltiplas agendas
|
||||
- `clinic_full` → clínica tipo 3: coworking + secretaria
|
||||
- ✅ `kind` agora é imutável após criação (trigger `trg_tenant_kind_immutable`)
|
||||
- ✅ 10 tenants `saas` órfãos (sem admin, sem subscriptions) deletados
|
||||
- ✅ Tenants `saas` com admin ativo migrados para `kind='therapist'`
|
||||
- ⚠️ `saas` e `clinic` (legados) mantidos no CHECK por compatibilidade.
|
||||
Não criar novos tenants com esses kinds.
|
||||
|
||||
- Changed menu button location at topbar
|
||||
- Add border to overlay menu
|
||||
- Animation for mobile mask
|
||||
- Fixed chart colors
|
||||
#### `plans`
|
||||
- ✅ Adicionado `patient` como valor válido em `target`
|
||||
- ✅ Inserido plano `patient_free` (gratuito, target=patient)
|
||||
|
||||
## 4.0.0 (2024-07-29)
|
||||
#### Novas funções
|
||||
| Função | Descrição |
|
||||
|--------|-----------|
|
||||
| `provision_account_tenant(user_id, kind, name?)` | Cria tenant + membership + atualiza account_type. Chamar no onboarding. |
|
||||
| `is_therapist_tenant(tenant_id)` | Retorna true se tenant é do tipo therapist |
|
||||
| `is_clinic_tenant(tenant_id)` | Atualizada: inclui todos os subtipos de clínica |
|
||||
| `guard_tenant_kind_immutable()` | Trigger: bloqueia alteração de tenants.kind |
|
||||
| `guard_account_type_immutable()` | Trigger: bloqueia alteração de account_type após escolha |
|
||||
| `guard_patient_cannot_own_tenant()` | Trigger: bloqueia paciente de ser tenant_admin/therapist |
|
||||
|
||||
- Updated to PrimeVue v4
|
||||
#### Funções atualizadas
|
||||
| Função | O que mudou |
|
||||
|--------|-------------|
|
||||
| `handle_new_user()` | Agora insere `account_type='free'` |
|
||||
| `handle_new_user_create_personal_tenant()` | Desabilitada — tenant criado no onboarding |
|
||||
| `ensure_personal_tenant()` | Busca por `kind IN ('therapist','saas')` e delega para `provision_account_tenant` |
|
||||
|
||||
## 3.10.0 (2024-03-11)
|
||||
### Regras de negócio agora garantidas no banco
|
||||
1. **Paciente é para sempre paciente** — `account_type` imutável após escolha
|
||||
2. **Terapeuta nunca vira clínica e vice-versa** — `tenants.kind` imutável
|
||||
3. **Paciente não pode ter tenant** — trigger bloqueia na inserção
|
||||
4. **Cada tipo de conta tem seu tipo de tenant** — `provision_account_tenant` garante
|
||||
|
||||
**Migration Guide**
|
||||
### Usuários de seed (apenas dev/staging)
|
||||
| Email | Tipo | Tenant |
|
||||
|-------|------|--------|
|
||||
| paciente@agenciapsi.com.br | patient | nenhum |
|
||||
| terapeuta@agenciapsi.com.br | therapist | tenant próprio (therapist) + vinculado à Clínica 3 |
|
||||
| clinica1@agenciapsi.com.br | clinic | clinic_coworking |
|
||||
| clinica2@agenciapsi.com.br | clinic | clinic_reception |
|
||||
| clinica3@agenciapsi.com.br | clinic | clinic_full |
|
||||
| saas@agenciapsi.com.br | saas_admin | nenhum |
|
||||
> Senha de todos: `Teste@123`
|
||||
|
||||
- Update theme files.
|
||||
---
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
## [002] — seed_002.sql
|
||||
|
||||
- Upgrade to PrimeVue 3.49.1
|
||||
**Arquivo:** `Novo-DB/seed_002.sql`
|
||||
|
||||
## 3.9.0 (2023-11-01)
|
||||
### O que cria
|
||||
|
||||
**Migration Guide**
|
||||
#### Migration embutida
|
||||
- ✅ `profiles.platform_roles text[] NOT NULL DEFAULT '{}'` — adicionada via `ADD COLUMN IF NOT EXISTS` (idempotente)
|
||||
|
||||
- Update theme files.
|
||||
#### Usuários de teste
|
||||
| Email | Senha | Papel | Tenant |
|
||||
|-------|-------|-------|--------|
|
||||
| `supervisor@agenciapsi.com.br` | `Teste@123` | `supervisor` em `tenant_members` | Clínica Bem Estar (Full) |
|
||||
| `editor@agenciapsi.com.br` | `Teste@123` | `therapist` em `tenant_members` + `platform_roles = '{editor}'` | Clínica Bem Estar (Full) |
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
UUIDs reservados:
|
||||
- Supervisor: `aaaaaaaa-0007-0007-0007-000000000007`
|
||||
- Editor: `aaaaaaaa-0008-0008-0008-000000000008`
|
||||
|
||||
- Upgrade to PrimeVue 3.39.0
|
||||
---
|
||||
|
||||
## 3.8.0 (2023-07-24)
|
||||
## [PENDENTE] — Migration necessária: `platform_roles` em `profiles`
|
||||
|
||||
**Migration Guide**
|
||||
**Contexto:**
|
||||
Implementação das áreas de **Supervisor** (papel de tenant) e **Editor** (papel de plataforma).
|
||||
O papel de Editor é atribuído pelo `saas_admin` e armazenado diretamente no perfil do usuário,
|
||||
independente de qual tenant ele pertence.
|
||||
|
||||
- Update theme files.
|
||||
- Update assets style files
|
||||
- Remove code highlight
|
||||
### O que precisa ser aplicado no banco
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
#### `profiles`
|
||||
- ⚠️ **Adicionar coluna** `platform_roles text[] NOT NULL DEFAULT '{}'`
|
||||
- Armazena papéis globais de plataforma. Ex.: `'{editor}'`
|
||||
- Quem pode escrever: somente `saas_admin` (via RLS ou função privilegiada)
|
||||
- Quem pode ter: qualquer usuário autenticado, **exceto** `account_type = 'patient'`
|
||||
- Valores previstos: `editor` (mais podem ser adicionados futuramente)
|
||||
|
||||
- Upgrade to PrimeVue 3.30.2
|
||||
#### SQL sugerido
|
||||
```sql
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS platform_roles text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
## 3.7.0 (2023-05-06)
|
||||
-- Comentário descritivo
|
||||
COMMENT ON COLUMN public.profiles.platform_roles IS
|
||||
'Papéis globais de plataforma, independentes de tenant. Ex: editor de microlearning. Atribuído pelo saas_admin.';
|
||||
|
||||
- Upgrade to PrimeVue 3.28.0
|
||||
-- RLS: somente saas_admin pode atualizar platform_roles (exemplo)
|
||||
-- CREATE POLICY "saas_admin pode atualizar platform_roles"
|
||||
-- ON public.profiles FOR UPDATE
|
||||
-- USING (auth.uid() IN (SELECT id FROM public.profiles WHERE role = 'saas_admin'))
|
||||
-- WITH CHECK (true);
|
||||
```
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
#### `tenant_members` (sem alteração necessária)
|
||||
- O papel `supervisor` já é suportado como valor text em `tenant_members.role`.
|
||||
- Nenhuma alteração de schema é necessária — basta inserir memberships com `role = 'supervisor'`.
|
||||
|
||||
## 3.6.0 (2023-04-12)
|
||||
### Impacto se não aplicado
|
||||
- Área do Editor (`/editor`) fica inacessível a todos (coluna ausente → `platform_roles` vem `null` → acesso negado).
|
||||
- Área do Supervisor (`/supervisor`) funciona normalmente — não depende desta migration.
|
||||
|
||||
**Implemented New Features and Enhancements**
|
||||
---
|
||||
|
||||
- Upgrade to PrimeVue 3.26.1
|
||||
- Upgrade to vite 4.2.1
|
||||
## Futuro — registrado mas não implementado
|
||||
|
||||
### Vínculo Terapeuta ↔ Clínica (a implementar)
|
||||
- Terapeuta autoriza explicitamente que secretaria gerencie suas sessões
|
||||
- Permissão só válida se clínica tiver `kind IN ('clinic_reception', 'clinic_full')`
|
||||
- Secretaria acessa apenas sessões — não prontuário nem anotações
|
||||
- Dissociação bloqueada se houver `agenda_eventos` futuros (`inicio_em > now()`)
|
||||
- Após dissociação: cada parte fica com seus próprios pacientes
|
||||
- Requer: coluna de permissão no vínculo + função de dissociação com validação
|
||||
|
||||
---
|
||||
|
||||
*Última atualização: 2026-03-03*
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Docker Setup — Projetos Locais
|
||||
|
||||
## Tabela Resumo
|
||||
|
||||
| Projeto | Container(s) | Porta Host | Rede | Volume(s) |
|
||||
|---|---|---|---|---|
|
||||
| **AgenciaPsi** | `agenciapsi_app` | `5173` → Vite dev | `agenciapsi_net` | `agenciapsi_node_modules` |
|
||||
| | `agenciapsi_mysql` | `3307` → MySQL | `agenciapsi_net` | `agenciapsi_mysql_data` |
|
||||
| **Evolution API** | `evolution_api` | `8080` → API | `agenciapsi_net` (external) | — |
|
||||
| | `evolution_db` | interno | `agenciapsi_net` | `evolution_db_data` |
|
||||
| | `evolution_redis` | interno | `agenciapsi_net` | — |
|
||||
| | `evolution_mailpit` | `1025` SMTP / `8025` Web | `agenciapsi_net` | — |
|
||||
| **Supabase AgenciaPsi** | `supabase_*_agenciapsi-primesakai` | `54321` API / `54322` PG / `54323` Studio | — | volumes internos |
|
||||
| **Sakai-Vue** | `sakaivue_app` | `5174` → Vite dev | `sakaivue_net` | `sakaivue_node_modules` |
|
||||
| | `sakaivue_mysql` | `3308` → MySQL | `sakaivue_net` | `sakaivue_mysql_data` |
|
||||
| **Supabase Sakai-Vue** | `supabase_*_sakai-vue` | `54331` API / `54332` PG / `54333` Studio | — | volumes internos |
|
||||
| **Gisaf Local** | `gisaf_mysql` | `3309` → MySQL | `gisaf_net` | `gisaf_mysql_data` |
|
||||
|
||||
## Mapa de Portas
|
||||
|
||||
| Porta | Serviço |
|
||||
|---|---|
|
||||
| 3307 | AgenciaPsi MySQL |
|
||||
| 3308 | Sakai-Vue MySQL |
|
||||
| 3309 | Gisaf MySQL |
|
||||
| 5173 | AgenciaPsi Vite dev |
|
||||
| 5174 | Sakai-Vue Vite dev |
|
||||
| 8080 | Evolution API |
|
||||
| 1025 | Mailpit SMTP |
|
||||
| 8025 | Mailpit Web UI |
|
||||
| 54321 | Supabase AgenciaPsi — Kong (API) |
|
||||
| 54322 | Supabase AgenciaPsi — PostgreSQL |
|
||||
| 54323 | Supabase AgenciaPsi — Studio |
|
||||
| 54327 | Supabase AgenciaPsi — Analytics |
|
||||
| 54331 | Supabase Sakai-Vue — Kong (API) |
|
||||
| 54332 | Supabase Sakai-Vue — PostgreSQL |
|
||||
| 54333 | Supabase Sakai-Vue — Studio |
|
||||
| 54337 | Supabase Sakai-Vue — Analytics |
|
||||
|
||||
## Ordem de Start
|
||||
|
||||
```bash
|
||||
# 1. AgenciaPsi (cria a rede agenciapsi_net)
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
|
||||
docker compose up -d
|
||||
|
||||
# 2. Supabase AgenciaPsi (porta 54321)
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
|
||||
npx supabase start
|
||||
|
||||
# 3. Evolution API (depende da rede agenciapsi_net)
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api"
|
||||
docker compose up -d
|
||||
|
||||
# 4. Sakai-Vue
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
|
||||
docker compose up -d
|
||||
|
||||
# 5. Supabase Sakai-Vue (porta 54331)
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
|
||||
npx supabase start
|
||||
|
||||
# 6. Gisaf Local
|
||||
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Parar tudo
|
||||
|
||||
```bash
|
||||
# Na ordem inversa
|
||||
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local" && docker compose down
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && npx supabase stop
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && docker compose down
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api" && docker compose down
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && npx supabase stop
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && docker compose down
|
||||
```
|
||||
|
||||
## Caminhos dos docker-compose.yml
|
||||
|
||||
| Projeto | Caminho |
|
||||
|---|---|
|
||||
| AgenciaPsi | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\docker-compose.yml` |
|
||||
| Evolution API | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\evolution-api\docker-compose.yml` |
|
||||
| Sakai-Vue | `D:\leonohama\UniaoApp.com.br\Sistema\sakai-vue\docker-compose.yml` |
|
||||
| Gisaf Local | `D:\leonohama\UniaoApp.com.br\Gisaf Local\docker-compose.yml` |
|
||||
|
||||
## DBeaver — Conexões MySQL
|
||||
|
||||
| Conexão | Host | Port | Database | User | Password |
|
||||
|---|---|---|---|---|---|
|
||||
| Gisaf | `localhost` | `3309` | `sindsp` | `sindsp` | `marlboro` |
|
||||
| AgenciaPsi | `localhost` | `3307` | `agenciapsi` | `agenciapsi` | `agenciapsi123` |
|
||||
| Sakai-Vue | `localhost` | `3308` | `sakaivue` | `sakaivue` | `sakaivue123` |
|
||||
|
||||
Para criar cada conexão: **Database → New Database Connection → MySQL → preencher dados → Test Connection → Finish**
|
||||
|
||||
## Supabase — Instancias Locais
|
||||
|
||||
Cada projeto tem sua propria instancia Supabase (schemas diferentes, nao podem compartilhar).
|
||||
|
||||
| Projeto | API URL | Studio | PostgreSQL | Anon Key |
|
||||
|---|---|---|---|---|
|
||||
| AgenciaPsi | `http://127.0.0.1:54321` | `http://127.0.0.1:54323` | `127.0.0.1:54322` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
|
||||
| Sakai-Vue | `http://127.0.0.1:54331` | `http://127.0.0.1:54333` | `127.0.0.1:54332` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
|
||||
|
||||
**Resetar banco (aplica migrations + seed):**
|
||||
|
||||
```bash
|
||||
# AgenciaPsi
|
||||
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
|
||||
npx supabase db reset
|
||||
|
||||
# Sakai-Vue
|
||||
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
|
||||
npx supabase db reset
|
||||
```
|
||||
|
||||
### Sakai-Vue — Usuarios de teste
|
||||
|
||||
| Email | Senha | Role |
|
||||
|---|---|---|
|
||||
| `dev@sistema.com.br` | `Dev@12345` | dev |
|
||||
| `master@tenant.com.br` | `Master@12345` | master |
|
||||
| `admin@tenant.com.br` | `Admin@12345` | admin |
|
||||
| `chefe@tenant.com.br` | `Chefe@12345` | chefe_setor |
|
||||
| `servidor@tenant.com.br` | `Servidor@12345` | servidor |
|
||||
| `leitura@tenant.com.br` | `Leitura@12345` | leitura |
|
||||
|
||||
## Importar dump SQL no Gisaf
|
||||
|
||||
```bash
|
||||
# Via CLI (já feito)
|
||||
docker exec -i gisaf_mysql mysql -usindsp -pmarlboro sindsp < "D:/leonohama/UniaoApp.com.br/Gisaf Local/Dump20260330.sql"
|
||||
```
|
||||
|
||||
Ou via DBeaver: conectar no banco `sindsp` → **Tools → Execute SQL Script** → selecionar `Dump20260330.sql`
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# HANDOFF — 2026-04-19 (após Sessões 1-10)
|
||||
|
||||
Documento de continuidade. **Quando voltar, comece lendo esta página.**
|
||||
Todo o estado vive no banco (`/saas/desenvolvimento` → Auditoria/Verificações/Testes).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Estado atual
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **A# auditoria** abertos | **1** (A#31 — reformular pra "Preparação pra deploy") |
|
||||
| **V# verificações** abertos | 14 (todos médios/baixos adiados, plano completo no DB) |
|
||||
| **🔴 Críticos** | **0** ✅ |
|
||||
| **🟠 Altos** | **0** ✅ |
|
||||
| **Áreas auditadas** | **15** (todas as principais do SaaS) |
|
||||
| Vitest | 208/208 |
|
||||
| SQL integration | 33/33 |
|
||||
| E2E (Playwright) | 5/5 |
|
||||
| Migrations totais | 18 |
|
||||
| Último commit | `d6eb992` (pushed ao Gitea) |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Próxima sessão — A#31-rev (Preparação pra deploy)
|
||||
|
||||
**Contexto:** A#31 era "Deploy real" mas você não tem cloud Supabase nem
|
||||
secrets reais ainda (MVP). Precisa virar **preparação** — deixar tudo
|
||||
pronto pra quando criar a cloud você executar sozinho com mínimo atrito.
|
||||
|
||||
**Tarefas (~2-3h, zero risco):**
|
||||
|
||||
1. **`DEPLOY.md`** na raiz — checklist de 8 passos com comandos exatos +
|
||||
diagnóstico de erros comuns + ordem de execução
|
||||
2. **Validar migrations num container limpo** — recriar banco do zero,
|
||||
aplicar as 18 migrations + seeds em ordem, garantir zero erro
|
||||
3. **`.env.example`** completo — todas VITE_ vars + cada secret de edge
|
||||
function listado com instrução
|
||||
4. **Auditoria das edge functions** — CORS, fallback de env var ausente,
|
||||
error handling. Documentar quais env cada uma precisa
|
||||
5. **Script `db.cjs deploy-check`** — comando que valida pré-condições
|
||||
antes de deploy (ordena migrations, verifica diffs, lista secrets)
|
||||
6. **Atualizar HANDOFF.md** com seção "Pra deployar"
|
||||
|
||||
**Quando voltar, é só dizer "começa A#31-rev" e eu sigo o plano.**
|
||||
|
||||
---
|
||||
|
||||
## 📚 Memória persistente (carregada automaticamente)
|
||||
|
||||
Já saved no memory system (`MEMORY.md` — não precisa lembrar):
|
||||
- **Sanitização sempre** — trim, length, regex em toda entrada/saída
|
||||
- **Priorização por severidade** — críticos+altos imediatos, médios/baixos adiam com plano
|
||||
- **Self-hosted > provider externo** — LGPD/clínico
|
||||
- **Gotcha supabase_admin** — `psql -U supabase_admin -h localhost` direto pra ALTER POLICY em tabelas owned
|
||||
- **Tracking dev_*_items** — A#/V#/T# vivem no DB, UI `/saas/desenvolvimento`
|
||||
- **Project Overview** + **MVP Assessment**
|
||||
|
||||
---
|
||||
|
||||
## 📦 Commits relevantes
|
||||
|
||||
```
|
||||
d6eb992 Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS ← último
|
||||
7c20b51 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
|
||||
d088a89 (commit anterior do projeto)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Áreas auditadas (15)
|
||||
|
||||
| Área | Estado |
|
||||
|---|---|
|
||||
| auth, router, stores, agenda, seguranca, saas | 100% fechado/ok |
|
||||
| **pacientes** ✨ | **100% fechado** (V#9 — script extraído; template breakdown adiado pra quando houver E2E) |
|
||||
| **documentos** ✨ | 100% fechado |
|
||||
| **calendario** ✨ | 100% fechado |
|
||||
| **servicos** ✨ | 100% fechado |
|
||||
| financeiro | 5 fechados, 6 médios/baixos adiados |
|
||||
| comunicacao | 5 fechados, 5 médios/baixos adiados |
|
||||
| tenants | 6 fechados, 2 baixos adiados |
|
||||
| addons | 3 resolvidos, 1 médio adiado |
|
||||
| central_saas | 1 alto fechado, 2 médios adiados |
|
||||
|
||||
✨ = áreas 100% fechadas (zero pendência).
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Pendências documentadas no DB (14 V# adiados)
|
||||
|
||||
Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`.
|
||||
**Não esquecer.** Sprint dedicado de polimento depois do deploy.
|
||||
|
||||
- **financeiro** (6): parcelamento CHECK, payouts flow, recurrence DELETE,
|
||||
composables, máscara PIX, dashboard inadimplência
|
||||
- **comunicacao** (5): notifications/schedules silos, email_templates_global
|
||||
filtros, retention notification_logs, dashboard health, audit dismissals
|
||||
- **tenants** (2): owner_users policies, company_profiles + dev_user_credentials
|
||||
- **central_saas** (2): rate limit voto, valores tipo_acesso
|
||||
- **addons** (1): UI de extrato
|
||||
|
||||
Plus (não V#):
|
||||
- **PatientsCadastroPage template breakdown** — 1951 linhas. Esperar E2E
|
||||
- **Sprint de polimento** dos 14 médios/baixos juntos
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack lembretes (caso precise)
|
||||
|
||||
- **DB local:** `docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres`
|
||||
- **DB local como supabase_admin (pra ALTER POLICY em tabelas owned):**
|
||||
```bash
|
||||
docker exec -i -e PGPASSWORD=postgres -e PGCLIENTENCODING=UTF8 \
|
||||
supabase_db_agenciapsi-primesakai \
|
||||
psql -U supabase_admin -d postgres -h localhost -f migration.sql
|
||||
```
|
||||
- **Vitest:** `npx vitest run`
|
||||
- **SQL integration:** `node database-novo/tests/run.cjs`
|
||||
- **E2E:** `npx playwright test` (precisa dev server: `npm run dev`)
|
||||
|
||||
---
|
||||
|
||||
## 📌 Bom descanso, até amanhã!
|
||||
@@ -1,101 +0,0 @@
|
||||
O que foi feito (até agora)
|
||||
Usuários de teste criados
|
||||
|
||||
admin@agenciapsi.com.br
|
||||
— senha: 123Mudar@
|
||||
|
||||
patient@agenciapsi.com.br
|
||||
— senha: 123Mudar@
|
||||
|
||||
therapist@agenciapsi.com.br
|
||||
— senha: 123Mudar@
|
||||
|
||||
Base funcionando
|
||||
|
||||
✅ Auth (Supabase) está funcionando
|
||||
✅ Tabela profiles criada e ok
|
||||
✅ Trigger automático cria profile após signup
|
||||
✅ Campo role definido (admin | therapist | patient)
|
||||
✅ RLS básico ativo
|
||||
✅ Login funcionando
|
||||
✅ Logout funcionando
|
||||
✅ Guard de rota implementado e ativo
|
||||
✅ RBAC básico operando via meta.role + redirect para painel correto
|
||||
✅ Home pública / com 3 cards (Admin | Therapist | Patient) levando ao login
|
||||
✅ Pós-login: busca profiles.role e redireciona para:
|
||||
|
||||
/admin
|
||||
|
||||
/therapist
|
||||
|
||||
/patient
|
||||
|
||||
Estrutura implementada agora (menus e sessão para o Sakai)
|
||||
Sessão central (evita menu errado e if(role) espalhado)
|
||||
|
||||
✅ Criado src/app/session.js com:
|
||||
|
||||
sessionUser, sessionRole, sessionReady (refs globais)
|
||||
|
||||
initSession() (carrega user + role antes de renderizar o layout)
|
||||
|
||||
listenAuthChanges() (atualiza sessão ao logar/deslogar)
|
||||
|
||||
✅ Ajustado src/main.js para usar bootstrap async:
|
||||
|
||||
chama await initSession() antes de app.mount()
|
||||
|
||||
liga listenAuthChanges()
|
||||
|
||||
mantém PrimeVue, tema Aura, ToastService e ConfirmationService
|
||||
|
||||
mantém imports de CSS existentes
|
||||
|
||||
Menu dinâmico por role no Sakai
|
||||
|
||||
✅ Menus foram estruturados no formato do Sakai (sections com label + items) e separados por role:
|
||||
|
||||
src/navigation/menus/admin.menu.js
|
||||
|
||||
src/navigation/menus/therapist.menu.js
|
||||
|
||||
src/navigation/menus/patient.menu.js
|
||||
|
||||
✅ Criado src/navigation/index.js com getMenuByRole(role) para centralizar a escolha do menu (sem if(role) em componentes).
|
||||
|
||||
✅ Ajustado o AppMenu.vue (menu do Sakai) para:
|
||||
|
||||
usar computed() com sessionRole/sessionReady
|
||||
|
||||
carregar dinamicamente getMenuByRole(sessionRole.value)
|
||||
|
||||
evitar “piscar” menu errado antes de carregar (sessionReady)
|
||||
|
||||
Menu demo do Sakai mantido sem quebrar o produto
|
||||
|
||||
✅ Mantivemos o menu demo (UIKit/Blocks/Start etc.) em arquivo separado para não perder as páginas do template:
|
||||
|
||||
src/navigation/menus/sakai.demo.menu.js (conteúdo original do template)
|
||||
|
||||
✅ Estratégia adotada:
|
||||
|
||||
Admin pode ver o menu demo (idealmente só em DEV)
|
||||
|
||||
Therapist/Patient ficam com menu limpo (clínico)
|
||||
|
||||
Rotas demo do Sakai corrigidas (arquivos com sufixo Doc)
|
||||
|
||||
✅ Problema resolvido: itens do menu demo davam 404 porque as rotas/imports não existiam com os nomes esperados (Input.vue etc.).
|
||||
✅ Ajuste aplicado: rotas demo apontam para arquivos *Doc.vue (ex.: ButtonDoc.vue, InputDoc.vue).
|
||||
|
||||
📌 Criado/ajustado src/router/routes.demo.js para mapear:
|
||||
|
||||
/uikit/* → @/views/uikit/*Doc.vue
|
||||
|
||||
e demais demos conforme existirem
|
||||
|
||||
✅ Incluído demoRoutes no router principal para o menu demo funcionar.
|
||||
|
||||
Testes
|
||||
|
||||
✅ Confirmado que localStorage.clear() limpa sessão para testar outros usuários/roles rapidamente.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Guia de Testes — AgenciaPsi
|
||||
|
||||
## Testes Automatizados
|
||||
|
||||
### Pré-requisito
|
||||
Vitest já instalado (`npm install` resolve). Não precisa de banco, Supabase ou variáveis de ambiente.
|
||||
|
||||
### Comandos
|
||||
|
||||
| Comando | Descrição |
|
||||
|---|---|
|
||||
| `npm test` | Roda todos os testes uma vez e exibe resultado |
|
||||
| `npm run test:watch` | Modo watch — re-roda ao salvar arquivos |
|
||||
| `npm run test:ui` | Abre UI visual no browser (`http://localhost:51204`) |
|
||||
|
||||
### Arquivos de teste
|
||||
|
||||
| Arquivo | O que cobre |
|
||||
|---|---|
|
||||
| `src/features/agenda/composables/__tests__/useRecurrence.spec.js` | Geração de datas por tipo de regra, max_occurrences global, exceções, remarcação cross-range |
|
||||
| `src/features/agenda/services/__tests__/agendaMappers.spec.js` | Mapeamento para FullCalendar, ícones de status, cores, buildNextSessions, minutesToDuration, buildWeeklyBreakBackgroundEvents |
|
||||
|
||||
### Quando rodar
|
||||
- Antes de commitar qualquer mudança em `useRecurrence.js` ou `agendaMappers.js`
|
||||
- Ao adicionar novo tipo de frequência (mensal, quinzenal, etc.)
|
||||
- Ao mexer em exceções de recorrência
|
||||
- Em CI/CD antes do deploy
|
||||
|
||||
---
|
||||
|
||||
## Testes Manuais
|
||||
|
||||
### Preparação
|
||||
1. Limpar dados de teste no banco:
|
||||
```sql
|
||||
TRUNCATE TABLE recurrence_exceptions CASCADE;
|
||||
TRUNCATE TABLE recurrence_rules CASCADE;
|
||||
TRUNCATE TABLE agenda_eventos CASCADE;
|
||||
TRUNCATE TABLE agendador_solicitacoes CASCADE;
|
||||
```
|
||||
2. Fazer login com seu usuário real
|
||||
3. Selecionar a clínica/tenant correto
|
||||
|
||||
---
|
||||
|
||||
### 1. Evento Avulso
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Clicar em um horário livre na agenda | Dialog de criação abre |
|
||||
| Preencher paciente, horário, modalidade → Salvar | Evento aparece no calendário |
|
||||
| Clicar no evento → Editar horário → Salvar | Horário atualiza |
|
||||
| Clicar no evento → Marcar como "Faltou" | Cor muda para vermelho, ícone ✗ |
|
||||
| Clicar no evento → Marcar como "Realizado" | Cor muda para cinza, ícone ✓ |
|
||||
| Clicar no evento → Cancelar sessão | Cor muda para laranja, ícone ∅ |
|
||||
| Clicar no evento → Excluir | Evento some do calendário |
|
||||
|
||||
---
|
||||
|
||||
### 2. Recorrência Semanal
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar evento com frequência "Semanal" | Ocorrências aparecem em todas as semanas seguintes com ícone ↻ |
|
||||
| Navegar para a semana seguinte | Ocorrências continuam aparecendo |
|
||||
| Navegar para além do end_date | Não aparecem ocorrências após a data final |
|
||||
| Criar série com "4 sessões" (max_occurrences) | Exatamente 4 ocorrências visíveis no calendário |
|
||||
|
||||
---
|
||||
|
||||
### 3. Recorrência Quinzenal e Dias Específicos
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar série "Quinzenal" | Ocorrências aparecem a cada 2 semanas |
|
||||
| Criar série "Dias específicos" (ex: seg + qua) | Ambos os dias aparecem toda semana |
|
||||
| Navegar para semanas futuras | Padrão se mantém |
|
||||
|
||||
---
|
||||
|
||||
### 4. Edição de Série
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Clicar em ocorrência → Editar → "Somente este" → mudar horário | Só aquela data muda; as outras continuam iguais |
|
||||
| Clicar em ocorrência → Cancelar → "Somente este" | Só aquela data some (ou aparece cancelada) |
|
||||
| Clicar em ocorrência → Cancelar → "Este e os seguintes" | A partir daquela data, sem mais ocorrências |
|
||||
| Clicar em ocorrência → Cancelar → "Todos" | Série inteira some |
|
||||
|
||||
---
|
||||
|
||||
### 5. Remarcação Cross-Range ⭐
|
||||
|
||||
Este é o caso mais importante a testar.
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar série semanal (ex: toda segunda) | Ocorrências nas segundas |
|
||||
| Clicar na sessão da **semana 1** → Remarcar para **terça da semana 2** | — |
|
||||
| Navegar para a **semana 1** | Segunda da semana 1 aparece vazia ou como "remarcado" |
|
||||
| Navegar para a **semana 2** | Terça aparece com ícone ↺ e status "remarcado" |
|
||||
|
||||
---
|
||||
|
||||
### 6. Bloqueio de Agenda
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar bloqueio de horário | Aparece no calendário com visual diferente (ícone ⊘) |
|
||||
| Tentar agendar no horário bloqueado | Aviso de conflito |
|
||||
|
||||
---
|
||||
|
||||
### 7. Agendamento Online (Agendador Público)
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Acessar URL pública do agendador | Página pública abre sem login |
|
||||
| Selecionar data/horário disponível → Enviar solicitação | Confirmação exibida |
|
||||
| No painel do terapeuta → "Agendamentos Recebidos" | Solicitação aparece na lista |
|
||||
| Clicar em "Confirmar" | Evento criado na agenda |
|
||||
| Clicar em "Recusar" | Solicitação removida, sem evento na agenda |
|
||||
|
||||
---
|
||||
|
||||
### 8. Suporte Técnico SaaS
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Logar como `saas_admin` → Menu "Suporte Técnico" | Página de suporte abre |
|
||||
| Selecionar um tenant → "Criar Sessão de Suporte" | URL com token é gerada |
|
||||
| Copiar URL e abrir em outra aba | Agenda do terapeuta abre com banner de debug no rodapé |
|
||||
| No banner → filtrar logs por categoria | Logs filtram corretamente |
|
||||
| No banner → "Desativar suporte" | Banner some |
|
||||
| No painel SaaS → "Revogar" na sessão ativa | Token invalidado |
|
||||
|
||||
---
|
||||
|
||||
### 9. Multi-Tenancy (se você tem 2 clínicas cadastradas)
|
||||
|
||||
| Passo | Esperado |
|
||||
|---|---|
|
||||
| Criar evento na clínica A | Evento aparece na agenda da clínica A |
|
||||
| Trocar para clínica B | Evento da clínica A **não aparece** |
|
||||
| Criar evento na clínica B | Aparece apenas na clínica B |
|
||||
|
||||
---
|
||||
|
||||
## Pedindo ao Claude para Executar os Testes
|
||||
|
||||
### Como usar o Claude Code para rodar e corrigir testes
|
||||
|
||||
O Claude Code (este agente) consegue rodar os testes, ler os erros e corrigir os problemas automaticamente. Basta iniciar a conversa com o contexto certo.
|
||||
|
||||
### Prompt de retomada recomendado
|
||||
|
||||
Cole isso no início de uma nova sessão com o Claude:
|
||||
|
||||
---
|
||||
|
||||
> Estou desenvolvendo o AgenciaPsi. Temos testes automatizados com Vitest.
|
||||
>
|
||||
> **Arquivos de teste:**
|
||||
> - `src/features/agenda/composables/__tests__/useRecurrence.spec.js` — testa `generateDates`, `expandRules`, `mergeWithStoredSessions`
|
||||
> - `src/features/agenda/services/__tests__/agendaMappers.spec.js` — testa mapeamento para FullCalendar
|
||||
>
|
||||
> **Rodar os testes:** `npm test`
|
||||
>
|
||||
> Por favor, rode os testes agora e me informe o resultado. Se houver falhas, analise a causa e corrija.
|
||||
|
||||
---
|
||||
|
||||
### O que o Claude consegue fazer automaticamente
|
||||
|
||||
| Pedido | O Claude faz |
|
||||
|---|---|
|
||||
| "Rode os testes" | Executa `npm test` e exibe o resultado |
|
||||
| "Tem algum teste falhando?" | Roda e diagnóstica a causa raiz |
|
||||
| "Corrija os testes que falham" | Analisa erro, ajusta o código ou o teste e re-roda |
|
||||
| "Adicionei a funcionalidade X, crie testes para ela" | Lê o código e escreve novos casos no spec |
|
||||
| "O teste Y está errado, o comportamento correto é Z" | Atualiza a asserção e confirma que passa |
|
||||
|
||||
### Boas práticas ao pedir testes ao Claude
|
||||
|
||||
- **Forneça o `AUDITORIA.md`** no início da sessão — dá contexto sobre a arquitetura e decisões já tomadas
|
||||
- **Descreva o comportamento esperado** em português, não o código — o Claude escreve o código do teste
|
||||
- **Se um teste falhar e você achar que o código está certo**, diga isso explicitamente: *"o teste está errado, não o código"* — o Claude vai ajustar a asserção
|
||||
- **Se um teste falhar e você achar que o código está errado**, diga: *"o comportamento esperado é X"* — o Claude vai corrigir a implementação
|
||||
|
||||
### Exemplo de sessão típica
|
||||
|
||||
```
|
||||
Você: Rodei npm test e 2 testes falharam. Analise e corrija.
|
||||
|
||||
Claude: [roda npm test, lê os erros, corrige o código ou as asserções, re-roda até 63/63 passarem]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adicionando Novos Testes
|
||||
|
||||
### Para `useRecurrence.spec.js`
|
||||
|
||||
```js
|
||||
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js'
|
||||
|
||||
it('meu novo caso', () => {
|
||||
const r = {
|
||||
id: 'rule-1', type: 'weekly', weekdays: [1], interval: 1,
|
||||
start_date: '2026-03-02', end_date: null, max_occurrences: null,
|
||||
status: 'ativo', start_time: '09:00', end_time: '10:00',
|
||||
// ... outros campos necessários
|
||||
}
|
||||
const dates = generateDates(r, new Date(2026, 2, 1), new Date(2026, 2, 31))
|
||||
expect(dates.length).toBe(/* esperado */)
|
||||
})
|
||||
```
|
||||
|
||||
### Para `agendaMappers.spec.js`
|
||||
|
||||
```js
|
||||
import { mapAgendaEventosToCalendarEvents } from '../agendaMappers.js'
|
||||
|
||||
it('meu novo caso de mapeamento', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([{
|
||||
id: 'ev-1', titulo: 'Teste', tipo: 'sessao', status: 'agendado',
|
||||
inicio_em: '2026-03-10T09:00:00', fim_em: '2026-03-10T10:00:00',
|
||||
owner_id: 'owner-1',
|
||||
}])
|
||||
expect(ev.extendedProps./* campo */).toBe(/* esperado */)
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,159 @@
|
||||
# DialogConfirmation — Padrão de Componente
|
||||
|
||||
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## Regras gerais
|
||||
|
||||
| Propriedade | Valor obrigatório |
|
||||
|---|---|
|
||||
| `group` | sempre `"headless"` — desacopla o template do trigger |
|
||||
| `ConfirmDialog` | declarado **uma única vez**, no componente pai (página) |
|
||||
| Filhos | disparam via `useConfirm()` com `group: 'headless'` — sem declarar `ConfirmDialog` próprio |
|
||||
| `icon` | passado em `confirm.require({ icon })` — classe PrimeIcons sem o prefixo `pi` (ex: `'pi-trash'`) |
|
||||
| `color` | passado em `confirm.require({ color })` — hex; define o fundo do círculo e a cor do botão Confirmar |
|
||||
| `ConfirmationService` | obrigatório em `main.js` — `app.use(ConfirmationService)` |
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura pai / filho
|
||||
|
||||
```
|
||||
Pai (página)
|
||||
└── <ConfirmDialog group="headless" /> ← único, renderiza aqui
|
||||
├── Filho A (componente qualquer) → confirm.require({ group: 'headless', ... })
|
||||
└── Filho B (Dialog interno) → confirm.require({ group: 'headless', ... })
|
||||
```
|
||||
|
||||
> O `ConfirmDialog` **não** deve ser colocado dentro de um `<Dialog>` filho — isso causaria dois popups simultâneos. Sempre no pai.
|
||||
|
||||
---
|
||||
|
||||
## Setup obrigatório — `main.js`
|
||||
|
||||
```js
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
|
||||
app.use(ConfirmationService) // sem isso, useConfirm() não funciona
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template do `ConfirmDialog` (somente no pai)
|
||||
|
||||
```vue
|
||||
<!-- Declarado uma única vez, antes do conteúdo principal -->
|
||||
<ConfirmDialog group="headless">
|
||||
<template #container="{ message, acceptCallback, rejectCallback }">
|
||||
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
|
||||
|
||||
<!-- Círculo central: cor e ícone vindos de message -->
|
||||
<div
|
||||
class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20"
|
||||
:style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }"
|
||||
>
|
||||
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
|
||||
</div>
|
||||
|
||||
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
|
||||
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
|
||||
|
||||
<div class="flex items-center gap-2 mt-6">
|
||||
<!-- Confirmar: cor dinâmica via message.color -->
|
||||
<Button
|
||||
label="Confirmar"
|
||||
class="rounded-full"
|
||||
:style="{
|
||||
background: message.color || 'var(--p-primary-color)',
|
||||
borderColor: message.color || 'var(--p-primary-color)'
|
||||
}"
|
||||
@click="acceptCallback"
|
||||
/>
|
||||
<!-- Cancelar: sempre outlined, neutro -->
|
||||
<Button
|
||||
label="Cancelar"
|
||||
variant="outlined"
|
||||
class="rounded-full"
|
||||
@click="rejectCallback"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uso nos componentes (pai ou filhos)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
const confirm = useConfirm()
|
||||
|
||||
function confirmDelete(item) {
|
||||
confirm.require({
|
||||
group: 'headless',
|
||||
header: 'Excluir item?',
|
||||
message: `"${item.name}" será removido permanentemente. Essa ação não pode ser desfeita.`,
|
||||
icon: 'pi-trash',
|
||||
color: '#ef4444',
|
||||
accept: () => onDelete(item)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paleta de ícones e cores por ação
|
||||
|
||||
| Ação | `icon` | `color` | Observação |
|
||||
|---|---|---|---|
|
||||
| Excluir / Remover | `pi-trash` | `#ef4444` | Vermelho — ação destrutiva |
|
||||
| Salvar / Confirmar | `pi-save` | `var(--p-primary-color)` | Cor primária do tema |
|
||||
| Editar / Atualizar | `pi-pencil` | `#f97316` | Laranja — mudança de estado |
|
||||
| Aviso / Atenção | `pi-exclamation-triangle` | `#eab308` | Amarelo — ação reversível |
|
||||
| Info / Neutro | `pi-info-circle` | `#3b82f6` | Azul — informativo |
|
||||
|
||||
---
|
||||
|
||||
## Referência completa de `confirm.require`
|
||||
|
||||
```js
|
||||
confirm.require({
|
||||
group: 'headless', // obrigatório — aponta para o ConfirmDialog correto
|
||||
header: 'Título do popup', // linha em negrito
|
||||
message: 'Descrição clara.', // linha secundária
|
||||
icon: 'pi-trash', // sufixo PrimeIcons sem o "pi " inicial
|
||||
color: '#ef4444', // hex — fundo do círculo + cor do botão Confirmar
|
||||
accept: () => { /* ação confirmada */ },
|
||||
reject: () => { /* opcional — ação cancelada */ }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de usar
|
||||
|
||||
- [ ] `ConfirmationService` registrado no `main.js`
|
||||
- [ ] `<ConfirmDialog group="headless">` declarado **apenas no pai**, antes do conteúdo
|
||||
- [ ] Filhos usam `useConfirm()` com `group: 'headless'` — sem `ConfirmDialog` próprio
|
||||
- [ ] `icon` passado como sufixo PrimeIcons: `'pi-trash'`, não `'pi pi-trash'`
|
||||
- [ ] `color` em hex para ações com semântica de cor (delete = `#ef4444`)
|
||||
- [ ] `header` curto e direto | `message` com contexto suficiente para o usuário decidir
|
||||
- [ ] `accept` contém a ação real — `reject` é opcional
|
||||
|
||||
---
|
||||
|
||||
## Variações de confirmação
|
||||
|
||||
| Contexto | `header` | `icon` | `color` |
|
||||
|---|---|---|---|
|
||||
| Excluir registro | `'Excluir <entidade>?'` | `pi-trash` | `#ef4444` |
|
||||
| Remover item de lista | `'Remover campo?'` | `pi-trash` | `#ef4444` |
|
||||
| Salvar com impacto | `'Confirmar alterações?'` | `pi-save` | primária |
|
||||
| Atualizar com risco | `'Atualizar <entidade>?'` | `pi-pencil` | `#f97316` |
|
||||
| Ação irreversível genérica | `'Tem certeza?'` | `pi-exclamation-triangle` | `#eab308` |
|
||||
@@ -0,0 +1,183 @@
|
||||
# Dialog — Padrão de Componente
|
||||
|
||||
> **Stack**: Vue 3 + PrimeVue 4 + Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## Regras gerais
|
||||
|
||||
| Propriedade | Valor obrigatório |
|
||||
|---|---|
|
||||
| `modal` | sempre `true` |
|
||||
| `maximizable` | sempre presente — botão nativo do PrimeVue, sem estado manual |
|
||||
| `:draggable` | sempre `false` |
|
||||
| `:closable` | `!saving` — desabilita o X durante operações assíncronas |
|
||||
| `:dismissableMask` | `!saving` — impede fechar clicando fora durante saving |
|
||||
| `pt:mask:class` | `backdrop-blur-xs` |
|
||||
| Largura | `w-[50rem]` (padrão); responsivo via `:breakpoints` |
|
||||
| Breakpoints | `{ '1199px': '90vw', '768px': '94vw' }` |
|
||||
|
||||
---
|
||||
|
||||
## Estrutura obrigatória
|
||||
|
||||
```
|
||||
<Dialog>
|
||||
├── #header ← dot de cor (se aplicável), título/subtítulo, btn Excluir
|
||||
├── Banner ← preview visual (opcional — apenas quando há cor/identidade visual)
|
||||
├── Corpo ← campos do formulário
|
||||
└── #footer ← Cancelar (flat) | Salvar (primary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuração completa do `<Dialog>`
|
||||
|
||||
```vue
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
maximizable
|
||||
class="dc-dialog w-[50rem]"
|
||||
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
|
||||
:pt="{
|
||||
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
content: { class: '!p-3' },
|
||||
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
|
||||
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
|
||||
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
|
||||
}"
|
||||
pt:mask:class="backdrop-blur-xs"
|
||||
>
|
||||
```
|
||||
|
||||
### Detalhes do `pt`
|
||||
|
||||
| Chave | O que faz |
|
||||
|---|---|
|
||||
| `header` | `!p-3` padding uniforme; `!rounded-t-[12px]` borda top arredondada; `border-b` + `shadow` separador com profundidade; `bg-gray-100` fundo levemente cinza |
|
||||
| `content` | `!p-3` padding interno do corpo |
|
||||
| `footer` | `!p-0` remove padding nativo (controlado pelo wrapper interno); `!rounded-b-[12px]` borda bottom arredondada; `border-t` + `shadow` separador; `bg-gray-100` fundo levemente cinza |
|
||||
| `pcCloseButton` | `!rounded-md` remove o círculo nativo; `hover:!text-red-500` feedback de danger no hover |
|
||||
| `pcMaximizeButton` | `!rounded-md` remove o círculo nativo; `hover:!text-primary` feedback de cor primária no hover |
|
||||
|
||||
> O `!` (important) é necessário porque o PrimeVue injeta estilos inline nos botões e no root do Dialog — sem ele o Tailwind perde a disputa de especificidade.
|
||||
|
||||
---
|
||||
|
||||
## Header — slot `#header`
|
||||
|
||||
```
|
||||
[dot-cor] [título / subtítulo] [btn-excluir] ← Close e Maximize nativos vêm após
|
||||
```
|
||||
|
||||
- O PrimeVue injeta **Maximize** e **Close** automaticamente à direita do slot `#header`.
|
||||
- O botão **Excluir** fica **sempre no header**, nunca no footer.
|
||||
- Excluir desabilitado quando o registro é nativo/padrão: `:disabled="saving || isNativeRecord"`.
|
||||
|
||||
```vue
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Dot de cor (omitir se não houver cor associada) -->
|
||||
<span
|
||||
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30
|
||||
shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ form.name || (mode === 'create' ? 'Novo item' : 'Editar item') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ mode === 'create' ? 'Criando novo registro' : 'Editando registro' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<!-- Excluir — visível apenas em edit, desabilitado se nativo -->
|
||||
<Button
|
||||
v-if="mode === 'edit' && canDelete !== undefined"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="saving || isNativeRecord"
|
||||
v-tooltip.top="'Excluir'"
|
||||
@click="emitDelete"
|
||||
/>
|
||||
<!-- Maximize e Close nativos do PrimeVue são injetados aqui automaticamente -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Footer — slot `#footer`
|
||||
|
||||
```vue
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-3">
|
||||
<!-- Cancelar: sempre flat, hover vermelho suave -->
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
text
|
||||
class="rounded-full hover:!text-red-500"
|
||||
:disabled="saving"
|
||||
@click="close"
|
||||
/>
|
||||
<!-- Salvar: sempre primary -->
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="saving"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> **Regra**: Cancelar = `severity="secondary" text` + `hover:!text-red-500`. Salvar = primary (sem severity, usa o padrão do tema). Padding controlado pelo `div` interno (`px-3 py-3`), não pelo `pt.footer`.
|
||||
|
||||
---
|
||||
|
||||
## Maximizar
|
||||
|
||||
Use a prop nativa `maximizable`. O PrimeVue injeta e gerencia o botão automaticamente — sem `ref`, sem `isMaximized`, sem `<Button>` manual.
|
||||
|
||||
```vue
|
||||
<Dialog maximizable ...>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de publicar um Dialog
|
||||
|
||||
- [ ] `modal`, `:draggable="false"`, `:closable="!saving"`, `:dismissableMask="!saving"` presentes
|
||||
- [ ] `maximizable` na prop (botão nativo, sem estado manual)
|
||||
- [ ] `class="dc-dialog w-[50rem]"` + `:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"`
|
||||
- [ ] `pt` completo: header, content, footer, pcCloseButton, pcMaximizeButton
|
||||
- [ ] Header com `bg-gray-100`, `border-b`, shadow e `!rounded-t-[12px]`
|
||||
- [ ] Footer com `bg-gray-100`, `border-t`, shadow e `!rounded-b-[12px]`
|
||||
- [ ] Botão **Excluir** no header (nunca no footer), desabilitado se nativo
|
||||
- [ ] Cancelar = `text` + `hover:!text-red-500` | Salvar = primary
|
||||
- [ ] Padding do footer via `px-3 py-3` no `div` interno
|
||||
|
||||
---
|
||||
|
||||
## Variações de largura
|
||||
|
||||
| Uso | Classe |
|
||||
|---|---|
|
||||
| Formulário simples | `w-[36rem]` |
|
||||
| Formulário padrão | `w-[50rem]` ← **padrão** |
|
||||
| Formulário complexo | `w-[70rem]` |
|
||||
| Tela cheia | `maximizable` — usuário controla |
|
||||
@@ -1,47 +0,0 @@
|
||||
🔁 CONTEXTO DO PROJETO (SaaS multi-tenant)
|
||||
|
||||
Stack:
|
||||
- Supabase
|
||||
- Multi-tenant por clinic/tenant
|
||||
- Assinaturas por tenant (subscriptions.tenant_id)
|
||||
- Controle de features: features, plan_features, subscription_intents, entitlementsStore, view v_tenant_entitlements
|
||||
- Ativação manual: activate_subscription_from_intent()
|
||||
- Merge concluído: agenda_online → online_scheduling.manage
|
||||
- Entitlements e bloqueio PRO no menu funcionando
|
||||
- Signup + intent funcionando; ativação cria subscription ativa; view retorna feature correta
|
||||
|
||||
Modelo de “Contas” decidido:
|
||||
- Auth user (login) ≠ Clínica (tenant)
|
||||
- Clínica = tenant; Usuário pode ser dono/admin de clínica e também profissional
|
||||
- Clínica convida usuários (tenant_members). Usuário pode aceitar/recusar.
|
||||
- Profissional pode trabalhar anos e depois sair: clínica mantém registros; profissional mantém histórico (audit trail), sem acesso após saída.
|
||||
|
||||
Regras de offboarding:
|
||||
- Profissional só pode sair se NÃO houver agenda futura atribuída a ele.
|
||||
- Se houver, cria “pedido de saída” e admin precisa realocar/cancelar; depois finaliza saída.
|
||||
|
||||
Tabelas existentes:
|
||||
- tenant_members: (id uuid pk, tenant_id uuid, user_id uuid, role text, status text, created_at timestamptz)
|
||||
- UNIQUE (tenant_id, user_id) atualmente
|
||||
- Agenda: agenda_eventos, agenda_excecoes, agenda_configuracoes, agenda_regras_semanais
|
||||
- Outros: subscriptions, subscription_intents, plan_features, features, subscription_events
|
||||
|
||||
O que estamos fazendo agora:
|
||||
- Ajustar modelo de membership lifecycle e offboarding (exit_requests)
|
||||
- Garantir integridade: histórico de vínculos + auditoria + bloqueio de saída com agenda futura
|
||||
- Implementar SQL + RPC + RLS + UI (passo a passo)
|
||||
|
||||
✔ subscriptions
|
||||
Representa o plano da clínica (tenant)
|
||||
✔ tenant_members
|
||||
Define quais usuários pertencem à clínica
|
||||
✔ entitlements
|
||||
|
||||
Define o que aquela clínica pode usar
|
||||
|
||||
Dados que faltam confirmar:
|
||||
1) Estrutura de agenda_eventos (colunas e como relaciona com profissional)
|
||||
2) Valores usados em tenant_members.status (active/invited/etc)
|
||||
3) Estratégia de reentrada: remover UNIQUE (tenant_id,user_id) e usar unique parcial por status ativo/convite
|
||||
4) Se existe tabela public.users como espelho do auth.users
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Sessões 6 (continuação) → 10 — hardening em 6 áreas + scan completo do SaaS
|
||||
|
||||
Continuação do commit `7c20b51` (Sessões 1-6 iniciais). Esta etapa fechou
|
||||
**toda revisão sênior do SaaS** + refator parcial de pacientes.
|
||||
|
||||
**Estado final do projeto:**
|
||||
- A# auditoria abertos: **1** (A#31 Deploy real)
|
||||
- V# verificações abertos: 14 (todos médios/baixos adiados com plano completo no DB)
|
||||
- 🔴 Críticos: **0** ✅
|
||||
- 🟠 Altos: **0** ✅
|
||||
- Vitest: **208/208** (era 192)
|
||||
- SQL integration: **33/33**
|
||||
- E2E (Playwright): **5/5**
|
||||
- Áreas auditadas: **15** (todas as principais do SaaS)
|
||||
|
||||
---
|
||||
|
||||
## Sessão 6 (continuação) — Documentos pendentes + Pacientes V#3
|
||||
|
||||
### Documentos: 100% fechado (V#50, V#51, V#52)
|
||||
- **V#50** — Policy `documents: portal patient read` adicional. Paciente lê documento via portal quando `compartilhado_portal=true` AND patient pertence a auth.uid AND não expirou.
|
||||
- **V#51** — `documents.content_sha256` (nullable, índice parcial). `Documents.service.uploadDocument` calcula SHA-256 hex client-side via `crypto.subtle.digest`. Helper novo `verifyDocumentIntegrity(docId)` baixa arquivo e re-hash.
|
||||
- **V#52** — Migration `...13` cron retention via pg_cron: 4 jobs (document_access_logs 1 ano, math_challenges 1h, public_submission_attempts 90 dias, submission_rate_limits 30 dias).
|
||||
|
||||
### Pacientes V#3 (parcial — fundação)
|
||||
- `src/features/patients/services/patientsRepository.js` — list/get/create/update/softDelete + groups + tags + getSessionCounts.
|
||||
- `src/features/patients/composables/usePatients.js` — wrapper reativo (rows/loading/error).
|
||||
- PatientsListPage.hydrateAssociationsSupabase migrada — substitui 4 queries diretas por chamadas ao repo (paralelismo preservado).
|
||||
- V#9 (PatientsCadastroPage 1991 linhas) → adiado pra Sessão 10.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 7 — Tenants + Calendário
|
||||
|
||||
### Tenants (8 V#)
|
||||
- 🔴 **V#1 P0** — `tenant_invites` com RLS off + 0 policies (mesmo padrão A#30 Sessão 5). Tabela tinha 0 rows. Migration: ENABLE RLS + 4 policies (SELECT tenant_admin/saas; INSERT WITH CHECK invited_by=auth.uid; UPDATE só revogação; DELETE tenant_admin/saas). Aceitar invite continua via RPC `tenant_accept_invite` SECURITY DEFINER.
|
||||
- 🟠 **V#2** profiles INSERT WITH CHECK (id = auth.uid)
|
||||
- 🟠 **V#3** support_sessions INSERT WITH CHECK (admin_id = auth.uid + saas_admin guard)
|
||||
- 🟡 **V#4 (signup público)** verificado: RPC `ensure_personal_tenant` SECURITY DEFINER já existia (Signup.vue:232) → **ok**
|
||||
- 🟡 **V#5 (accept_invite)** verificado: RPCs `tenant_accept_invite` + `tenant_invite_member_by_email` já existiam → **ok**
|
||||
- 🟡 **V#6** user_settings INSERT WITH CHECK
|
||||
- 🟢 V#7/V#8 baixos — adiados
|
||||
|
||||
### Calendário (2 V#) — 100% fechado
|
||||
- 🔴 **V#1** feriados_insert + feriados_saas_insert ganharam WITH CHECK. Spam de feriado global bloqueado.
|
||||
- 🟢 **V#2** feriados_delete agora permite tenant_admin (não só owner).
|
||||
|
||||
---
|
||||
|
||||
## Sessão 8 — Addons + Central SaaS
|
||||
|
||||
### Addons (4 V#)
|
||||
- 🔴 **V#1 P0 (dinheiro)** — `addon_transactions_admin_insert` ganhou WITH CHECK (EXISTS saas_admins). Edge functions com service_role bypassam RLS, pipeline preservado. **Authenticated comum não cria mais transação fake.**
|
||||
- 🟠 **V#2** — 3 CHECK constraints em `addon_credits`: balance >= 0, total_consumed >= 0, total_purchased >= 0. Saldo negativo silencioso eliminado.
|
||||
- 🟡 V#3 (UI extrato) — adiado.
|
||||
- 🟡 V#4 — verificado: `addon_products` não tem `tenant_id` (catálogo global por design) → **ok**.
|
||||
|
||||
### Central SaaS (3 V#)
|
||||
- 🟠 **V#1** — `faq_admin_write` substituído por `faq_saas_admin_write` em `saas_faq` E `saas_faq_itens` — só saas_admin escreve. Tenant_admin lê via `faq_auth_read` (permanece).
|
||||
- 🟢 V#2/V#3 médios/baixos — adiados.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 9 — Serviços/Prontuários (100% fechado)
|
||||
|
||||
5/5 V# corrigidos:
|
||||
- 🔴 **V#1** services + insurance_plans → 4 policies separadas (SELECT tenant_member; INSERT/UPDATE/DELETE owner+saas).
|
||||
- 🔴 **V#2** medicos → 4 policies separadas (catálogo de médicos referenciadores compartilhado entre profissionais do tenant).
|
||||
- 🟠 **V#3** commitment_services — cascade reescrito via JOIN com services (USING permite tenant_member; WITH CHECK só owner).
|
||||
- 🟠 **V#4** insurance_plan_services — cascade reescrito via JOIN com insurance_plans.
|
||||
- 🟡 **V#5** commitment_time_logs/determined_commitments/determined_commitment_fields ganharam WITH CHECK em INSERT.
|
||||
|
||||
---
|
||||
|
||||
## Sessão 10 — Pacientes V#9 (script extraído)
|
||||
|
||||
PatientsCadastroPage.vue: 1991 → 1951 linhas (qualitativo > quantitativo).
|
||||
|
||||
### 2 composables novos
|
||||
- **`useCep.js`** — busca ViaCEP reutilizável. 6 testes (sem rede, mock fetch).
|
||||
- **`usePatientSupportContacts.js`** — CRUD de contatos de suporte encapsulado (load/save/add/remove/iniciaisFor). 10 testes com builder thenable.
|
||||
|
||||
### patientsRepository estendido
|
||||
- `getPatientRelations(patientId)` — retorna {groupIds, tagIds}
|
||||
- `replacePatientGroup(patientId, groupId, {tenantId})`
|
||||
- `replacePatientTags(patientId, tagIds, {tenantId, ownerId})`
|
||||
|
||||
### PatientsCadastroPage refatorado
|
||||
- 8 funções de query → delegação 1-linha ao patientsRepository
|
||||
- onCepBlur → usa composable useCep
|
||||
- Contatos de suporte → composable
|
||||
- Template **não** foi tocado (zero risco de regressão visual)
|
||||
- Quebra de template em sub-componentes Vue → adiado pra quando houver E2E cobrindo a página
|
||||
|
||||
---
|
||||
|
||||
## 📦 Migrations consolidadas neste commit (8)
|
||||
|
||||
```
|
||||
20260419000011_documents_portal_patient_policy.sql (V#50)
|
||||
20260419000012_documents_content_hash.sql (V#51)
|
||||
20260419000013_cron_retention_jobs.sql (V#52 + math_challenges + submissions + rate_limits)
|
||||
20260419000014_financial_security_hardening.sql (5 V# financeiro — fechados na Sessão 6)
|
||||
20260419000015_communication_security_hardening.sql (5 V# comunicação — fechados na Sessão 6)
|
||||
20260419000016_tenants_calendario_hardening.sql (Tenants V#1-V#3,V#6 + Calendário V#1-V#2)
|
||||
20260419000017_addons_central_saas_hardening.sql (Addons V#1-V#2 + Central SaaS V#1)
|
||||
20260419000018_servicos_prontuarios_hardening.sql (Serviços V#1-V#5)
|
||||
```
|
||||
|
||||
**Total acumulado de migrations no histórico: 18** (Sessões 1-10).
|
||||
|
||||
Várias dessas exigiram conexão direta como `supabase_admin` (ver memory `project_supabase_admin_gotcha.md` e `commit.md` anterior) por causa de tabelas owned por esse role.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Novos arquivos (código)
|
||||
|
||||
```
|
||||
src/features/patients/composables/useCep.js
|
||||
src/features/patients/composables/usePatientSupportContacts.js
|
||||
src/features/patients/composables/usePatients.js
|
||||
src/features/patients/composables/__tests__/useCep.spec.js (+6 testes)
|
||||
src/features/patients/composables/__tests__/usePatientSupportContacts.spec.js (+10 testes)
|
||||
src/features/patients/services/patientsRepository.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Modificações
|
||||
|
||||
- `src/features/patients/PatientsListPage.vue` — hydrateAssociationsSupabase usa repo
|
||||
- `src/features/patients/cadastro/PatientsCadastroPage.vue` — script extraído (queries → repo, CEP → composable, contatos → composable). Template intocado.
|
||||
- `src/services/Documents.service.js` — uploadDocument calcula content_sha256 + helper verifyDocumentIntegrity
|
||||
|
||||
---
|
||||
|
||||
## 📊 Áreas auditadas (estado final)
|
||||
|
||||
| Área | V# total | Estado |
|
||||
|---|---|---|
|
||||
| auth | 10 | 100% fechado/ok |
|
||||
| router | 9 | 100% |
|
||||
| stores | 1 | 100% |
|
||||
| agenda | 11 | 100% |
|
||||
| pacientes | 10 | **100% fechado** ✅ |
|
||||
| seguranca | 1 | 100% |
|
||||
| saas | 10 | 100% |
|
||||
| documentos | 10 | **100% fechado** ✅ |
|
||||
| financeiro | 11 | 5 fechados, 6 médios/baixos adiados |
|
||||
| comunicacao | 10 | 5 fechados, 5 médios/baixos adiados |
|
||||
| tenants | 8 | 6 fechados, 2 baixos adiados |
|
||||
| calendario | 2 | **100% fechado** ✅ |
|
||||
| addons | 4 | 3 resolvidos, 1 médio adiado |
|
||||
| central_saas | 3 | 1 alto fechado, 2 médios adiados |
|
||||
| servicos | 5 | **100% fechado** ✅ |
|
||||
|
||||
**Zero crítico/alto restante no sistema inteiro.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Pendências documentadas no DB (não esquecidas)
|
||||
|
||||
### A# (1 aberto)
|
||||
- **A#31 Deploy real** — alto. Reformulação pendente: como ainda não há cloud Supabase nem secrets reais, próxima sessão é "Preparação completa pra deploy" (DEPLOY.md, validar migrations num container limpo, audit de edge functions, listar env vars, script `db.cjs deploy-check`).
|
||||
|
||||
### V# adiados (14)
|
||||
Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`:
|
||||
- financeiro (6): parcelamento CHECK, payouts flow, recurrence DELETE policy, composables, máscara PIX, dashboard inadimplência
|
||||
- comunicacao (5): notifications/schedules silos, email_templates_global filtros, retention notification_logs, dashboard health, audit dismissals/preferences
|
||||
- tenants (2): owner_users policies, company_profiles + dev_user_credentials
|
||||
- central_saas (2): rate limit voto, valores tipo_acesso
|
||||
- addons (1): UI de extrato
|
||||
|
||||
### Outros
|
||||
- PatientsCadastroPage template breakdown — quando houver E2E
|
||||
- Pacientes V#9 segue 100% no banco (script foi extraído; template é refator visual separado)
|
||||
@@ -0,0 +1,96 @@
|
||||
# README — generate-dashboard.js
|
||||
|
||||
Script Node.js que lê o `schema.sql` do backup mais recente e gera um `dashboard.html` interativo com a visão completa do banco de dados do projeto.
|
||||
|
||||
---
|
||||
|
||||
## Como usar
|
||||
|
||||
Coloque o `generate-dashboard.js` na **raiz do projeto** (mesma pasta do `db.cjs`) e rode:
|
||||
|
||||
```bash
|
||||
# Usa o backup mais recente automaticamente
|
||||
node generate-dashboard.js
|
||||
|
||||
# Ou especifica uma data
|
||||
node generate-dashboard.js 2026-03-27
|
||||
```
|
||||
|
||||
O arquivo `dashboard.html` será gerado na raiz do projeto. Basta abrir no browser.
|
||||
|
||||
---
|
||||
|
||||
## Fluxo recomendado
|
||||
|
||||
Sempre que fizer alterações no banco, rode os dois comandos em sequência:
|
||||
|
||||
```bash
|
||||
node db.cjs backup # gera o backup em database-novo/backups/YYYY-MM-DD/
|
||||
node generate-dashboard.js # lê o backup mais recente e gera o dashboard.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O que o dashboard mostra
|
||||
|
||||
- **Visão geral** — cards com os 9 domínios do projeto, quantidade de tabelas e FKs por domínio
|
||||
- **Tabelas** — todas as 86 tabelas com colunas, tipos, badges PK/FK
|
||||
- **Foreign Keys** — cada FK aparece como link clicável que pula direto para a tabela destino
|
||||
- **Views** — lista das 24 views do schema público
|
||||
- **Busca** — busca em tempo real por nome de tabela ou nome de coluna
|
||||
- **Sidebar** — navegação por domínio
|
||||
|
||||
---
|
||||
|
||||
## Estrutura de pastas esperada
|
||||
|
||||
O script espera essa estrutura para funcionar:
|
||||
|
||||
```
|
||||
raiz-do-projeto/
|
||||
├── db.cjs
|
||||
├── db.config.json
|
||||
├── generate-dashboard.js ← script
|
||||
├── dashboard.html ← gerado aqui
|
||||
└── database-novo/
|
||||
└── backups/
|
||||
└── 2026-03-27/
|
||||
├── schema.sql ← lido pelo script
|
||||
├── data.sql
|
||||
└── full_dump.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tabelas novas não aparecem no domínio certo?
|
||||
|
||||
Quando você criar uma migration nova com uma tabela nova, ela aparecerá no dashboard na seção **"Outros"** e o script vai avisar no terminal:
|
||||
|
||||
```
|
||||
⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):
|
||||
- minha_tabela_nova
|
||||
→ Edite DOMAIN_TABLES no script para mapeá-las.
|
||||
```
|
||||
|
||||
Para corrigir, abra o `generate-dashboard.js` e adicione a tabela no domínio correto dentro do objeto `DOMAIN_TABLES` no topo do arquivo:
|
||||
|
||||
```js
|
||||
const DOMAIN_TABLES = {
|
||||
'Agenda': [
|
||||
'agenda_eventos',
|
||||
'agenda_configuracoes',
|
||||
// ...
|
||||
'minha_tabela_nova', // ← adiciona aqui
|
||||
],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
Depois rode `node generate-dashboard.js` novamente.
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node.js instalado (qualquer versão >= 14)
|
||||
- Sem dependências externas — usa apenas módulos nativos (`fs`, `path`)
|
||||
@@ -0,0 +1,119 @@
|
||||
# database-novo
|
||||
|
||||
Banco de dados do AgenciaPsi — organizado, documentado e com CLI para gerenciamento.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd database-novo
|
||||
|
||||
# Instalação do zero (schema + fixes + seeds + backup)
|
||||
node db.cjs setup
|
||||
|
||||
# Ver estado do banco
|
||||
node db.cjs status
|
||||
|
||||
# Backup
|
||||
node db.cjs backup
|
||||
|
||||
# Restaurar (perdi o banco!)
|
||||
node db.cjs restore
|
||||
```
|
||||
|
||||
Para o guia completo, veja **`docs/setup_guide.md`**.
|
||||
|
||||
## Comandos do CLI
|
||||
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `node db.cjs setup` | Instala do zero (schema + fixes + seeds) |
|
||||
| `node db.cjs backup` | Exporta backup com data para `backups/` |
|
||||
| `node db.cjs restore [data]` | Restaura de um backup |
|
||||
| `node db.cjs migrate` | Aplica migrations pendentes |
|
||||
| `node db.cjs seed [grupo]` | Roda seeds (all, users, system, test_data) |
|
||||
| `node db.cjs status` | Estado do banco, backups, migrations |
|
||||
| `node db.cjs diff` | Compara schema atual vs último backup |
|
||||
| `node db.cjs reset` | Reseta e reinstala tudo |
|
||||
| `node db.cjs verify` | Verifica integridade dos dados |
|
||||
|
||||
## Estrutura
|
||||
|
||||
```
|
||||
database-novo/
|
||||
│
|
||||
├── db.cjs # CLI de gerenciamento do banco
|
||||
├── db.config.json # Configuração (container, seeds, fixes)
|
||||
│
|
||||
├── schema/ # Schema SQL separado por seção
|
||||
│ ├── 00_full/schema.sql # Schema completo (referência)
|
||||
│ ├── 01_extensions/ # Schemas + extensões PostgreSQL
|
||||
│ ├── 02_types/ # Enums (auth, public, infra)
|
||||
│ ├── 03_functions/ # 11 arquivos por domínio
|
||||
│ ├── 04_tables/ # 10 arquivos por domínio
|
||||
│ ├── 05_views/ # 24 views
|
||||
│ ├── 06_indexes/ # Índices
|
||||
│ ├── 07_foreign_keys/ # PKs, FKs, constraints
|
||||
│ ├── 08_triggers/ # Triggers
|
||||
│ ├── 09_policies/ # 217 RLS policies
|
||||
│ └── 10_grants/ # Grants
|
||||
│
|
||||
├── seeds/ # Seeds de dados
|
||||
│ ├── seed_001_fixed.sql # 6 usuários base + tenants
|
||||
│ ├── seed_002.sql # Supervisor + Editor
|
||||
│ ├── seed_003.sql # Therapist2, Therapist3, Secretary
|
||||
│ ├── seed_010_plans.sql # 7 planos + 4 preços
|
||||
│ ├── seed_011_features.sql # 26 features
|
||||
│ ├── seed_012_plan_features.sql # 85 vínculos plano↔feature
|
||||
│ ├── seed_013_subscriptions.sql # 9 subscriptions + compromissos
|
||||
│ ├── seed_014_global_data.sql # 11 email + 16 notif templates + 3 slides
|
||||
│ ├── seed_020_test_data.sql # Dados de teste (50 pacientes, eventos, etc.)
|
||||
│ ├── seed_020_test_data_cleanup.sql # Limpeza dos dados de teste
|
||||
│ └── run_all_seeds.sh # Script bash alternativo
|
||||
│
|
||||
├── migrations/ # Migrations incrementais
|
||||
│
|
||||
├── fixes/ # 7 correções aplicadas
|
||||
│
|
||||
├── backups/ # Backups com data (auto-gerenciados)
|
||||
│ └── 2026-03-23/ # schema.sql + data.sql + full_dump.sql
|
||||
│
|
||||
└── docs/ # Documentação
|
||||
├── setup_guide.md # Guia completo de instalação e uso
|
||||
├── schema_map.md # Mapa das 84 tabelas
|
||||
├── business_rules.md # Regras de negócio
|
||||
└── users_test.md # 11 usuários de teste (UUIDs + vínculos)
|
||||
```
|
||||
|
||||
## Planos
|
||||
|
||||
| Key | Target | Preço | Limites |
|
||||
|-----|--------|-------|---------|
|
||||
| `patient_free` | patient | R$0 | — |
|
||||
| `therapist_free` | therapist | R$0 | 40 agendamentos/mês, 50 lembretes/mês |
|
||||
| `therapist_pro` | therapist | R$49/mês · R$490/ano | Ilimitado |
|
||||
| `clinic_free` | clinic | R$0 | 30 pacientes, 5 terapeutas, 40 agend/mês |
|
||||
| `clinic_pro` | clinic | R$149/mês · R$1490/ano | Ilimitado |
|
||||
| `supervisor_free` | supervisor | R$0 | Até 3 supervisionados |
|
||||
| `supervisor_pro` | supervisor | R$0 | Até 20 supervisionados |
|
||||
|
||||
## Usuários de Teste
|
||||
|
||||
Senha de todos: `Teste@123`
|
||||
|
||||
| Email | Plano | Tipo |
|
||||
|-------|-------|------|
|
||||
| paciente@agenciapsi.com.br | patient_free | Paciente |
|
||||
| terapeuta@agenciapsi.com.br | therapist_free | Terapeuta solo + Clínica 3 |
|
||||
| clinica1@agenciapsi.com.br | clinic_free | Clínica coworking |
|
||||
| clinica2@agenciapsi.com.br | clinic_free | Clínica recepção |
|
||||
| clinica3@agenciapsi.com.br | clinic_free | Clínica full |
|
||||
| saas@agenciapsi.com.br | — | Admin plataforma |
|
||||
| supervisor@agenciapsi.com.br | supervisor_free | Supervisor |
|
||||
| editor@agenciapsi.com.br | therapist_free | Editor |
|
||||
| therapist2@agenciapsi.com.br | therapist_free | Terapeuta |
|
||||
| therapist3@agenciapsi.com.br | therapist_free | Terapeuta |
|
||||
| secretary@agenciapsi.com.br | — | Secretária (Clínica 2) |
|
||||
|
||||
## Idempotência
|
||||
|
||||
Todos os seeds são idempotentes (ON CONFLICT DO UPDATE ou DELETE + INSERT). Podem ser re-executados quantas vezes necessário.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
{
|
||||
"container": "supabase_db_agenciapsi-primesakai",
|
||||
"database": "postgres",
|
||||
"user": "postgres",
|
||||
"backupRetentionDays": 30,
|
||||
"schema": "schema/00_full/schema.sql",
|
||||
"migrationsDir": "migrations",
|
||||
"seedsDir": "seeds",
|
||||
"fixesDir": "fixes",
|
||||
"seeds": {
|
||||
"users": [
|
||||
"seed_001_fixed.sql",
|
||||
"seed_002.sql",
|
||||
"seed_003.sql"
|
||||
],
|
||||
"system": [
|
||||
"seed_010_plans.sql",
|
||||
"seed_011_features.sql",
|
||||
"seed_012_plan_features.sql",
|
||||
"seed_013_subscriptions.sql",
|
||||
"seed_014_global_data.sql",
|
||||
"seed_015_document_templates.sql",
|
||||
"seed_030_dev_phases_items.sql",
|
||||
"seed_031_dev_auditoria.sql",
|
||||
"seed_032_dev_competitors.sql"
|
||||
],
|
||||
"test_data": [
|
||||
"seed_020_test_data.sql"
|
||||
]
|
||||
},
|
||||
"fixes": [
|
||||
"fix_addon_credits_fk.sql",
|
||||
"fix_addon_rls_saas_admin.sql",
|
||||
"fix_missing_subscriptions.sql",
|
||||
"fix_notification_templates_rls_admin.sql",
|
||||
"fix_seed_patient_groups.sql",
|
||||
"fix_subscriptions_validate_scope.sql",
|
||||
"fix_template_keys_match_populate.sql",
|
||||
"fix_encoding_accents.sql"
|
||||
],
|
||||
"verify": {
|
||||
"tables": [
|
||||
{ "name": "auth.users", "min": 1 },
|
||||
{ "name": "profiles", "min": 1 },
|
||||
{ "name": "tenants", "min": 1 },
|
||||
{ "name": "plans", "min": 7 },
|
||||
{ "name": "features", "min": 20 },
|
||||
{ "name": "plan_features", "min": 50 },
|
||||
{ "name": "subscriptions", "min": 1 },
|
||||
{ "name": "email_templates_global", "min": 10 },
|
||||
{ "name": "notification_templates", "min": 5 },
|
||||
{ "name": "document_templates", "min": 1 }
|
||||
],
|
||||
"views": [
|
||||
"v_tenant_entitlements",
|
||||
"v_tenant_active_subscription"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"tables": [
|
||||
"auth.users",
|
||||
"profiles",
|
||||
"tenants",
|
||||
"tenant_members",
|
||||
"plans",
|
||||
"features",
|
||||
"plan_features",
|
||||
"subscriptions",
|
||||
"patients",
|
||||
"agenda_eventos",
|
||||
"services",
|
||||
"financial_records",
|
||||
"document_templates",
|
||||
"documents",
|
||||
"email_templates_global",
|
||||
"notification_templates"
|
||||
]
|
||||
},
|
||||
"domains": {
|
||||
"SaaS / Planos": [
|
||||
"plans", "plan_features", "plan_prices", "plan_public", "plan_public_bullets",
|
||||
"features", "modules", "module_features",
|
||||
"subscriptions", "subscription_events",
|
||||
"subscription_intents_legacy", "subscription_intents_personal", "subscription_intents_tenant",
|
||||
"tenant_modules", "tenant_features", "tenant_feature_exceptions_log",
|
||||
"billing_contracts", "entitlements_invalidation"
|
||||
],
|
||||
"Addons / Créditos": [
|
||||
"addon_products", "addon_credits", "addon_transactions"
|
||||
],
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# Regras de Negócio — Banco de Dados AgenciaPsi
|
||||
|
||||
## 1. Planos e Targets
|
||||
|
||||
| Target | Planos | Escopo da Subscription |
|
||||
|--------|--------|----------------------|
|
||||
| `patient` | patient_free | `user_id` (sem tenant_id) |
|
||||
| `therapist` | therapist_free, therapist_pro | `user_id` (sem tenant_id) |
|
||||
| `clinic` | clinic_free, clinic_pro | `tenant_id` (sem user_id) |
|
||||
| `supervisor` | supervisor_free, supervisor_pro | `user_id` (sem tenant_id) |
|
||||
|
||||
**Constraint `subscriptions_owner_xor`**: Uma subscription DEVE ter `tenant_id` XOR `user_id`, nunca ambos.
|
||||
|
||||
**Trigger `subscriptions_validate_scope`**: Valida que o target do plano casa com o escopo:
|
||||
- `clinic` → exige `tenant_id`, rejeita `user_id`
|
||||
- `therapist`, `supervisor`, `patient` → exige `user_id`, rejeita `tenant_id`
|
||||
|
||||
## 2. Planos Core (protegidos)
|
||||
|
||||
Os planos `clinic_free`, `clinic_pro`, `therapist_free`, `therapist_pro` são **core**:
|
||||
- **Não podem ter `key` alterada** (trigger `trg_no_change_core_plan_key`)
|
||||
- **Não podem ter `target` alterado** (trigger `trg_no_change_plan_target`)
|
||||
- **Não podem ser deletados** (trigger `trg_no_delete_core_plans`)
|
||||
|
||||
Para bypass (migração): `SET LOCAL app.plan_migration_bypass = '1'`
|
||||
|
||||
## 3. Entitlements (Features)
|
||||
|
||||
### Resolução de features para TENANTS (clínicas)
|
||||
```
|
||||
tenant_has_feature(tenant_id, feature_key) =
|
||||
EXISTS em v_tenant_entitlements (via plano)
|
||||
OR
|
||||
EXISTS em tenant_features (override direto)
|
||||
```
|
||||
|
||||
### Resolução de features para USERS (terapeutas, supervisores)
|
||||
```
|
||||
user_has_feature(user_id, feature_key) =
|
||||
EXISTS em v_user_entitlements (via plano pessoal)
|
||||
```
|
||||
|
||||
### Cadeia de resolução
|
||||
```
|
||||
subscription → plan → plan_features → features
|
||||
↓
|
||||
plan_features.limits (jsonb) → limites quantitativos
|
||||
```
|
||||
|
||||
### Views de entitlements
|
||||
- `v_tenant_active_subscription` → subscription ativa do tenant
|
||||
- `v_user_active_subscription` → subscription ativa do user
|
||||
- `v_tenant_entitlements` → feature_key + allowed
|
||||
- `v_tenant_entitlements_full` → + limits + plan_id + plan_key
|
||||
- `v_user_entitlements` → feature_key + allowed (para planos pessoais)
|
||||
|
||||
## 4. Tipos de Tenant
|
||||
|
||||
| kind | Descrição | Criação |
|
||||
|------|-----------|---------|
|
||||
| `therapist` | Terapeuta solo | Automático ao criar conta de terapeuta |
|
||||
| `clinic_coworking` | Clínica coworking | Manual |
|
||||
| `clinic_reception` | Clínica com recepção | Manual |
|
||||
| `clinic_full` | Clínica completa | Manual |
|
||||
| `supervisor` | Supervisor | Automático |
|
||||
| `saas` | Sistema (legado) | — |
|
||||
| `clinic` | Legado | — |
|
||||
|
||||
**O `kind` é imutável após criação** (trigger `trg_tenant_kind_immutable`).
|
||||
|
||||
## 5. Roles e Permissões
|
||||
|
||||
### Profile roles
|
||||
| Role | Descrição |
|
||||
|------|-----------|
|
||||
| `saas_admin` | Administrador da plataforma |
|
||||
| `tenant_member` | Membro de um ou mais tenants |
|
||||
| `portal_user` | Paciente (acesso ao portal) |
|
||||
| `patient` | Paciente (legado) |
|
||||
|
||||
### Tenant member roles
|
||||
| Role | Descrição |
|
||||
|------|-----------|
|
||||
| `tenant_admin` | Admin do tenant (dono) |
|
||||
| `therapist` | Terapeuta membro |
|
||||
| `clinic_admin` | Admin da clínica (secretária com poderes) |
|
||||
| `secretary` | Secretária |
|
||||
| `supervisor` | Supervisor |
|
||||
| `patient` | Paciente do tenant |
|
||||
|
||||
### Platform roles (array em profiles)
|
||||
| Role | Descrição |
|
||||
|------|-----------|
|
||||
| `editor` | Editor de conteúdo da plataforma |
|
||||
|
||||
## 6. Compromissos Determinados
|
||||
|
||||
A função `seed_determined_commitments(tenant_id)` cria 5 tipos nativos:
|
||||
|
||||
| native_key | Nome | locked | active |
|
||||
|------------|------|--------|--------|
|
||||
| `session` | Sessão | true | true |
|
||||
| `reading` | Leitura | false | true |
|
||||
| `supervision` | Supervisão | false | true |
|
||||
| `class` | Aula | false | **false** |
|
||||
| `analysis` | Análise Pessoal | false | true |
|
||||
|
||||
- `session` é **locked** (não pode ser editado/deletado)
|
||||
- O `native_key = 'session'` é usado pelo agendador online para identificar o compromisso padrão
|
||||
|
||||
## 7. Grupos de Pacientes Padrão
|
||||
|
||||
A função `seed_default_patient_groups(tenant_id)` cria 3 grupos sistema:
|
||||
|
||||
| Nome | Cor | is_system |
|
||||
|------|-----|-----------|
|
||||
| Crianças | #60a5fa | true |
|
||||
| Adolescentes | #a78bfa | true |
|
||||
| Idosos | #34d399 | true |
|
||||
|
||||
Grupos sistema não podem ser editados/deletados (trigger `prevent_system_group_changes`).
|
||||
|
||||
## 8. Subscriptions — Status
|
||||
|
||||
| Status | Descrição |
|
||||
|--------|-----------|
|
||||
| `pending` | Aguardando ativação |
|
||||
| `active` | Ativa |
|
||||
| `past_due` | Pagamento atrasado |
|
||||
| `suspended` | Suspensa |
|
||||
| `cancelled` | Cancelada |
|
||||
| `expired` | Expirada |
|
||||
|
||||
## 9. Templates de Email
|
||||
|
||||
**Globais** (`email_templates_global`): templates padrão da plataforma, gerenciados pelo saas_admin.
|
||||
|
||||
**Tenant** (`email_templates_tenant`): overrides por tenant. Se existir, usa o do tenant; se não, usa o global.
|
||||
|
||||
### Keys de template
|
||||
| Domínio | Templates |
|
||||
|---------|-----------|
|
||||
| session | reminder, confirmation, cancellation, rescheduled |
|
||||
| intake | received, approved, rejected |
|
||||
| scheduler | request_accepted, request_rejected |
|
||||
| system | welcome, password_reset |
|
||||
|
||||
Canais: `email`, `whatsapp`, `sms`
|
||||
|
||||
## 10. Notificações — Sistema
|
||||
|
||||
| Canal | Tipos |
|
||||
|-------|-------|
|
||||
| WhatsApp | lembrete_sessao, confirmacao_sessao, cancelamento_sessao |
|
||||
| SMS | lembrete_sessao |
|
||||
|
||||
### Schedule keys
|
||||
| Key | Descrição |
|
||||
|-----|-----------|
|
||||
| `lembrete_24h` | 24 horas antes |
|
||||
| `lembrete_2h` | 2 horas antes |
|
||||
| `lembrete_30min` | 30 minutos antes |
|
||||
| `confirmacao_imediata` | Imediato após confirmar |
|
||||
| `cancelamento_imediato` | Imediato após cancelar |
|
||||
|
||||
## 11. RLS (Row Level Security)
|
||||
|
||||
Todas as tabelas do schema `public` têm RLS habilitado. As policies usam:
|
||||
- `auth.uid()` — ID do usuário autenticado
|
||||
- `is_saas_admin()` — verifica se é admin da plataforma
|
||||
- `is_tenant_member(tenant_id)` — verifica se pertence ao tenant
|
||||
- `is_tenant_admin(tenant_id)` — verifica se é admin do tenant
|
||||
- `current_member_role(tenant_id)` — role do membro no tenant
|
||||
- `tenant_has_feature(tenant_id, feature_key)` — verifica feature
|
||||
|
||||
**Se as features/plan_features não existirem no banco, as policies de RLS bloqueiam o acesso.**
|
||||
@@ -0,0 +1,191 @@
|
||||
# Schema Map — AgenciaPsi
|
||||
|
||||
Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-03-23).
|
||||
**84 tabelas** no schema `public` + tabelas de infraestrutura (auth, storage, realtime).
|
||||
|
||||
## Domínios
|
||||
|
||||
### Core (11 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `profiles` | Perfil do usuário (role, account_type, full_name, platform_roles) |
|
||||
| `tenants` | Organizações (clínicas, terapeutas solo, supervisores) |
|
||||
| `tenant_members` | Vínculo usuário↔tenant com role (tenant_admin, therapist, secretary, etc.) |
|
||||
| `tenant_invites` | Convites pendentes para ingressar em um tenant |
|
||||
| `tenant_features` | Overrides de features por tenant (exceções comerciais) |
|
||||
| `tenant_feature_exceptions_log` | Log de alterações em tenant_features |
|
||||
| `saas_admins` | Administradores da plataforma |
|
||||
| `owner_users` | Mapeamento owner_id→user_id para RLS |
|
||||
| `user_settings` | Configurações pessoais do usuário |
|
||||
| `company_profiles` | Perfil da empresa/clínica (logo, endereço, etc.) |
|
||||
| `dev_user_credentials` | Credenciais de teste (apenas dev) |
|
||||
|
||||
### Plans & Billing (20 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `plans` | Planos disponíveis (key, target, price_cents, max_supervisees) |
|
||||
| `plan_prices` | Preços por intervalo (month/year) com versionamento |
|
||||
| `plan_features` | Vínculo plano↔feature com limites (limits jsonb) |
|
||||
| `plan_public` | Info pública dos planos (para página de preços) |
|
||||
| `plan_public_bullets` | Bullets de marketing dos planos |
|
||||
| `features` | Features do sistema (key, name, descricao) |
|
||||
| `entitlements_invalidation` | Cache invalidation de entitlements |
|
||||
| `subscriptions` | Assinaturas ativas (user_id XOR tenant_id) |
|
||||
| `subscription_events` | Histórico de eventos de assinatura |
|
||||
| `subscription_intents_personal` | Intenções de assinatura pessoal |
|
||||
| `subscription_intents_tenant` | Intenções de assinatura de tenant |
|
||||
| `subscription_intents_legacy` | Intenções legadas |
|
||||
| `billing_contracts` | Contratos de cobrança |
|
||||
| `addon_credits` | Créditos de add-ons por tenant |
|
||||
| `addon_products` | Produtos add-on disponíveis |
|
||||
| `addon_transactions` | Transações de add-ons |
|
||||
| `modules` | Módulos do sistema |
|
||||
| `module_features` | Features por módulo |
|
||||
| `tenant_modules` | Módulos ativos por tenant |
|
||||
|
||||
### Agenda (11 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `agenda_bloqueios` | Bloqueios de horário |
|
||||
| `agenda_configuracoes` | Configurações da agenda por tenant_member |
|
||||
| `agenda_eventos` | Eventos da agenda (sessões, bloqueios) |
|
||||
| `agenda_excecoes` | Exceções na agenda (horários extras, bloqueios pontuais) |
|
||||
| `agenda_online_slots` | Slots de agendamento online |
|
||||
| `agenda_regras_semanais` | Regras semanais de disponibilidade |
|
||||
| `agenda_slots_bloqueados_semanais` | Slots bloqueados na semana |
|
||||
| `agenda_slots_regras` | Regras de slots |
|
||||
| `recurrence_rules` | Regras de recorrência de sessões |
|
||||
| `recurrence_exceptions` | Exceções a recorrências |
|
||||
| `recurrence_rule_services` | Serviços vinculados a recorrências |
|
||||
|
||||
### Agendador Online (2 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `agendador_configuracoes` | Configurações do agendador online público |
|
||||
| `agendador_solicitacoes` | Solicitações de agendamento recebidas |
|
||||
|
||||
### Pacientes (8 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `patients` | Pacientes vinculados a um tenant |
|
||||
| `patient_groups` | Grupos de pacientes (sistema + customizados) |
|
||||
| `patient_group_patient` | Vínculo paciente↔grupo |
|
||||
| `patient_tags` | Tags personalizadas |
|
||||
| `patient_patient_tag` | Vínculo paciente↔tag |
|
||||
| `patient_intake_requests` | Solicitações de cadastro (triagem) |
|
||||
| `patient_invites` | Convites para portal do paciente |
|
||||
| `patient_discounts` | Descontos por paciente |
|
||||
|
||||
### Compromissos Determinados (4 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `determined_commitments` | Tipos de compromisso (sessão, leitura, supervisão, etc.) |
|
||||
| `determined_commitment_fields` | Campos customizados por tipo de compromisso |
|
||||
| `commitment_services` | Serviços vinculados a compromissos |
|
||||
| `commitment_time_logs` | Logs de tempo por compromisso |
|
||||
|
||||
### Financeiro (9 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `financial_records` | Lançamentos financeiros (receita/despesa) |
|
||||
| `financial_categories` | Categorias de lançamento |
|
||||
| `financial_exceptions` | Exceções financeiras |
|
||||
| `payment_settings` | Configurações de pagamento por tenant |
|
||||
| `professional_pricing` | Precificação por profissional |
|
||||
| `therapist_payouts` | Repasses a terapeutas |
|
||||
| `therapist_payout_records` | Registros de repasse |
|
||||
| `services` | Serviços oferecidos |
|
||||
| `insurance_plans` + `insurance_plan_services` | Convênios e serviços por convênio |
|
||||
|
||||
### Notificações (10 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `notification_channels` | Canais de notificação por tenant |
|
||||
| `notification_logs` | Logs de envio |
|
||||
| `notification_preferences` | Preferências do paciente (opt-in/out) |
|
||||
| `notification_queue` | Fila de envio |
|
||||
| `notification_schedules` | Agendamentos de notificação |
|
||||
| `notification_templates` | Templates WhatsApp/SMS (default + tenant) |
|
||||
| `notifications` | Notificações in-app |
|
||||
| `email_templates_global` | Templates de email globais (plataforma) |
|
||||
| `email_templates_tenant` | Overrides de templates por tenant |
|
||||
| `email_layout_config` | Configuração de layout de email |
|
||||
|
||||
### SaaS Admin / UI (8 tabelas)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `saas_docs` | Documentação da plataforma |
|
||||
| `saas_doc_votos` | Votos em docs |
|
||||
| `saas_faq` | Categorias de FAQ |
|
||||
| `saas_faq_itens` | Itens de FAQ |
|
||||
| `feriados` | Feriados nacionais/regionais |
|
||||
| `global_notices` | Avisos globais da plataforma |
|
||||
| `login_carousel_slides` | Slides do carrossel de login |
|
||||
| `notice_dismissals` | Dismissals de avisos por usuário |
|
||||
|
||||
### Suporte (1 tabela)
|
||||
| Tabela | Descrição |
|
||||
|--------|-----------|
|
||||
| `support_sessions` | Sessões de suporte técnico |
|
||||
|
||||
---
|
||||
|
||||
## Views Principais
|
||||
|
||||
| View | Descrição |
|
||||
|------|-----------|
|
||||
| `v_tenant_active_subscription` | Subscription ativa por tenant |
|
||||
| `v_user_active_subscription` | Subscription ativa por user |
|
||||
| `v_tenant_entitlements` | Features habilitadas por tenant (via plano) |
|
||||
| `v_tenant_entitlements_full` | Entitlements + limits + plan info |
|
||||
| `v_tenant_entitlements_json` | Entitlements agregados como JSON |
|
||||
| `v_user_entitlements` | Features habilitadas por user (via plano) |
|
||||
| `v_tenant_members_with_profiles` | Membros do tenant com dados do perfil |
|
||||
| `v_tenant_staff` | Staff do tenant (membros + convites) |
|
||||
| `v_tenant_people` | Todas as pessoas do tenant |
|
||||
| `v_plan_active_prices` | Preços ativos dos planos |
|
||||
| `v_public_pricing` | Preços públicos para página de marketing |
|
||||
| `v_subscription_health` | Saúde das subscriptions |
|
||||
| `v_cashflow_projection` | Projeção de fluxo de caixa |
|
||||
| `v_commitment_totals` | Totais de compromissos |
|
||||
| `v_patient_groups_with_counts` | Grupos com contagem de pacientes |
|
||||
| `v_tag_patient_counts` | Tags com contagem de pacientes |
|
||||
| `subscription_intents` | View unificada de intenções (com INSTEAD OF trigger) |
|
||||
| `owner_feature_entitlements` | Entitlements por owner |
|
||||
| `current_tenant_id` | Tenant ativo do usuário corrente |
|
||||
|
||||
---
|
||||
|
||||
## Funções Críticas
|
||||
|
||||
| Função | Tipo | Descrição |
|
||||
|--------|------|-----------|
|
||||
| `tenant_has_feature(uuid, text)` | Query | Verifica se tenant tem feature (plano + override) |
|
||||
| `user_has_feature(uuid, text)` | Query | Verifica se user tem feature via plano pessoal |
|
||||
| `has_feature(uuid, text)` | Query | Alias genérico |
|
||||
| `seed_determined_commitments(uuid)` | Seed | Cria 5 tipos de compromisso nativos por tenant |
|
||||
| `seed_default_patient_groups(uuid)` | Seed | Cria 3 grupos de pacientes padrão |
|
||||
| `seed_default_financial_categories(uuid)` | Seed | Cria categorias financeiras padrão |
|
||||
| `subscriptions_validate_scope()` | Trigger | Valida XOR (user_id vs tenant_id) por target |
|
||||
| `activate_subscription_from_intent(uuid)` | RPC | Ativa subscription a partir de intent |
|
||||
| `handle_new_user()` | Trigger | Cria profile + tenant pessoal ao cadastrar |
|
||||
| `ensure_personal_tenant()` | RPC | Garante que o user tem um tenant pessoal |
|
||||
| `populate_notification_queue()` | Cron | Popula fila de notificações |
|
||||
| `agendador_slots_disponiveis(text, date)` | RPC | Retorna slots disponíveis para agendamento |
|
||||
|
||||
---
|
||||
|
||||
## Enums (public schema)
|
||||
|
||||
| Tipo | Valores |
|
||||
|------|---------|
|
||||
| `commitment_log_source` | manual, auto |
|
||||
| `determined_field_type` | text, textarea, number, date, select, boolean |
|
||||
| `financial_record_type` | receita, despesa |
|
||||
| `recurrence_exception_type` | cancel_session, reschedule_session, patient_missed, therapist_canceled, holiday_block |
|
||||
| `recurrence_type` | weekly, biweekly, monthly, yearly, custom_weekdays |
|
||||
| `status_agenda_serie` | ativo, pausado, cancelado |
|
||||
| `status_evento_agenda` | agendado, realizado, faltou, cancelado, remarcar |
|
||||
| `status_excecao_agenda` | pendente, ativo, arquivado |
|
||||
| `tipo_evento_agenda` | sessao, bloqueio |
|
||||
| `tipo_excecao_agenda` | bloqueio, horario_extra |
|
||||
@@ -0,0 +1,297 @@
|
||||
# Guia de Instalação e Uso — AgenciaPsi Database
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
1. **Docker Desktop** instalado e rodando
|
||||
2. **Node.js** 18+ instalado
|
||||
3. **Supabase CLI** instalado (`npm install -g supabase`)
|
||||
|
||||
## Instalação do Zero (banco vazio)
|
||||
|
||||
### 1. Iniciar o Supabase
|
||||
|
||||
```bash
|
||||
# Na raiz do projeto (agenciapsi-primesakai/)
|
||||
npx supabase start
|
||||
```
|
||||
|
||||
Aguarde até o container `supabase_db_agenciapsi-primesakai` estar rodando.
|
||||
|
||||
### 2. Verificar se o container está ok
|
||||
|
||||
```bash
|
||||
docker ps | grep supabase_db
|
||||
```
|
||||
|
||||
Deve mostrar o container com status `Up`.
|
||||
|
||||
### 3. Instalar o banco completo
|
||||
|
||||
```bash
|
||||
cd database-novo
|
||||
node db.cjs setup
|
||||
```
|
||||
|
||||
Isso faz tudo automaticamente:
|
||||
- Aplica o schema completo (84 tabelas, funções, triggers, policies)
|
||||
- Aplica os 7 fixes conhecidos
|
||||
- Cria os 11 usuários de teste
|
||||
- Cria os 7 planos + 4 preços
|
||||
- Cria as 26 features + 85 vínculos plano↔feature
|
||||
- Cria as 9 subscriptions + compromissos determinados
|
||||
- Cria os templates de email, notificação e carousel
|
||||
- Cria backup automático pós-instalação
|
||||
- Verifica integridade no final
|
||||
|
||||
### 4. Verificar
|
||||
|
||||
```bash
|
||||
node db.cjs status
|
||||
```
|
||||
|
||||
Deve mostrar todos os counts verdes.
|
||||
|
||||
## Backup
|
||||
|
||||
### Criar backup manual
|
||||
|
||||
```bash
|
||||
node db.cjs backup
|
||||
```
|
||||
|
||||
Salva em `backups/YYYY-MM-DD/` com 3 arquivos:
|
||||
- `schema.sql` — estrutura do banco
|
||||
- `data.sql` — dados (sem schemas de infra)
|
||||
- `full_dump.sql` — tudo junto
|
||||
|
||||
### Backup automático
|
||||
|
||||
O backup é feito automaticamente:
|
||||
- Após o `setup`
|
||||
- Antes de cada `migrate`
|
||||
- Antes de cada `restore`
|
||||
- Antes de cada `reset`
|
||||
|
||||
### Retenção
|
||||
|
||||
Backups com mais de 30 dias são removidos automaticamente. Para alterar, edite `backupRetentionDays` no `db.config.json`.
|
||||
|
||||
## Restaurar o Banco
|
||||
|
||||
### Restaurar do último backup
|
||||
|
||||
```bash
|
||||
node db.cjs restore
|
||||
```
|
||||
|
||||
### Restaurar de uma data específica
|
||||
|
||||
```bash
|
||||
node db.cjs restore 2026-03-23
|
||||
```
|
||||
|
||||
O restore:
|
||||
1. Cria backup de segurança do estado atual
|
||||
2. Limpa o schema public
|
||||
3. Aplica o full_dump.sql do backup
|
||||
4. Verifica integridade
|
||||
|
||||
## Migrations (alterações no banco)
|
||||
|
||||
### Criar uma migration
|
||||
|
||||
Crie um arquivo SQL na pasta `migrations/` com nome sequencial:
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── 001_add_column_x.sql
|
||||
├── 002_create_table_y.sql
|
||||
└── 003_fix_something.sql
|
||||
```
|
||||
|
||||
O nome deve começar com número para garantir a ordem.
|
||||
|
||||
### Aplicar migrations pendentes
|
||||
|
||||
```bash
|
||||
node db.cjs migrate
|
||||
```
|
||||
|
||||
O CLI:
|
||||
1. Cria backup automático
|
||||
2. Compara com a tabela `_db_migrations` no banco
|
||||
3. Aplica apenas as que ainda não foram executadas
|
||||
4. Registra cada migration aplicada
|
||||
5. Se uma falhar, para imediatamente (use `restore` para voltar)
|
||||
|
||||
### Ver migrations aplicadas
|
||||
|
||||
```bash
|
||||
node db.cjs status
|
||||
```
|
||||
|
||||
## Seeds (dados de teste)
|
||||
|
||||
### Rodar todos os seeds
|
||||
|
||||
```bash
|
||||
node db.cjs seed all # ou simplesmente: node db.cjs seed
|
||||
```
|
||||
|
||||
### Rodar grupo específico
|
||||
|
||||
```bash
|
||||
node db.cjs seed users # Apenas usuários (seed_001 a 003)
|
||||
node db.cjs seed system # Apenas sistema (seed_010 a 014)
|
||||
node db.cjs seed test_data # Dados de teste (seed_020)
|
||||
```
|
||||
|
||||
### Ordem dos seeds
|
||||
|
||||
| # | Arquivo | O que faz |
|
||||
|---|---------|-----------|
|
||||
| 1 | `seed_001_fixed.sql` | 6 usuários base + tenants |
|
||||
| 2 | `seed_002.sql` | Supervisor + Editor |
|
||||
| 3 | `seed_003.sql` | Therapist2, Therapist3, Secretary |
|
||||
| 4 | `seed_010_plans.sql` | 7 planos + 4 preços |
|
||||
| 5 | `seed_011_features.sql` | 26 features |
|
||||
| 6 | `seed_012_plan_features.sql` | 85 vínculos plano↔feature |
|
||||
| 7 | `seed_013_subscriptions.sql` | 9 subscriptions + compromissos |
|
||||
| 8 | `seed_014_global_data.sql` | Templates + carousel |
|
||||
|
||||
## Outros Comandos
|
||||
|
||||
### Ver status
|
||||
|
||||
```bash
|
||||
node db.cjs status
|
||||
```
|
||||
|
||||
Mostra: container, backups, migrations aplicadas/pendentes, counts de todas as tabelas.
|
||||
|
||||
### Comparar mudanças
|
||||
|
||||
```bash
|
||||
node db.cjs diff
|
||||
```
|
||||
|
||||
Compara o schema atual no banco com o último backup. Mostra tabelas adicionadas, removidas ou alteradas.
|
||||
|
||||
### Verificar integridade
|
||||
|
||||
```bash
|
||||
node db.cjs verify
|
||||
```
|
||||
|
||||
Checa se os dados essenciais existem (plans, features, subscriptions, etc).
|
||||
|
||||
### Reset completo
|
||||
|
||||
```bash
|
||||
node db.cjs reset
|
||||
```
|
||||
|
||||
**⚠ CUIDADO**: Apaga tudo e reinstala do zero. Cria backup antes.
|
||||
|
||||
## Estrutura de Pastas
|
||||
|
||||
```
|
||||
database-novo/
|
||||
├── db.js ← CLI principal
|
||||
├── db.config.json ← Configuração (container, seeds, fixes)
|
||||
│
|
||||
├── schema/ ← Schema SQL separado por seção
|
||||
│ ├── 00_full/ ← Schema completo (referência)
|
||||
│ ├── 01_extensions/ ← Extensões PostgreSQL
|
||||
│ ├── 02_types/ ← Enums e tipos
|
||||
│ ├── 03_functions/ ← Funções (11 arquivos por domínio)
|
||||
│ ├── 04_tables/ ← Tabelas (10 arquivos por domínio)
|
||||
│ ├── 05_views/ ← 24 views
|
||||
│ ├── 06_indexes/ ← Índices
|
||||
│ ├── 07_foreign_keys/ ← PKs, FKs, constraints
|
||||
│ ├── 08_triggers/ ← Triggers
|
||||
│ ├── 09_policies/ ← 217 RLS policies
|
||||
│ └── 10_grants/ ← Grants
|
||||
│
|
||||
├── seeds/ ← Seeds de dados
|
||||
│ ├── seed_001_fixed.sql
|
||||
│ ├── ...
|
||||
│ └── run_all_seeds.sh
|
||||
│
|
||||
├── migrations/ ← Migrations (alterações incrementais)
|
||||
│
|
||||
├── fixes/ ← Correções aplicadas
|
||||
│
|
||||
├── backups/ ← Backups com data
|
||||
│ ├── 2026-03-23/
|
||||
│ └── ...
|
||||
│
|
||||
└── docs/ ← Documentação
|
||||
├── setup_guide.md ← Este arquivo
|
||||
├── schema_map.md ← Mapa de 84 tabelas
|
||||
├── business_rules.md ← Regras de negócio
|
||||
└── users_test.md ← Usuários de teste
|
||||
```
|
||||
|
||||
## Credenciais de Teste
|
||||
|
||||
| Email | Senha | Tipo |
|
||||
|-------|-------|------|
|
||||
| paciente@agenciapsi.com.br | Teste@123 | Paciente |
|
||||
| terapeuta@agenciapsi.com.br | Teste@123 | Terapeuta solo |
|
||||
| clinica1@agenciapsi.com.br | Teste@123 | Clínica coworking |
|
||||
| clinica2@agenciapsi.com.br | Teste@123 | Clínica recepção |
|
||||
| clinica3@agenciapsi.com.br | Teste@123 | Clínica full |
|
||||
| saas@agenciapsi.com.br | Teste@123 | Admin plataforma |
|
||||
| supervisor@agenciapsi.com.br | Teste@123 | Supervisor |
|
||||
| editor@agenciapsi.com.br | Teste@123 | Editor |
|
||||
| therapist2@agenciapsi.com.br | Teste@123 | Terapeuta |
|
||||
| therapist3@agenciapsi.com.br | Teste@123 | Terapeuta |
|
||||
| secretary@agenciapsi.com.br | Teste@123 | Secretária |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Container não está rodando"
|
||||
|
||||
```bash
|
||||
# Verificar
|
||||
docker ps | grep supabase
|
||||
|
||||
# Reiniciar
|
||||
npx supabase stop
|
||||
npx supabase start
|
||||
```
|
||||
|
||||
### "Tabela não existe" após setup
|
||||
|
||||
O schema pode não ter sido aplicado corretamente. Rode:
|
||||
|
||||
```bash
|
||||
node db.cjs reset
|
||||
```
|
||||
|
||||
### "Permission denied" / RLS bloqueando
|
||||
|
||||
Se features/plan_features estiverem vazios, o RLS bloqueia tudo. Rode:
|
||||
|
||||
```bash
|
||||
node db.cjs seed system
|
||||
```
|
||||
|
||||
### Migration falhou no meio
|
||||
|
||||
```bash
|
||||
# Voltar ao estado anterior
|
||||
node db.cjs restore
|
||||
|
||||
# Corrigir o SQL da migration, depois tentar de novo
|
||||
node db.cjs migrate
|
||||
```
|
||||
|
||||
### Quero começar do zero
|
||||
|
||||
```bash
|
||||
node db.cjs reset
|
||||
```
|
||||
|
||||
Isso apaga tudo, reaplica schema, fixes, seeds, e verifica.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Usuários de Teste — AgenciaPsi
|
||||
|
||||
Senha de todos: `Teste@123`
|
||||
|
||||
## Mapa de UUIDs
|
||||
|
||||
### Users (auth.users.id = profiles.id)
|
||||
| Email | UUID | Nome |
|
||||
|-------|------|------|
|
||||
| paciente@agenciapsi.com.br | `aaaaaaaa-0001-0001-0001-000000000001` | Ana Paciente |
|
||||
| terapeuta@agenciapsi.com.br | `aaaaaaaa-0002-0002-0002-000000000002` | Bruno Terapeuta |
|
||||
| clinica1@agenciapsi.com.br | `aaaaaaaa-0003-0003-0003-000000000003` | Clínica Espaço Psi |
|
||||
| clinica2@agenciapsi.com.br | `aaaaaaaa-0004-0004-0004-000000000004` | Clínica Mente sã |
|
||||
| clinica3@agenciapsi.com.br | `aaaaaaaa-0005-0005-0005-000000000005` | Clínica Bem Estar |
|
||||
| saas@agenciapsi.com.br | `aaaaaaaa-0006-0006-0006-000000000006` | Admin Plataforma |
|
||||
| supervisor@agenciapsi.com.br | `aaaaaaaa-0007-0007-0007-000000000007` | Carlos Supervisor |
|
||||
| editor@agenciapsi.com.br | `aaaaaaaa-0008-0008-0008-000000000008` | Diana Editora |
|
||||
| therapist2@agenciapsi.com.br | `aaaaaaaa-0009-0009-0009-000000000009` | Eva Terapeuta |
|
||||
| therapist3@agenciapsi.com.br | `aaaaaaaa-0010-0010-0010-000000000010` | Felipe Terapeuta |
|
||||
| secretary@agenciapsi.com.br | `aaaaaaaa-0011-0011-0011-000000000011` | Gabriela Secretária |
|
||||
|
||||
### Tenants
|
||||
| Nome | UUID | Kind |
|
||||
|------|------|------|
|
||||
| Bruno Terapeuta | `bbbbbbbb-0002-0002-0002-000000000002` | therapist |
|
||||
| Clínica Espaço Psi | `bbbbbbbb-0003-0003-0003-000000000003` | clinic_coworking |
|
||||
| Clínica Mente sã | `bbbbbbbb-0004-0004-0004-000000000004` | clinic_reception |
|
||||
| Clínica Bem Estar | `bbbbbbbb-0005-0005-0005-000000000005` | clinic_full |
|
||||
| Eva Terapeuta | `bbbbbbbb-0009-0009-0009-000000000009` | therapist |
|
||||
| Felipe Terapeuta | `bbbbbbbb-0010-0010-0010-000000000010` | therapist |
|
||||
|
||||
## Mapa de Vínculos
|
||||
|
||||
```
|
||||
paciente@ → portal_user / patient_free (user_id)
|
||||
Sem tenant próprio
|
||||
|
||||
terapeuta@ → tenant_member / therapist
|
||||
Tenant: bbbbbbbb-0002 (therapist) → tenant_admin
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
clinica1@ → tenant_member / clinic
|
||||
Tenant: bbbbbbbb-0003 (clinic_coworking) → tenant_admin
|
||||
Subscription: clinic_free (tenant_id)
|
||||
|
||||
clinica2@ → tenant_member / clinic
|
||||
Tenant: bbbbbbbb-0004 (clinic_reception) → tenant_admin
|
||||
Subscription: clinic_free (tenant_id)
|
||||
|
||||
clinica3@ → tenant_member / clinic
|
||||
Tenant: bbbbbbbb-0005 (clinic_full) → tenant_admin
|
||||
Subscription: clinic_free (tenant_id)
|
||||
|
||||
saas@ → saas_admin
|
||||
Sem tenant, sem subscription
|
||||
|
||||
supervisor@ → tenant_member / therapist
|
||||
Clínica 3: bbbbbbbb-0005 → supervisor
|
||||
Subscription: supervisor_free (user_id)
|
||||
|
||||
editor@ → tenant_member / therapist + platform_roles: {editor}
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
therapist2@ → tenant_member / therapist
|
||||
Tenant: bbbbbbbb-0009 (therapist) → tenant_admin
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
therapist3@ → tenant_member / therapist
|
||||
Tenant: bbbbbbbb-0010 (therapist) → tenant_admin
|
||||
Clínica 3: bbbbbbbb-0005 → therapist
|
||||
Subscription: therapist_free (user_id)
|
||||
|
||||
secretary@ → tenant_member / therapist (profile)
|
||||
Clínica 2: bbbbbbbb-0004 → clinic_admin
|
||||
Sem subscription própria (usa plano da Clínica 2)
|
||||
```
|
||||
|
||||
## Clínica 3 — Bem Estar (Full) — Membros
|
||||
|
||||
| Membro | Role |
|
||||
|--------|------|
|
||||
| clinica3@ | tenant_admin |
|
||||
| terapeuta@ | therapist |
|
||||
| supervisor@ | supervisor |
|
||||
| editor@ | therapist |
|
||||
| therapist2@ | therapist |
|
||||
| therapist3@ | therapist |
|
||||
@@ -0,0 +1,11 @@
|
||||
-- ============================================================
|
||||
-- Fix: addon_credits e addon_transactions tenant_id FK
|
||||
-- Corrige FK que apontava para auth.users → agora aponta para public.tenants
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE public.addon_credits DROP CONSTRAINT IF EXISTS addon_credits_tenant_id_fkey;
|
||||
ALTER TABLE public.addon_credits ADD CONSTRAINT addon_credits_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
|
||||
|
||||
ALTER TABLE public.addon_transactions DROP CONSTRAINT IF EXISTS addon_transactions_tenant_id_fkey;
|
||||
ALTER TABLE public.addon_transactions ADD CONSTRAINT addon_transactions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
|
||||
@@ -0,0 +1,83 @@
|
||||
-- ============================================================
|
||||
-- Fix: RLS addon_credits e addon_transactions
|
||||
-- 1. SaaS Admin: acesso total
|
||||
-- 2. Tenant members: SELECT nos seus créditos/transações
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
|
||||
-- ── addon_products: admin pode tudo (CRUD) ────────────────────
|
||||
DROP POLICY IF EXISTS "addon_products_admin_all" ON public.addon_products;
|
||||
CREATE POLICY "addon_products_admin_all"
|
||||
ON public.addon_products FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_credits: admin pode ver todos ───────────────────────
|
||||
DROP POLICY IF EXISTS "addon_credits_admin_select" ON public.addon_credits;
|
||||
CREATE POLICY "addon_credits_admin_select"
|
||||
ON public.addon_credits FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_credits: admin pode inserir/atualizar ───────────────
|
||||
DROP POLICY IF EXISTS "addon_credits_admin_write" ON public.addon_credits;
|
||||
CREATE POLICY "addon_credits_admin_write"
|
||||
ON public.addon_credits FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_transactions: admin pode ver todas ──────────────────
|
||||
DROP POLICY IF EXISTS "addon_transactions_admin_select" ON public.addon_transactions;
|
||||
CREATE POLICY "addon_transactions_admin_select"
|
||||
ON public.addon_transactions FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ── addon_transactions: admin pode inserir ────────────────────
|
||||
DROP POLICY IF EXISTS "addon_transactions_admin_insert" ON public.addon_transactions;
|
||||
CREATE POLICY "addon_transactions_admin_insert"
|
||||
ON public.addon_transactions FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- Corrige policies de tenant members (SELECT)
|
||||
-- A policy original usava tenant_id = auth.uid(), mas o auth.uid()
|
||||
-- é o user_id, não o tenant_id. Usa is_tenant_member() em vez disso.
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
|
||||
-- addon_credits: membro do tenant vê os créditos do seu tenant
|
||||
DROP POLICY IF EXISTS "addon_credits_select_own" ON public.addon_credits;
|
||||
CREATE POLICY "addon_credits_select_own"
|
||||
ON public.addon_credits FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.is_tenant_member(tenant_id)
|
||||
OR owner_id = auth.uid()
|
||||
);
|
||||
|
||||
-- addon_transactions: membro do tenant vê as transações do seu tenant
|
||||
DROP POLICY IF EXISTS "addon_transactions_select_own" ON public.addon_transactions;
|
||||
CREATE POLICY "addon_transactions_select_own"
|
||||
ON public.addon_transactions FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.is_tenant_member(tenant_id)
|
||||
OR owner_id = auth.uid()
|
||||
);
|
||||
@@ -0,0 +1,179 @@
|
||||
-- =============================================================================
|
||||
-- FIX: Corrige acentuação perdida (caracteres ?? no banco)
|
||||
-- =============================================================================
|
||||
-- Causa: Seeds aplicados originalmente sem encoding UTF-8 correto.
|
||||
-- Os ?? são bytes literais 0x3F (ASCII ?) onde deveria haver UTF-8.
|
||||
-- Este fix faz UPDATE direto nos valores conhecidos.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
SET client_encoding TO 'UTF8';
|
||||
|
||||
-- ============================================================
|
||||
-- 1. PROFILES — full_name
|
||||
-- ============================================================
|
||||
UPDATE profiles SET full_name = 'Clínica Espaço Psi' WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003' AND full_name != 'Clínica Espaço Psi';
|
||||
UPDATE profiles SET full_name = 'Clínica Mente Sã' WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004' AND full_name != 'Clínica Mente Sã';
|
||||
UPDATE profiles SET full_name = 'Clínica Bem Estar' WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005' AND full_name != 'Clínica Bem Estar';
|
||||
UPDATE profiles SET full_name = 'Gabriela Secretária' WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011' AND full_name != 'Gabriela Secretária';
|
||||
|
||||
-- ============================================================
|
||||
-- 2. TENANTS — name
|
||||
-- ============================================================
|
||||
UPDATE tenants SET name = 'Clínica Espaço Psi' WHERE id = 'bbbbbbbb-0003-0003-0003-000000000003';
|
||||
UPDATE tenants SET name = 'Clínica Mente Sã' WHERE id = 'bbbbbbbb-0004-0004-0004-000000000004';
|
||||
UPDATE tenants SET name = 'Clínica Bem Estar' WHERE id = 'bbbbbbbb-0005-0005-0005-000000000005';
|
||||
|
||||
-- ============================================================
|
||||
-- 3. DETERMINED_COMMITMENTS — name
|
||||
-- ============================================================
|
||||
UPDATE determined_commitments SET name = 'Sessão' WHERE native_key = 'session';
|
||||
UPDATE determined_commitments SET name = 'Supervisão' WHERE native_key = 'supervision';
|
||||
UPDATE determined_commitments SET name = 'Análise Pessoal' WHERE native_key = 'analysis';
|
||||
|
||||
-- ============================================================
|
||||
-- 4. PLANS — name, description
|
||||
-- ============================================================
|
||||
UPDATE plans SET name = 'THERAPIST PRO', description = 'Plano profissional para terapeutas' WHERE key = 'therapist_pro' AND description LIKE '%??%';
|
||||
UPDATE plans SET name = 'CLINIC PRO', description = 'Plano profissional para clínicas' WHERE key = 'clinic_pro' AND description LIKE '%??%';
|
||||
UPDATE plans SET name = 'THERAPIST FREE', description = 'Plano gratuito para terapeutas' WHERE key = 'therapist_free' AND description LIKE '%??%';
|
||||
UPDATE plans SET name = 'CLINIC FREE', description = 'Plano gratuito para clínicas' WHERE key = 'clinic_free' AND description LIKE '%??%';
|
||||
|
||||
-- ============================================================
|
||||
-- 5. FEATURES — name, description
|
||||
-- ============================================================
|
||||
UPDATE features SET name = 'Agenda - Visualizar', description = 'Visualização da agenda' WHERE key = 'agenda.view';
|
||||
UPDATE features SET name = 'Agenda - Gerenciar', description = 'Gerenciamento completo da agenda' WHERE key = 'agenda.manage';
|
||||
UPDATE features SET name = 'Pacientes', description = 'Módulo de pacientes' WHERE key = 'patients';
|
||||
UPDATE features SET name = 'Pacientes - Visualizar', description = 'Visualização de pacientes' WHERE key = 'patients.view';
|
||||
UPDATE features SET name = 'Pacientes - Gerenciar', description = 'Gerenciamento completo de pacientes' WHERE key = 'patients.manage';
|
||||
UPDATE features SET name = 'Agendamento Online', description = 'Sistema de agendamento online' WHERE key = 'online_scheduling';
|
||||
UPDATE features SET name = 'Agendamento Online - Gerenciar', description = 'Gerenciamento do agendamento online' WHERE key = 'online_scheduling.manage';
|
||||
UPDATE features SET name = 'Agendamento Online - Público', description = 'Página pública do agendador' WHERE key = 'online_scheduling.public';
|
||||
UPDATE features SET name = 'Lembretes', description = 'Sistema de lembretes automáticos' WHERE key = 'reminders';
|
||||
UPDATE features SET name = 'Relatórios Básicos', description = 'Relatórios básicos' WHERE key = 'reports_basic';
|
||||
UPDATE features SET name = 'Relatórios Avançados', description = 'Relatórios avançados com exportação' WHERE key = 'reports_advanced';
|
||||
UPDATE features SET name = 'Secretária', description = 'Funcionalidade de secretária' WHERE key = 'secretary';
|
||||
UPDATE features SET name = 'Recepção Compartilhada', description = 'Recepção compartilhada entre terapeutas' WHERE key = 'shared_reception';
|
||||
UPDATE features SET name = 'Salas', description = 'Gerenciamento de salas' WHERE key = 'rooms';
|
||||
UPDATE features SET name = 'Intake Público', description = 'Formulário de intake público' WHERE key = 'intake_public';
|
||||
UPDATE features SET name = 'Intakes PRO', description = 'Funcionalidades avançadas de intake' WHERE key = 'intakes_pro';
|
||||
UPDATE features SET name = 'Branding Personalizado', description = 'Personalização de marca' WHERE key = 'custom_branding';
|
||||
UPDATE features SET name = 'Acesso API', description = 'Acesso via API' WHERE key = 'api_access';
|
||||
UPDATE features SET name = 'Log de Auditoria', description = 'Log de auditoria completo' WHERE key = 'audit_log';
|
||||
UPDATE features SET name = 'Lembrete SMS', description = 'Lembretes via SMS' WHERE key = 'sms_reminder';
|
||||
UPDATE features SET name = 'Calendário da Clínica', description = 'Visão consolidada do calendário' WHERE key = 'clinic_calendar';
|
||||
UPDATE features SET name = 'Relatórios Avançados (Clínica)', description = 'Relatórios avançados da clínica' WHERE key = 'advanced_reports';
|
||||
UPDATE features SET name = 'Supervisor - Acesso', description = 'Acesso ao módulo de supervisão' WHERE key = 'supervisor.access';
|
||||
UPDATE features SET name = 'Supervisor - Convidar', description = 'Convidar supervisionados' WHERE key = 'supervisor.invite';
|
||||
UPDATE features SET name = 'Supervisor - Sessões', description = 'Gerenciar sessões de supervisão' WHERE key = 'supervisor.sessions';
|
||||
UPDATE features SET name = 'Supervisor - Relatórios', description = 'Relatórios de supervisão' WHERE key = 'supervisor.reports';
|
||||
|
||||
-- ============================================================
|
||||
-- 6. EMAIL_TEMPLATES_GLOBAL — subject, body_html, body_text
|
||||
-- ============================================================
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Lembrete: sua sessão amanhã às {{session_time}}',
|
||||
body_text = 'Olá {{patient_name}}, lembrete da sua sessão amanhã às {{session_time}} com {{therapist_name}}.'
|
||||
WHERE key = 'session.reminder';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Sessão confirmada — {{session_date}} às {{session_time}}',
|
||||
body_text = 'Sua sessão com {{therapist_name}} em {{session_date}} às {{session_time}} foi confirmada.'
|
||||
WHERE key = 'session.confirmation';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Sessão cancelada — {{session_date}}',
|
||||
body_text = 'A sessão de {{session_date}} às {{session_time}} com {{therapist_name}} foi cancelada.'
|
||||
WHERE key = 'session.cancellation';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Sessão reagendada — novo horário: {{session_date}} às {{session_time}}',
|
||||
body_text = 'Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.'
|
||||
WHERE key = 'session.rescheduled';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Recebemos seu cadastro — {{patient_name}}',
|
||||
body_text = 'Olá {{patient_name}}, recebemos seu formulário de cadastro. Entraremos em contato em breve.'
|
||||
WHERE key = 'intake.received';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Cadastro aprovado — Bem-vindo(a)!',
|
||||
body_text = 'Olá {{patient_name}}, seu cadastro foi aprovado. Você já pode acessar a plataforma.'
|
||||
WHERE key = 'intake.approved';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Cadastro não aprovado',
|
||||
body_text = 'Olá {{patient_name}}, infelizmente seu cadastro não foi aprovado no momento.'
|
||||
WHERE key = 'intake.rejected';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Solicitação aceita — {{session_date}} às {{session_time}}',
|
||||
body_text = 'Sua solicitação de agendamento para {{session_date}} às {{session_time}} foi aceita.'
|
||||
WHERE key = 'scheduler.request_accepted';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Solicitação não disponível',
|
||||
body_text = 'Infelizmente o horário solicitado não está disponível. Por favor, escolha outro horário.'
|
||||
WHERE key = 'scheduler.request_rejected';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Bem-vindo(a) à AgenciaPsi!',
|
||||
body_text = 'Olá {{user_name}}, sua conta foi criada com sucesso. Acesse a plataforma para começar.'
|
||||
WHERE key = 'system.welcome';
|
||||
|
||||
UPDATE email_templates_global SET
|
||||
subject = 'Redefinição de senha — AgenciaPsi',
|
||||
body_text = 'Clique no link abaixo para redefinir sua senha: {{reset_link}}'
|
||||
WHERE key = 'system.password_reset';
|
||||
|
||||
-- ============================================================
|
||||
-- 7. LOGIN_CAROUSEL_SLIDES — title, description
|
||||
-- ============================================================
|
||||
UPDATE login_carousel_slides SET
|
||||
title = '<strong>Gestão clínica simplificada</strong>',
|
||||
body = 'Gerencie agenda, pacientes e financeiro em um só lugar. Simples, rápido e seguro.'
|
||||
WHERE ordem = 1;
|
||||
|
||||
UPDATE login_carousel_slides SET
|
||||
title = '<strong>Múltiplos profissionais, uma só plataforma</strong>',
|
||||
body = 'Ideal para clínicas com vários terapeutas. Cada profissional com sua agenda e seus pacientes.'
|
||||
WHERE ordem = 2;
|
||||
|
||||
UPDATE login_carousel_slides SET
|
||||
title = '<strong>Seguro, privado e sempre disponível</strong>',
|
||||
body = 'Seus dados protegidos com criptografia. Acesse de qualquer lugar, a qualquer hora.'
|
||||
WHERE ordem = 3;
|
||||
|
||||
-- ============================================================
|
||||
-- 8. PATIENT_GROUPS (default groups) — name
|
||||
-- ============================================================
|
||||
UPDATE patient_groups SET nome = 'Crianças' WHERE nome LIKE 'Crian%' AND is_system = true;
|
||||
UPDATE patient_groups SET nome = 'Adolescentes' WHERE nome LIKE 'Adolescen%' AND is_system = true;
|
||||
UPDATE patient_groups SET nome = 'Idosos' WHERE nome LIKE 'Idoso%' AND is_system = true;
|
||||
|
||||
-- ============================================================
|
||||
-- 9. AUTH.USERS — raw_user_meta_data (name field)
|
||||
-- ============================================================
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Espaço Psi"') WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003';
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Mente Sã"') WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004';
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Bem Estar"') WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005';
|
||||
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Gabriela Secretária"') WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
broken_count int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO broken_count
|
||||
FROM profiles WHERE full_name LIKE '%??%';
|
||||
|
||||
IF broken_count = 0 THEN
|
||||
RAISE NOTICE 'fix_encoding_accents: Todos os acentos corrigidos com sucesso.';
|
||||
ELSE
|
||||
RAISE WARNING 'fix_encoding_accents: Ainda restam % registros com ?? em profiles.full_name', broken_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,220 @@
|
||||
-- =============================================================================
|
||||
-- FIX: Atribuir plano free a usuários/tenants sem assinatura ativa
|
||||
-- =============================================================================
|
||||
-- Execute no SQL Editor do Supabase (service_role)
|
||||
-- Idempotente: só insere onde não existe assinatura ativa.
|
||||
--
|
||||
-- Regras:
|
||||
-- • tenant kind = 'therapist' → therapist_free (por user_id do admin)
|
||||
-- • tenant kind IN (clinic_*) → clinic_free (por tenant_id)
|
||||
-- • profiles.account_type = 'patient' / portal_user → patient_free (por user_id)
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- DIAGNÓSTICO — mostra o estado atual antes de corrigir
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
RAISE NOTICE '=== DIAGNÓSTICO DE ASSINATURAS ===';
|
||||
RAISE NOTICE '';
|
||||
|
||||
-- Terapeutas sem plano
|
||||
RAISE NOTICE '--- Terapeutas SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT
|
||||
tm.user_id,
|
||||
p.full_name,
|
||||
t.id AS tenant_id,
|
||||
t.name AS tenant_name
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
JOIN public.profiles p ON p.id = tm.user_id
|
||||
WHERE t.kind = 'therapist'
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = tm.user_id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%) — tenant %', r.full_name, r.user_id, r.tenant_id;
|
||||
END LOOP;
|
||||
|
||||
-- Clínicas sem plano
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '--- Clínicas SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT t.id, t.name, t.kind
|
||||
FROM public.tenants t
|
||||
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%) — kind %', r.name, r.id, r.kind;
|
||||
END LOOP;
|
||||
|
||||
-- Pacientes sem plano
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '--- Pacientes SEM assinatura ativa ---';
|
||||
FOR r IN
|
||||
SELECT p.id, p.full_name
|
||||
FROM public.profiles p
|
||||
WHERE p.account_type = 'patient'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = p.id
|
||||
AND s.status = 'active'
|
||||
)
|
||||
LOOP
|
||||
RAISE NOTICE ' FALTANDO: % (%)', r.full_name, r.id;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '=== FIM DO DIAGNÓSTICO — aplicando correções... ===';
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 1: Terapeutas sem assinatura → therapist_free
|
||||
-- Escopo: user_id do tenant_admin do tenant kind='therapist'
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
tm.user_id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
JOIN public.plans p ON p.key = 'therapist_free'
|
||||
WHERE t.kind = 'therapist'
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = tm.user_id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 2: Clínicas sem assinatura → clinic_free
|
||||
-- Escopo: tenant_id
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
tenant_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
t.id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.tenants t
|
||||
JOIN public.plans p ON p.key = 'clinic_free'
|
||||
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CORREÇÃO 3: Pacientes sem assinatura → patient_free
|
||||
-- Escopo: user_id
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.subscriptions (
|
||||
user_id, plan_id, plan_key, status, interval,
|
||||
current_period_start, current_period_end,
|
||||
source, started_at, activated_at
|
||||
)
|
||||
SELECT
|
||||
pr.id,
|
||||
p.id,
|
||||
p.key,
|
||||
'active',
|
||||
'month',
|
||||
now(),
|
||||
now() + interval '30 days',
|
||||
'fix_seed',
|
||||
now(),
|
||||
now()
|
||||
FROM public.profiles pr
|
||||
JOIN public.plans p ON p.key = 'patient_free'
|
||||
WHERE pr.account_type = 'patient'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.subscriptions s
|
||||
WHERE s.user_id = pr.id
|
||||
AND s.status = 'active'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
-- CONFIRMAÇÃO — mostra o que foi inserido (source = 'fix_seed')
|
||||
-- ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
total INT := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE '=== ASSINATURAS CRIADAS NESTA EXECUÇÃO ===';
|
||||
|
||||
FOR r IN
|
||||
SELECT
|
||||
s.plan_key,
|
||||
COALESCE(pr.full_name, t.name) AS nome,
|
||||
COALESCE(s.user_id::text, s.tenant_id::text) AS owner_id
|
||||
FROM public.subscriptions s
|
||||
LEFT JOIN public.profiles pr ON pr.id = s.user_id
|
||||
LEFT JOIN public.tenants t ON t.id = s.tenant_id
|
||||
WHERE s.source = 'fix_seed'
|
||||
AND s.started_at >= now() - interval '5 seconds'
|
||||
ORDER BY s.plan_key, nome
|
||||
LOOP
|
||||
RAISE NOTICE ' ✅ % → % (%)', r.plan_key, r.nome, r.owner_id;
|
||||
total := total + 1;
|
||||
END LOOP;
|
||||
|
||||
IF total = 0 THEN
|
||||
RAISE NOTICE ' (nenhuma nova assinatura criada — todos já tinham plano ativo)';
|
||||
ELSE
|
||||
RAISE NOTICE '';
|
||||
RAISE NOTICE ' Total: % assinatura(s) criada(s).', total;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- ============================================================
|
||||
-- Fix: RLS notification_templates — acesso SaaS Admin
|
||||
-- Admin precisa criar/editar/excluir templates globais (tenant_id IS NULL)
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
|
||||
-- SaaS Admin: acesso total (SELECT + INSERT + UPDATE + DELETE)
|
||||
DROP POLICY IF EXISTS "notif_templates_admin_all" ON public.notification_templates;
|
||||
CREATE POLICY "notif_templates_admin_all"
|
||||
ON public.notification_templates FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- Tenant member: pode ler os globais + os do seu tenant
|
||||
DROP POLICY IF EXISTS "notif_templates_read_global" ON public.notification_templates;
|
||||
CREATE POLICY "notif_templates_read_global"
|
||||
ON public.notification_templates FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
(tenant_id IS NULL AND is_default = true)
|
||||
OR owner_id = auth.uid()
|
||||
OR public.is_tenant_member(tenant_id)
|
||||
)
|
||||
);
|
||||
|
||||
-- Tenant member: pode inserir/atualizar templates do seu tenant
|
||||
DROP POLICY IF EXISTS "notif_templates_write_owner" ON public.notification_templates;
|
||||
CREATE POLICY "notif_templates_write_owner"
|
||||
ON public.notification_templates FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_tenant_member(tenant_id)
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_tenant_member(tenant_id)
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ============================================================
|
||||
-- Fix: cria função seed_default_patient_groups
|
||||
-- Colunas reais: nome, cor, descricao, tenant_id (NOT NULL)
|
||||
-- ============================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
BEGIN
|
||||
-- busca o owner (tenant_admin) do tenant
|
||||
SELECT user_id INTO v_owner_id
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND role = 'tenant_admin'
|
||||
AND status = 'active'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.patient_groups (owner_id, nome, cor, is_system, tenant_id)
|
||||
VALUES
|
||||
(v_owner_id, 'Crianças', '#60a5fa', true, p_tenant_id),
|
||||
(v_owner_id, 'Adolescentes', '#a78bfa', true, p_tenant_id),
|
||||
(v_owner_id, 'Idosos', '#34d399', true, p_tenant_id)
|
||||
ON CONFLICT (owner_id, nome) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.seed_default_patient_groups(uuid)
|
||||
TO postgres, anon, authenticated, service_role;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Fix: subscriptions_validate_scope — adiciona suporte a target='patient'
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target text;
|
||||
BEGIN
|
||||
SELECT lower(p.target) INTO v_target
|
||||
FROM public.plans p
|
||||
WHERE p.id = NEW.plan_id;
|
||||
|
||||
IF v_target IS NULL THEN
|
||||
RAISE EXCEPTION 'Plano inválido (target nulo).';
|
||||
END IF;
|
||||
|
||||
IF v_target = 'clinic' THEN
|
||||
IF NEW.tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'therapist' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura therapist não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura therapist exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'patient' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.subscriptions_validate_scope() OWNER TO supabase_admin;
|
||||
@@ -0,0 +1,78 @@
|
||||
-- ============================================================
|
||||
-- Fix: Template keys devem casar com o que populate_notification_queue gera
|
||||
-- Agência PSI — 2026-03-22
|
||||
-- ============================================================
|
||||
-- O populate gera: 'session.' || REPLACE(event_type, '_sessao', '') || '.' || channel
|
||||
-- Ex: event_type='lembrete_sessao' → 'session.lembrete.whatsapp'
|
||||
--
|
||||
-- Os seeds usavam nomes em inglês (session.reminder.whatsapp).
|
||||
-- Este fix renomeia para casar com o populate.
|
||||
-- ============================================================
|
||||
|
||||
-- ── 1. Renomeia templates existentes ──────────────────────────
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete.whatsapp'
|
||||
WHERE key = 'session.reminder.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete_2h.whatsapp'
|
||||
WHERE key = 'session.reminder_2h.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.confirmacao.whatsapp'
|
||||
WHERE key = 'session.confirmation.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.cancelamento.whatsapp'
|
||||
WHERE key = 'session.cancellation.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.reagendamento.whatsapp'
|
||||
WHERE key = 'session.reschedule.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'cobranca.pendente.whatsapp'
|
||||
WHERE key = 'billing.pending.whatsapp';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'sistema.boas_vindas.whatsapp'
|
||||
WHERE key = 'system.welcome.whatsapp';
|
||||
|
||||
-- ── SMS templates (mesmo padrão) ──────────────────────────────
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete.sms'
|
||||
WHERE key = 'session.reminder.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.lembrete_2h.sms'
|
||||
WHERE key = 'session.reminder_2h.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.confirmacao.sms'
|
||||
WHERE key = 'session.confirmation.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.cancelamento.sms'
|
||||
WHERE key = 'session.cancellation.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'session.reagendamento.sms'
|
||||
WHERE key = 'session.reschedule.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'cobranca.pendente.sms'
|
||||
WHERE key = 'billing.pending.sms';
|
||||
|
||||
UPDATE public.notification_templates
|
||||
SET key = 'sistema.boas_vindas.sms'
|
||||
WHERE key = 'system.welcome.sms';
|
||||
|
||||
|
||||
-- ── 2. Verifica resultado ─────────────────────────────────────
|
||||
|
||||
SELECT key, channel, domain, event_type, is_default
|
||||
FROM notification_templates
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY channel, key;
|
||||
@@ -0,0 +1,516 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// AgenciaPsi — Dashboard Generator
|
||||
// =============================================================================
|
||||
// Uso:
|
||||
// node generate-dashboard.cjs → usa backup mais recente
|
||||
// node generate-dashboard.cjs 2026-04-17 → usa backup de data específica
|
||||
//
|
||||
// Lê de: ./backups/YYYY-MM-DD/schema.sql
|
||||
// Lê de: ./db.config.json (domínios, cores e infraestrutura)
|
||||
// Gera: ./agenciapsi-db-dashboard.html (na mesma pasta do script)
|
||||
// =============================================================================
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = __dirname;
|
||||
const BACKUPS_DIR = path.join(ROOT, 'backups');
|
||||
const OUTPUT_FILE = path.join(ROOT, 'agenciapsi-db-dashboard.html');
|
||||
const CONFIG_FILE = path.join(ROOT, 'db.config.json');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carrega config (domínios, cores e infraestrutura)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
console.error(`✖ Config não encontrada: ${CONFIG_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const CONFIG = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
const DOMAIN_TABLES = CONFIG.domains || {};
|
||||
const DOMAIN_COLORS = CONFIG.domainColors || {};
|
||||
const INFRASTRUCTURE = CONFIG.infrastructure || {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Resolve qual schema.sql usar
|
||||
// ---------------------------------------------------------------------------
|
||||
function resolveSchema() {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (!fs.existsSync(BACKUPS_DIR)) {
|
||||
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
|
||||
console.error(` Rode primeiro: node db.cjs backup`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const available = fs
|
||||
.readdirSync(BACKUPS_DIR)
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (available.length === 0) {
|
||||
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
|
||||
console.error(' Rode primeiro: node db.cjs backup');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const date = arg && /^\d{4}-\d{2}-\d{2}$/.test(arg) ? arg : available[0];
|
||||
|
||||
if (!available.includes(date)) {
|
||||
console.error(`✖ Backup não encontrado para: ${date}`);
|
||||
console.error(` Disponíveis: ${available.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
console.error(`✖ schema.sql não encontrado em backups/${date}/`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { schemaPath, date, available };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseSchema(content) {
|
||||
const tables = {};
|
||||
|
||||
// Tabelas public.*
|
||||
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
|
||||
let m;
|
||||
while ((m = tableRe.exec(content)) !== null) {
|
||||
const name = m[1].replace('public.', '');
|
||||
const body = m[2];
|
||||
const columns = [];
|
||||
|
||||
for (let line of body.split('\n')) {
|
||||
line = line.trim().replace(/,$/, '');
|
||||
if (!line || line.startsWith('--')) continue;
|
||||
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
|
||||
|
||||
const col = line.match(
|
||||
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
|
||||
);
|
||||
if (col) {
|
||||
columns.push({
|
||||
name: col[1],
|
||||
type: col[2].trim().split('(')[0].trim(),
|
||||
pk: col[1] === 'id'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tables[name] = { columns, fks: [] };
|
||||
}
|
||||
|
||||
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
|
||||
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
|
||||
while ((m = fkRe.exec(content)) !== null) {
|
||||
const [, fromTable, fromCol, toTable, toCol] = m;
|
||||
if (tables[fromTable]) {
|
||||
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
|
||||
}
|
||||
}
|
||||
|
||||
// Views
|
||||
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
|
||||
const views = [];
|
||||
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
|
||||
|
||||
return { tables, views };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Monta os domínios
|
||||
// Tabelas novas que ainda não estão mapeadas vão para "Outros"
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildDomains(tables) {
|
||||
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
|
||||
const others = Object.keys(tables).filter((t) => !mapped.has(t) && t !== '_db_migrations');
|
||||
|
||||
const domains = {};
|
||||
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
|
||||
const present = list.filter((t) => tables[t]);
|
||||
if (present.length > 0) domains[domain] = present;
|
||||
}
|
||||
if (others.length > 0) {
|
||||
domains['Outros'] = others;
|
||||
DOMAIN_COLORS['Outros'] = '#6b7280';
|
||||
}
|
||||
|
||||
return domains;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Gera o HTML final (standalone, sem dependências externas de JS)
|
||||
// ---------------------------------------------------------------------------
|
||||
function generateHTML(tables, views, domains, date, available) {
|
||||
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
|
||||
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
|
||||
const infraGroups = Object.keys(INFRASTRUCTURE).length;
|
||||
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
|
||||
const generated = new Date().toLocaleString('pt-BR');
|
||||
|
||||
// Slug por domínio — usado como id para scroll (ex: "SaaS / Planos" → "saas-planos")
|
||||
const slugify = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const domainSlugs = {};
|
||||
for (const d of Object.keys(domains)) domainSlugs[d] = slugify(d);
|
||||
|
||||
// Serializa dados para embutir no HTML
|
||||
const jsonData = JSON.stringify({ tables, views, domains, slugs: domainSlugs });
|
||||
const jsonColors = JSON.stringify(DOMAIN_COLORS);
|
||||
const jsonInfra = JSON.stringify(INFRASTRUCTURE);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AgenciaPsi DB · ${date}</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#6366f1;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6;--ok:#34d399;--warn:#fbbf24;--pend:#f87171;--leg:#94a3b8}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;min-height:100vh;overflow-x:hidden}
|
||||
|
||||
.topbar{position:sticky;top:0;z-index:100;background:rgba(11,13,18,.94);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 28px;height:56px;display:flex;align-items:center;gap:20px}
|
||||
.brand{font-weight:700;font-size:15px;letter-spacing:-.3px}.brand span{color:var(--accent)}
|
||||
.gen{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
|
||||
.pills{display:flex;gap:10px;margin-left:auto}
|
||||
.pill{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 12px}
|
||||
.pill strong{color:var(--text);font-size:13px}
|
||||
.search{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;color:var(--text);font-family:'Space Grotesk',sans-serif;font-size:13px;outline:none;width:200px;transition:border-color .2s,width .2s}
|
||||
.search:focus{border-color:var(--accent);width:280px}
|
||||
.search::placeholder{color:var(--text3)}
|
||||
|
||||
.layout{display:flex;height:calc(100vh - 56px)}
|
||||
|
||||
.sidebar{width:260px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
|
||||
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
|
||||
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
|
||||
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
|
||||
.sb-i:hover{color:var(--text);background:var(--bg3)}
|
||||
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(99,102,241,.08)}
|
||||
.sb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
.sb-c{margin-left:auto;font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
|
||||
|
||||
.main{flex:1;overflow-y:auto}
|
||||
.main::-webkit-scrollbar{width:5px}.main::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
|
||||
|
||||
.overview{padding:32px 36px;border-bottom:1px solid var(--border)}
|
||||
.ov-t{font-size:22px;font-weight:700;margin-bottom:6px}
|
||||
.ov-s{font-size:14px;color:var(--text2);margin-bottom:28px}
|
||||
.dgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
|
||||
.dc{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:16px 18px;cursor:pointer;transition:all .2s;position:relative;overflow:hidden}
|
||||
.dc::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--c)}
|
||||
.dc:hover{border-color:var(--border2);transform:translateY(-1px)}
|
||||
.dc-n{font-size:14px;font-weight:600;margin-bottom:6px}
|
||||
.dc-m{font-size:12px;color:var(--text2);font-family:'IBM Plex Mono',monospace}
|
||||
.dc-m span{font-weight:600}
|
||||
|
||||
.section{padding:28px 36px}
|
||||
.sec-h{display:flex;align-items:center;gap:14px;margin-bottom:20px}
|
||||
.sec-t{font-size:18px;font-weight:700}
|
||||
.sec-b{font-size:11px;font-family:'IBM Plex Mono',monospace;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:3px 10px;color:var(--text2)}
|
||||
|
||||
.tgrid{display:flex;flex-direction:column;gap:10px}
|
||||
.tc{background:var(--bg3);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .15s}
|
||||
.tc:hover{border-color:var(--border2)}.tc.hl{border-color:var(--accent)}
|
||||
.tc-h{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;user-select:none}
|
||||
.tc-n{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600}
|
||||
.tc-m{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
|
||||
.tc-f{font-size:11px;color:var(--fk);font-family:'IBM Plex Mono',monospace;margin-left:4px}
|
||||
.tc-tg{margin-left:auto;color:var(--text3);font-size:11px;transition:transform .2s}
|
||||
.tc-tg.open{transform:rotate(180deg)}
|
||||
.tc-b{display:none;border-top:1px solid var(--border)}.tc-b.open{display:block}
|
||||
.cols{padding:6px 0}
|
||||
.cr{display:flex;align-items:center;gap:10px;padding:5px 16px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2)}
|
||||
.cr:hover{background:rgba(255,255,255,.02)}
|
||||
.bdg{font-size:9px;font-weight:700;letter-spacing:.5px;padding:1px 5px;border-radius:3px;width:26px;text-align:center;flex-shrink:0}
|
||||
.bdg.pk{background:rgba(251,191,36,.15);color:var(--pk)}.bdg.fk{background:rgba(244,114,182,.15);color:var(--fk)}.bdg.x{background:transparent}
|
||||
.cn{color:var(--text)}.ct{color:var(--text3);margin-left:auto;font-size:11px}
|
||||
.fksec{border-top:1px solid var(--border);padding:10px 16px}
|
||||
.fkt{font-size:10px;font-weight:600;letter-spacing:1px;color:var(--text3);text-transform:uppercase;margin-bottom:8px}
|
||||
.fkr{display:flex;align-items:center;gap:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2);padding:3px 0}
|
||||
.fka{color:var(--fk)}.fkl{color:var(--accent);cursor:pointer}.fkl:hover{text-decoration:underline}
|
||||
|
||||
.vsec{padding:0 36px 32px}
|
||||
.vgrid{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
||||
.vc{background:rgba(110,231,183,.08);border:1px solid rgba(110,231,183,.2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--accent2)}
|
||||
.empty{padding:40px;text-align:center;color:var(--text3);font-size:14px}
|
||||
mark{background:rgba(99,102,241,.3);color:#fff;border-radius:2px}
|
||||
|
||||
/* Infraestrutura */
|
||||
.igroup{margin-bottom:28px}
|
||||
.igroup-h{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
||||
.igroup-t{font-size:15px;font-weight:600;letter-spacing:-.2px}
|
||||
.igroup-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
||||
.igrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
|
||||
.ic{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:16px 18px;transition:border-color .15s;position:relative;overflow:hidden}
|
||||
.ic::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;background:var(--c)}
|
||||
.ic:hover{border-color:var(--border2)}
|
||||
.ic-h{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||
.ic-n{font-size:14px;font-weight:600;flex:1;min-width:0}
|
||||
.ic-st{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;padding:2px 7px;border-radius:10px;flex-shrink:0;white-space:nowrap}
|
||||
.ic-st.ativo{background:rgba(52,211,153,.15);color:var(--ok)}
|
||||
.ic-st.pendente{background:rgba(248,113,113,.15);color:var(--pend)}
|
||||
.ic-st.planejado{background:rgba(251,191,36,.15);color:var(--warn)}
|
||||
.ic-st.legado{background:rgba(148,163,184,.2);color:var(--leg)}
|
||||
.ic-r{font-size:12px;color:var(--text2);margin-bottom:8px;line-height:1.5}
|
||||
.ic-e{font-size:10px;color:var(--text3);font-family:'IBM Plex Mono',monospace;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.ic-nt{font-size:11px;color:var(--text3);line-height:1.55;border-top:1px solid var(--border);padding-top:8px;margin-top:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="brand">Agência<span>Psi</span> DB</div>
|
||||
<span class="gen">${date} · ${generated}</span>
|
||||
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
|
||||
<div class="pills">
|
||||
<div class="pill"><strong>${Object.keys(tables).length}</strong> tabelas</div>
|
||||
<div class="pill"><strong>${totalFKs}</strong> FKs</div>
|
||||
<div class="pill"><strong>${views.length}</strong> views</div>
|
||||
<div class="pill"><strong>${totalCols}</strong> colunas</div>
|
||||
<div class="pill"><strong>${infraItems}</strong> infra</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<nav class="sidebar" id="sb"></nav>
|
||||
<main class="main" id="mn"></main>
|
||||
</div>
|
||||
<script>
|
||||
const D=${jsonData};
|
||||
const C=${jsonColors};
|
||||
const INFRA=${jsonInfra};
|
||||
const INFRA_GROUPS=${infraGroups};
|
||||
const INFRA_ITEMS=${infraItems};
|
||||
const T2D={};
|
||||
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
|
||||
let dom=null,view='overview',q='';
|
||||
function gc(d){return C[d]||'#6b7280';}
|
||||
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
||||
|
||||
function buildSB(){
|
||||
let h=\`<div class="sb-h">Visão Geral</div>
|
||||
<div class="sb-i \${view==='overview'&&!dom?'active':''}" onclick="selOverview()">
|
||||
<div class="sb-dot" style="background:#6366f1"></div>Todos (tabelas)
|
||||
<span class="sb-c">\${Object.keys(D.tables).length}</span>
|
||||
</div>
|
||||
<div class="sb-i \${view==='infra'?'active':''}" onclick="selInfra()">
|
||||
<div class="sb-dot" style="background:#fbbf24"></div>Infraestrutura
|
||||
<span class="sb-c">\${INFRA_ITEMS}</span>
|
||||
</div>
|
||||
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
|
||||
for(const[d,ts]of Object.entries(D.domains)){
|
||||
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain(\`+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;
|
||||
}
|
||||
|
||||
function buildMN(){
|
||||
const mn=document.getElementById('mn');
|
||||
let h='';
|
||||
if(q){
|
||||
const matches=Object.entries(D.tables).filter(([n,t])=>n.includes(q)||t.columns.some(c=>c.name.includes(q)));
|
||||
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${escapeHtml(q)}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
|
||||
h+=matches.length?matches.map(([n,t])=>card(n,t,q)).join(''):'<div class="empty">Nenhum resultado.</div>';
|
||||
h+='</div></div>';
|
||||
} else if(view==='infra'){
|
||||
h+=\`<div class="overview"><div class="ov-t">Infraestrutura</div>
|
||||
<div class="ov-s">Serviços, bibliotecas e ferramentas que o sistema usa · \${INFRA_GROUPS} grupos · \${INFRA_ITEMS} itens</div></div>
|
||||
<div class="section">\`;
|
||||
for(const[grupo,info]of Object.entries(INFRA)){
|
||||
const color=info.color||'#6b7280';
|
||||
h+=\`<div class="igroup">
|
||||
<div class="igroup-h">
|
||||
<div class="igroup-c" style="background:\${color}"></div>
|
||||
<div class="igroup-t" style="color:\${color}">\${escapeHtml(grupo)}</div>
|
||||
<div class="sec-b">\${info.items.length} itens</div>
|
||||
</div>
|
||||
<div class="igrid">\${info.items.map(item=>infraCard(item,color)).join('')}</div>
|
||||
</div>\`;
|
||||
}
|
||||
h+='</div>';
|
||||
} else {
|
||||
const ds=dom?{[dom]:D.domains[dom]}:D.domains;
|
||||
if(!dom){
|
||||
h+=\`<div class="overview"><div class="ov-t">AgenciaPsi — Banco de Dados</div>
|
||||
<div class="ov-s">Schema público · \${Object.keys(D.tables).length} tabelas · \${Object.values(D.tables).reduce((a,t)=>a+t.fks.length,0)} FKs · \${D.views.length} views</div>
|
||||
<div class="dgrid">\`;
|
||||
for(const[d,ts]of Object.entries(D.domains)){
|
||||
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
|
||||
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain(\`+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" id="dom-\${D.slugs[d]||''}"><div class="sec-h">
|
||||
<div class="sec-t" style="color:\${gc(d)}">\${escapeHtml(d)}</div>
|
||||
<div class="sec-b">\${ts.length} tabelas</div>
|
||||
</div><div class="tgrid">\`;
|
||||
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
|
||||
h+='</div></div>';
|
||||
}
|
||||
if(!dom){
|
||||
h+=\`<div class="vsec" id="dom-views"><div class="sec-h">
|
||||
<div class="sec-t" style="color:#6ee7b7">Views</div>
|
||||
<div class="sec-b">\${D.views.length}</div>
|
||||
</div><div class="vgrid">\${D.views.map(v=>\`<div class="vc">\${v}</div>\`).join('')}</div></div>\`;
|
||||
}
|
||||
}
|
||||
mn.innerHTML=h;
|
||||
}
|
||||
|
||||
function infraCard(item,color){
|
||||
const status=(item.status||'ativo').toLowerCase();
|
||||
return \`<div class="ic" style="--c:\${color}">
|
||||
<div class="ic-h">
|
||||
<div class="ic-n">\${escapeHtml(item.name)}</div>
|
||||
<div class="ic-st \${status}">\${escapeHtml(item.status||'ativo')}</div>
|
||||
</div>
|
||||
<div class="ic-r">\${escapeHtml(item.role||'')}</div>
|
||||
\${item.env?\`<div class="ic-e">\${escapeHtml(item.env)}</div>\`:''}
|
||||
\${item.notes?\`<div class="ic-nt">\${escapeHtml(item.notes)}</div>\`:''}
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
function card(name,t,hl){
|
||||
const fkCols=new Set(t.fks.map(f=>f.from_col));
|
||||
const c=gc(T2D[name]);
|
||||
const cols=t.columns.map(col=>{
|
||||
let n=col.name;
|
||||
if(hl&&n.includes(hl))n=n.replace(new RegExp(\`(\${hl})\`,'gi'),'<mark>$1</mark>');
|
||||
const b=col.pk?'pk':fkCols.has(col.name)?'fk':'x';
|
||||
const l=col.pk?'PK':fkCols.has(col.name)?'FK':'';
|
||||
return \`<div class="cr"><span class="bdg \${b}">\${l}</span><span class="cn">\${n}</span><span class="ct">\${col.type}</span></div>\`;
|
||||
}).join('');
|
||||
const fks=t.fks.length?\`<div class="fksec"><div class="fkt">Foreign Keys</div>\${
|
||||
t.fks.map(f=>\`<div class="fkr"><span>\${f.from_col}</span><span class="fka">→</span><span class="fkl" onclick="jump('\${f.to_table}')">\${f.to_table}.\${f.to_col}</span></div>\`).join('')
|
||||
}</div>\`:'';
|
||||
return \`<div class="tc \${hl&&name.includes(hl)?'hl':''}" id="tc-\${name}">
|
||||
<div class="tc-h" onclick="tog('\${name}')">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:\${c};flex-shrink:0"></div>
|
||||
<div class="tc-n">\${name}</div>
|
||||
<span class="tc-m">\${t.columns.length} cols</span>
|
||||
\${t.fks.length?\`<span class="tc-f">\${t.fks.length} FK</span>\`:''}
|
||||
<span class="tc-tg" id="tg-\${name}">▼</span>
|
||||
</div>
|
||||
<div class="tc-b" id="bd-\${name}"><div class="cols">\${cols}</div>\${fks}</div>
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
function tog(n){
|
||||
document.getElementById('bd-'+n)?.classList.toggle('open');
|
||||
document.getElementById('tg-'+n)?.classList.toggle('open');
|
||||
}
|
||||
function sel(d){
|
||||
dom=d;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function scrollToDomain(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;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('tc-'+name);
|
||||
if(!el)return;
|
||||
el.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
const bd=document.getElementById('bd-'+name);
|
||||
const tg=document.getElementById('tg-'+name);
|
||||
if(bd&&!bd.classList.contains('open')){bd.classList.add('open');tg?.classList.add('open');}
|
||||
el.style.borderColor='#6366f1';
|
||||
setTimeout(()=>el.style.borderColor='',2000);
|
||||
},80);
|
||||
}
|
||||
let st;
|
||||
function search(v){
|
||||
clearTimeout(st);q=v.trim();
|
||||
st=setTimeout(()=>{dom=null;view='overview';buildSB();buildMN();},200);
|
||||
}
|
||||
buildSB();buildMN();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Execução
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('\n═══ AgenciaPsi — Dashboard Generator ═══\n');
|
||||
|
||||
const { schemaPath, date, available } = resolveSchema();
|
||||
console.log(` → Schema: ${schemaPath}`);
|
||||
if (available.length > 1) console.log(` → Outros backups: ${available.slice(1).join(', ')}`);
|
||||
|
||||
const content = fs.readFileSync(schemaPath, 'utf8');
|
||||
console.log(` → Lendo schema... (${(content.length / 1024).toFixed(0)} KB)`);
|
||||
|
||||
const { tables, views } = parseSchema(content);
|
||||
const domains = buildDomains(tables);
|
||||
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
|
||||
|
||||
console.log(` → ${Object.keys(tables).length} tabelas · ${totalFKs} FKs · ${views.length} views`);
|
||||
|
||||
// Avisa sobre tabelas novas não mapeadas
|
||||
if (domains['Outros']) {
|
||||
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
|
||||
domains['Outros'].forEach((t) => console.log(` - ${t}`));
|
||||
console.log(` → Edite "domains" em db.config.json para mapeá-las.\n`);
|
||||
}
|
||||
|
||||
// Infra stats
|
||||
const infraGroups = Object.keys(INFRASTRUCTURE).length;
|
||||
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
|
||||
console.log(` → Infraestrutura: ${infraGroups} grupos, ${infraItems} itens`);
|
||||
|
||||
const html = generateHTML(tables, views, domains, date, available);
|
||||
fs.writeFileSync(OUTPUT_FILE, html, 'utf8');
|
||||
|
||||
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
|
||||
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
|
||||
console.log(` Abra no browser: file://${OUTPUT_FILE.replace(/\\/g, '/')}\n`);
|
||||
@@ -0,0 +1,132 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Migration 001: Twilio WhatsApp Subaccounts
|
||||
-- =============================================================================
|
||||
-- Adiciona suporte a subcontas Twilio com número WhatsApp dedicado por tenant.
|
||||
-- Cada clínica/terapeuta recebe sua própria subconta Twilio com número exclusivo.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── 1. Campos de subconta Twilio em notification_channels ──────────────────
|
||||
|
||||
ALTER TABLE public.notification_channels
|
||||
ADD COLUMN IF NOT EXISTS twilio_subaccount_sid text,
|
||||
ADD COLUMN IF NOT EXISTS twilio_phone_number text,
|
||||
ADD COLUMN IF NOT EXISTS twilio_phone_sid text,
|
||||
ADD COLUMN IF NOT EXISTS webhook_url text,
|
||||
ADD COLUMN IF NOT EXISTS cost_per_message_usd numeric(8,6) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS price_per_message_brl numeric(8,4) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS provisioned_at timestamp with time zone;
|
||||
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_subaccount_sid IS 'SID da subconta Twilio criada para este tenant';
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_phone_number IS 'Número WhatsApp provisionado (E.164, ex: +5511999990000)';
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_phone_sid IS 'SID do número de telefone na subconta Twilio';
|
||||
COMMENT ON COLUMN public.notification_channels.webhook_url IS 'URL do webhook configurada na Twilio para receber callbacks de status';
|
||||
COMMENT ON COLUMN public.notification_channels.cost_per_message_usd IS 'Custo real Twilio por mensagem WhatsApp (USD)';
|
||||
COMMENT ON COLUMN public.notification_channels.price_per_message_brl IS 'Valor cobrado do tenant por mensagem (BRL, inclui margem SaaS)';
|
||||
COMMENT ON COLUMN public.notification_channels.provisioned_at IS 'Timestamp do provisionamento da subconta';
|
||||
|
||||
-- Índice para busca rápida por subconta
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channels_twilio_subaccount_sid
|
||||
ON public.notification_channels (twilio_subaccount_sid)
|
||||
WHERE twilio_subaccount_sid IS NOT NULL;
|
||||
|
||||
-- ── 2. Tabela de consumo por subconta ─────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.twilio_subaccount_usage (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
channel_id uuid NOT NULL,
|
||||
twilio_subaccount_sid text NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
period_end date NOT NULL,
|
||||
messages_sent integer DEFAULT 0 NOT NULL,
|
||||
messages_delivered integer DEFAULT 0 NOT NULL,
|
||||
messages_failed integer DEFAULT 0 NOT NULL,
|
||||
cost_usd numeric(12,6) DEFAULT 0 NOT NULL,
|
||||
cost_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
revenue_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
margin_brl numeric(12,4) GENERATED ALWAYS AS (revenue_brl - cost_brl) STORED,
|
||||
usd_brl_rate numeric(8,4) DEFAULT 0,
|
||||
synced_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT twilio_subaccount_usage_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT twilio_subaccount_usage_channel_fk
|
||||
FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE,
|
||||
CONSTRAINT twilio_subaccount_usage_period_check
|
||||
CHECK (period_end >= period_start)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.twilio_subaccount_usage IS
|
||||
'Consumo mensal de mensagens WhatsApp por subconta Twilio. Sincronizado via Edge Function.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_twilio_usage_tenant_period
|
||||
ON public.twilio_subaccount_usage (tenant_id, period_start DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_twilio_usage_channel
|
||||
ON public.twilio_subaccount_usage (channel_id, period_start DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_twilio_usage_unique_period
|
||||
ON public.twilio_subaccount_usage (channel_id, period_start, period_end);
|
||||
|
||||
ALTER TABLE public.twilio_subaccount_usage OWNER TO supabase_admin;
|
||||
|
||||
-- ── 3. RLS: twilio_subaccount_usage ───────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Tenant vê apenas seu próprio consumo
|
||||
CREATE POLICY "tenant_select_own_usage"
|
||||
ON public.twilio_subaccount_usage
|
||||
FOR SELECT
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT tenant_id FROM public.tenant_members
|
||||
WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Apenas service_role pode inserir/atualizar (via Edge Function)
|
||||
CREATE POLICY "service_role_manage_usage"
|
||||
ON public.twilio_subaccount_usage
|
||||
FOR ALL
|
||||
USING (auth.role() = 'service_role');
|
||||
|
||||
-- ── 4. RLS: notification_channels — acesso ao twilio_subaccount_sid ───────
|
||||
-- As políticas existentes já cobrem SELECT/UPDATE. Nenhuma alteração necessária.
|
||||
|
||||
-- ── 5. View: resumo de subcontas para o painel SaaS admin ─────────────────
|
||||
|
||||
CREATE OR REPLACE VIEW public.v_twilio_whatsapp_overview AS
|
||||
SELECT
|
||||
nc.id AS channel_id,
|
||||
nc.tenant_id,
|
||||
nc.owner_id,
|
||||
nc.is_active,
|
||||
nc.connection_status,
|
||||
nc.display_name,
|
||||
nc.twilio_subaccount_sid,
|
||||
nc.twilio_phone_number,
|
||||
nc.twilio_phone_sid,
|
||||
nc.cost_per_message_usd,
|
||||
nc.price_per_message_brl,
|
||||
nc.provisioned_at,
|
||||
nc.created_at,
|
||||
nc.updated_at,
|
||||
-- Uso do mês atual
|
||||
COALESCE(u.messages_sent, 0) AS current_month_sent,
|
||||
COALESCE(u.messages_delivered, 0) AS current_month_delivered,
|
||||
COALESCE(u.messages_failed, 0) AS current_month_failed,
|
||||
COALESCE(u.cost_usd, 0) AS current_month_cost_usd,
|
||||
COALESCE(u.cost_brl, 0) AS current_month_cost_brl,
|
||||
COALESCE(u.revenue_brl, 0) AS current_month_revenue_brl,
|
||||
COALESCE(u.margin_brl, 0) AS current_month_margin_brl
|
||||
FROM public.notification_channels nc
|
||||
LEFT JOIN public.twilio_subaccount_usage u
|
||||
ON u.channel_id = nc.id
|
||||
AND u.period_start = date_trunc('month', CURRENT_DATE)::date
|
||||
WHERE nc.channel = 'whatsapp'
|
||||
AND nc.provider = 'twilio'
|
||||
AND nc.deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW public.v_twilio_whatsapp_overview IS
|
||||
'Visão consolidada de subcontas Twilio WhatsApp com uso do mês corrente.';
|
||||
@@ -0,0 +1,57 @@
|
||||
-- ============================================================
|
||||
-- Migration 002 — SetupWizard1: campos Negócio e Atendimento
|
||||
-- ============================================================
|
||||
-- Tabela: tenants (Step 2 — Negócio)
|
||||
-- Tabela: agenda_configuracoes (Step 3 — Atendimento)
|
||||
-- ============================================================
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- tenants: dados do negócio
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.tenants
|
||||
ADD COLUMN IF NOT EXISTS business_type text,
|
||||
ADD COLUMN IF NOT EXISTS logo_url text,
|
||||
ADD COLUMN IF NOT EXISTS address text,
|
||||
ADD COLUMN IF NOT EXISTS phone text,
|
||||
ADD COLUMN IF NOT EXISTS contact_email text,
|
||||
ADD COLUMN IF NOT EXISTS site_url text,
|
||||
ADD COLUMN IF NOT EXISTS social_instagram text;
|
||||
|
||||
-- Valores aceitos: consultorio | clinica | instituto | grupo
|
||||
ALTER TABLE public.tenants
|
||||
ADD CONSTRAINT tenants_business_type_check
|
||||
CHECK (business_type IS NULL OR business_type = ANY (ARRAY[
|
||||
'consultorio'::text,
|
||||
'clinica'::text,
|
||||
'instituto'::text,
|
||||
'grupo'::text
|
||||
]));
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- agenda_configuracoes: modo de atendimento
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes
|
||||
ADD COLUMN IF NOT EXISTS atendimento_mode text DEFAULT 'particular'::text;
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes
|
||||
ADD CONSTRAINT agenda_configuracoes_atendimento_mode_check
|
||||
CHECK (atendimento_mode IS NULL OR atendimento_mode = ANY (ARRAY[
|
||||
'particular'::text,
|
||||
'convenio'::text,
|
||||
'ambos'::text
|
||||
]));
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- Comments
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
COMMENT ON COLUMN public.tenants.business_type IS 'Tipo de negócio: consultorio, clinica, instituto, grupo';
|
||||
COMMENT ON COLUMN public.tenants.logo_url IS 'URL da logo do negócio (Storage bucket)';
|
||||
COMMENT ON COLUMN public.tenants.address IS 'Endereço do negócio (texto livre)';
|
||||
COMMENT ON COLUMN public.tenants.phone IS 'Telefone/WhatsApp do negócio';
|
||||
COMMENT ON COLUMN public.tenants.contact_email IS 'E-mail público de contato do negócio';
|
||||
COMMENT ON COLUMN public.tenants.site_url IS 'Site do negócio';
|
||||
COMMENT ON COLUMN public.tenants.social_instagram IS 'Instagram do negócio (sem @)';
|
||||
COMMENT ON COLUMN public.agenda_configuracoes.atendimento_mode IS 'Modo de atendimento: particular | convenio | ambos';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- ============================================================
|
||||
-- Migration 003 — Tenants: campos de endereço detalhado
|
||||
-- ============================================================
|
||||
-- Substitui o campo address (texto livre) por campos estruturados
|
||||
-- preenchidos via consulta de CEP (ViaCEP)
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE public.tenants
|
||||
ADD COLUMN IF NOT EXISTS cep text,
|
||||
ADD COLUMN IF NOT EXISTS logradouro text,
|
||||
ADD COLUMN IF NOT EXISTS numero text,
|
||||
ADD COLUMN IF NOT EXISTS complemento text,
|
||||
ADD COLUMN IF NOT EXISTS bairro text,
|
||||
ADD COLUMN IF NOT EXISTS cidade text,
|
||||
ADD COLUMN IF NOT EXISTS estado text;
|
||||
|
||||
-- Migra dados existentes do campo address para logradouro
|
||||
UPDATE public.tenants
|
||||
SET logradouro = address
|
||||
WHERE address IS NOT NULL
|
||||
AND logradouro IS NULL;
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- Comments
|
||||
-- ----------------------------------------------------------
|
||||
|
||||
COMMENT ON COLUMN public.tenants.cep IS 'CEP do endereço do negócio';
|
||||
COMMENT ON COLUMN public.tenants.logradouro IS 'Logradouro (rua, avenida, etc.)';
|
||||
COMMENT ON COLUMN public.tenants.numero IS 'Número do endereço';
|
||||
COMMENT ON COLUMN public.tenants.complemento IS 'Complemento (sala, andar, etc.)';
|
||||
COMMENT ON COLUMN public.tenants.bairro IS 'Bairro';
|
||||
COMMENT ON COLUMN public.tenants.cidade IS 'Cidade';
|
||||
COMMENT ON COLUMN public.tenants.estado IS 'UF (2 letras)';
|
||||
@@ -0,0 +1,147 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: tabela `medicos`
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026 · São Carlos/SP — Brasil
|
||||
--
|
||||
-- Propósito:
|
||||
-- Armazena médicos e profissionais de referência (psiquiatras, neurologistas,
|
||||
-- clínicos gerais, etc.) que encaminham pacientes ou fazem parte da rede de
|
||||
-- suporte clínico do terapeuta.
|
||||
--
|
||||
-- Usado em:
|
||||
-- - PatientsCadastroPage: campo "Encaminhado por" (FK medico_id)
|
||||
-- - CadastroRapidoMedico.vue: cadastro rápido dentro do formulário
|
||||
-- - MedicosCadastroPage.vue: página completa de gestão de médicos
|
||||
--
|
||||
-- Relacionamentos:
|
||||
-- medicos.owner_id → auth.users(id)
|
||||
-- medicos.tenant_id → tenants(id)
|
||||
-- patients.medico_encaminhador_id → medicos(id) (opcional, ver abaixo)
|
||||
--
|
||||
-- RLS: owner_id = auth.uid() — cada profissional vê apenas seus médicos.
|
||||
-- ==========================================================================
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Tabela principal
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.medicos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Contexto de acesso
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Identidade profissional
|
||||
nome text NOT NULL,
|
||||
crm text, -- Ex: "123456/SP"
|
||||
especialidade text, -- Ex: "Psiquiatria"
|
||||
|
||||
-- Contatos — telefone_pessoal é sensível (exibido com ícone de olho)
|
||||
telefone_profissional text, -- Consultório / clínica
|
||||
telefone_pessoal text, -- WhatsApp / pessoal
|
||||
email text,
|
||||
|
||||
-- Local de atuação
|
||||
clinica text, -- Nome da clínica/hospital
|
||||
cidade text,
|
||||
estado text DEFAULT 'SP',
|
||||
|
||||
-- Notas internas do terapeuta
|
||||
observacoes text,
|
||||
|
||||
-- Controle
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT medicos_pkey PRIMARY KEY (id),
|
||||
|
||||
-- CRM único por owner (mesmo terapeuta não cadastra o mesmo CRM duas vezes)
|
||||
CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm)
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Índices de performance
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS medicos_owner_idx
|
||||
ON public.medicos USING btree (owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS medicos_tenant_idx
|
||||
ON public.medicos USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS medicos_nome_idx
|
||||
ON public.medicos USING btree (nome);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS medicos_especialidade_idx
|
||||
ON public.medicos USING btree (especialidade);
|
||||
|
||||
-- Busca textual por nome e especialidade
|
||||
CREATE INDEX IF NOT EXISTS medicos_nome_trgm_idx
|
||||
ON public.medicos USING gin (nome gin_trgm_ops);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Trigger de updated_at
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.set_medicos_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_medicos_updated_at
|
||||
BEFORE UPDATE ON public.medicos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_medicos_updated_at();
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. Row Level Security
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner tem acesso total aos seus próprios médicos
|
||||
CREATE POLICY "medicos: owner full access"
|
||||
ON public.medicos
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. Comentários de documentação
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.medicos IS 'Médicos e profissionais de referência cadastrados pelo terapeuta.';
|
||||
COMMENT ON COLUMN public.medicos.owner_id IS 'Terapeuta dono do cadastro (auth.uid()).';
|
||||
COMMENT ON COLUMN public.medicos.tenant_id IS 'Tenant do terapeuta.';
|
||||
COMMENT ON COLUMN public.medicos.nome IS 'Nome completo do médico/profissional.';
|
||||
COMMENT ON COLUMN public.medicos.crm IS 'CRM com UF. Ex: 123456/SP. Único por owner_id.';
|
||||
COMMENT ON COLUMN public.medicos.especialidade IS 'Especialidade médica. Ex: Psiquiatria, Neurologia.';
|
||||
COMMENT ON COLUMN public.medicos.telefone_profissional IS 'Telefone do consultório ou clínica.';
|
||||
COMMENT ON COLUMN public.medicos.telefone_pessoal IS 'Telefone pessoal / WhatsApp. Campo sensível.';
|
||||
COMMENT ON COLUMN public.medicos.email IS 'E-mail profissional.';
|
||||
COMMENT ON COLUMN public.medicos.clinica IS 'Nome da clínica ou hospital onde atua.';
|
||||
COMMENT ON COLUMN public.medicos.cidade IS 'Cidade de atuação.';
|
||||
COMMENT ON COLUMN public.medicos.estado IS 'UF de atuação. Default SP.';
|
||||
COMMENT ON COLUMN public.medicos.observacoes IS 'Notas internas do terapeuta sobre o médico.';
|
||||
COMMENT ON COLUMN public.medicos.ativo IS 'Soft delete: false oculta da listagem.';
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 6. Coluna FK opcional em patients
|
||||
-- (Conecta "Encaminhado por" ao cadastro de médico)
|
||||
-- Execute apenas se quiser a FK estruturada; caso contrário,
|
||||
-- o campo encaminhado_por (text) no PatientsCadastroPage já funciona.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
-- ALTER TABLE public.patients
|
||||
-- ADD COLUMN IF NOT EXISTS medico_encaminhador_id uuid
|
||||
-- REFERENCES public.medicos(id) ON DELETE SET NULL;
|
||||
|
||||
-- CREATE INDEX IF NOT EXISTS patients_medico_encaminhador_idx
|
||||
-- ON public.patients USING btree (medico_encaminhador_id);
|
||||
|
||||
-- COMMENT ON COLUMN public.patients.medico_encaminhador_id
|
||||
-- IS 'FK para medicos.id — quem encaminhou o paciente.';
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,119 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: novos campos em `patients`
|
||||
-- ==========================================================================
|
||||
-- Arquivo: supabase/migrations/20260328000002_patients_new_columns.sql
|
||||
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
|
||||
--
|
||||
-- Adiciona as colunas identificadas na engenharia reversa da tela de detalhe
|
||||
-- (PatientsDetailPage) que ainda não existiam na tabela `patients`.
|
||||
--
|
||||
-- Também ajusta os CHECK constraints de `status` e `patient_scope` para
|
||||
-- aceitar os valores usados no novo formulário de cadastro.
|
||||
-- ==========================================================================
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Colunas novas
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
-- Identidade
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS pronomes text,
|
||||
ADD COLUMN IF NOT EXISTS nome_social text,
|
||||
ADD COLUMN IF NOT EXISTS etnia text;
|
||||
|
||||
-- Contato
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS canal_preferido text,
|
||||
ADD COLUMN IF NOT EXISTS horario_contato text;
|
||||
|
||||
-- Clínico / convênio
|
||||
-- convenio: nome de exibição (badge azul no header)
|
||||
-- convenio_id: FK para insurance_plans (opcional — permite vincular ao cadastro)
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS convenio text,
|
||||
ADD COLUMN IF NOT EXISTS convenio_id uuid REFERENCES public.insurance_plans(id) ON DELETE SET NULL;
|
||||
|
||||
-- Origem
|
||||
ALTER TABLE public.patients
|
||||
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
|
||||
ADD COLUMN IF NOT EXISTS motivo_saida text;
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Ajuste do CHECK constraint de `status`
|
||||
-- Valores originais: Ativo | Inativo | Alta | Encaminhado | Arquivado
|
||||
-- Valores novos: + Em espera
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_status_check;
|
||||
|
||||
ALTER TABLE public.patients
|
||||
ADD CONSTRAINT patients_status_check CHECK (
|
||||
status = ANY (ARRAY[
|
||||
'Ativo'::text,
|
||||
'Em espera'::text,
|
||||
'Inativo'::text,
|
||||
'Alta'::text,
|
||||
'Encaminhado'::text,
|
||||
'Arquivado'::text
|
||||
])
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Ajuste do CHECK constraint de `patient_scope`
|
||||
-- Valores originais: clinic | therapist (valores técnicos internos)
|
||||
-- Valores novos: + Clínica | Particular | Online | Híbrido
|
||||
-- Estratégia: remover o constraint restritivo e deixar livre (text),
|
||||
-- pois o controle já é feito no frontend via Select com opções fixas.
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
|
||||
|
||||
-- Também remove a constraint de consistência que dependia do scope antigo
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. Índices de performance
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS patients_convenio_id_idx
|
||||
ON public.patients USING btree (convenio_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS patients_pronomes_idx
|
||||
ON public.patients USING btree (pronomes);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS patients_etnia_idx
|
||||
ON public.patients USING btree (etnia);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. Comentários
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON COLUMN public.patients.pronomes
|
||||
IS 'Pronomes de tratamento. Ex: ela/dela, ele/dele. Exibido no header do perfil.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.nome_social
|
||||
IS 'Nome social / como prefere ser chamado(a) no atendimento.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.etnia
|
||||
IS 'Etnia / raça autodeclarada. Exibida no card "Dados pessoais".';
|
||||
|
||||
COMMENT ON COLUMN public.patients.canal_preferido
|
||||
IS 'Canal preferido de contato. Ex: WhatsApp, Telefone, E-mail.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.horario_contato
|
||||
IS 'Horário preferido para contato. Ex: 08h–18h.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.convenio
|
||||
IS 'Nome do convênio para exibição (badge azul no header). Derivado de convenio_id.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.convenio_id
|
||||
IS 'FK para insurance_plans.id. Vincula o paciente ao convênio cadastrado.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido
|
||||
IS 'Método de pagamento preferido. Ex: PIX, Cartão crédito. Exibido no card Origem.';
|
||||
|
||||
COMMENT ON COLUMN public.patients.motivo_saida
|
||||
IS 'Motivo de encerramento do acompanhamento. Exibido no card Origem quando preenchido.';
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,70 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: remove check constraints dos novos campos
|
||||
-- ==========================================================================
|
||||
-- Arquivo: supabase/migrations/20260328000003_patients_drop_check_constraints.sql
|
||||
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
|
||||
--
|
||||
-- O banco tinha CHECK constraints nos novos campos que foram adicionados
|
||||
-- pela migration anterior (ou que já existiam no schema ao vivo).
|
||||
-- O frontend já controla os valores via Select com opções fixas,
|
||||
-- então os constraints são desnecessários e serão removidos.
|
||||
-- ==========================================================================
|
||||
|
||||
-- canal_preferido
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check;
|
||||
|
||||
-- horario_contato
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_horario_contato_check;
|
||||
|
||||
-- pronomes
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_pronomes_check;
|
||||
|
||||
-- nome_social
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_nome_social_check;
|
||||
|
||||
-- etnia
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_etnia_check;
|
||||
|
||||
-- convenio
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_convenio_check;
|
||||
|
||||
-- metodo_pagamento_preferido
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_preferido_check;
|
||||
|
||||
-- motivo_saida
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_motivo_saida_check;
|
||||
|
||||
-- status (já ajustado na migration anterior, mas garante)
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_status_check;
|
||||
|
||||
ALTER TABLE public.patients
|
||||
ADD CONSTRAINT patients_status_check CHECK (
|
||||
status = ANY (ARRAY[
|
||||
'Ativo'::text,
|
||||
'Em espera'::text,
|
||||
'Inativo'::text,
|
||||
'Alta'::text,
|
||||
'Encaminhado'::text,
|
||||
'Arquivado'::text
|
||||
])
|
||||
);
|
||||
|
||||
-- patient_scope (já ajustado na migration anterior, mas garante)
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
|
||||
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,56 @@
|
||||
-- ==========================================================================
|
||||
-- Agência PSI — Migração: tabela `patient_support_contacts`
|
||||
-- ==========================================================================
|
||||
-- Arquivo: supabase/migrations/20260328000004_create_patient_support_contacts.sql
|
||||
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
|
||||
--
|
||||
-- Contatos da rede de suporte do paciente.
|
||||
-- Alimenta o card "Contatos & rede de suporte" na tela de detalhe.
|
||||
-- is_primario = true → badge vermelho "emergência" no perfil.
|
||||
-- ==========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_support_contacts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
nome text,
|
||||
relacao text, -- Ex: mãe, psiquiatra, cônjuge
|
||||
tipo text, -- emergencia | familiar | profissional_saude | amigo | outro
|
||||
telefone text,
|
||||
email text,
|
||||
is_primario boolean DEFAULT false NOT NULL,
|
||||
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT patient_support_contacts_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- Índices
|
||||
CREATE INDEX IF NOT EXISTS psc_patient_idx ON public.patient_support_contacts USING btree (patient_id);
|
||||
CREATE INDEX IF NOT EXISTS psc_owner_idx ON public.patient_support_contacts USING btree (owner_id);
|
||||
|
||||
-- Trigger updated_at
|
||||
CREATE TRIGGER trg_psc_updated_at
|
||||
BEFORE UPDATE ON public.patient_support_contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.patient_support_contacts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "psc: owner full access"
|
||||
ON public.patient_support_contacts
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- Comentários
|
||||
COMMENT ON TABLE public.patient_support_contacts IS 'Rede de suporte do paciente. Exibida no card "Contatos & rede de suporte" do perfil.';
|
||||
COMMENT ON COLUMN public.patient_support_contacts.is_primario IS 'true = badge vermelho "emergência" no perfil do paciente.';
|
||||
COMMENT ON COLUMN public.patient_support_contacts.tipo IS 'emergencia | familiar | profissional_saude | amigo | outro';
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRAÇÃO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,454 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: tabelas de Documentos & Arquivos
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Proposito:
|
||||
-- Modulo completo de documentos do paciente.
|
||||
-- Tabelas: documents, document_access_logs, document_signatures,
|
||||
-- document_share_links.
|
||||
--
|
||||
-- Relacionamentos:
|
||||
-- documents.patient_id → patients(id)
|
||||
-- documents.owner_id → auth.users(id)
|
||||
-- documents.tenant_id → tenants(id)
|
||||
-- documents.agenda_evento_id → agenda_eventos(id) (opcional)
|
||||
-- document_access_logs.documento_id → documents(id)
|
||||
-- document_signatures.documento_id → documents(id)
|
||||
-- document_share_links.documento_id → documents(id)
|
||||
--
|
||||
-- RLS: owner_id = auth.uid() para documents, signatures e share_links.
|
||||
-- access_logs: somente INSERT (imutavel) + SELECT por tenant.
|
||||
-- ==========================================================================
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Tabela principal: documents
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.documents (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Contexto de acesso
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Vinculo com paciente
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
|
||||
-- Arquivo no Storage
|
||||
bucket_path text NOT NULL,
|
||||
storage_bucket text NOT NULL DEFAULT 'documents',
|
||||
nome_original text NOT NULL,
|
||||
mime_type text,
|
||||
tamanho_bytes bigint,
|
||||
|
||||
-- Classificacao
|
||||
tipo_documento text NOT NULL DEFAULT 'outro',
|
||||
-- laudo | receita | exame | termo_assinado | relatorio_externo
|
||||
-- identidade | convenio | declaracao | atestado | recibo | outro
|
||||
categoria text,
|
||||
descricao text,
|
||||
tags text[] DEFAULT '{}',
|
||||
|
||||
-- Vinculo opcional com sessao/nota
|
||||
agenda_evento_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
|
||||
session_note_id uuid,
|
||||
|
||||
-- Visibilidade & controle de acesso
|
||||
visibilidade text NOT NULL DEFAULT 'privado',
|
||||
-- privado | compartilhado_supervisor | compartilhado_portal
|
||||
compartilhado_portal boolean DEFAULT false NOT NULL,
|
||||
compartilhado_supervisor boolean DEFAULT false NOT NULL,
|
||||
compartilhado_em timestamptz,
|
||||
expira_compartilhamento timestamptz,
|
||||
|
||||
-- Upload pelo paciente (portal)
|
||||
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
|
||||
status_revisao text DEFAULT 'aprovado',
|
||||
-- pendente | aprovado | rejeitado
|
||||
revisado_por uuid,
|
||||
revisado_em timestamptz,
|
||||
|
||||
-- Quem fez upload
|
||||
uploaded_by uuid NOT NULL,
|
||||
uploaded_at timestamptz DEFAULT now() NOT NULL,
|
||||
|
||||
-- Soft delete com retencao (LGPD / CFP)
|
||||
deleted_at timestamptz,
|
||||
deleted_by uuid,
|
||||
retencao_ate timestamptz,
|
||||
|
||||
-- Controle
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT documents_pkey PRIMARY KEY (id),
|
||||
|
||||
-- Validacoes
|
||||
CONSTRAINT documents_tipo_check CHECK (
|
||||
tipo_documento = ANY (ARRAY[
|
||||
'laudo', 'receita', 'exame', 'termo_assinado', 'relatorio_externo',
|
||||
'identidade', 'convenio', 'declaracao', 'atestado', 'recibo', 'outro'
|
||||
])
|
||||
),
|
||||
CONSTRAINT documents_visibilidade_check CHECK (
|
||||
visibilidade = ANY (ARRAY['privado', 'compartilhado_supervisor', 'compartilhado_portal'])
|
||||
),
|
||||
CONSTRAINT documents_status_revisao_check CHECK (
|
||||
status_revisao = ANY (ARRAY['pendente', 'aprovado', 'rejeitado'])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Indices — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS docs_patient_idx
|
||||
ON public.documents USING btree (patient_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_owner_idx
|
||||
ON public.documents USING btree (owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_tenant_idx
|
||||
ON public.documents USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_tipo_idx
|
||||
ON public.documents USING btree (patient_id, tipo_documento);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_tags_idx
|
||||
ON public.documents USING gin (tags);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS docs_uploaded_at_idx
|
||||
ON public.documents USING btree (patient_id, uploaded_at DESC);
|
||||
|
||||
-- Excluir soft-deleted da listagem padrao
|
||||
CREATE INDEX IF NOT EXISTS docs_active_idx
|
||||
ON public.documents USING btree (patient_id, uploaded_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Busca textual no nome do arquivo
|
||||
CREATE INDEX IF NOT EXISTS docs_nome_trgm_idx
|
||||
ON public.documents USING gin (nome_original gin_trgm_ops);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Trigger updated_at — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TRIGGER trg_documents_updated_at
|
||||
BEFORE UPDATE ON public.documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. Trigger: registrar na patient_timeline ao adicionar documento
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.patient_timeline (
|
||||
patient_id, tenant_id, evento_tipo,
|
||||
titulo, descricao, icone_cor,
|
||||
link_ref_tipo, link_ref_id,
|
||||
gerado_por, ocorrido_em
|
||||
) VALUES (
|
||||
NEW.patient_id,
|
||||
NEW.tenant_id,
|
||||
'documento_adicionado',
|
||||
'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
|
||||
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
|
||||
'blue',
|
||||
'documento',
|
||||
NEW.id,
|
||||
NEW.uploaded_by,
|
||||
NEW.uploaded_at
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_documents_timeline_insert
|
||||
AFTER INSERT ON public.documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_documents_timeline_insert();
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. RLS — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "documents: owner full access"
|
||||
ON public.documents
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 6. Comentarios — documents
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.documents IS 'Documentos e arquivos vinculados a pacientes. Armazenados no Supabase Storage.';
|
||||
COMMENT ON COLUMN public.documents.owner_id IS 'Terapeuta dono do documento (auth.uid()).';
|
||||
COMMENT ON COLUMN public.documents.tenant_id IS 'Tenant do terapeuta.';
|
||||
COMMENT ON COLUMN public.documents.patient_id IS 'Paciente ao qual o documento pertence.';
|
||||
COMMENT ON COLUMN public.documents.bucket_path IS 'Caminho do arquivo no Supabase Storage bucket.';
|
||||
COMMENT ON COLUMN public.documents.storage_bucket IS 'Nome do bucket no Storage. Default: documents.';
|
||||
COMMENT ON COLUMN public.documents.nome_original IS 'Nome original do arquivo enviado.';
|
||||
COMMENT ON COLUMN public.documents.mime_type IS 'MIME type do arquivo. Ex: application/pdf, image/jpeg.';
|
||||
COMMENT ON COLUMN public.documents.tamanho_bytes IS 'Tamanho do arquivo em bytes.';
|
||||
COMMENT ON COLUMN public.documents.tipo_documento IS 'Tipo: laudo|receita|exame|termo_assinado|relatorio_externo|identidade|convenio|declaracao|atestado|recibo|outro.';
|
||||
COMMENT ON COLUMN public.documents.categoria IS 'Categoria livre para organizacao adicional.';
|
||||
COMMENT ON COLUMN public.documents.tags IS 'Tags livres para busca e filtro. Array de text.';
|
||||
COMMENT ON COLUMN public.documents.visibilidade IS 'privado|compartilhado_supervisor|compartilhado_portal.';
|
||||
COMMENT ON COLUMN public.documents.compartilhado_portal IS 'true = visivel para o paciente no portal.';
|
||||
COMMENT ON COLUMN public.documents.compartilhado_supervisor IS 'true = visivel para o supervisor.';
|
||||
COMMENT ON COLUMN public.documents.enviado_pelo_paciente IS 'true = upload feito pelo paciente via portal.';
|
||||
COMMENT ON COLUMN public.documents.status_revisao IS 'pendente|aprovado|rejeitado — para uploads do paciente.';
|
||||
COMMENT ON COLUMN public.documents.deleted_at IS 'Soft delete: data da exclusao. NULL = ativo.';
|
||||
COMMENT ON COLUMN public.documents.retencao_ate IS 'LGPD/CFP: arquivo retido ate esta data mesmo apos soft delete.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 7. Tabela: document_access_logs (imutavel — auditoria)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_access_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Acao realizada
|
||||
acao text NOT NULL,
|
||||
-- visualizou | baixou | imprimiu | compartilhou | assinou
|
||||
user_id uuid,
|
||||
ip inet,
|
||||
user_agent text,
|
||||
|
||||
acessado_em timestamptz DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT document_access_logs_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT dal_acao_check CHECK (
|
||||
acao = ANY (ARRAY['visualizou', 'baixou', 'imprimiu', 'compartilhou', 'assinou'])
|
||||
)
|
||||
);
|
||||
|
||||
-- Indices
|
||||
CREATE INDEX IF NOT EXISTS dal_documento_idx
|
||||
ON public.document_access_logs USING btree (documento_id, acessado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dal_tenant_idx
|
||||
ON public.document_access_logs USING btree (tenant_id, acessado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dal_user_idx
|
||||
ON public.document_access_logs USING btree (user_id, acessado_em DESC);
|
||||
|
||||
-- RLS — somente INSERT (imutavel) + SELECT
|
||||
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "dal: tenant members can insert"
|
||||
ON public.document_access_logs
|
||||
FOR INSERT
|
||||
WITH CHECK (true);
|
||||
|
||||
CREATE POLICY "dal: tenant members can select"
|
||||
ON public.document_access_logs
|
||||
FOR SELECT
|
||||
USING (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
));
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE public.document_access_logs IS 'Log imutavel de acessos a documentos. Conformidade CFP e LGPD. Sem UPDATE/DELETE.';
|
||||
COMMENT ON COLUMN public.document_access_logs.acao IS 'visualizou|baixou|imprimiu|compartilhou|assinou.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 8. Tabela: document_signatures (assinatura eletronica)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_signatures (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Signatario
|
||||
signatario_tipo text NOT NULL,
|
||||
-- paciente | responsavel_legal | terapeuta
|
||||
signatario_id uuid,
|
||||
signatario_nome text,
|
||||
signatario_email text,
|
||||
|
||||
-- Ordem e status
|
||||
ordem smallint DEFAULT 1 NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pendente',
|
||||
-- pendente | enviado | assinado | recusado | expirado
|
||||
|
||||
-- Dados da assinatura (preenchidos ao assinar)
|
||||
ip inet,
|
||||
user_agent text,
|
||||
assinado_em timestamptz,
|
||||
hash_documento text,
|
||||
|
||||
-- Controle
|
||||
criado_em timestamptz DEFAULT now(),
|
||||
atualizado_em timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT document_signatures_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT ds_signatario_tipo_check CHECK (
|
||||
signatario_tipo = ANY (ARRAY['paciente', 'responsavel_legal', 'terapeuta'])
|
||||
),
|
||||
CONSTRAINT ds_status_check CHECK (
|
||||
status = ANY (ARRAY['pendente', 'enviado', 'assinado', 'recusado', 'expirado'])
|
||||
)
|
||||
);
|
||||
|
||||
-- Indices
|
||||
CREATE INDEX IF NOT EXISTS ds_documento_idx
|
||||
ON public.document_signatures USING btree (documento_id, ordem);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ds_tenant_idx
|
||||
ON public.document_signatures USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ds_status_idx
|
||||
ON public.document_signatures USING btree (documento_id, status);
|
||||
|
||||
-- Trigger updated_at
|
||||
CREATE TRIGGER trg_ds_updated_at
|
||||
BEFORE UPDATE ON public.document_signatures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
-- Trigger: ao assinar, registrar na patient_timeline
|
||||
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
v_patient_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_doc_nome text;
|
||||
BEGIN
|
||||
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
|
||||
SELECT d.patient_id, d.tenant_id, d.nome_original
|
||||
INTO v_patient_id, v_tenant_id, v_doc_nome
|
||||
FROM public.documents d
|
||||
WHERE d.id = NEW.documento_id;
|
||||
|
||||
IF v_patient_id IS NOT NULL THEN
|
||||
INSERT INTO public.patient_timeline (
|
||||
patient_id, tenant_id, evento_tipo,
|
||||
titulo, descricao, icone_cor,
|
||||
link_ref_tipo, link_ref_id,
|
||||
gerado_por, ocorrido_em
|
||||
) VALUES (
|
||||
v_patient_id,
|
||||
v_tenant_id,
|
||||
'documento_assinado',
|
||||
'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
|
||||
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
|
||||
'green',
|
||||
'documento',
|
||||
NEW.documento_id,
|
||||
NEW.signatario_id,
|
||||
NEW.assinado_em
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_ds_timeline
|
||||
AFTER UPDATE ON public.document_signatures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_document_signature_timeline();
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.document_signatures ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "ds: tenant members access"
|
||||
ON public.document_signatures
|
||||
USING (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
))
|
||||
WITH CHECK (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
));
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE public.document_signatures IS 'Assinaturas eletronicas de documentos. Cada signatario tem seu registro.';
|
||||
COMMENT ON COLUMN public.document_signatures.signatario_tipo IS 'paciente|responsavel_legal|terapeuta.';
|
||||
COMMENT ON COLUMN public.document_signatures.status IS 'pendente|enviado|assinado|recusado|expirado.';
|
||||
COMMENT ON COLUMN public.document_signatures.hash_documento IS 'Hash SHA-256 do documento no momento da assinatura. Garante integridade.';
|
||||
COMMENT ON COLUMN public.document_signatures.ip IS 'IP do signatario no momento da assinatura.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 9. Tabela: document_share_links (links temporarios)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_share_links (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Token unico para o link
|
||||
token text NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
|
||||
|
||||
-- Limites
|
||||
expira_em timestamptz NOT NULL,
|
||||
usos_max smallint DEFAULT 5 NOT NULL,
|
||||
usos smallint DEFAULT 0 NOT NULL,
|
||||
|
||||
-- Quem criou
|
||||
criado_por uuid NOT NULL,
|
||||
criado_em timestamptz DEFAULT now(),
|
||||
|
||||
-- Controle
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
|
||||
CONSTRAINT document_share_links_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT dsl_token_unique UNIQUE (token)
|
||||
);
|
||||
|
||||
-- Indices
|
||||
CREATE INDEX IF NOT EXISTS dsl_documento_idx
|
||||
ON public.document_share_links USING btree (documento_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dsl_token_idx
|
||||
ON public.document_share_links USING btree (token)
|
||||
WHERE ativo = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dsl_expira_idx
|
||||
ON public.document_share_links USING btree (expira_em)
|
||||
WHERE ativo = true;
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "dsl: creator full access"
|
||||
ON public.document_share_links
|
||||
USING (criado_por = auth.uid())
|
||||
WITH CHECK (criado_por = auth.uid());
|
||||
|
||||
-- Politica publica de leitura por token (para acesso externo sem login)
|
||||
CREATE POLICY "dsl: public read by token"
|
||||
ON public.document_share_links
|
||||
FOR SELECT
|
||||
USING (ativo = true AND expira_em > now() AND usos < usos_max);
|
||||
|
||||
-- Comentarios
|
||||
COMMENT ON TABLE public.document_share_links IS 'Links temporarios assinados para compartilhar documento com profissional externo.';
|
||||
COMMENT ON COLUMN public.document_share_links.token IS 'Token unico gerado automaticamente (32 bytes hex).';
|
||||
COMMENT ON COLUMN public.document_share_links.expira_em IS 'Data/hora de expiracao do link.';
|
||||
COMMENT ON COLUMN public.document_share_links.usos_max IS 'Numero maximo de acessos permitidos.';
|
||||
COMMENT ON COLUMN public.document_share_links.usos IS 'Numero de vezes que o link ja foi acessado.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO 005
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,260 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: tabelas de Templates de Documentos
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Proposito:
|
||||
-- Templates de documentos (declaracao, atestado, recibo, relatorio etc.)
|
||||
-- e registro de cada documento gerado (instancia PDF).
|
||||
--
|
||||
-- Tabelas: document_templates, document_generated.
|
||||
--
|
||||
-- Relacionamentos:
|
||||
-- document_templates.tenant_id → tenants(id)
|
||||
-- document_templates.owner_id → auth.users(id)
|
||||
-- document_generated.template_id → document_templates(id)
|
||||
-- document_generated.patient_id → patients(id)
|
||||
-- document_generated.tenant_id → tenants(id)
|
||||
--
|
||||
-- Templates globais: is_global = true, tenant_id = NULL.
|
||||
-- Templates do tenant: is_global = false, tenant_id preenchido.
|
||||
-- ==========================================================================
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 1. Tabela: document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.document_templates (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Contexto
|
||||
tenant_id uuid,
|
||||
owner_id uuid,
|
||||
|
||||
-- Identificacao
|
||||
nome_template text NOT NULL,
|
||||
tipo text NOT NULL DEFAULT 'outro',
|
||||
-- declaracao_comparecimento | atestado_psicologico
|
||||
-- relatorio_acompanhamento | recibo_pagamento
|
||||
-- termo_consentimento | encaminhamento | outro
|
||||
descricao text,
|
||||
|
||||
-- Corpo do template
|
||||
corpo_html text NOT NULL DEFAULT '',
|
||||
cabecalho_html text,
|
||||
rodape_html text,
|
||||
|
||||
-- Variaveis que o template utiliza
|
||||
variaveis text[] DEFAULT '{}',
|
||||
-- Ex: {paciente_nome, paciente_cpf, data_sessao, terapeuta_nome, ...}
|
||||
|
||||
-- Personalizacao visual
|
||||
logo_url text,
|
||||
|
||||
-- Escopo
|
||||
is_global boolean DEFAULT false NOT NULL,
|
||||
-- true = template padrao do sistema (visivel para todos)
|
||||
-- false = template criado pelo tenant/terapeuta
|
||||
|
||||
-- Controle
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
|
||||
CONSTRAINT document_templates_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT dt_tipo_check CHECK (
|
||||
tipo = ANY (ARRAY[
|
||||
'declaracao_comparecimento', 'atestado_psicologico',
|
||||
'relatorio_acompanhamento', 'recibo_pagamento',
|
||||
'termo_consentimento', 'encaminhamento',
|
||||
'contrato_servicos', 'tcle', 'autorizacao_menor',
|
||||
'laudo_psicologico', 'parecer_psicologico',
|
||||
'termo_sigilo', 'declaracao_inicio_tratamento',
|
||||
'termo_alta', 'tcle_online', 'outro'
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 2. Indices — document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS dt_tenant_idx
|
||||
ON public.document_templates USING btree (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_owner_idx
|
||||
ON public.document_templates USING btree (owner_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_global_idx
|
||||
ON public.document_templates USING btree (is_global)
|
||||
WHERE is_global = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_tipo_idx
|
||||
ON public.document_templates USING btree (tipo);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dt_nome_trgm_idx
|
||||
ON public.document_templates USING gin (nome_template gin_trgm_ops);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 3. Trigger updated_at
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TRIGGER trg_dt_updated_at
|
||||
BEFORE UPDATE ON public.document_templates
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 4. RLS — document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Templates globais: todos podem ler
|
||||
CREATE POLICY "dt: global templates readable by all"
|
||||
ON public.document_templates
|
||||
FOR SELECT
|
||||
USING (is_global = true);
|
||||
|
||||
-- Templates do tenant: membros do tenant podem ler
|
||||
CREATE POLICY "dt: tenant members can select"
|
||||
ON public.document_templates
|
||||
FOR SELECT
|
||||
USING (
|
||||
is_global = false
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- Owner pode inserir/atualizar/deletar seus templates
|
||||
CREATE POLICY "dt: owner can insert"
|
||||
ON public.document_templates
|
||||
FOR INSERT
|
||||
WITH CHECK (owner_id = auth.uid() AND is_global = false);
|
||||
|
||||
CREATE POLICY "dt: owner can update"
|
||||
ON public.document_templates
|
||||
FOR UPDATE
|
||||
USING (owner_id = auth.uid() AND is_global = false)
|
||||
WITH CHECK (owner_id = auth.uid() AND is_global = false);
|
||||
|
||||
CREATE POLICY "dt: owner can delete"
|
||||
ON public.document_templates
|
||||
FOR DELETE
|
||||
USING (owner_id = auth.uid() AND is_global = false);
|
||||
|
||||
-- SaaS admin pode gerenciar templates globais (usa funcao public.is_saas_admin())
|
||||
CREATE POLICY "dt: saas admin can insert global"
|
||||
ON public.document_templates
|
||||
FOR INSERT
|
||||
WITH CHECK (is_global = true AND public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "dt: saas admin can update global"
|
||||
ON public.document_templates
|
||||
FOR UPDATE
|
||||
USING (is_global = true AND public.is_saas_admin())
|
||||
WITH CHECK (is_global = true AND public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "dt: saas admin can delete global"
|
||||
ON public.document_templates
|
||||
FOR DELETE
|
||||
USING (is_global = true AND public.is_saas_admin());
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 5. Comentarios — document_templates
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.document_templates IS 'Templates de documentos para geracao automatica (declaracao, atestado, recibo etc.).';
|
||||
COMMENT ON COLUMN public.document_templates.nome_template IS 'Nome do template. Ex: Declaracao de Comparecimento.';
|
||||
COMMENT ON COLUMN public.document_templates.tipo IS 'declaracao_comparecimento|atestado_psicologico|relatorio_acompanhamento|recibo_pagamento|termo_consentimento|encaminhamento|outro.';
|
||||
COMMENT ON COLUMN public.document_templates.corpo_html IS 'Corpo do template em HTML com variaveis {{nome_variavel}}.';
|
||||
COMMENT ON COLUMN public.document_templates.cabecalho_html IS 'HTML do cabecalho (logo, nome da clinica etc.).';
|
||||
COMMENT ON COLUMN public.document_templates.rodape_html IS 'HTML do rodape (CRP, endereco, contato etc.).';
|
||||
COMMENT ON COLUMN public.document_templates.variaveis IS 'Array com nomes das variaveis usadas no template. Ex: {paciente_nome, data_sessao}.';
|
||||
COMMENT ON COLUMN public.document_templates.is_global IS 'true = template padrao do sistema visivel para todos. false = template do tenant.';
|
||||
COMMENT ON COLUMN public.document_templates.logo_url IS 'URL do logo personalizado para o cabecalho do documento.';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- 6. Tabela: document_generated (cada PDF gerado)
|
||||
-- ==========================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.document_generated (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
|
||||
-- Origem
|
||||
template_id uuid NOT NULL REFERENCES public.document_templates(id) ON DELETE RESTRICT,
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
|
||||
-- Dados usados no preenchimento (snapshot — permite auditoria futura)
|
||||
dados_preenchidos jsonb NOT NULL DEFAULT '{}',
|
||||
|
||||
-- PDF gerado
|
||||
pdf_path text NOT NULL,
|
||||
storage_bucket text NOT NULL DEFAULT 'generated-docs',
|
||||
|
||||
-- Vinculo opcional com documento pai (se o PDF gerado tambem for registrado em documents)
|
||||
documento_id uuid REFERENCES public.documents(id) ON DELETE SET NULL,
|
||||
|
||||
-- Quem gerou
|
||||
gerado_por uuid NOT NULL,
|
||||
gerado_em timestamptz DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT document_generated_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 7. Indices — document_generated
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS dg_template_idx
|
||||
ON public.document_generated USING btree (template_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dg_patient_idx
|
||||
ON public.document_generated USING btree (patient_id, gerado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dg_tenant_idx
|
||||
ON public.document_generated USING btree (tenant_id, gerado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dg_gerado_por_idx
|
||||
ON public.document_generated USING btree (gerado_por, gerado_em DESC);
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 8. RLS — document_generated
|
||||
-- --------------------------------------------------------------------------
|
||||
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "dg: generator full access"
|
||||
ON public.document_generated
|
||||
USING (gerado_por = auth.uid())
|
||||
WITH CHECK (gerado_por = auth.uid());
|
||||
|
||||
-- Membros do tenant podem visualizar
|
||||
CREATE POLICY "dg: tenant members can select"
|
||||
ON public.document_generated
|
||||
FOR SELECT
|
||||
USING (tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
));
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- 9. Comentarios — document_generated
|
||||
-- --------------------------------------------------------------------------
|
||||
COMMENT ON TABLE public.document_generated IS 'Registro de cada documento PDF gerado a partir de um template.';
|
||||
COMMENT ON COLUMN public.document_generated.template_id IS 'Template usado para gerar o documento.';
|
||||
COMMENT ON COLUMN public.document_generated.dados_preenchidos IS 'Snapshot JSON dos dados usados no preenchimento. Permite auditoria futura.';
|
||||
COMMENT ON COLUMN public.document_generated.pdf_path IS 'Caminho do PDF gerado no Supabase Storage bucket.';
|
||||
COMMENT ON COLUMN public.document_generated.documento_id IS 'FK opcional para documents — se o PDF gerado tambem foi registrado como documento do paciente.';
|
||||
COMMENT ON COLUMN public.document_generated.gerado_por IS 'Usuario que gerou o documento (auth.uid()).';
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO 006
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,93 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Storage Buckets para Documentos
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Cria os buckets no Supabase Storage para documentos de pacientes
|
||||
-- e PDFs gerados pelo sistema.
|
||||
-- ==========================================================================
|
||||
|
||||
-- Bucket: documents (uploads de terapeuta/paciente)
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'documents',
|
||||
'documents',
|
||||
false,
|
||||
52428800, -- 50 MB
|
||||
ARRAY[
|
||||
'application/pdf',
|
||||
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain'
|
||||
]
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Bucket: generated-docs (PDFs gerados pelo sistema)
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'generated-docs',
|
||||
'generated-docs',
|
||||
false,
|
||||
20971520, -- 20 MB
|
||||
ARRAY['application/pdf']
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Storage RLS Policies — bucket: documents
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
-- Upload: usuario autenticado pode fazer upload no path do seu tenant
|
||||
CREATE POLICY "documents: authenticated upload"
|
||||
ON storage.objects
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (bucket_id = 'documents');
|
||||
|
||||
-- Download: usuario autenticado pode ler arquivos do seu tenant
|
||||
CREATE POLICY "documents: authenticated read"
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'documents');
|
||||
|
||||
-- Delete: usuario autenticado pode deletar seus arquivos
|
||||
CREATE POLICY "documents: authenticated delete"
|
||||
ON storage.objects
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'documents');
|
||||
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Storage RLS Policies — bucket: generated-docs
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
CREATE POLICY "generated-docs: authenticated upload"
|
||||
ON storage.objects
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (bucket_id = 'generated-docs');
|
||||
|
||||
CREATE POLICY "generated-docs: authenticated read"
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'generated-docs');
|
||||
|
||||
CREATE POLICY "generated-docs: authenticated delete"
|
||||
ON storage.objects
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'generated-docs');
|
||||
|
||||
|
||||
-- ==========================================================================
|
||||
-- FIM DA MIGRACAO
|
||||
-- ==========================================================================
|
||||
@@ -0,0 +1,275 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260417000001_dev_tables
|
||||
-- Área de Desenvolvimento (dev_*) — roadmap, auditoria, concorrentes, logs
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Tabelas usadas pela página /saas/desenvolvimento. Todas restritas a
|
||||
-- saas_admins via RLS (helper public.is_saas_admin()).
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Helper trigger: updated_at
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.dev_set_updated_at()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. dev_roadmap_phases — Fases (1, 2, 3...)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_roadmap_phases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
numero INTEGER NOT NULL UNIQUE,
|
||||
nome VARCHAR(160) NOT NULL,
|
||||
objetivo TEXT,
|
||||
timeline_sugerida VARCHAR(160),
|
||||
criterio_saida TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'planejada'
|
||||
CHECK (status IN ('planejada','em_andamento','concluida','arquivada')),
|
||||
data_inicio DATE,
|
||||
data_fim DATE,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_status ON public.dev_roadmap_phases(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases(ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_roadmap_phases_updated_at ON public.dev_roadmap_phases;
|
||||
CREATE TRIGGER trg_dev_roadmap_phases_updated_at
|
||||
BEFORE UPDATE ON public.dev_roadmap_phases
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. dev_roadmap_items — Itens das fases
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_roadmap_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phase_id BIGINT NOT NULL REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE,
|
||||
numero INTEGER,
|
||||
bloco VARCHAR(160),
|
||||
feature TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
esforco VARCHAR(4)
|
||||
CHECK (esforco IS NULL OR esforco IN ('S','M','L','XL')),
|
||||
prioridade VARCHAR(20)
|
||||
CHECK (prioridade IS NULL OR prioridade IN ('bloqueador','alta','media','diferencial')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
|
||||
CHECK (status IN ('pendente','em_andamento','concluido','cancelado','bloqueado')),
|
||||
notas TEXT,
|
||||
assignee VARCHAR(120),
|
||||
data_inicio DATE,
|
||||
data_conclusao DATE,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_phase ON public.dev_roadmap_items(phase_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_status ON public.dev_roadmap_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_prior ON public.dev_roadmap_items(prioridade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_ordem ON public.dev_roadmap_items(phase_id, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_roadmap_items_updated_at ON public.dev_roadmap_items;
|
||||
CREATE TRIGGER trg_dev_roadmap_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_roadmap_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. dev_auditoria_items — Bugs / débitos técnicos / decisões
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_auditoria_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
categoria VARCHAR(120),
|
||||
titulo TEXT NOT NULL,
|
||||
descricao_problema TEXT,
|
||||
solucao TEXT,
|
||||
severidade VARCHAR(20)
|
||||
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'aberto'
|
||||
CHECK (status IN ('aberto','em_analise','resolvido','wontfix','duplicado')),
|
||||
resolvido_em DATE,
|
||||
sessao_resolucao VARCHAR(160),
|
||||
arquivo_afetado TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_status ON public.dev_auditoria_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_severidade ON public.dev_auditoria_items(severidade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_categoria ON public.dev_auditoria_items(categoria);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_auditoria_items_updated_at ON public.dev_auditoria_items;
|
||||
CREATE TRIGGER trg_dev_auditoria_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_auditoria_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. dev_competitors — Concorrentes
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_competitors (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
slug VARCHAR(80) NOT NULL UNIQUE,
|
||||
nome VARCHAR(160) NOT NULL,
|
||||
pais VARCHAR(40),
|
||||
foco VARCHAR(160),
|
||||
pricing TEXT,
|
||||
posicionamento TEXT,
|
||||
url TEXT,
|
||||
ultima_pesquisa DATE,
|
||||
notas TEXT,
|
||||
ativo BOOLEAN NOT NULL DEFAULT true,
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitors_ativo ON public.dev_competitors(ativo);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitors_pais ON public.dev_competitors(pais);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_competitors_updated_at ON public.dev_competitors;
|
||||
CREATE TRIGGER trg_dev_competitors_updated_at
|
||||
BEFORE UPDATE ON public.dev_competitors
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. dev_competitor_features — features de cada concorrente
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_competitor_features (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
|
||||
categoria VARCHAR(120),
|
||||
nome TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
fonte VARCHAR(20) NOT NULL DEFAULT 'publico'
|
||||
CHECK (fonte IN ('fetched','observacao','publico','hipotese')),
|
||||
fonte_url TEXT,
|
||||
data_fonte DATE,
|
||||
destaque BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_comp ON public.dev_competitor_features(competitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_cat ON public.dev_competitor_features(categoria);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_destaque ON public.dev_competitor_features(destaque);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_competitor_features_updated_at ON public.dev_competitor_features;
|
||||
CREATE TRIGGER trg_dev_competitor_features_updated_at
|
||||
BEFORE UPDATE ON public.dev_competitor_features
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. dev_comparison_matrix — AgenciaPsi × features-de-concorrente
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_comparison_matrix (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dominio VARCHAR(120),
|
||||
feature TEXT NOT NULL,
|
||||
nosso_status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
|
||||
CHECK (nosso_status IN ('tem','parcial','gap','na','a_definir')),
|
||||
nossa_nota TEXT,
|
||||
importancia VARCHAR(20)
|
||||
CHECK (importancia IS NULL OR importancia IN ('alta','media','baixa')),
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix(dominio);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_status ON public.dev_comparison_matrix(nosso_status);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_comparison_matrix_updated_at ON public.dev_comparison_matrix;
|
||||
CREATE TRIGGER trg_dev_comparison_matrix_updated_at
|
||||
BEFORE UPDATE ON public.dev_comparison_matrix
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- dev_comparison_competitor_status — opcional: status por concorrente por feature
|
||||
-- (se quisermos marcar que competitor X tem feature Y). Tabela ponte N-N.
|
||||
CREATE TABLE IF NOT EXISTS public.dev_comparison_competitor_status (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
comparison_id BIGINT NOT NULL REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE,
|
||||
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
|
||||
CHECK (status IN ('tem','parcial','gap','na','a_definir')),
|
||||
nota TEXT,
|
||||
fonte VARCHAR(20)
|
||||
CHECK (fonte IS NULL OR fonte IN ('fetched','observacao','publico','hipotese')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (comparison_id, competitor_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comp ON public.dev_comparison_competitor_status(competitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comparison ON public.dev_comparison_competitor_status(comparison_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_ccs_updated_at ON public.dev_comparison_competitor_status;
|
||||
CREATE TRIGGER trg_dev_ccs_updated_at
|
||||
BEFORE UPDATE ON public.dev_comparison_competitor_status
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. dev_generation_log — histórico de execuções (backup, dashboard, export...)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.dev_generation_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tipo VARCHAR(40) NOT NULL,
|
||||
comando TEXT,
|
||||
sucesso BOOLEAN NOT NULL DEFAULT false,
|
||||
stdout TEXT,
|
||||
stderr TEXT,
|
||||
duration_ms INTEGER,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
trigger_user_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_tipo ON public.dev_generation_log(tipo);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_created ON public.dev_generation_log(created_at DESC);
|
||||
|
||||
-- =============================================================================
|
||||
-- RLS — tudo restrito a saas_admins (helper existente: public.is_saas_admin())
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
t TEXT;
|
||||
dev_tables TEXT[] := ARRAY[
|
||||
'dev_roadmap_phases',
|
||||
'dev_roadmap_items',
|
||||
'dev_auditoria_items',
|
||||
'dev_competitors',
|
||||
'dev_competitor_features',
|
||||
'dev_comparison_matrix',
|
||||
'dev_comparison_competitor_status',
|
||||
'dev_generation_log'
|
||||
];
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY dev_tables
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY;', t);
|
||||
|
||||
-- Drop policy se existir (idempotente)
|
||||
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I;', t || '_saas_admin_all', t);
|
||||
|
||||
-- Cria policy que permite tudo pra saas_admin
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON public.%I FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());',
|
||||
t || '_saas_admin_all',
|
||||
t
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Comentários
|
||||
-- =============================================================================
|
||||
COMMENT ON TABLE public.dev_roadmap_phases IS 'Fases do roadmap (MVP, Paridade, Diferenciação). Visível só pra saas_admins.';
|
||||
COMMENT ON TABLE public.dev_roadmap_items IS 'Itens de cada fase do roadmap.';
|
||||
COMMENT ON TABLE public.dev_auditoria_items IS 'Bugs, dívidas técnicas e decisões arquiteturais.';
|
||||
COMMENT ON TABLE public.dev_competitors IS 'Concorrentes analisados no benchmark.';
|
||||
COMMENT ON TABLE public.dev_competitor_features IS 'Features catalogadas de cada concorrente.';
|
||||
COMMENT ON TABLE public.dev_comparison_matrix IS 'Matriz de comparação AgenciaPsi × features esperadas do mercado.';
|
||||
COMMENT ON TABLE public.dev_comparison_competitor_status IS 'Qual concorrente tem qual feature (ponte N-N com matrix).';
|
||||
COMMENT ON TABLE public.dev_generation_log IS 'Histórico de execuções (backup, dashboard, export, seed, etc).';
|
||||
@@ -0,0 +1,48 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260417000002_dev_tables_ordem
|
||||
-- Adiciona coluna `ordem` em dev_auditoria_items e dev_competitor_features
|
||||
-- (pra suportar reordenação por drag-and-drop na UI).
|
||||
-- =============================================================================
|
||||
|
||||
-- dev_auditoria_items
|
||||
ALTER TABLE public.dev_auditoria_items
|
||||
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_ordem ON public.dev_auditoria_items(ordem);
|
||||
|
||||
-- Popular ordem existente (status + id pra evitar colisão)
|
||||
UPDATE public.dev_auditoria_items SET ordem = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'aberto' THEN 1
|
||||
WHEN 'em_analise' THEN 2
|
||||
WHEN 'resolvido' THEN 3
|
||||
WHEN 'wontfix' THEN 4
|
||||
WHEN 'duplicado' THEN 5
|
||||
ELSE 6
|
||||
END,
|
||||
id
|
||||
) AS rn
|
||||
FROM public.dev_auditoria_items
|
||||
) sub
|
||||
WHERE public.dev_auditoria_items.id = sub.id;
|
||||
|
||||
-- dev_competitor_features
|
||||
ALTER TABLE public.dev_competitor_features
|
||||
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_ordem
|
||||
ON public.dev_competitor_features(competitor_id, ordem);
|
||||
|
||||
-- Popular ordem existente (por competitor + categoria + id)
|
||||
UPDATE public.dev_competitor_features SET ordem = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY competitor_id
|
||||
ORDER BY COALESCE(categoria, 'zzz'), id
|
||||
) AS rn
|
||||
FROM public.dev_competitor_features
|
||||
) sub
|
||||
WHERE public.dev_competitor_features.id = sub.id;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000001_dev_verificacoes
|
||||
-- Nova aba "Verificações" em /saas/desenvolvimento
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Diferente de dev_auditoria_items (bugs conhecidos), esta tabela registra o
|
||||
-- PROCESSO de revisão sênior sessão-a-sessão: o que já foi olhado, o que falta
|
||||
-- olhar, o que foi encontrado em cada área do sistema.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.dev_verificacoes_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area VARCHAR(80) NOT NULL,
|
||||
categoria VARCHAR(120),
|
||||
titulo TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
resultado TEXT,
|
||||
acao_sugerida TEXT,
|
||||
severidade VARCHAR(20)
|
||||
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
|
||||
CHECK (status IN ('pendente','verificando','ok','problema','corrigido','wontfix')),
|
||||
verificado_em DATE,
|
||||
sessao_verificacao VARCHAR(160),
|
||||
arquivo_afetado TEXT,
|
||||
auditoria_item_id BIGINT REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_area ON public.dev_verificacoes_items(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_status ON public.dev_verificacoes_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_severidade ON public.dev_verificacoes_items(severidade);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_ordem ON public.dev_verificacoes_items(area, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_verificacoes_updated_at ON public.dev_verificacoes_items;
|
||||
CREATE TRIGGER trg_dev_verificacoes_updated_at
|
||||
BEFORE UPDATE ON public.dev_verificacoes_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items;
|
||||
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.dev_verificacoes_items IS 'Revisão sênior por área/sessão — o que foi verificado e o que foi encontrado.';
|
||||
COMMENT ON COLUMN public.dev_verificacoes_items.area IS 'Domínio revisado: auth, router, agenda, financeiro, pacientes, comunicacao, etc.';
|
||||
COMMENT ON COLUMN public.dev_verificacoes_items.auditoria_item_id IS 'Link opcional: se a verificação virou um bug em dev_auditoria_items.';
|
||||
@@ -0,0 +1,403 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000002_patient_intake_security_hardening
|
||||
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
|
||||
-- avatars + storage policies.
|
||||
--
|
||||
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
|
||||
-- whitelist de valores, validação de token completa (active/expires/max_uses).
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. create_patient_intake_request_v2 — versão hardened
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Mudanças vs versão anterior:
|
||||
-- • A#16: valida active, expires_at, max_uses; incrementa uses no final
|
||||
-- • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
|
||||
-- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
|
||||
-- • A#27: length checks em TODOS os campos texto
|
||||
-- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
|
||||
-- lower em emails, whitelist para genero/estado_civil
|
||||
-- • Consent obrigatório (raise se false)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
|
||||
p_token text,
|
||||
p_payload jsonb
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_active boolean;
|
||||
v_expires timestamptz;
|
||||
v_max_uses int;
|
||||
v_uses int;
|
||||
v_intake_id uuid;
|
||||
v_birth_raw text;
|
||||
v_birth date;
|
||||
v_email text;
|
||||
v_email_alt text;
|
||||
v_nome text;
|
||||
v_consent boolean;
|
||||
v_genero text;
|
||||
v_estado_civil text;
|
||||
|
||||
-- Whitelists para campos tipados
|
||||
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
|
||||
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
|
||||
BEGIN
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Carrega invite e valida TUDO (A#16)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
|
||||
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
|
||||
FROM public.patient_invites
|
||||
WHERE token = p_token
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_active IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_expires IS NOT NULL AND now() > v_expires THEN
|
||||
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
|
||||
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Resolver tenant_id (A#19)
|
||||
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_tenant_id IS NULL THEN
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_owner_id
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização + validações de campos (A#27)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Nome obrigatório (max 200)
|
||||
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
|
||||
IF v_nome IS NULL THEN
|
||||
RAISE EXCEPTION 'Nome é obrigatório';
|
||||
END IF;
|
||||
IF length(v_nome) > 200 THEN
|
||||
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
|
||||
END IF;
|
||||
|
||||
-- Email principal obrigatório + lower + max 120
|
||||
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
|
||||
IF v_email IS NULL THEN
|
||||
RAISE EXCEPTION 'E-mail é obrigatório';
|
||||
END IF;
|
||||
IF length(v_email) > 120 THEN
|
||||
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
|
||||
END IF;
|
||||
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
RAISE EXCEPTION 'E-mail inválido';
|
||||
END IF;
|
||||
|
||||
-- Email alternativo opcional mas validado se presente
|
||||
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
|
||||
IF v_email_alt IS NOT NULL THEN
|
||||
IF length(v_email_alt) > 120 THEN
|
||||
RAISE EXCEPTION 'E-mail alternativo muito longo';
|
||||
END IF;
|
||||
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
RAISE EXCEPTION 'E-mail alternativo inválido';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Consent obrigatório
|
||||
v_consent := coalesce((p_payload->>'consent')::boolean, false);
|
||||
IF v_consent IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Consentimento é obrigatório';
|
||||
END IF;
|
||||
|
||||
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
|
||||
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
|
||||
v_birth := CASE
|
||||
WHEN v_birth_raw IS NULL THEN NULL
|
||||
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
|
||||
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
|
||||
ELSE NULL
|
||||
END;
|
||||
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
|
||||
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
|
||||
v_birth := NULL;
|
||||
END IF;
|
||||
|
||||
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
|
||||
v_genero := nullif(trim(p_payload->>'genero'), '');
|
||||
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
|
||||
v_genero := NULL;
|
||||
END IF;
|
||||
|
||||
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
|
||||
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
|
||||
v_estado_civil := NULL;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- INSERT com sanitização inline
|
||||
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
|
||||
-- do terapeuta, não deve vir do paciente.
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.patient_intake_requests (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
token,
|
||||
status,
|
||||
consent,
|
||||
|
||||
nome_completo,
|
||||
email_principal,
|
||||
email_alternativo,
|
||||
telefone,
|
||||
telefone_alternativo,
|
||||
|
||||
avatar_url,
|
||||
|
||||
data_nascimento,
|
||||
cpf,
|
||||
rg,
|
||||
genero,
|
||||
estado_civil,
|
||||
profissao,
|
||||
escolaridade,
|
||||
nacionalidade,
|
||||
naturalidade,
|
||||
|
||||
cep,
|
||||
pais,
|
||||
cidade,
|
||||
estado,
|
||||
endereco,
|
||||
numero,
|
||||
complemento,
|
||||
bairro,
|
||||
|
||||
observacoes,
|
||||
|
||||
encaminhado_por,
|
||||
onde_nos_conheceu
|
||||
)
|
||||
VALUES (
|
||||
v_owner_id,
|
||||
v_tenant_id,
|
||||
p_token,
|
||||
'new',
|
||||
v_consent,
|
||||
|
||||
v_nome,
|
||||
v_email,
|
||||
v_email_alt,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
|
||||
|
||||
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
|
||||
|
||||
v_birth,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'rg'), ''), 20),
|
||||
v_genero,
|
||||
v_estado_civil,
|
||||
left(nullif(trim(p_payload->>'profissao'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
|
||||
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
|
||||
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'pais'), ''), 60),
|
||||
left(nullif(trim(p_payload->>'cidade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'estado'), ''), 2),
|
||||
left(nullif(trim(p_payload->>'endereco'), ''), 200),
|
||||
left(nullif(trim(p_payload->>'numero'), ''), 20),
|
||||
left(nullif(trim(p_payload->>'complemento'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'bairro'), ''), 120),
|
||||
|
||||
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
|
||||
|
||||
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
|
||||
)
|
||||
RETURNING id INTO v_intake_id;
|
||||
|
||||
-- Incrementa contador de uso (A#16)
|
||||
UPDATE public.patient_invites
|
||||
SET uses = uses + 1
|
||||
WHERE token = p_token;
|
||||
|
||||
RETURN v_intake_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) IS
|
||||
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas (campo interno); exige consent=true.';
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
|
||||
-- Novo: gera gen_random_uuid() server-side e retorna.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_tenant_id uuid;
|
||||
v_new_token text;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
|
||||
v_new_token := replace(gen_random_uuid()::text, '-', '');
|
||||
|
||||
-- Resolve tenant_id do usuário (active)
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_uid
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- Desativa tokens ativos anteriores
|
||||
UPDATE public.patient_invites
|
||||
SET active = false
|
||||
WHERE owner_id = v_uid
|
||||
AND active = true;
|
||||
|
||||
-- Insere novo
|
||||
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
|
||||
VALUES (v_uid, v_tenant_id, v_new_token, true);
|
||||
|
||||
RETURN v_new_token;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
|
||||
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Substitui o client-side newToken() + direct insert em patient_invites.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_tenant_id uuid;
|
||||
v_token text;
|
||||
v_existing text;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
|
||||
SELECT token
|
||||
INTO v_existing
|
||||
FROM public.patient_invites
|
||||
WHERE owner_id = v_uid
|
||||
AND active = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN v_existing;
|
||||
END IF;
|
||||
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_uid
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
v_token := replace(gen_random_uuid()::text, '-', '');
|
||||
|
||||
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
|
||||
VALUES (v_uid, v_tenant_id, v_token, true);
|
||||
|
||||
RETURN v_token;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.issue_patient_invite() IS
|
||||
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;
|
||||
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
|
||||
-- -----------------------------------------------------------------------------
|
||||
UPDATE storage.buckets
|
||||
SET file_size_limit = 5242880, -- 5 MB
|
||||
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
|
||||
WHERE id = 'avatars';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 5. Storage policies — remover upload anon irrestrito (A#15)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
|
||||
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
|
||||
-- arquivo. Removemos essas policies. Upload público passa a exigir token
|
||||
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
|
||||
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;
|
||||
|
||||
-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
|
||||
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
|
||||
-- o terapeuta vê no painel de cadastros recebidos).
|
||||
CREATE POLICY "intake_read_owner_only"
|
||||
ON storage.objects FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
bucket_id = 'avatars'
|
||||
AND (storage.foldername(name))[1] = 'intakes'
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
|
||||
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';
|
||||
@@ -0,0 +1,280 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000003_patient_invite_attempts_log
|
||||
-- Resolve A#24: log de tentativas de submit no cadastro público externo.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Observação sobre IP: em RPC Postgres chamada via PostgREST o IP real do
|
||||
-- cliente não chega aqui (só o do connection pooler). Por isso o registro
|
||||
-- guarda o user_agent enviado pelo cliente (quando disponível) + metadados
|
||||
-- resolvidos (owner, tenant). Rate-limit real por IP deve ser feito em edge
|
||||
-- function no futuro (A#20).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_invite_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token text NOT NULL,
|
||||
ok boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
client_info text, -- user_agent enviado pelo cliente (cap 500 no INSERT)
|
||||
owner_id uuid, -- resolvido do token quando possível
|
||||
tenant_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_created ON public.patient_invite_attempts(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_token ON public.patient_invite_attempts(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_owner ON public.patient_invite_attempts(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_ok ON public.patient_invite_attempts(ok) WHERE ok = false;
|
||||
|
||||
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Owner vê suas próprias tentativas (qualquer flood/erro que envolveu seus links)
|
||||
DROP POLICY IF EXISTS patient_invite_attempts_owner_read ON public.patient_invite_attempts;
|
||||
CREATE POLICY patient_invite_attempts_owner_read
|
||||
ON public.patient_invite_attempts FOR SELECT
|
||||
TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.patient_invite_attempts IS
|
||||
'Log de tentativas (ok e falhas) de submit do form público de cadastro externo. Base para monitoramento de flood/tentativas maliciosas. Sem IP direto — proteção LGPD.';
|
||||
|
||||
COMMENT ON COLUMN public.patient_invite_attempts.client_info IS
|
||||
'User-agent enviado pelo cliente (opcional). Limitado a 500 chars no insert. Não contém PII.';
|
||||
|
||||
-- =============================================================================
|
||||
-- create_patient_intake_request_v2 — versão instrumentada
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Mesma função do hardening anterior, agora com log em patient_invite_attempts.
|
||||
-- O log é feito num bloco EXCEPTION que NUNCA propaga falha de log pro fluxo
|
||||
-- principal (log falhar jamais deve impedir o cadastro de ser aceito).
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
|
||||
p_token text,
|
||||
p_payload jsonb,
|
||||
p_client_info text DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_owner_id uuid;
|
||||
v_tenant_id uuid;
|
||||
v_active boolean;
|
||||
v_expires timestamptz;
|
||||
v_max_uses int;
|
||||
v_uses int;
|
||||
v_intake_id uuid;
|
||||
v_birth_raw text;
|
||||
v_birth date;
|
||||
v_email text;
|
||||
v_email_alt text;
|
||||
v_nome text;
|
||||
v_consent boolean;
|
||||
v_genero text;
|
||||
v_estado_civil text;
|
||||
v_err_msg text;
|
||||
v_err_code text;
|
||||
v_clean_info text;
|
||||
|
||||
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
|
||||
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
|
||||
|
||||
-- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
|
||||
-- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
|
||||
BEGIN
|
||||
-- Sanitiza client_info recebido (cap + trim)
|
||||
v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Resolve invite + valida TUDO (A#16)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
|
||||
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
|
||||
FROM public.patient_invites
|
||||
WHERE token = p_token
|
||||
LIMIT 1;
|
||||
|
||||
IF v_owner_id IS NULL THEN
|
||||
v_err_code := 'TOKEN_INVALID';
|
||||
v_err_msg := 'Token inválido';
|
||||
-- Log + raise (owner_id NULL porque token não bateu)
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_active IS NOT TRUE THEN
|
||||
v_err_code := 'TOKEN_DISABLED';
|
||||
v_err_msg := 'Link desativado';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_expires IS NOT NULL AND now() > v_expires THEN
|
||||
v_err_code := 'TOKEN_EXPIRED';
|
||||
v_err_msg := 'Link expirado';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
|
||||
v_err_code := 'TOKEN_MAX_USES';
|
||||
v_err_msg := 'Limite de uso atingido';
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Resolve tenant_id se invite não tiver (A#19)
|
||||
IF v_tenant_id IS NULL THEN
|
||||
SELECT tenant_id
|
||||
INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_owner_id
|
||||
AND status = 'active'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização + validações de campos (A#27)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
|
||||
IF v_nome IS NULL THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF length(v_nome) > 200 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
|
||||
IF v_email IS NULL THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF length(v_email) > 120 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
|
||||
IF v_email_alt IS NOT NULL THEN
|
||||
IF length(v_email_alt) > 120 THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
|
||||
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_consent := coalesce((p_payload->>'consent')::boolean, false);
|
||||
IF v_consent IS NOT TRUE THEN
|
||||
v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
|
||||
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
RAISE EXCEPTION '%', v_err_msg;
|
||||
END IF;
|
||||
|
||||
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
|
||||
v_birth := CASE
|
||||
WHEN v_birth_raw IS NULL THEN NULL
|
||||
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
|
||||
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
|
||||
ELSE NULL
|
||||
END;
|
||||
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
|
||||
v_birth := NULL;
|
||||
END IF;
|
||||
|
||||
v_genero := nullif(trim(p_payload->>'genero'), '');
|
||||
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
|
||||
v_genero := NULL;
|
||||
END IF;
|
||||
|
||||
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
|
||||
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
|
||||
v_estado_civil := NULL;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- INSERT
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.patient_intake_requests (
|
||||
owner_id, tenant_id, token, status, consent,
|
||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||
avatar_url,
|
||||
data_nascimento, cpf, rg, genero, estado_civil,
|
||||
profissao, escolaridade, nacionalidade, naturalidade,
|
||||
cep, pais, cidade, estado, endereco, numero, complemento, bairro,
|
||||
observacoes, encaminhado_por, onde_nos_conheceu
|
||||
)
|
||||
VALUES (
|
||||
v_owner_id, v_tenant_id, p_token, 'new', v_consent,
|
||||
v_nome, v_email, v_email_alt,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
|
||||
v_birth,
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'rg'), ''), 20),
|
||||
v_genero, v_estado_civil,
|
||||
left(nullif(trim(p_payload->>'profissao'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
|
||||
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
|
||||
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
|
||||
left(nullif(trim(p_payload->>'pais'), ''), 60),
|
||||
left(nullif(trim(p_payload->>'cidade'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'estado'), ''), 2),
|
||||
left(nullif(trim(p_payload->>'endereco'), ''), 200),
|
||||
left(nullif(trim(p_payload->>'numero'), ''), 20),
|
||||
left(nullif(trim(p_payload->>'complemento'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'bairro'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
|
||||
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
|
||||
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
|
||||
)
|
||||
RETURNING id INTO v_intake_id;
|
||||
|
||||
UPDATE public.patient_invites
|
||||
SET uses = uses + 1
|
||||
WHERE token = p_token;
|
||||
|
||||
-- Log de sucesso (best-effort, não propaga erro)
|
||||
BEGIN
|
||||
INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
|
||||
VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
|
||||
RETURN v_intake_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) IS
|
||||
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas; exige consent=true; registra cada tentativa em patient_invite_attempts (A#24).';
|
||||
@@ -0,0 +1,149 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000004_dev_tests
|
||||
-- Nova aba "Testes" em /saas/desenvolvimento — catálogo de suítes de teste.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Espelha a estrutura de dev_verificacoes_items. Uma linha = uma suíte de
|
||||
-- teste (arquivo .spec.js ou grupo de testes). Serve para responder "quais
|
||||
-- áreas estão cobertas por teste?" sem rodar npm test.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.dev_test_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area VARCHAR(80) NOT NULL,
|
||||
categoria VARCHAR(120), -- unit, integration, e2e, manual
|
||||
titulo TEXT NOT NULL,
|
||||
arquivo TEXT,
|
||||
descricao TEXT,
|
||||
total_tests INTEGER DEFAULT 0,
|
||||
passing INTEGER DEFAULT 0,
|
||||
failing INTEGER DEFAULT 0,
|
||||
skipped INTEGER DEFAULT 0,
|
||||
cobertura_pct NUMERIC(5,2), -- cobertura estimada daquela área
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ok'
|
||||
CHECK (status IN ('ok','falhando','pendente','obsoleto','a_escrever')),
|
||||
last_run_at TIMESTAMPTZ,
|
||||
sessao_criacao VARCHAR(160),
|
||||
notas TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
ordem INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_area ON public.dev_test_items(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_status ON public.dev_test_items(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dev_test_items_ordem ON public.dev_test_items(area, ordem);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_dev_test_items_updated_at ON public.dev_test_items;
|
||||
CREATE TRIGGER trg_dev_test_items_updated_at
|
||||
BEFORE UPDATE ON public.dev_test_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
|
||||
|
||||
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS dev_test_items_saas_admin_all ON public.dev_test_items;
|
||||
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.dev_test_items IS
|
||||
'Catálogo de suítes de teste por área. Responde "o que está testado?" sem precisar rodar npm test.';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- Seed inicial — testes existentes em 2026-04-18
|
||||
-- =============================================================================
|
||||
INSERT INTO public.dev_test_items
|
||||
(area, categoria, titulo, arquivo, descricao, total_tests, passing, failing, skipped, cobertura_pct, status, last_run_at, sessao_criacao, notas, tags, ordem)
|
||||
VALUES
|
||||
('agenda', 'unit',
|
||||
'useRecurrence — geração de ocorrências',
|
||||
'src/features/agenda/composables/__tests__/useRecurrence.spec.js',
|
||||
$$Cobre: generateDates (weekly, biweekly, custom_weekdays, monthly, yearly), expandRules com exceções (cancel_session, patient_missed, reschedule_session, holiday_block), mergeWithStoredSessions, max_occurrences, range boundaries, remarcação inbound.$$,
|
||||
23, 23, 0, 0, NULL,
|
||||
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
|
||||
'Suite sólida. Cobre os branches críticos da expansão de recorrência. Testes sobreviveram à adição do cap de range (V#20) e ao filtro de tenant_id nas CRUDs (V#12).',
|
||||
ARRAY['unit','agenda','recurrence','critical'], 1),
|
||||
|
||||
('agenda', 'unit',
|
||||
'agendaMappers — transformação pra FullCalendar',
|
||||
'src/features/agenda/services/__tests__/agendaMappers.spec.js',
|
||||
$$Cobre: mapAgendaEventosToCalendarEvents (shape, campos extras), status → cor + ícone (agendado, realizado, faltou, cancelado, remarcado), aliases de FK (patients, determined_commitments), tipo fallback, ocorrência virtual (is_occurrence), resource events (clinic mosaic).$$,
|
||||
40, 40, 0, 0, NULL,
|
||||
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
|
||||
'Quatro testes estavam falhando antes do V#21 (status "remarcado" vs "remarcar" + cores faltou/cancelado invertidas). Agora 100%.',
|
||||
ARRAY['unit','agenda','mappers'], 2),
|
||||
|
||||
('auth', 'a_escrever',
|
||||
'guards.js — branches do router beforeEach',
|
||||
'src/router/__tests__/guards.spec.js (não existe)',
|
||||
$$Deveria cobrir: rotas públicas liberadas, redirect pra /auth/login sem session, área /account sem tenant, saas_admin só em /saas, tenant lockdown, trocaTenantScope, matchesRoles com aliases, cache de globalRole, cache de saasAdmin.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'guards.js tem ~650 linhas e só roda via navegação real. Sem teste unitário → mudanças no guard são de alto risco. Prioridade média para criar (mock do router + pinia).',
|
||||
ARRAY['unit','auth','router','guard','missing'], 3),
|
||||
|
||||
('auth', 'a_escrever',
|
||||
'session.js — hydrate e race conditions',
|
||||
'src/app/__tests__/session.spec.js (não existe)',
|
||||
$$Deveria cobrir: initSession com/sem session, refreshSession que não dispara se refreshing, SIGNED_IN redundante ignorado, SIGNED_OUT zera state, TOKEN_REFRESHED não derruba cache, hydrate preserva user em erro.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'Módulo tem histórico de race conditions (comentado no próprio arquivo). Teste unitário daria garantia contra regressão.',
|
||||
ARRAY['unit','auth','session','race','missing'], 4),
|
||||
|
||||
('stores', 'a_escrever',
|
||||
'tenantStore — singleflight + persist',
|
||||
'src/stores/__tests__/tenantStore.spec.js (não existe)',
|
||||
$$Deveria cobrir: loadSessionAndTenant com Promise compartilhada (V#3), ensureLoaded sem setInterval, tenant salvo só se pertence ao user, normalizeTenantRole, reset, persistência em localStorage.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'V#3 trocou polling por Promise singleflight — a correção não tem teste que proteja contra regressão.',
|
||||
ARRAY['unit','store','tenant','missing'], 5),
|
||||
|
||||
('utils', 'a_escrever',
|
||||
'roleNormalizer — saídas esperadas',
|
||||
'src/utils/__tests__/roleNormalizer.spec.js (não existe)',
|
||||
$$Fácil de testar — função pura, sem IO. Cobre: tenant_admin+therapist→therapist, tenant_admin+clinic→clinic_admin, tenant_admin+supervisor→supervisor, tenant_admin sem kind→clinic_admin, clinic_admin→clinic_admin, pass-through.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 1 — auth/router',
|
||||
'Criado em V#4. É função pura — fácil de cobrir em 10min. Baixa prioridade técnica mas alto valor simbólico (garantir que os 2 consumidores — guards.js e tenantStore.js — concordam).',
|
||||
ARRAY['unit','utils','trivial'], 6),
|
||||
|
||||
('pacientes', 'a_escrever',
|
||||
'Cadastros externos — fluxo do paciente',
|
||||
'src/features/patients/__tests__/external-intake.spec.js (não existe)',
|
||||
$$Deveria cobrir: validação client-side (token regex, email, consent), truncation em todos os campos, payload final, não envio de notas_internas, comportamento com token inválido.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Página pública é ponto crítico de segurança. Teste de regressão importante após A#17/A#18/A#21 — garantir que nenhum dos valores "perigosos" voltem a ser enviados.',
|
||||
ARRAY['unit','pacientes','external','security-regression'], 7),
|
||||
|
||||
('database', 'manual',
|
||||
'RPCs de intake — validação de inputs maliciosos',
|
||||
'database-novo/tests/test_patient_intake_security.sql (sugerido)',
|
||||
$$Deveria cobrir: token inválido raise, token desativado raise (A#16), token expirado raise, max_uses raise, uses incrementa após sucesso, consent=false raise, payload com notas_internas é ignorado (A#17), tenant_id é preenchido (A#19), nome > 200 chars raise, email inválido raise, genero fora whitelist vira NULL, data_nascimento futura vira NULL.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Testes SQL diretos via psql. Importantes porque as validações estão dentro do RPC SECURITY DEFINER. Executar antes de cada deploy.',
|
||||
ARRAY['manual','sql','security','rpc'], 8),
|
||||
|
||||
('agenda', 'a_escrever',
|
||||
'useAgendaEvents — wrapper do repository',
|
||||
'src/features/agenda/composables/__tests__/useAgendaEvents.spec.js (não existe)',
|
||||
$$Deveria cobrir: loadMyRange chama listMyAgendaEvents, estado loading/error transições, sem ownerId retorna cedo, rollback em erro.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 2 — agenda',
|
||||
'Após refactor V#14 o composable virou fino. Teste garante que continue fino.',
|
||||
ARRAY['unit','agenda','composable','missing'], 9),
|
||||
|
||||
('e2e', 'a_escrever',
|
||||
'Fluxo completo: terapeuta cria link → paciente preenche → terapeuta vê',
|
||||
'(não existe)',
|
||||
$$Deveria cobrir o happy path integrado: login terapeuta, gera link via issue_patient_invite, abre /cadastro/paciente em aba anônima, preenche, submit, terapeuta vê em /therapist/patients/recebidos.$$,
|
||||
0, 0, 0, 0, NULL,
|
||||
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
|
||||
'Não há E2E hoje. Playwright ou Cypress valem? Decidir provider. Alta prioridade pra confiança em deploy.',
|
||||
ARRAY['e2e','critical','missing','decisão-pendente'], 10);
|
||||
|
||||
SELECT id, area, categoria, status, total_tests, passing FROM public.dev_test_items ORDER BY ordem;
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260418000005_saas_rls_emergency_fix
|
||||
-- Corrige A#30 (P0) — 7 tabelas SaaS estavam com RLS desabilitado + grants
|
||||
-- totais pra anon/authenticated/service_role. Qualquer usuário anônimo
|
||||
-- podia alterar/deletar dados críticos (tenant_features, plan_prices,
|
||||
-- subscription_intents_personal/tenant, plan_public, ...).
|
||||
--
|
||||
-- Estratégia:
|
||||
-- 1. Habilitar RLS em todas as 7 tabelas
|
||||
-- 2. REVOKE ALL de anon (nunca deveria ter tido)
|
||||
-- 3. REVOKE ALL de authenticated (controle passa a ser via policy)
|
||||
-- 4. Policies explícitas por caso de uso
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. REVOKE grants inseguros
|
||||
-- -----------------------------------------------------------------------------
|
||||
REVOKE ALL ON public.tenant_features FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_prices FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_public FROM anon, authenticated;
|
||||
REVOKE ALL ON public.plan_public_bullets FROM anon, authenticated;
|
||||
REVOKE ALL ON public.subscription_intents_personal FROM anon, authenticated;
|
||||
REVOKE ALL ON public.subscription_intents_tenant FROM anon, authenticated;
|
||||
REVOKE ALL ON public.tenant_feature_exceptions_log FROM anon, authenticated;
|
||||
|
||||
-- Concede o mínimo necessário (controlado por RLS abaixo)
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_features TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_prices TO authenticated;
|
||||
GRANT SELECT ON public.plan_public TO anon, authenticated;
|
||||
GRANT INSERT, UPDATE, DELETE ON public.plan_public TO authenticated;
|
||||
GRANT SELECT ON public.plan_public_bullets TO anon, authenticated;
|
||||
GRANT INSERT, UPDATE, DELETE ON public.plan_public_bullets TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_personal TO authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_tenant TO authenticated;
|
||||
GRANT SELECT ON public.tenant_feature_exceptions_log TO authenticated;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. HABILITAR RLS em todas
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. POLICIES — tenant_features
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- SELECT: membros do tenant leem as features do próprio tenant. Saas admin lê tudo.
|
||||
DROP POLICY IF EXISTS tenant_features_select ON public.tenant_features;
|
||||
CREATE POLICY tenant_features_select ON public.tenant_features
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
|
||||
);
|
||||
|
||||
-- WRITE: apenas tenant_admin do próprio tenant OU saas_admin.
|
||||
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
|
||||
CREATE POLICY tenant_features_write ON public.tenant_features
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. POLICIES — plan_prices (SaaS admin only pra escrita; authenticated lê)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS plan_prices_read ON public.plan_prices;
|
||||
CREATE POLICY plan_prices_read ON public.plan_prices
|
||||
FOR SELECT TO authenticated
|
||||
USING (true); -- preços são públicos pra usuários logados
|
||||
|
||||
DROP POLICY IF EXISTS plan_prices_write ON public.plan_prices;
|
||||
CREATE POLICY plan_prices_write ON public.plan_prices
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 5. POLICIES — plan_public + plan_public_bullets (anon pode ler — landing page)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS plan_public_read_anon ON public.plan_public;
|
||||
CREATE POLICY plan_public_read_anon ON public.plan_public
|
||||
FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_write ON public.plan_public;
|
||||
CREATE POLICY plan_public_write ON public.plan_public
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_bullets_read_anon ON public.plan_public_bullets;
|
||||
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets
|
||||
FOR SELECT TO anon, authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS plan_public_bullets_write ON public.plan_public_bullets;
|
||||
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 6. POLICIES — subscription_intents_personal + _tenant
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Dono vê o próprio intent; saas admin vê tudo; owner cria/atualiza seus próprios.
|
||||
DROP POLICY IF EXISTS subscription_intents_personal_owner ON public.subscription_intents_personal;
|
||||
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal
|
||||
FOR ALL TO authenticated
|
||||
USING (user_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (user_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS subscription_intents_tenant_member ON public.subscription_intents_tenant;
|
||||
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 7. POLICY — tenant_feature_exceptions_log (somente leitura)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Log de auditoria. Inserts vêm de triggers/funções server-side (SECURITY DEFINER).
|
||||
DROP POLICY IF EXISTS tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log;
|
||||
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.tenant_features IS
|
||||
'Controle de features por tenant. RLS: member do tenant lê; tenant_admin ou saas_admin escreve. Antes da migration 20260418000005 estava com RLS off + GRANT ALL pra anon (A#30).';
|
||||
@@ -0,0 +1,214 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000001_tenant_features_b2_governance
|
||||
-- Resolve V#34 (isEnabled opt-out por padrão) + V#41 (dupla fonte entitlements
|
||||
-- vs tenant_features) — Opção B2 (plano + override com exceção comercial).
|
||||
--
|
||||
-- Mudanças:
|
||||
-- 1. Trigger tenant_features_guard_with_plan ganha bypass via session flag
|
||||
-- (current_setting('app.allow_feature_exception')) — só RPC pode setar.
|
||||
-- 2. Nova RPC set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)
|
||||
-- SECURITY DEFINER, com regras assimétricas:
|
||||
-- - p_enabled=false → tenant_admin OU saas_admin (preferência)
|
||||
-- - p_enabled=true AND plano permite → tenant_admin OU saas_admin
|
||||
-- - p_enabled=true AND plano NÃO permite → SOMENTE saas_admin + reason obrigatório
|
||||
-- Toda mudança grava em tenant_feature_exceptions_log.
|
||||
-- 3. Policy tenant_features_write restringida a saas_admin (writes diretos).
|
||||
-- Tenant_admin agora muda só via RPC.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Trigger: bypass controlado por session flag
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.tenant_features_guard_with_plan()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_allowed boolean;
|
||||
v_bypass text;
|
||||
BEGIN
|
||||
-- Só valida quando está habilitando
|
||||
IF new.enabled IS DISTINCT FROM true THEN
|
||||
RETURN new;
|
||||
END IF;
|
||||
|
||||
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
|
||||
-- após validar que o caller é saas_admin com reason.
|
||||
v_bypass := current_setting('app.allow_feature_exception', true);
|
||||
IF v_bypass = 'true' THEN
|
||||
RETURN new;
|
||||
END IF;
|
||||
|
||||
-- Permitido pelo plano do tenant?
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.v_tenant_entitlements_full v
|
||||
WHERE v.tenant_id = new.tenant_id
|
||||
AND v.feature_key = new.feature_key
|
||||
AND v.allowed = true
|
||||
) INTO v_allowed;
|
||||
|
||||
IF NOT v_allowed THEN
|
||||
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
|
||||
new.feature_key, new.tenant_id
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. RPC set_tenant_feature_exception
|
||||
-- (substitui versão anterior que retornava void; retorna jsonb agora)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP FUNCTION IF EXISTS public.set_tenant_feature_exception(uuid, text, boolean, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_tenant_feature_exception(
|
||||
p_tenant_id uuid,
|
||||
p_feature_key text,
|
||||
p_enabled boolean,
|
||||
p_reason text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller uuid := auth.uid();
|
||||
v_is_saas boolean := public.is_saas_admin();
|
||||
v_is_tenant_adm boolean;
|
||||
v_plan_allows boolean;
|
||||
v_feature_key text;
|
||||
v_reason text;
|
||||
v_is_exception boolean;
|
||||
BEGIN
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Sanitização (padrão V#31)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
IF p_enabled IS NULL THEN
|
||||
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
|
||||
IF v_feature_key IS NULL THEN
|
||||
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF length(v_feature_key) > 80 THEN
|
||||
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
|
||||
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
|
||||
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
|
||||
v_reason := substring(v_reason FROM 1 FOR 500);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
|
||||
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Plano permite essa feature?
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.v_tenant_entitlements vte
|
||||
WHERE vte.tenant_id = p_tenant_id
|
||||
AND vte.feature_key = v_feature_key
|
||||
) INTO v_plan_allows;
|
||||
|
||||
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Caller é tenant_admin desse tenant?
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
v_is_tenant_adm := EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = p_tenant_id
|
||||
AND tm.user_id = v_caller
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
);
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Autorização (assimétrica — V#34 Opção B2)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_is_exception THEN
|
||||
-- Override positivo fora do plano = exceção comercial
|
||||
IF NOT v_is_saas THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF v_reason IS NULL THEN
|
||||
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
ELSE
|
||||
-- Demais casos: tenant_admin OR saas_admin
|
||||
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
|
||||
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
-- Persistência: bypass controlado do trigger guard quando é exceção
|
||||
-- (escopo de transação via SET LOCAL — só esta RPC vê)
|
||||
-- ───────────────────────────────────────────────────────────────────────
|
||||
IF v_is_exception THEN
|
||||
PERFORM set_config('app.allow_feature_exception', 'true', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
|
||||
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
|
||||
ON CONFLICT (tenant_id, feature_key)
|
||||
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
|
||||
|
||||
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
|
||||
IF v_is_exception THEN
|
||||
PERFORM set_config('app.allow_feature_exception', 'false', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.tenant_feature_exceptions_log
|
||||
(tenant_id, feature_key, enabled, reason, created_by)
|
||||
VALUES
|
||||
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'tenant_id', p_tenant_id,
|
||||
'feature_key', v_feature_key,
|
||||
'enabled', p_enabled,
|
||||
'plan_allows', v_plan_allows,
|
||||
'is_exception', v_is_exception,
|
||||
'reason', v_reason
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) TO authenticated;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Policy: writes diretos só via saas_admin
|
||||
-- (tenant_admin agora muda só via RPC set_tenant_feature_exception)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
|
||||
DROP POLICY IF EXISTS tenant_features_write_saas_only ON public.tenant_features;
|
||||
|
||||
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000002_features_is_active
|
||||
-- V#40 — features hard-deleted: adiciona is_active para soft-delete.
|
||||
--
|
||||
-- Estratégia conservadora:
|
||||
-- - features.is_active boolean DEFAULT true NOT NULL
|
||||
-- - SaasFeaturesPage substitui DELETE por UPDATE is_active=false
|
||||
-- - Views que expõem features para o app (v_tenant_entitlements etc) NÃO são
|
||||
-- alteradas: features depreciadas ainda servem tenants legados via plan_features
|
||||
-- enquanto não houver migração explícita
|
||||
-- - Permite reativar feature acidentalmente deprecada
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.features
|
||||
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_features_is_active
|
||||
ON public.features (is_active) WHERE is_active = false;
|
||||
|
||||
COMMENT ON COLUMN public.features.is_active IS
|
||||
'V#40: false = feature depreciada, escondida no catálogo SaaS mas continua válida em planos/tenants existentes.';
|
||||
@@ -0,0 +1,69 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000003_delete_plan_safe
|
||||
-- V#36 — DELETE de plans sem checagem de assinaturas ativas pode quebrar tenants.
|
||||
--
|
||||
-- Cria RPC delete_plan_safe(plan_id) que:
|
||||
-- - Valida saas_admin
|
||||
-- - Conta subscriptions ativas (status='active') no plano
|
||||
-- - Se houver, RAISE EXCEPTION descritivo com a contagem
|
||||
-- - Se OK, desativa prices ativos e deleta o plano (atomic)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_plan_safe(
|
||||
p_plan_id uuid
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_active_count int;
|
||||
v_plan_key text;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF p_plan_id IS NULL THEN
|
||||
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
|
||||
IF v_plan_key IS NULL THEN
|
||||
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_active_count
|
||||
FROM public.subscriptions
|
||||
WHERE plan_id = p_plan_id
|
||||
AND status = 'active';
|
||||
|
||||
IF v_active_count > 0 THEN
|
||||
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
|
||||
v_plan_key, v_active_count
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- desativa preços ativos antes de deletar
|
||||
UPDATE public.plan_prices
|
||||
SET is_active = false,
|
||||
active_to = now()
|
||||
WHERE plan_id = p_plan_id
|
||||
AND is_active = true;
|
||||
|
||||
DELETE FROM public.plans WHERE id = p_plan_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'plan_key', v_plan_key
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.delete_plan_safe(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.delete_plan_safe(uuid) TO authenticated;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000004_consolidate_policies
|
||||
-- V#35 — Consolida policies duplicadas em plans, features, plan_features e
|
||||
-- subscriptions. Remove legado redundante e documenta as que ficam.
|
||||
--
|
||||
-- Análise (auditada via pg_policies):
|
||||
-- • plans/features/plan_features: cada uma tem "read * (auth)" duplicado
|
||||
-- com "*_read_authenticated" (mesmo USING true). Removidos os legados.
|
||||
-- • subscriptions:
|
||||
-- - "subscriptions read own" (USING user_id = auth.uid()) é SUBSET de
|
||||
-- "subscriptions_read_own" (USING user_id = auth.uid() OR is_saas_admin())
|
||||
-- - "subscriptions_select_own_personal" (user_id = auth.uid() AND tenant_id IS NULL)
|
||||
-- é SUBSET de "subscriptions_read_own"
|
||||
-- - "subscriptions_no_direct_update" (USING false) é no-op em OR com
|
||||
-- "subscriptions_update_only_saas_admin"
|
||||
-- Removidas as 3 redundâncias.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Drops dos legados redundantes
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "read plans (auth)" ON public.plans;
|
||||
DROP POLICY IF EXISTS "read features (auth)" ON public.features;
|
||||
DROP POLICY IF EXISTS "read plan_features (auth)" ON public.plan_features;
|
||||
|
||||
DROP POLICY IF EXISTS "subscriptions read own" ON public.subscriptions;
|
||||
DROP POLICY IF EXISTS "subscriptions_select_own_personal" ON public.subscriptions;
|
||||
DROP POLICY IF EXISTS "subscriptions_no_direct_update" ON public.subscriptions;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- COMMENT ON POLICY — documenta escopo das que ficaram
|
||||
-- -----------------------------------------------------------------------------
|
||||
COMMENT ON POLICY plans_read_authenticated ON public.plans IS 'Qualquer usuário autenticado lê o catálogo de planos (vitrine, upgrade UI).';
|
||||
COMMENT ON POLICY plans_write_saas_admin ON public.plans IS 'Somente saas_admin escreve. DELETE deve ser via RPC delete_plan_safe (V#36).';
|
||||
|
||||
COMMENT ON POLICY features_read_authenticated ON public.features IS 'Qualquer logado lê o catálogo de features.';
|
||||
COMMENT ON POLICY features_write_saas_admin ON public.features IS 'Somente saas_admin escreve. DELETE = soft delete via is_active=false (V#40).';
|
||||
|
||||
COMMENT ON POLICY plan_features_read_authenticated ON public.plan_features IS 'Qualquer logado lê o vínculo plano↔feature (necessário para entitlements).';
|
||||
COMMENT ON POLICY plan_features_write_saas_admin ON public.plan_features IS 'Somente saas_admin escreve.';
|
||||
|
||||
COMMENT ON POLICY subscriptions_read_own ON public.subscriptions IS 'Dono da assinatura (user_id) ou saas_admin. Cobre o caso pessoal.';
|
||||
COMMENT ON POLICY subscriptions_select_for_tenant_members ON public.subscriptions IS 'Membros ativos do tenant leem assinaturas do tenant.';
|
||||
COMMENT ON POLICY "subscriptions: read if linked owner_users" ON public.subscriptions IS 'Caso especial: usuários ligados ao owner via owner_users (terapeutas de uma clínica que precisam ver a assinatura do owner).';
|
||||
COMMENT ON POLICY subscriptions_insert_own_personal ON public.subscriptions IS 'Usuário cria a própria assinatura pessoal (intent → conversion).';
|
||||
COMMENT ON POLICY subscriptions_update_only_saas_admin ON public.subscriptions IS 'UPDATE direto somente via saas_admin. Mudanças de tenant devem passar por RPC dedicada.';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000005_restrict_intake_rpc
|
||||
-- A#20 — Restringe create_patient_intake_request_v2 a service_role.
|
||||
--
|
||||
-- Antes: anon (e PUBLIC) podia chamar direto. Bot bypassava qualquer
|
||||
-- proteção do front (Turnstile etc).
|
||||
-- Agora: edge function `submit-patient-intake` valida CAPTCHA e chama
|
||||
-- a RPC com service_role. Anon não chama mais a RPC direto.
|
||||
-- =============================================================================
|
||||
|
||||
-- Revoga PUBLIC (DEFAULT) e anon
|
||||
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) FROM PUBLIC, anon;
|
||||
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) FROM PUBLIC, anon;
|
||||
|
||||
-- Mantém grants explícitos pra authenticated (uso interno futuro) e service_role (edge function)
|
||||
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) TO authenticated, service_role;
|
||||
|
||||
-- Mesma proteção para RPC v1 legada (caso ainda exista)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public' AND p.proname = 'create_patient_intake_request'
|
||||
) THEN
|
||||
EXECUTE 'REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) FROM PUBLIC, anon';
|
||||
EXECUTE 'GRANT EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) TO authenticated, service_role';
|
||||
END IF;
|
||||
END$$;
|
||||
@@ -0,0 +1,136 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000006_layered_bot_defense
|
||||
-- A#20 (rev2) — Defesa em camadas self-hosted (substitui Turnstile).
|
||||
--
|
||||
-- Camadas:
|
||||
-- 1. Honeypot field (no front) → invisível, sempre ativo
|
||||
-- 2. Rate limit por IP no edge → submission_rate_limits
|
||||
-- 3. Math captcha CONDICIONAL → só se IP teve N falhas recentes
|
||||
-- 4. Logging em public_submission_attempts (genérico, não só intake)
|
||||
-- 5. Modo paranoid global → saas_security_config.captcha_required
|
||||
--
|
||||
-- Substitui chamadas Turnstile na edge function submit-patient-intake.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 1. saas_security_config (singleton)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.saas_security_config (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
honeypot_enabled boolean NOT NULL DEFAULT true,
|
||||
rate_limit_enabled boolean NOT NULL DEFAULT true,
|
||||
rate_limit_window_min integer NOT NULL DEFAULT 10,
|
||||
rate_limit_max_attempts integer NOT NULL DEFAULT 5,
|
||||
captcha_after_failures integer NOT NULL DEFAULT 3,
|
||||
captcha_required_globally boolean NOT NULL DEFAULT false,
|
||||
block_duration_min integer NOT NULL DEFAULT 30,
|
||||
captcha_required_window_min integer NOT NULL DEFAULT 60,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_security_config_singleton CHECK (id = true)
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_security_config (id) VALUES (true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.saas_security_config FROM anon, authenticated;
|
||||
GRANT SELECT, UPDATE ON public.saas_security_config TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS saas_security_config_read ON public.saas_security_config;
|
||||
CREATE POLICY saas_security_config_read ON public.saas_security_config
|
||||
FOR SELECT TO authenticated
|
||||
USING (true); -- qualquer logado pode ler config global (não tem segredo)
|
||||
|
||||
DROP POLICY IF EXISTS saas_security_config_write ON public.saas_security_config;
|
||||
CREATE POLICY saas_security_config_write ON public.saas_security_config
|
||||
FOR UPDATE TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.saas_security_config IS 'Singleton: configuração global de defesa contra bots em endpoints públicos.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 2. public_submission_attempts (log genérico)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.public_submission_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
endpoint text NOT NULL,
|
||||
ip_hash text,
|
||||
success boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
blocked_by text, -- 'honeypot' | 'rate_limit' | 'captcha' | 'rpc' | null
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_endpoint_created ON public.public_submission_attempts (endpoint, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_ip_hash_created ON public.public_submission_attempts (ip_hash, created_at DESC) WHERE ip_hash IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_psa_failed ON public.public_submission_attempts (created_at DESC) WHERE success = false;
|
||||
|
||||
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.public_submission_attempts FROM anon, authenticated;
|
||||
GRANT SELECT ON public.public_submission_attempts TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS psa_read_saas_admin ON public.public_submission_attempts;
|
||||
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.public_submission_attempts IS 'Log de tentativas em endpoints públicos (intake, signup, agendador). Escrita apenas via RPC SECURITY DEFINER.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 3. submission_rate_limits (estado vigente por IP+endpoint)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.submission_rate_limits (
|
||||
ip_hash text NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
fail_count integer NOT NULL DEFAULT 0,
|
||||
window_start timestamptz NOT NULL DEFAULT now(),
|
||||
blocked_until timestamptz,
|
||||
requires_captcha_until timestamptz,
|
||||
last_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (ip_hash, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_srl_blocked_until ON public.submission_rate_limits (blocked_until) WHERE blocked_until IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_srl_endpoint ON public.submission_rate_limits (endpoint, last_attempt_at DESC);
|
||||
|
||||
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.submission_rate_limits FROM anon, authenticated;
|
||||
GRANT SELECT ON public.submission_rate_limits TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS srl_read_saas_admin ON public.submission_rate_limits;
|
||||
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin());
|
||||
|
||||
COMMENT ON TABLE public.submission_rate_limits IS 'Estado de rate limit por IP+endpoint. Escrita apenas via RPC. SaaS admin lê pra dashboard.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- 4. math_challenges (TTL 5min, limpa via cron)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.math_challenges (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
question text NOT NULL,
|
||||
answer integer NOT NULL,
|
||||
used boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL DEFAULT (now() + interval '5 minutes')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mc_expires ON public.math_challenges (expires_at);
|
||||
|
||||
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.math_challenges FROM anon, authenticated;
|
||||
-- nenhum grant: tabela acessada apenas via RPC SECURITY DEFINER
|
||||
|
||||
COMMENT ON TABLE public.math_challenges IS 'Challenges de math captcha. TTL 5min. Escrita/leitura apenas via RPC.';
|
||||
@@ -0,0 +1,299 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000007_bot_defense_rpcs
|
||||
-- A#20 (rev2) — RPCs da defesa em camadas:
|
||||
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
|
||||
-- • record_submission_attempt — log + atualiza contadores e bloqueios
|
||||
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
|
||||
-- • verify_math_challenge — valida {id, answer}, marca used
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- check_rate_limit
|
||||
-- Lê config + estado atual, decide o que retornar.
|
||||
-- Se fora da janela atual, "rolha" os contadores (reset).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.check_rate_limit(
|
||||
p_ip_hash text,
|
||||
p_endpoint text
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_security_config%ROWTYPE;
|
||||
rl submission_rate_limits%ROWTYPE;
|
||||
v_now timestamptz := now();
|
||||
v_window_start timestamptz;
|
||||
v_in_window boolean;
|
||||
v_requires_captcha boolean := false;
|
||||
v_blocked_until timestamptz;
|
||||
v_retry_after_seconds integer := 0;
|
||||
BEGIN
|
||||
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
|
||||
IF NOT FOUND THEN
|
||||
-- Sem config: fail-open (libera). Logado.
|
||||
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
|
||||
END IF;
|
||||
|
||||
-- Modo paranoid global: sempre captcha
|
||||
IF cfg.captcha_required_globally THEN
|
||||
v_requires_captcha := true;
|
||||
END IF;
|
||||
|
||||
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
|
||||
IF NOT cfg.rate_limit_enabled THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Sem ip_hash: libera (não dá pra rastrear)
|
||||
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', 'no_ip'
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO rl
|
||||
FROM submission_rate_limits
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
-- Bloqueio temporário ativo?
|
||||
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
|
||||
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'requires_captcha', false,
|
||||
'retry_after_seconds', v_retry_after_seconds,
|
||||
'reason', 'blocked'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Captcha condicional ativo?
|
||||
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
|
||||
v_requires_captcha := true;
|
||||
END IF;
|
||||
|
||||
-- Janela atual ainda válida?
|
||||
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
|
||||
v_in_window := FOUND AND rl.window_start >= v_window_start;
|
||||
|
||||
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
|
||||
-- Excedeu — bloqueia
|
||||
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
|
||||
UPDATE submission_rate_limits
|
||||
SET blocked_until = v_blocked_until,
|
||||
last_attempt_at = v_now
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', false,
|
||||
'requires_captcha', false,
|
||||
'retry_after_seconds', v_retry_after_seconds,
|
||||
'reason', 'rate_limit_exceeded'
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'allowed', true,
|
||||
'requires_captcha', v_requires_captcha,
|
||||
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- record_submission_attempt
|
||||
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
|
||||
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
|
||||
-- requires_captcha_until = now + captcha_required_window_min.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
|
||||
p_endpoint text,
|
||||
p_ip_hash text,
|
||||
p_success boolean,
|
||||
p_blocked_by text DEFAULT NULL,
|
||||
p_error_code text DEFAULT NULL,
|
||||
p_error_msg text DEFAULT NULL,
|
||||
p_user_agent text DEFAULT NULL,
|
||||
p_metadata jsonb DEFAULT NULL
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_security_config%ROWTYPE;
|
||||
v_now timestamptz := now();
|
||||
v_window_start timestamptz;
|
||||
rl submission_rate_limits%ROWTYPE;
|
||||
BEGIN
|
||||
-- Log sempre (mesmo sem ip)
|
||||
INSERT INTO public_submission_attempts
|
||||
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
|
||||
VALUES
|
||||
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
|
||||
left(coalesce(p_error_code, ''), 80),
|
||||
left(coalesce(p_error_msg, ''), 500),
|
||||
left(coalesce(p_user_agent, ''), 500),
|
||||
p_metadata);
|
||||
|
||||
-- Sem ip ou rate limit desligado: não atualiza contador
|
||||
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
|
||||
|
||||
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
|
||||
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
|
||||
|
||||
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
|
||||
|
||||
SELECT * INTO rl
|
||||
FROM submission_rate_limits
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
INSERT INTO submission_rate_limits
|
||||
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
|
||||
VALUES
|
||||
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
|
||||
ELSE
|
||||
IF rl.window_start < v_window_start THEN
|
||||
-- Reset janela
|
||||
UPDATE submission_rate_limits
|
||||
SET attempt_count = 1,
|
||||
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
|
||||
window_start = v_now,
|
||||
last_attempt_at = v_now,
|
||||
blocked_until = NULL
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
ELSE
|
||||
UPDATE submission_rate_limits
|
||||
SET attempt_count = attempt_count + 1,
|
||||
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
|
||||
last_attempt_at = v_now
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
END IF;
|
||||
|
||||
-- Se atingiu threshold de captcha condicional, marca
|
||||
IF NOT p_success THEN
|
||||
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
IF rl.fail_count >= cfg.captcha_after_failures
|
||||
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
|
||||
UPDATE submission_rate_limits
|
||||
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
|
||||
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- generate_math_challenge
|
||||
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
|
||||
-- Operações: + - * (resultado sempre positivo)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_a integer;
|
||||
v_b integer;
|
||||
v_op text;
|
||||
v_ans integer;
|
||||
v_q text;
|
||||
v_id uuid;
|
||||
BEGIN
|
||||
v_a := 1 + floor(random() * 9)::int;
|
||||
v_b := 1 + floor(random() * 9)::int;
|
||||
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
|
||||
|
||||
-- garantir resultado positivo na subtração
|
||||
IF v_op = '-' AND v_b > v_a THEN
|
||||
v_a := v_a + v_b;
|
||||
END IF;
|
||||
|
||||
v_ans := CASE v_op
|
||||
WHEN '+' THEN v_a + v_b
|
||||
WHEN '-' THEN v_a - v_b
|
||||
WHEN '*' THEN v_a * v_b
|
||||
END;
|
||||
|
||||
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
|
||||
|
||||
INSERT INTO math_challenges (question, answer)
|
||||
VALUES (v_q, v_ans)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object('id', v_id, 'question', v_q);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- verify_math_challenge
|
||||
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
|
||||
p_id uuid,
|
||||
p_answer integer
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
mc math_challenges%ROWTYPE;
|
||||
BEGIN
|
||||
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
|
||||
|
||||
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
|
||||
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
UPDATE math_challenges SET used = true WHERE id = p_id;
|
||||
|
||||
RETURN mc.answer = p_answer;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- cleanup_expired_math_challenges (chamável via cron)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
|
||||
RETURNS integer
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH d AS (
|
||||
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*)::int FROM d;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
|
||||
@@ -0,0 +1,155 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000008_saas_twilio_config
|
||||
-- Permite saas_admin editar config Twilio operacional pelo painel, sem redeploy.
|
||||
--
|
||||
-- DECISÃO DE SEGURANÇA:
|
||||
-- • TWILIO_AUTH_TOKEN (secret) NÃO entra na tabela. Continua em env var
|
||||
-- da Edge Function. Painel apenas exibe se está configurado (best-effort).
|
||||
-- • TWILIO_ACCOUNT_SID (público no Twilio dashboard, identificador) → DB
|
||||
-- • TWILIO_WHATSAPP_WEBHOOK (URL) → DB
|
||||
-- • USD_BRL_RATE / MARGIN_MULTIPLIER (operacional) → DB
|
||||
--
|
||||
-- Edge function: lê primeiro do banco; cai pra env vars como fallback se row
|
||||
-- ainda não foi configurada (back-compat com deploys antigos).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.saas_twilio_config (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
account_sid text,
|
||||
whatsapp_webhook_url text,
|
||||
usd_brl_rate numeric(10,4) NOT NULL DEFAULT 5.5,
|
||||
margin_multiplier numeric(10,4) NOT NULL DEFAULT 1.4,
|
||||
notes text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_twilio_config_singleton CHECK (id = true),
|
||||
CONSTRAINT saas_twilio_config_rate_chk CHECK (usd_brl_rate > 0 AND usd_brl_rate < 100),
|
||||
CONSTRAINT saas_twilio_config_mult_chk CHECK (margin_multiplier >= 1 AND margin_multiplier <= 10),
|
||||
CONSTRAINT saas_twilio_config_sid_chk CHECK (account_sid IS NULL OR account_sid ~ '^AC[a-zA-Z0-9]{32}$'),
|
||||
CONSTRAINT saas_twilio_config_url_chk CHECK (whatsapp_webhook_url IS NULL OR whatsapp_webhook_url ~ '^https?://')
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_twilio_config (id) VALUES (true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.saas_twilio_config FROM anon, authenticated;
|
||||
GRANT SELECT ON public.saas_twilio_config TO authenticated;
|
||||
|
||||
DROP POLICY IF EXISTS saas_twilio_config_read ON public.saas_twilio_config;
|
||||
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin()); -- só admin vê config (mesmo sem secret, é dado operacional)
|
||||
|
||||
COMMENT ON TABLE public.saas_twilio_config IS
|
||||
'Config operacional Twilio editável via painel. AUTH_TOKEN continua em env var por segurança.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- RPC get_twilio_config — retorna config atual (saas_admin OU service_role)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_twilio_config()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
cfg saas_twilio_config%ROWTYPE;
|
||||
BEGIN
|
||||
-- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
|
||||
-- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
|
||||
IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
|
||||
RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object(
|
||||
'account_sid', NULL,
|
||||
'whatsapp_webhook_url', NULL,
|
||||
'usd_brl_rate', 5.5,
|
||||
'margin_multiplier', 1.4
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'account_sid', cfg.account_sid,
|
||||
'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
|
||||
'usd_brl_rate', cfg.usd_brl_rate,
|
||||
'margin_multiplier', cfg.margin_multiplier,
|
||||
'notes', cfg.notes,
|
||||
'updated_at', cfg.updated_at,
|
||||
'updated_by', cfg.updated_by
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_twilio_config() FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.get_twilio_config() TO authenticated, service_role;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- RPC update_twilio_config — só saas_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.update_twilio_config(
|
||||
p_account_sid text DEFAULT NULL,
|
||||
p_whatsapp_webhook_url text DEFAULT NULL,
|
||||
p_usd_brl_rate numeric DEFAULT NULL,
|
||||
p_margin_multiplier numeric DEFAULT NULL,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller uuid := auth.uid();
|
||||
v_account_sid text;
|
||||
v_webhook_url text;
|
||||
v_notes text;
|
||||
BEGIN
|
||||
IF v_caller IS NULL THEN
|
||||
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Sanitização
|
||||
v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
|
||||
v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
|
||||
v_notes := nullif(btrim(coalesce(p_notes, '')), '');
|
||||
|
||||
IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
|
||||
RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
|
||||
RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
|
||||
RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
|
||||
RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
|
||||
v_notes := substring(v_notes FROM 1 FOR 1000);
|
||||
END IF;
|
||||
|
||||
UPDATE saas_twilio_config
|
||||
SET account_sid = COALESCE(v_account_sid, account_sid),
|
||||
whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
|
||||
usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate),
|
||||
margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier),
|
||||
notes = COALESCE(v_notes, notes),
|
||||
updated_at = now(),
|
||||
updated_by = v_caller
|
||||
WHERE id = true;
|
||||
|
||||
RETURN public.get_twilio_config();
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) TO authenticated;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000009_patient_session_counts_rpc
|
||||
-- V#8 — Substitui o .limit(1000) arbitrário em PatientsListPage por RPC
|
||||
-- agregada que retorna contagens por paciente (sempre atualizada, sem teto).
|
||||
--
|
||||
-- Tenant scoping é feito via WHERE tenant_id IN (memberships do caller),
|
||||
-- consistente com a policy SELECT de agenda_eventos.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_patient_session_counts(
|
||||
p_patient_ids uuid[]
|
||||
)
|
||||
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT
|
||||
ae.patient_id,
|
||||
COUNT(*)::int AS session_count,
|
||||
MAX(ae.inicio_em) AS last_session_at
|
||||
FROM public.agenda_eventos ae
|
||||
WHERE ae.patient_id = ANY(p_patient_ids)
|
||||
AND ae.tenant_id IN (
|
||||
SELECT tm.tenant_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
)
|
||||
GROUP BY ae.patient_id;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_patient_session_counts(uuid[]) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_patient_session_counts(uuid[]) TO authenticated;
|
||||
@@ -0,0 +1,304 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000010_documents_security_hardening
|
||||
-- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos
|
||||
-- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes
|
||||
-- pra próxima sessão (precisam de design/decisão).
|
||||
--
|
||||
-- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}"
|
||||
-- (storage.foldername(name))[1] = tenant_id
|
||||
-- =============================================================================
|
||||
|
||||
-- Tabelas de documents são owned por supabase_admin
|
||||
SET LOCAL ROLE supabase_admin;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs"
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects;
|
||||
|
||||
CREATE POLICY "documents: tenant member read" ON storage.objects
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'documents'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "documents: tenant member upload" ON storage.objects
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'documents'
|
||||
AND (storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "documents: tenant member delete" ON storage.objects
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
bucket_id = 'documents'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects;
|
||||
|
||||
CREATE POLICY "generated-docs: tenant member read" ON storage.objects
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'generated-docs'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'generated-docs'
|
||||
AND (storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
bucket_id = 'generated-docs'
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR
|
||||
(storage.foldername(name))[1]::uuid IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#45: documents — policies separadas por cmd
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "documents: owner full access" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: select" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: insert" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: update" ON public.documents;
|
||||
DROP POLICY IF EXISTS "documents: delete" ON public.documents;
|
||||
|
||||
-- SELECT: owner OR tenant_member ativo OR saas_admin
|
||||
CREATE POLICY "documents: select" ON public.documents
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller
|
||||
CREATE POLICY "documents: insert" ON public.documents
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE: só owner
|
||||
CREATE POLICY "documents: update" ON public.documents
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- DELETE: só owner ou saas_admin
|
||||
CREATE POLICY "documents: delete" ON public.documents
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#46: document_share_links — RPC validate_share_token + remover SELECT direto
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
sl document_share_links%ROWTYPE;
|
||||
v_doc documents%ROWTYPE;
|
||||
v_token text;
|
||||
BEGIN
|
||||
v_token := nullif(btrim(coalesce(p_token, '')), '');
|
||||
IF v_token IS NULL THEN
|
||||
RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF sl.ativo IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
|
||||
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
|
||||
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- Incrementa uso atomicamente
|
||||
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
|
||||
|
||||
-- Loga acesso (best-effort)
|
||||
BEGIN
|
||||
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
|
||||
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
|
||||
FROM documents d WHERE d.id = sl.document_id;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- não derruba a request se log falhar (schema pode variar)
|
||||
NULL;
|
||||
END;
|
||||
|
||||
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'document_id', sl.document_id,
|
||||
'bucket', v_doc.storage_bucket,
|
||||
'bucket_path', v_doc.bucket_path,
|
||||
'nome_original', v_doc.nome_original,
|
||||
'mime_type', v_doc.mime_type,
|
||||
'tamanho_bytes', v_doc.tamanho_bytes
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role;
|
||||
|
||||
-- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário)
|
||||
DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links;
|
||||
DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links;
|
||||
|
||||
CREATE POLICY "dsl: creator full access" ON public.document_share_links
|
||||
FOR ALL TO authenticated
|
||||
USING (criado_por = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (criado_por = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: select" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: update" ON public.document_signatures;
|
||||
DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures;
|
||||
|
||||
CREATE POLICY "ds: select" ON public.document_signatures
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller
|
||||
-- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row
|
||||
-- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo)
|
||||
CREATE POLICY "ds: insert" ON public.document_signatures
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
AND (signatario_id IS NULL OR signatario_id = auth.uid())
|
||||
);
|
||||
|
||||
-- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado')
|
||||
CREATE POLICY "ds: update" ON public.document_signatures
|
||||
FOR UPDATE TO authenticated
|
||||
USING (signatario_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- DELETE: signatário, saas_admin ou tenant_admin/owner
|
||||
CREATE POLICY "ds: delete" ON public.document_signatures
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
signatario_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#48: document_access_logs — INSERT com WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs;
|
||||
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#49: document_templates — INSERT com WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates;
|
||||
DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates;
|
||||
|
||||
CREATE POLICY "dt: owner can insert" ON public.document_templates
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
is_global = false
|
||||
AND owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (is_global = true AND public.is_saas_admin());
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000011_documents_portal_patient_policy
|
||||
-- V#50 — paciente vê documento via portal quando compartilhado_portal=true.
|
||||
--
|
||||
-- Adiciona policy SELECT ADICIONAL em documents (combina via OR com a policy
|
||||
-- existente "documents: select"). Paciente conseguem ler documentos próprios
|
||||
-- quando o terapeuta compartilhou via portal.
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "documents: portal patient read" ON public.documents;
|
||||
|
||||
CREATE POLICY "documents: portal patient read" ON public.documents
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
compartilhado_portal = true
|
||||
AND patient_id IN (
|
||||
SELECT p.id FROM public.patients p
|
||||
WHERE p.user_id = auth.uid()
|
||||
)
|
||||
AND (expira_compartilhamento IS NULL OR expira_compartilhamento > now())
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "documents: portal patient read" ON public.documents IS
|
||||
'V#50: paciente lê documento quando compartilhado_portal=true E patient_id pertence ao auth.uid + não expirou.';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000012_documents_content_hash
|
||||
-- V#51 — hash SHA-256 do conteúdo pra detecção de tampering.
|
||||
--
|
||||
-- Coluna nullable (documentos antigos não têm). Calculado client-side via
|
||||
-- crypto.subtle.digest('SHA-256') antes do upload pro storage.
|
||||
-- Integridade pode ser verificada baixando o arquivo e recalculando o hash.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.documents
|
||||
ADD COLUMN IF NOT EXISTS content_sha256 text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_content_sha256
|
||||
ON public.documents (content_sha256)
|
||||
WHERE content_sha256 IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.documents.content_sha256 IS
|
||||
'V#51: SHA-256 hex (64 chars) do conteúdo no momento do upload. Permite verificar integridade. NULL pra documentos legados pré-V#51.';
|
||||
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000013_cron_retention_jobs
|
||||
-- V#52 — retention automática de logs/challenges via pg_cron.
|
||||
--
|
||||
-- Jobs:
|
||||
-- • document_access_logs_cleanup — diário, retém 1 ano (CFP típico)
|
||||
-- • math_challenges_cleanup — horário, remove expirados há >1h
|
||||
-- • public_submission_attempts_cleanup — diário, retém 90 dias
|
||||
-- =============================================================================
|
||||
|
||||
-- Garante extensão (idempotente em ambientes que não têm)
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- document_access_logs: retém 1 ano (suficiente pra auditoria CFP)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('document_access_logs_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'document_access_logs_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'document_access_logs_cleanup',
|
||||
'0 3 * * *', -- todo dia às 03:00
|
||||
$$DELETE FROM public.document_access_logs WHERE created_at < now() - interval '1 year'$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- math_challenges: remove expirados (> 1h após expiração)
|
||||
-- (RPC cleanup_expired_math_challenges já existe desde 20260419000007)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('math_challenges_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'math_challenges_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'math_challenges_cleanup',
|
||||
'0 * * * *', -- toda hora
|
||||
$$SELECT public.cleanup_expired_math_challenges()$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- public_submission_attempts: retém 90 dias (analytics + alertas)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('public_submission_attempts_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'public_submission_attempts_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'public_submission_attempts_cleanup',
|
||||
'15 3 * * *', -- todo dia 03:15 (após o de docs)
|
||||
$$DELETE FROM public.public_submission_attempts WHERE created_at < now() - interval '90 days'$$
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- submission_rate_limits: limpa entradas antigas (>30 dias sem atividade)
|
||||
-- (estados expirados não fazem mal, mas tabela cresce sem limite)
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT cron.unschedule('submission_rate_limits_cleanup')
|
||||
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'submission_rate_limits_cleanup');
|
||||
|
||||
SELECT cron.schedule(
|
||||
'submission_rate_limits_cleanup',
|
||||
'30 3 * * *', -- todo dia 03:30
|
||||
$$DELETE FROM public.submission_rate_limits
|
||||
WHERE last_attempt_at < now() - interval '30 days'
|
||||
AND (blocked_until IS NULL OR blocked_until < now())
|
||||
AND (requires_captcha_until IS NULL OR requires_captcha_until < now())$$
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000014_financial_security_hardening
|
||||
-- Sessão 6 — revisão Financeiro. Resolve V#1-V#5 (2 críticos + 3 altos).
|
||||
-- V#6-V#11 adiados (médios/baixos com plano).
|
||||
--
|
||||
-- Auditoria prévia confirmou:
|
||||
-- • 0 financial_records com tenant_id NULL
|
||||
-- • 0 records com clinic_fee_amount > amount
|
||||
-- → seguro aplicar NOT NULL e CHECK constraints.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1: billing_contracts policy granular
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "billing_contracts: owner full access" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: select" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: insert" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: update" ON public.billing_contracts;
|
||||
DROP POLICY IF EXISTS "billing_contracts: delete" ON public.billing_contracts;
|
||||
|
||||
CREATE POLICY "billing_contracts: select" ON public.billing_contracts
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "billing_contracts: update" ON public.billing_contracts
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "billing_contracts: delete" ON public.billing_contracts
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2: financial_records.tenant_id NOT NULL + trigger backfill
|
||||
-- (auditoria: 0 órfãos, seguro aplicar)
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.financial_records ALTER COLUMN tenant_id SET NOT NULL;
|
||||
|
||||
-- Trigger defensivo: se tentar inserir sem tenant_id, busca via owner_id->tenant_members
|
||||
CREATE OR REPLACE FUNCTION public.financial_records_inject_tenant()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
|
||||
SELECT tm.tenant_id INTO NEW.tenant_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
|
||||
ORDER BY tm.created_at DESC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_financial_records_inject_tenant ON public.financial_records;
|
||||
CREATE TRIGGER trg_financial_records_inject_tenant
|
||||
BEFORE INSERT ON public.financial_records
|
||||
FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5: financial_records CHECK contra net_amount negativo
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.financial_records
|
||||
DROP CONSTRAINT IF EXISTS financial_records_fee_lte_amount_chk;
|
||||
|
||||
ALTER TABLE public.financial_records
|
||||
ADD CONSTRAINT financial_records_fee_lte_amount_chk
|
||||
CHECK (clinic_fee_amount IS NULL OR (clinic_fee_amount >= 0 AND clinic_fee_amount <= amount));
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3: payment_settings — adicionar SELECT pra tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "payment_settings: tenant_admin read" ON public.payment_settings;
|
||||
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
tenant_id IS NOT NULL
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
-- (a policy ALL "owner full access" continua — owner mexe nos próprios)
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4: professional_pricing — adicionar SELECT pra tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "professional_pricing: tenant_admin read" ON public.professional_pricing;
|
||||
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,127 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000015_communication_security_hardening
|
||||
-- Sessão 6 — revisão Comunicação. Resolve V#1-V#5 (2 críticos + 3 altos).
|
||||
-- V#6-V#10 adiados (médios/baixos com plano completo no DB).
|
||||
--
|
||||
-- 🔴 V#1+V#2 são bugs P0: policies usavam (tenant_id = auth.uid()) — comparação
|
||||
-- de UUID de tenant com UUID de user. Tabelas inacessíveis na prática.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1: email_layout_config — fix BUG do tenant_id = auth.uid()
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "tenant owns email layout config" ON public.email_layout_config;
|
||||
DROP POLICY IF EXISTS "email_layout_config: tenant_admin all" ON public.email_layout_config;
|
||||
|
||||
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2: email_templates_tenant — MESMO bug
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "tenant manages own overrides" ON public.email_templates_tenant;
|
||||
DROP POLICY IF EXISTS "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant;
|
||||
|
||||
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3: notification_logs — SELECT pra tenant_member
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notif_logs_tenant_member" ON public.notification_logs;
|
||||
CREATE POLICY "notif_logs_tenant_member" ON public.notification_logs
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4: notification_queue — SELECT pra tenant_member
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notif_queue_tenant_member" ON public.notification_queue;
|
||||
CREATE POLICY "notif_queue_tenant_member" ON public.notification_queue
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5: notification_channels — SELECT pra tenant_member; INSERT tenant_admin; UPDATE/DELETE owner
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "notification_channels_owner" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_select" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
|
||||
DROP POLICY IF EXISTS "notif_channels_modify" ON public.notification_channels;
|
||||
|
||||
CREATE POLICY "notif_channels_select" ON public.notification_channels
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
public.is_saas_admin()
|
||||
OR owner_id = auth.uid()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "notif_channels_insert" ON public.notification_channels
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "notif_channels_modify" ON public.notification_channels
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "notif_channels_delete" ON public.notification_channels
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
@@ -0,0 +1,157 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000016_tenants_calendario_hardening
|
||||
-- Sessão 7 — Tenants + Calendário scan (corrige críticos + altos + WITH CHECKs).
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Tenants V#1 (P0) — tenant_invites RLS off + 0 policies
|
||||
-- • Tenants V#2 — profiles_insert_own sem WITH CHECK
|
||||
-- • Tenants V#3 — support_sessions_saas_insert sem WITH CHECK
|
||||
-- • Tenants V#6 — user_settings_insert_own sem WITH CHECK
|
||||
-- • Calendário V#1 — feriados_insert + feriados_saas_insert sem WITH CHECK
|
||||
--
|
||||
-- Auditoria prévia: tenant_invites tem 0 rows (seguro habilitar RLS sem
|
||||
-- migração de dados).
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#1 (P0): tenant_invites
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
REVOKE ALL ON public.tenant_invites FROM anon, authenticated;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_invites TO authenticated;
|
||||
|
||||
-- SELECT: tenant_admin/admin/owner do tenant + saas_admin
|
||||
DROP POLICY IF EXISTS tenant_invites_select ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_select ON public.tenant_invites
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: só tenant_admin do tenant_id, e invited_by deve ser o caller
|
||||
DROP POLICY IF EXISTS tenant_invites_insert ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_insert ON public.tenant_invites
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
invited_by = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- UPDATE: só revogação por tenant_admin do tenant. Aceitar é via RPC tenant_accept_invite (SECURITY DEFINER).
|
||||
DROP POLICY IF EXISTS tenant_invites_update ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_update ON public.tenant_invites
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: tenant_admin OR saas_admin
|
||||
DROP POLICY IF EXISTS tenant_invites_delete ON public.tenant_invites;
|
||||
CREATE POLICY tenant_invites_delete ON public.tenant_invites
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.tenant_invites IS
|
||||
'Convites pra entrar em tenant. Aceitar deve ser via RPC tenant_accept_invite (SECURITY DEFINER). Criar/revogar via UI por tenant_admin.';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#2: profiles INSERT WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS profiles_insert_own ON public.profiles;
|
||||
CREATE POLICY profiles_insert_own ON public.profiles
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (id = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#3: support_sessions INSERT WITH CHECK
|
||||
-- (admin_id deve ser o caller E o caller deve ser saas_admin)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS support_sessions_saas_insert ON public.support_sessions;
|
||||
CREATE POLICY support_sessions_saas_insert ON public.support_sessions
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
admin_id = auth.uid()
|
||||
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Tenants V#6: user_settings INSERT WITH CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS user_settings_insert_own ON public.user_settings;
|
||||
CREATE POLICY user_settings_insert_own ON public.user_settings
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Calendário V#1: feriados INSERT WITH CHECK (tenant + global)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS feriados_insert ON public.feriados;
|
||||
CREATE POLICY feriados_insert ON public.feriados
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IS NOT NULL
|
||||
AND owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS feriados_saas_insert ON public.feriados;
|
||||
CREATE POLICY feriados_saas_insert ON public.feriados
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Calendário V#2: feriados DELETE — adicionar tenant_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS feriados_delete ON public.feriados;
|
||||
CREATE POLICY feriados_delete ON public.feriados
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR (tenant_id IS NOT NULL AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid()
|
||||
AND tm.status = 'active'
|
||||
AND tm.role IN ('tenant_admin','admin','owner')
|
||||
))
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000017_addons_central_saas_hardening
|
||||
-- Sessão 8 — Addons + Central SaaS scan.
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Addons V#1 (CRÍTICO — dinheiro real): addon_transactions sem WITH CHECK
|
||||
-- • Addons V#2: addon_credits sem CHECK contra saldo negativo
|
||||
-- • Central SaaS V#1: saas_faq write permite tenant_admin/clinic_admin
|
||||
--
|
||||
-- Auditoria prévia: 0 addon_credits com balance < 0 (seguro CHECK).
|
||||
-- Edge functions consomem créditos via service_role (bypass RLS) — nova
|
||||
-- restrição não quebra pipeline.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Addons V#1: addon_transactions INSERT WITH CHECK (saas_admin only)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS addon_transactions_admin_insert ON public.addon_transactions;
|
||||
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Addons V#2: addon_credits CHECK contra saldo negativo
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_balance_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_balance_nonneg_chk
|
||||
CHECK (balance >= 0);
|
||||
|
||||
-- Aproveita: total_consumed também não deve ser negativo
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_consumed_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_consumed_nonneg_chk
|
||||
CHECK (total_consumed >= 0);
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
DROP CONSTRAINT IF EXISTS addon_credits_purchased_nonneg_chk;
|
||||
|
||||
ALTER TABLE public.addon_credits
|
||||
ADD CONSTRAINT addon_credits_purchased_nonneg_chk
|
||||
CHECK (total_purchased >= 0);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Central SaaS V#1: saas_faq + saas_faq_itens write SÓ saas_admin
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS faq_admin_write ON public.saas_faq;
|
||||
CREATE POLICY faq_saas_admin_write ON public.saas_faq
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
DROP POLICY IF EXISTS faq_itens_admin_write ON public.saas_faq_itens;
|
||||
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- (Policies de leitura — faq_auth_read, faq_public_read, faq_itens_auth_read — permanecem)
|
||||
@@ -0,0 +1,223 @@
|
||||
-- =============================================================================
|
||||
-- Migration: 20260419000018_servicos_prontuarios_hardening
|
||||
-- Sessão 9 — Serviços/Prontuários scan.
|
||||
--
|
||||
-- Resolve:
|
||||
-- • Serviços V#1+V#2 (CRÍTICOS): silos por owner em services/medicos/insurance_plans
|
||||
-- • Serviços V#3+V#4 (ALTOS): cascade silos em commitment_services/insurance_plan_services
|
||||
-- • Serviços V#5: WITH CHECK ausente em commitment_time_logs/determined_*
|
||||
--
|
||||
-- Padrão validado em 5 áreas anteriores (Documentos/Financeiro/Comunicação/etc):
|
||||
-- SELECT tenant_member, INSERT/UPDATE/DELETE owner+saas, com WITH CHECK explícito.
|
||||
-- =============================================================================
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1 services — split em 4 policies
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "services: owner full access" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: select" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: insert" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: update" ON public.services;
|
||||
DROP POLICY IF EXISTS "services: delete" ON public.services;
|
||||
|
||||
CREATE POLICY "services: select" ON public.services
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "services: insert" ON public.services
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "services: update" ON public.services
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "services: delete" ON public.services
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#2 medicos — mesmo padrão
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "medicos: owner full access" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: select" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: insert" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: update" ON public.medicos;
|
||||
DROP POLICY IF EXISTS "medicos: delete" ON public.medicos;
|
||||
|
||||
CREATE POLICY "medicos: select" ON public.medicos
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "medicos: insert" ON public.medicos
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "medicos: update" ON public.medicos
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "medicos: delete" ON public.medicos
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#1 (parte 2) insurance_plans — mesmo padrão
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "insurance_plans: owner full access" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: select" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: insert" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: update" ON public.insurance_plans;
|
||||
DROP POLICY IF EXISTS "insurance_plans: delete" ON public.insurance_plans;
|
||||
|
||||
CREATE POLICY "insurance_plans: select" ON public.insurance_plans
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "insurance_plans: update" ON public.insurance_plans
|
||||
FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin())
|
||||
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "insurance_plans: delete" ON public.insurance_plans
|
||||
FOR DELETE TO authenticated
|
||||
USING (owner_id = auth.uid() OR public.is_saas_admin());
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#3 commitment_services — cascade via JOIN com services.tenant_id
|
||||
-- (tabela N:N sem tenant_id próprio; herda do services pai)
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "commitment_services: owner full access" ON public.commitment_services;
|
||||
DROP POLICY IF EXISTS "commitment_services: tenant_member" ON public.commitment_services;
|
||||
|
||||
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = commitment_services.service_id
|
||||
AND (
|
||||
s.owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR s.tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = commitment_services.service_id
|
||||
AND (s.owner_id = auth.uid() OR public.is_saas_admin())
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#4 insurance_plan_services — cascade via JOIN com insurance_plans
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS "insurance_plan_services_owner" ON public.insurance_plan_services;
|
||||
DROP POLICY IF EXISTS "insurance_plan_services: tenant_member" ON public.insurance_plan_services;
|
||||
|
||||
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND (
|
||||
ip.owner_id = auth.uid()
|
||||
OR public.is_saas_admin()
|
||||
OR ip.tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND (ip.owner_id = auth.uid() OR public.is_saas_admin())
|
||||
)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- V#5 — adicionar WITH CHECK em INSERT das 3 tabelas que não tinham
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS ctl_insert_for_active_member ON public.commitment_time_logs;
|
||||
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS dcf_insert_for_active_member ON public.determined_commitment_fields;
|
||||
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS dc_insert_for_active_member ON public.determined_commitments;
|
||||
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
tenant_id IN (
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,661 @@
|
||||
-- =============================================================================
|
||||
-- MIGRATION: patients — melhorias completas
|
||||
-- Gerado em: 2025-03
|
||||
-- Estratégia: cirúrgico — só adiciona, nunca destrói o que existe
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. ALTERAÇÕES NA TABELA patients
|
||||
-- Novos campos adicionados sem tocar nos existentes
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.patients
|
||||
-- Identidade & pronomes
|
||||
ADD COLUMN IF NOT EXISTS nome_social text,
|
||||
ADD COLUMN IF NOT EXISTS pronomes text,
|
||||
|
||||
-- Dados socioeconômicos (opcionais, clínicamente relevantes)
|
||||
ADD COLUMN IF NOT EXISTS etnia text,
|
||||
ADD COLUMN IF NOT EXISTS religiao text,
|
||||
ADD COLUMN IF NOT EXISTS faixa_renda text,
|
||||
|
||||
-- Preferências de comunicação (alimenta lembretes automáticos)
|
||||
ADD COLUMN IF NOT EXISTS canal_preferido text DEFAULT 'whatsapp',
|
||||
ADD COLUMN IF NOT EXISTS horario_contato_inicio time DEFAULT '08:00',
|
||||
ADD COLUMN IF NOT EXISTS horario_contato_fim time DEFAULT '20:00',
|
||||
ADD COLUMN IF NOT EXISTS idioma text DEFAULT 'pt-BR',
|
||||
|
||||
-- Origem estruturada (permite filtros e relatórios)
|
||||
ADD COLUMN IF NOT EXISTS origem text,
|
||||
|
||||
-- Financeiro
|
||||
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
|
||||
|
||||
-- Ciclo de vida
|
||||
ADD COLUMN IF NOT EXISTS motivo_saida text,
|
||||
ADD COLUMN IF NOT EXISTS data_saida date,
|
||||
ADD COLUMN IF NOT EXISTS encaminhado_para text,
|
||||
|
||||
-- Risco clínico (flag de atenção visível no topo do cadastro)
|
||||
ADD COLUMN IF NOT EXISTS risco_elevado boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS risco_nota text,
|
||||
ADD COLUMN IF NOT EXISTS risco_sinalizado_em timestamp with time zone,
|
||||
ADD COLUMN IF NOT EXISTS risco_sinalizado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
|
||||
-- Constraints de validação para novos campos enum-like
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check,
|
||||
ADD CONSTRAINT patients_canal_preferido_check
|
||||
CHECK (canal_preferido IS NULL OR canal_preferido = ANY (
|
||||
ARRAY['whatsapp','email','sms','telefone']
|
||||
));
|
||||
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_check,
|
||||
ADD CONSTRAINT patients_metodo_pagamento_check
|
||||
CHECK (metodo_pagamento_preferido IS NULL OR metodo_pagamento_preferido = ANY (
|
||||
ARRAY['pix','cartao','dinheiro','deposito','convenio']
|
||||
));
|
||||
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_faixa_renda_check,
|
||||
ADD CONSTRAINT patients_faixa_renda_check
|
||||
CHECK (faixa_renda IS NULL OR faixa_renda = ANY (
|
||||
ARRAY['ate_1sm','1_3sm','3_6sm','6_10sm','acima_10sm','nao_informado']
|
||||
));
|
||||
|
||||
-- Constraint: risco_elevado = true exige nota e sinalizante
|
||||
ALTER TABLE public.patients
|
||||
DROP CONSTRAINT IF EXISTS patients_risco_consistency_check,
|
||||
ADD 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)
|
||||
);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON COLUMN public.patients.nome_social IS 'Nome social preferido — exibido no lugar do nome completo quando preenchido';
|
||||
COMMENT ON COLUMN public.patients.pronomes IS 'Pronomes preferidos: ele/dele, ela/dela, eles/deles, etc.';
|
||||
COMMENT ON COLUMN public.patients.etnia IS 'Autodeclaração étnico-racial (opcional)';
|
||||
COMMENT ON COLUMN public.patients.religiao IS 'Religião ou espiritualidade (opcional, relevante clinicamente)';
|
||||
COMMENT ON COLUMN public.patients.faixa_renda IS 'Faixa de renda em salários mínimos — usado para precificação solidária';
|
||||
COMMENT ON COLUMN public.patients.canal_preferido IS 'Canal de comunicação preferido para lembretes e notificações';
|
||||
COMMENT ON COLUMN public.patients.horario_contato_inicio IS 'Início da janela de horário preferida para contato';
|
||||
COMMENT ON COLUMN public.patients.horario_contato_fim IS 'Fim da janela de horário preferida para contato';
|
||||
COMMENT ON COLUMN public.patients.origem IS 'Como o paciente chegou: indicacao, agendador, redes_sociais, encaminhamento, outro';
|
||||
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido IS 'Método de pagamento habitual — sugerido ao criar cobrança';
|
||||
COMMENT ON COLUMN public.patients.motivo_saida IS 'Motivo da alta, inativação ou encaminhamento';
|
||||
COMMENT ON COLUMN public.patients.data_saida IS 'Data em que o paciente foi desligado/encaminhado';
|
||||
COMMENT ON COLUMN public.patients.encaminhado_para IS 'Nome ou serviço para onde o paciente foi encaminhado';
|
||||
COMMENT ON COLUMN public.patients.risco_elevado IS 'Flag de atenção clínica — exibe alerta no topo do cadastro e prontuário';
|
||||
COMMENT ON COLUMN public.patients.risco_nota IS 'Descrição do risco (obrigatória quando risco_elevado = true)';
|
||||
COMMENT ON COLUMN public.patients.risco_sinalizado_em IS 'Timestamp em que o risco foi sinalizado';
|
||||
COMMENT ON COLUMN public.patients.risco_sinalizado_por IS 'Usuário que sinalizou o risco';
|
||||
|
||||
-- Índices úteis para filtros frequentes
|
||||
CREATE INDEX IF NOT EXISTS idx_patients_risco_elevado
|
||||
ON public.patients (tenant_id, risco_elevado)
|
||||
WHERE risco_elevado = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patients_status_tenant
|
||||
ON public.patients (tenant_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patients_origem
|
||||
ON public.patients (tenant_id, origem)
|
||||
WHERE origem IS NOT NULL;
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. TABELA patient_contacts
|
||||
-- Substitui os campos soltos nome_parente/telefone_parente na tabela principal
|
||||
-- Os campos antigos ficam intactos (retrocompatibilidade)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_contacts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Identificação
|
||||
nome text NOT NULL,
|
||||
tipo text NOT NULL, -- emergencia | responsavel_legal | profissional_saude | outro
|
||||
relacao text, -- mãe, pai, psiquiatra, médico, cônjuge...
|
||||
|
||||
-- Contato
|
||||
telefone text,
|
||||
email text,
|
||||
cpf text,
|
||||
|
||||
-- Profissional de saúde
|
||||
especialidade text, -- preenchido quando tipo = profissional_saude
|
||||
registro_profissional text, -- CRM, CRP, etc.
|
||||
|
||||
-- Flags
|
||||
is_primario boolean DEFAULT false NOT NULL, -- contato principal de emergência
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
|
||||
-- Auditoria
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT patient_contacts_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT patient_contacts_tipo_check CHECK (tipo = ANY (
|
||||
ARRAY['emergencia','responsavel_legal','profissional_saude','outro']
|
||||
))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.patient_contacts IS 'Contatos vinculados ao paciente: emergência, responsável legal, outros profissionais de saúde';
|
||||
COMMENT ON COLUMN public.patient_contacts.tipo IS 'Categoria do contato: emergencia | responsavel_legal | profissional_saude | outro';
|
||||
COMMENT ON COLUMN public.patient_contacts.is_primario IS 'Contato de emergência principal — exibido em destaque no cadastro';
|
||||
|
||||
-- Garante no máximo 1 contato primário por paciente
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_patient_contacts_primario
|
||||
ON public.patient_contacts (patient_id)
|
||||
WHERE is_primario = true AND ativo = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_contacts_patient
|
||||
ON public.patient_contacts (patient_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_contacts_tenant
|
||||
ON public.patient_contacts (tenant_id);
|
||||
|
||||
-- updated_at automático
|
||||
CREATE TRIGGER trg_patient_contacts_updated_at
|
||||
BEFORE UPDATE ON public.patient_contacts
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
-- RLS — mesmas regras de patients
|
||||
ALTER TABLE public.patient_contacts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY patient_contacts_select ON public.patient_contacts
|
||||
FOR SELECT USING (
|
||||
public.is_clinic_tenant(tenant_id)
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
AND public.tenant_has_feature(tenant_id, 'patients.view')
|
||||
);
|
||||
|
||||
CREATE POLICY patient_contacts_write ON public.patient_contacts
|
||||
USING (
|
||||
public.is_clinic_tenant(tenant_id)
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
AND public.tenant_has_feature(tenant_id, 'patients.edit')
|
||||
)
|
||||
WITH CHECK (
|
||||
public.is_clinic_tenant(tenant_id)
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
AND public.tenant_has_feature(tenant_id, 'patients.edit')
|
||||
);
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. TABELA patient_status_history
|
||||
-- Trilha de auditoria de todas as mudanças de status do paciente
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_status_history (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
|
||||
status_anterior text, -- NULL na primeira inserção
|
||||
status_novo text NOT NULL,
|
||||
motivo text,
|
||||
encaminhado_para text, -- preenchido quando status = Encaminhado
|
||||
data_saida date, -- preenchido quando Alta/Encaminhado/Arquivado
|
||||
|
||||
alterado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT patient_status_history_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT psh_status_novo_check CHECK (status_novo = ANY (
|
||||
ARRAY['Ativo','Inativo','Alta','Encaminhado','Arquivado']
|
||||
))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.patient_status_history IS 'Histórico imutável de todas as mudanças de status do paciente — não editar, apenas inserir';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_psh_patient
|
||||
ON public.patient_status_history (patient_id, alterado_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_psh_tenant
|
||||
ON public.patient_status_history (tenant_id, alterado_em DESC);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.patient_status_history ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY psh_select ON public.patient_status_history
|
||||
FOR SELECT USING (
|
||||
public.is_clinic_tenant(tenant_id)
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
AND public.tenant_has_feature(tenant_id, 'patients.view')
|
||||
);
|
||||
|
||||
CREATE POLICY psh_insert ON public.patient_status_history
|
||||
FOR INSERT WITH CHECK (
|
||||
public.is_clinic_tenant(tenant_id)
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
AND public.tenant_has_feature(tenant_id, 'patients.edit')
|
||||
);
|
||||
|
||||
-- Trigger: registra automaticamente no histórico quando status muda em patients
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||
INSERT INTO public.patient_status_history (
|
||||
patient_id, tenant_id,
|
||||
status_anterior, status_novo,
|
||||
motivo, encaminhado_para, data_saida,
|
||||
alterado_por, alterado_em
|
||||
) VALUES (
|
||||
NEW.id, NEW.tenant_id,
|
||||
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
|
||||
NEW.status,
|
||||
NEW.motivo_saida,
|
||||
NEW.encaminhado_para,
|
||||
NEW.data_saida,
|
||||
auth.uid(),
|
||||
now()
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_patient_status_history ON public.patients;
|
||||
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();
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. TABELA patient_timeline
|
||||
-- Feed cronológico automático de eventos relevantes do paciente
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_timeline (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Tipo do evento
|
||||
evento_tipo text NOT NULL,
|
||||
-- Exemplos: primeira_sessao | sessao_realizada | sessao_cancelada | falta |
|
||||
-- status_alterado | risco_sinalizado | documento_assinado |
|
||||
-- escala_respondida | pagamento_vencido | pagamento_recebido |
|
||||
-- tarefa_combinada | contato_adicionado | prontuario_editado
|
||||
|
||||
titulo text NOT NULL, -- Ex: "Sessão realizada"
|
||||
descricao text, -- Ex: "Sessão 47 · presencial · 50min"
|
||||
icone_cor text DEFAULT 'gray', -- green | blue | amber | red | gray
|
||||
link_ref_tipo text, -- agenda_evento | financial_record | documento | escala
|
||||
link_ref_id uuid, -- FK genérico — sem constraint formal (polimórfico)
|
||||
gerado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
ocorrido_em timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT patient_timeline_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT pt_evento_tipo_check CHECK (evento_tipo = ANY (ARRAY[
|
||||
'primeira_sessao','sessao_realizada','sessao_cancelada','falta',
|
||||
'status_alterado','risco_sinalizado','risco_removido',
|
||||
'documento_assinado','documento_adicionado',
|
||||
'escala_respondida','escala_enviada',
|
||||
'pagamento_vencido','pagamento_recebido',
|
||||
'tarefa_combinada','contato_adicionado',
|
||||
'prontuario_editado','nota_adicionada','manual'
|
||||
])),
|
||||
CONSTRAINT pt_icone_cor_check CHECK (icone_cor = ANY (
|
||||
ARRAY['green','blue','amber','red','gray','purple']
|
||||
))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.patient_timeline IS 'Feed cronológico de eventos do paciente — alimentado por triggers e inserções manuais';
|
||||
COMMENT ON COLUMN public.patient_timeline.link_ref_tipo IS 'Tipo da entidade referenciada (polimórfico): agenda_evento | financial_record | documento | escala';
|
||||
COMMENT ON COLUMN public.patient_timeline.link_ref_id IS 'ID da entidade referenciada — sem FK formal para suportar múltiplos tipos';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_patient_ocorrido
|
||||
ON public.patient_timeline (patient_id, ocorrido_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_tenant
|
||||
ON public.patient_timeline (tenant_id, ocorrido_em DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pt_evento_tipo
|
||||
ON public.patient_timeline (patient_id, evento_tipo);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.patient_timeline ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY pt_select ON public.patient_timeline
|
||||
FOR SELECT USING (
|
||||
public.is_clinic_tenant(tenant_id)
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
AND public.tenant_has_feature(tenant_id, 'patients.view')
|
||||
);
|
||||
|
||||
CREATE POLICY pt_insert ON public.patient_timeline
|
||||
FOR INSERT WITH CHECK (
|
||||
public.is_clinic_tenant(tenant_id)
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
AND public.tenant_has_feature(tenant_id, 'patients.edit')
|
||||
);
|
||||
|
||||
-- Trigger: registra na timeline quando risco é sinalizado/removido
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
|
||||
INSERT INTO public.patient_timeline (
|
||||
patient_id, tenant_id,
|
||||
evento_tipo, titulo, descricao, icone_cor,
|
||||
gerado_por, ocorrido_em
|
||||
) VALUES (
|
||||
NEW.id, NEW.tenant_id,
|
||||
CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
|
||||
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
|
||||
NEW.risco_nota,
|
||||
CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END,
|
||||
auth.uid(),
|
||||
now()
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_patient_risco_timeline ON public.patients;
|
||||
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();
|
||||
|
||||
-- Trigger: registra na timeline quando status muda
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||
INSERT INTO public.patient_timeline (
|
||||
patient_id, tenant_id,
|
||||
evento_tipo, titulo, descricao, icone_cor,
|
||||
gerado_por, ocorrido_em
|
||||
) VALUES (
|
||||
NEW.id, NEW.tenant_id,
|
||||
'status_alterado',
|
||||
'Status alterado para ' || NEW.status,
|
||||
CASE
|
||||
WHEN TG_OP = 'INSERT' THEN 'Paciente cadastrado'
|
||||
ELSE 'De ' || OLD.status || ' → ' || NEW.status ||
|
||||
CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END
|
||||
END,
|
||||
CASE NEW.status
|
||||
WHEN 'Ativo' THEN 'green'
|
||||
WHEN 'Alta' THEN 'blue'
|
||||
WHEN 'Inativo' THEN 'gray'
|
||||
WHEN 'Encaminhado' THEN 'amber'
|
||||
WHEN 'Arquivado' THEN 'gray'
|
||||
ELSE 'gray'
|
||||
END,
|
||||
auth.uid(),
|
||||
now()
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_patient_status_timeline ON public.patients;
|
||||
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();
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. VIEW v_patient_engajamento
|
||||
-- Score calculado em tempo real — sem armazenar, sem inconsistência
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE 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') AS total_realizadas,
|
||||
COUNT(*) FILTER (WHERE ae.status IN ('realizado','cancelado','faltou')) AS total_marcadas,
|
||||
COUNT(*) FILTER (WHERE ae.status = 'faltou') AS total_faltas,
|
||||
MAX(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS ultima_sessao_em,
|
||||
MIN(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS primeira_sessao_em,
|
||||
COUNT(*) FILTER (WHERE ae.status = 'realizado'
|
||||
AND ae.inicio_em >= now() - interval '30 days') 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'), 0) AS total_pago,
|
||||
COALESCE(AVG(fr.final_amount) FILTER (WHERE fr.status = 'paid'), 0) AS ticket_medio,
|
||||
COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue')
|
||||
AND fr.due_date < now()) AS cobr_vencidas,
|
||||
COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue')) AS cobr_pendentes,
|
||||
COUNT(*) FILTER (WHERE fr.type = 'receita' AND fr.status = 'paid') 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,
|
||||
|
||||
-- Sessões
|
||||
COALESCE(s.total_realizadas, 0) AS total_sessoes,
|
||||
COALESCE(s.sessoes_ultimo_mes, 0) AS sessoes_ultimo_mes,
|
||||
s.primeira_sessao_em,
|
||||
s.ultima_sessao_em,
|
||||
EXTRACT(DAY FROM now() - s.ultima_sessao_em)::int AS dias_sem_sessao,
|
||||
|
||||
-- Taxa de comparecimento (%)
|
||||
CASE
|
||||
WHEN COALESCE(s.total_marcadas, 0) = 0 THEN NULL
|
||||
ELSE ROUND((s.total_realizadas::numeric / s.total_marcadas) * 100, 1)
|
||||
END AS taxa_comparecimento,
|
||||
|
||||
-- Financeiro
|
||||
COALESCE(f.total_pago, 0) AS ltv_total,
|
||||
ROUND(COALESCE(f.ticket_medio, 0), 2) AS ticket_medio,
|
||||
COALESCE(f.cobr_vencidas, 0) AS cobr_vencidas,
|
||||
COALESCE(f.cobr_pagas, 0) AS cobr_pagas,
|
||||
|
||||
-- Taxa de pagamentos em dia (%)
|
||||
CASE
|
||||
WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN NULL
|
||||
ELSE ROUND(
|
||||
f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 100, 1
|
||||
)
|
||||
END AS taxa_pagamentos_dia,
|
||||
|
||||
-- Score de engajamento composto (0-100)
|
||||
-- Pesos: comparecimento 50%, pagamentos 30%, recência 20%
|
||||
ROUND(
|
||||
LEAST(100,
|
||||
COALESCE(
|
||||
(
|
||||
-- Comparecimento (50 pts)
|
||||
CASE WHEN COALESCE(s.total_marcadas, 0) = 0 THEN 50
|
||||
ELSE LEAST(50, (s.total_realizadas::numeric / s.total_marcadas) * 50)
|
||||
END
|
||||
+
|
||||
-- Pagamentos em dia (30 pts)
|
||||
CASE WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN 30
|
||||
ELSE LEAST(30, f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 30)
|
||||
END
|
||||
+
|
||||
-- Recência (20 pts — penaliza quem está há muito tempo sem sessão)
|
||||
CASE
|
||||
WHEN s.ultima_sessao_em IS NULL THEN 0
|
||||
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 14 THEN 20
|
||||
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 30 THEN 15
|
||||
WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 60 THEN 8
|
||||
ELSE 0
|
||||
END
|
||||
), 0
|
||||
)
|
||||
)
|
||||
, 0) AS engajamento_score,
|
||||
|
||||
-- Duração do tratamento
|
||||
CASE
|
||||
WHEN s.primeira_sessao_em IS NULL THEN NULL
|
||||
ELSE EXTRACT(DAY FROM now() - s.primeira_sessao_em)::int
|
||||
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;
|
||||
|
||||
COMMENT ON VIEW public.v_patient_engajamento IS
|
||||
'Score de engajamento e métricas consolidadas por paciente. Calculado em tempo real via RLS (security_invoker=on).';
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. VIEW v_patients_risco
|
||||
-- Lista rápida de pacientes que precisam de atenção imediata
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE 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,
|
||||
-- Motivo do alerta
|
||||
CASE
|
||||
WHEN p.risco_elevado THEN 'risco_sinalizado'
|
||||
WHEN COALESCE(e.dias_sem_sessao, 999) > 30
|
||||
AND p.status = 'Ativo' THEN 'sem_sessao_30d'
|
||||
WHEN COALESCE(e.taxa_comparecimento, 100) < 60 THEN 'baixo_comparecimento'
|
||||
WHEN COALESCE(e.cobr_vencidas, 0) > 0 THEN 'cobranca_vencida'
|
||||
ELSE 'ok'
|
||||
END AS alerta_tipo
|
||||
FROM public.patients p
|
||||
JOIN public.v_patient_engajamento e ON e.patient_id = p.id
|
||||
WHERE p.status = 'Ativo'
|
||||
AND (
|
||||
p.risco_elevado = true
|
||||
OR COALESCE(e.dias_sem_sessao, 999) > 30
|
||||
OR COALESCE(e.taxa_comparecimento, 100) < 60
|
||||
OR COALESCE(e.cobr_vencidas, 0) > 0
|
||||
);
|
||||
|
||||
COMMENT ON VIEW public.v_patients_risco IS
|
||||
'Pacientes ativos que precisam de atenção: risco clínico, sem sessão há 30+ dias, baixo comparecimento ou cobrança vencida';
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 7. Migração de dados: popular patient_contacts com os dados já existentes
|
||||
-- Roda só uma vez — protegido por WHERE NOT EXISTS
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO public.patient_contacts (
|
||||
patient_id, tenant_id,
|
||||
nome, tipo, relacao,
|
||||
telefone, is_primario, ativo
|
||||
)
|
||||
SELECT
|
||||
p.id,
|
||||
p.tenant_id,
|
||||
p.nome_parente,
|
||||
'emergencia',
|
||||
p.grau_parentesco,
|
||||
p.telefone_parente,
|
||||
true,
|
||||
true
|
||||
FROM public.patients p
|
||||
WHERE p.nome_parente IS NOT NULL
|
||||
AND p.telefone_parente IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.patient_contacts pc
|
||||
WHERE pc.patient_id = p.id AND pc.tipo = 'emergencia'
|
||||
);
|
||||
|
||||
-- Migra responsável legal quando diferente do parente de emergência
|
||||
INSERT INTO public.patient_contacts (
|
||||
patient_id, tenant_id,
|
||||
nome, tipo, relacao,
|
||||
telefone, cpf,
|
||||
is_primario, ativo
|
||||
)
|
||||
SELECT
|
||||
p.id,
|
||||
p.tenant_id,
|
||||
p.nome_responsavel,
|
||||
'responsavel_legal',
|
||||
'Responsável legal',
|
||||
p.telefone_responsavel,
|
||||
p.cpf_responsavel,
|
||||
false,
|
||||
true
|
||||
FROM public.patients p
|
||||
WHERE p.nome_responsavel IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.patient_contacts pc
|
||||
WHERE pc.patient_id = p.id AND pc.tipo = 'responsavel_legal'
|
||||
);
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 8. Seed do histórico de status para pacientes já existentes
|
||||
-- Cria a primeira entrada de histórico com o status atual
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO public.patient_status_history (
|
||||
patient_id, tenant_id,
|
||||
status_anterior, status_novo,
|
||||
motivo, alterado_em
|
||||
)
|
||||
SELECT
|
||||
p.id,
|
||||
p.tenant_id,
|
||||
NULL,
|
||||
p.status,
|
||||
'Status inicial — migração de dados',
|
||||
COALESCE(p.created_at, now())
|
||||
FROM public.patients p
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.patient_status_history psh
|
||||
WHERE psh.patient_id = p.id
|
||||
);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- FIM DO MIGRATION
|
||||
-- Resumo do que foi feito:
|
||||
-- 1. ALTER TABLE patients — 16 novos campos (pronomes, risco, origem, etc.)
|
||||
-- 2. CREATE TABLE patient_contacts — múltiplos contatos por paciente
|
||||
-- 3. CREATE TABLE patient_status_history — trilha imutável de mudanças de status
|
||||
-- 4. CREATE TABLE patient_timeline — feed cronológico de eventos
|
||||
-- 5. Triggers automáticos — status history, timeline de risco e status
|
||||
-- 6. VIEW v_patient_engajamento — score 0-100 + métricas calculadas em tempo real
|
||||
-- 7. VIEW v_patients_risco — lista de pacientes que precisam de atenção
|
||||
-- 8. Migração de dados — popula patient_contacts e status_history com dados existentes
|
||||
-- =============================================================================
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
-- Extensions
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:04.148Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
|
||||
CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA pg_catalog;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_graphql WITH SCHEMA graphql;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA extensions;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;
|
||||
CREATE EXTENSION IF NOT EXISTS supabase_vault WITH SCHEMA vault;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
-- Functions: auth
|
||||
-- Gerado automaticamente em 2026-04-17T12:23:05.221Z
|
||||
-- Total: 4
|
||||
|
||||
CREATE FUNCTION auth.email() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
select
|
||||
coalesce(
|
||||
nullif(current_setting('request.jwt.claim.email', true), ''),
|
||||
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email')
|
||||
)::text
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION auth.jwt() RETURNS jsonb
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
select
|
||||
coalesce(
|
||||
nullif(current_setting('request.jwt.claim', true), ''),
|
||||
nullif(current_setting('request.jwt.claims', true), '')
|
||||
)::jsonb
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION auth.role() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
select
|
||||
coalesce(
|
||||
nullif(current_setting('request.jwt.claim.role', true), ''),
|
||||
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role')
|
||||
)::text
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION auth.uid() RETURNS uuid
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
select
|
||||
coalesce(
|
||||
nullif(current_setting('request.jwt.claim.sub', true), ''),
|
||||
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
|
||||
)::uuid
|
||||
$$;
|
||||
@@ -0,0 +1,223 @@
|
||||
-- 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
|
||||
AS $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT
|
||||
FROM pg_event_trigger_ddl_commands() AS ev
|
||||
JOIN pg_extension AS ext
|
||||
ON ev.objid = ext.oid
|
||||
WHERE ext.extname = 'pg_cron'
|
||||
)
|
||||
THEN
|
||||
grant usage on schema cron to postgres with grant option;
|
||||
|
||||
alter default privileges in schema cron grant all on tables to postgres with grant option;
|
||||
alter default privileges in schema cron grant all on functions to postgres with grant option;
|
||||
alter default privileges in schema cron grant all on sequences to postgres with grant option;
|
||||
|
||||
alter default privileges for user supabase_admin in schema cron grant all
|
||||
on sequences to postgres with grant option;
|
||||
alter default privileges for user supabase_admin in schema cron grant all
|
||||
on tables to postgres with grant option;
|
||||
alter default privileges for user supabase_admin in schema cron grant all
|
||||
on functions to postgres with grant option;
|
||||
|
||||
grant all privileges on all tables in schema cron to postgres with grant option;
|
||||
revoke all on table cron.job from postgres;
|
||||
grant select on table cron.job to postgres with grant option;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $_$
|
||||
DECLARE
|
||||
func_is_graphql_resolve bool;
|
||||
BEGIN
|
||||
func_is_graphql_resolve = (
|
||||
SELECT n.proname = 'resolve'
|
||||
FROM pg_event_trigger_ddl_commands() AS ev
|
||||
LEFT JOIN pg_catalog.pg_proc AS n
|
||||
ON ev.objid = n.oid
|
||||
);
|
||||
|
||||
IF func_is_graphql_resolve
|
||||
THEN
|
||||
-- Update public wrapper to pass all arguments through to the pg_graphql resolve func
|
||||
DROP FUNCTION IF EXISTS graphql_public.graphql;
|
||||
create or replace function graphql_public.graphql(
|
||||
"operationName" text default null,
|
||||
query text default null,
|
||||
variables jsonb default null,
|
||||
extensions jsonb default null
|
||||
)
|
||||
returns jsonb
|
||||
language sql
|
||||
as $$
|
||||
select graphql.resolve(
|
||||
query := query,
|
||||
variables := coalesce(variables, '{}'),
|
||||
"operationName" := "operationName",
|
||||
extensions := extensions
|
||||
);
|
||||
$$;
|
||||
|
||||
-- This hook executes when `graphql.resolve` is created. That is not necessarily the last
|
||||
-- function in the extension so we need to grant permissions on existing entities AND
|
||||
-- update default permissions to any others that are created after `graphql.resolve`
|
||||
grant usage on schema graphql to postgres, anon, authenticated, service_role;
|
||||
grant select on all tables in schema graphql to postgres, anon, authenticated, service_role;
|
||||
grant execute on all functions in schema graphql to postgres, anon, authenticated, service_role;
|
||||
grant all on all sequences in schema graphql to postgres, anon, authenticated, service_role;
|
||||
alter default privileges in schema graphql grant all on tables to postgres, anon, authenticated, service_role;
|
||||
alter default privileges in schema graphql grant all on functions to postgres, anon, authenticated, service_role;
|
||||
alter default privileges in schema graphql grant all on sequences to postgres, anon, authenticated, service_role;
|
||||
|
||||
-- Allow postgres role to allow granting usage on graphql and graphql_public schemas to custom roles
|
||||
grant usage on schema graphql_public to postgres with grant option;
|
||||
grant usage on schema graphql to postgres with grant option;
|
||||
END IF;
|
||||
|
||||
END;
|
||||
$_$;
|
||||
|
||||
CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_event_trigger_ddl_commands() AS ev
|
||||
JOIN pg_extension AS ext
|
||||
ON ev.objid = ext.oid
|
||||
WHERE ext.extname = 'pg_net'
|
||||
)
|
||||
THEN
|
||||
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
|
||||
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
cmd record;
|
||||
BEGIN
|
||||
FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands()
|
||||
LOOP
|
||||
IF cmd.command_tag IN (
|
||||
'CREATE SCHEMA', 'ALTER SCHEMA'
|
||||
, 'CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO', 'ALTER TABLE'
|
||||
, 'CREATE FOREIGN TABLE', 'ALTER FOREIGN TABLE'
|
||||
, 'CREATE VIEW', 'ALTER VIEW'
|
||||
, 'CREATE MATERIALIZED VIEW', 'ALTER MATERIALIZED VIEW'
|
||||
, 'CREATE FUNCTION', 'ALTER FUNCTION'
|
||||
, 'CREATE TRIGGER'
|
||||
, 'CREATE TYPE', 'ALTER TYPE'
|
||||
, 'CREATE RULE'
|
||||
, 'COMMENT'
|
||||
)
|
||||
-- don't notify in case of CREATE TEMP table or other objects created on pg_temp
|
||||
AND cmd.schema_name is distinct from 'pg_temp'
|
||||
THEN
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
END IF;
|
||||
END LOOP;
|
||||
END; $$;
|
||||
|
||||
CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
obj record;
|
||||
BEGIN
|
||||
FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects()
|
||||
LOOP
|
||||
IF obj.object_type IN (
|
||||
'schema'
|
||||
, 'table'
|
||||
, 'foreign table'
|
||||
, 'view'
|
||||
, 'materialized view'
|
||||
, 'function'
|
||||
, 'trigger'
|
||||
, 'type'
|
||||
, 'rule'
|
||||
)
|
||||
AND obj.is_temporary IS false -- no pg_temp objects
|
||||
THEN
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
END IF;
|
||||
END LOOP;
|
||||
END; $$;
|
||||
|
||||
CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $_$
|
||||
DECLARE
|
||||
graphql_is_dropped bool;
|
||||
BEGIN
|
||||
graphql_is_dropped = (
|
||||
SELECT ev.schema_name = 'graphql_public'
|
||||
FROM pg_event_trigger_dropped_objects() AS ev
|
||||
WHERE ev.schema_name = 'graphql_public'
|
||||
);
|
||||
|
||||
IF graphql_is_dropped
|
||||
THEN
|
||||
create or replace function graphql_public.graphql(
|
||||
"operationName" text default null,
|
||||
query text default null,
|
||||
variables jsonb default null,
|
||||
extensions jsonb default null
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
as $$
|
||||
DECLARE
|
||||
server_version float;
|
||||
BEGIN
|
||||
server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float);
|
||||
|
||||
IF server_version >= 14 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'errors', jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'message', 'pg_graphql extension is not enabled.'
|
||||
)
|
||||
)
|
||||
);
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'errors', jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'message', 'pg_graphql is only available on projects running Postgres 14 onwards.'
|
||||
)
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
END IF;
|
||||
|
||||
END;
|
||||
$_$;
|
||||
@@ -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
@@ -0,0 +1,636 @@
|
||||
-- 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
|
||||
AS $$
|
||||
declare
|
||||
-- Regclass of the table e.g. public.notes
|
||||
entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass;
|
||||
|
||||
-- I, U, D, T: insert, update ...
|
||||
action realtime.action = (
|
||||
case wal ->> 'action'
|
||||
when 'I' then 'INSERT'
|
||||
when 'U' then 'UPDATE'
|
||||
when 'D' then 'DELETE'
|
||||
else 'ERROR'
|
||||
end
|
||||
);
|
||||
|
||||
-- Is row level security enabled for the table
|
||||
is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_;
|
||||
|
||||
subscriptions realtime.subscription[] = array_agg(subs)
|
||||
from
|
||||
realtime.subscription subs
|
||||
where
|
||||
subs.entity = entity_;
|
||||
|
||||
-- Subscription vars
|
||||
roles regrole[] = array_agg(distinct us.claims_role::text)
|
||||
from
|
||||
unnest(subscriptions) us;
|
||||
|
||||
working_role regrole;
|
||||
claimed_role regrole;
|
||||
claims jsonb;
|
||||
|
||||
subscription_id uuid;
|
||||
subscription_has_access bool;
|
||||
visible_to_subscription_ids uuid[] = '{}';
|
||||
|
||||
-- structured info for wal's columns
|
||||
columns realtime.wal_column[];
|
||||
-- previous identity values for update/delete
|
||||
old_columns realtime.wal_column[];
|
||||
|
||||
error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes;
|
||||
|
||||
-- Primary jsonb output for record
|
||||
output jsonb;
|
||||
|
||||
begin
|
||||
perform set_config('role', null, true);
|
||||
|
||||
columns =
|
||||
array_agg(
|
||||
(
|
||||
x->>'name',
|
||||
x->>'type',
|
||||
x->>'typeoid',
|
||||
realtime.cast(
|
||||
(x->'value') #>> '{}',
|
||||
coalesce(
|
||||
(x->>'typeoid')::regtype, -- null when wal2json version <= 2.4
|
||||
(x->>'type')::regtype
|
||||
)
|
||||
),
|
||||
(pks ->> 'name') is not null,
|
||||
true
|
||||
)::realtime.wal_column
|
||||
)
|
||||
from
|
||||
jsonb_array_elements(wal -> 'columns') x
|
||||
left join jsonb_array_elements(wal -> 'pk') pks
|
||||
on (x ->> 'name') = (pks ->> 'name');
|
||||
|
||||
old_columns =
|
||||
array_agg(
|
||||
(
|
||||
x->>'name',
|
||||
x->>'type',
|
||||
x->>'typeoid',
|
||||
realtime.cast(
|
||||
(x->'value') #>> '{}',
|
||||
coalesce(
|
||||
(x->>'typeoid')::regtype, -- null when wal2json version <= 2.4
|
||||
(x->>'type')::regtype
|
||||
)
|
||||
),
|
||||
(pks ->> 'name') is not null,
|
||||
true
|
||||
)::realtime.wal_column
|
||||
)
|
||||
from
|
||||
jsonb_array_elements(wal -> 'identity') x
|
||||
left join jsonb_array_elements(wal -> 'pk') pks
|
||||
on (x ->> 'name') = (pks ->> 'name');
|
||||
|
||||
for working_role in select * from unnest(roles) loop
|
||||
|
||||
-- Update `is_selectable` for columns and old_columns
|
||||
columns =
|
||||
array_agg(
|
||||
(
|
||||
c.name,
|
||||
c.type_name,
|
||||
c.type_oid,
|
||||
c.value,
|
||||
c.is_pkey,
|
||||
pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')
|
||||
)::realtime.wal_column
|
||||
)
|
||||
from
|
||||
unnest(columns) c;
|
||||
|
||||
old_columns =
|
||||
array_agg(
|
||||
(
|
||||
c.name,
|
||||
c.type_name,
|
||||
c.type_oid,
|
||||
c.value,
|
||||
c.is_pkey,
|
||||
pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')
|
||||
)::realtime.wal_column
|
||||
)
|
||||
from
|
||||
unnest(old_columns) c;
|
||||
|
||||
if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then
|
||||
return next (
|
||||
jsonb_build_object(
|
||||
'schema', wal ->> 'schema',
|
||||
'table', wal ->> 'table',
|
||||
'type', action
|
||||
),
|
||||
is_rls_enabled,
|
||||
-- subscriptions is already filtered by entity
|
||||
(select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role),
|
||||
array['Error 400: Bad Request, no primary key']
|
||||
)::realtime.wal_rls;
|
||||
|
||||
-- The claims role does not have SELECT permission to the primary key of entity
|
||||
elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then
|
||||
return next (
|
||||
jsonb_build_object(
|
||||
'schema', wal ->> 'schema',
|
||||
'table', wal ->> 'table',
|
||||
'type', action
|
||||
),
|
||||
is_rls_enabled,
|
||||
(select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role),
|
||||
array['Error 401: Unauthorized']
|
||||
)::realtime.wal_rls;
|
||||
|
||||
else
|
||||
output = jsonb_build_object(
|
||||
'schema', wal ->> 'schema',
|
||||
'table', wal ->> 'table',
|
||||
'type', action,
|
||||
'commit_timestamp', to_char(
|
||||
((wal ->> 'timestamp')::timestamptz at time zone 'utc'),
|
||||
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
||||
),
|
||||
'columns', (
|
||||
select
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', pa.attname,
|
||||
'type', pt.typname
|
||||
)
|
||||
order by pa.attnum asc
|
||||
)
|
||||
from
|
||||
pg_attribute pa
|
||||
join pg_type pt
|
||||
on pa.atttypid = pt.oid
|
||||
where
|
||||
attrelid = entity_
|
||||
and attnum > 0
|
||||
and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT')
|
||||
)
|
||||
)
|
||||
-- Add "record" key for insert and update
|
||||
|| case
|
||||
when action in ('INSERT', 'UPDATE') then
|
||||
jsonb_build_object(
|
||||
'record',
|
||||
(
|
||||
select
|
||||
jsonb_object_agg(
|
||||
-- if unchanged toast, get column name and value from old record
|
||||
coalesce((c).name, (oc).name),
|
||||
case
|
||||
when (c).name is null then (oc).value
|
||||
else (c).value
|
||||
end
|
||||
)
|
||||
from
|
||||
unnest(columns) c
|
||||
full outer join unnest(old_columns) oc
|
||||
on (c).name = (oc).name
|
||||
where
|
||||
coalesce((c).is_selectable, (oc).is_selectable)
|
||||
and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))
|
||||
)
|
||||
)
|
||||
else '{}'::jsonb
|
||||
end
|
||||
-- Add "old_record" key for update and delete
|
||||
|| case
|
||||
when action = 'UPDATE' then
|
||||
jsonb_build_object(
|
||||
'old_record',
|
||||
(
|
||||
select jsonb_object_agg((c).name, (c).value)
|
||||
from unnest(old_columns) c
|
||||
where
|
||||
(c).is_selectable
|
||||
and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))
|
||||
)
|
||||
)
|
||||
when action = 'DELETE' then
|
||||
jsonb_build_object(
|
||||
'old_record',
|
||||
(
|
||||
select jsonb_object_agg((c).name, (c).value)
|
||||
from unnest(old_columns) c
|
||||
where
|
||||
(c).is_selectable
|
||||
and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))
|
||||
and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey
|
||||
)
|
||||
)
|
||||
else '{}'::jsonb
|
||||
end;
|
||||
|
||||
-- Create the prepared statement
|
||||
if is_rls_enabled and action <> 'DELETE' then
|
||||
if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then
|
||||
deallocate walrus_rls_stmt;
|
||||
end if;
|
||||
execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns);
|
||||
end if;
|
||||
|
||||
visible_to_subscription_ids = '{}';
|
||||
|
||||
for subscription_id, claims in (
|
||||
select
|
||||
subs.subscription_id,
|
||||
subs.claims
|
||||
from
|
||||
unnest(subscriptions) subs
|
||||
where
|
||||
subs.entity = entity_
|
||||
and subs.claims_role = working_role
|
||||
and (
|
||||
realtime.is_visible_through_filters(columns, subs.filters)
|
||||
or (
|
||||
action = 'DELETE'
|
||||
and realtime.is_visible_through_filters(old_columns, subs.filters)
|
||||
)
|
||||
)
|
||||
) loop
|
||||
|
||||
if not is_rls_enabled or action = 'DELETE' then
|
||||
visible_to_subscription_ids = visible_to_subscription_ids || subscription_id;
|
||||
else
|
||||
-- Check if RLS allows the role to see the record
|
||||
perform
|
||||
-- Trim leading and trailing quotes from working_role because set_config
|
||||
-- doesn't recognize the role as valid if they are included
|
||||
set_config('role', trim(both '"' from working_role::text), true),
|
||||
set_config('request.jwt.claims', claims::text, true);
|
||||
|
||||
execute 'execute walrus_rls_stmt' into subscription_has_access;
|
||||
|
||||
if subscription_has_access then
|
||||
visible_to_subscription_ids = visible_to_subscription_ids || subscription_id;
|
||||
end if;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
perform set_config('role', null, true);
|
||||
|
||||
return next (
|
||||
output,
|
||||
is_rls_enabled,
|
||||
visible_to_subscription_ids,
|
||||
case
|
||||
when error_record_exceeds_max_size then array['Error 413: Payload Too Large']
|
||||
else '{}'
|
||||
end
|
||||
)::realtime.wal_rls;
|
||||
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
perform set_config('role', null, true);
|
||||
end;
|
||||
$$;
|
||||
|
||||
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 $$
|
||||
DECLARE
|
||||
-- Declare a variable to hold the JSONB representation of the row
|
||||
row_data jsonb := '{}'::jsonb;
|
||||
BEGIN
|
||||
IF level = 'STATEMENT' THEN
|
||||
RAISE EXCEPTION 'function can only be triggered for each row, not for each statement';
|
||||
END IF;
|
||||
-- Check the operation type and handle accordingly
|
||||
IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN
|
||||
row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema);
|
||||
PERFORM realtime.send (row_data, event_name, topic_name);
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Unexpected operation type: %', operation;
|
||||
END IF;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE EXCEPTION 'Failed to process the row: %', SQLERRM;
|
||||
END;
|
||||
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[]) RETURNS text
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
/*
|
||||
Builds a sql string that, if executed, creates a prepared statement to
|
||||
tests retrive a row from *entity* by its primary key columns.
|
||||
Example
|
||||
select realtime.build_prepared_statement_sql('public.notes', '{"id"}'::text[], '{"bigint"}'::text[])
|
||||
*/
|
||||
select
|
||||
'prepare ' || prepared_statement_name || ' as
|
||||
select
|
||||
exists(
|
||||
select
|
||||
1
|
||||
from
|
||||
' || entity || '
|
||||
where
|
||||
' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '
|
||||
)'
|
||||
from
|
||||
unnest(columns) pkc
|
||||
where
|
||||
pkc.is_pkey
|
||||
group by
|
||||
entity
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime."cast"(val text, type_ regtype) RETURNS jsonb
|
||||
LANGUAGE plpgsql IMMUTABLE
|
||||
AS $$
|
||||
declare
|
||||
res jsonb;
|
||||
begin
|
||||
execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res;
|
||||
return res;
|
||||
end
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text) RETURNS boolean
|
||||
LANGUAGE plpgsql IMMUTABLE
|
||||
AS $$
|
||||
/*
|
||||
Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness
|
||||
*/
|
||||
declare
|
||||
op_symbol text = (
|
||||
case
|
||||
when op = 'eq' then '='
|
||||
when op = 'neq' then '!='
|
||||
when op = 'lt' then '<'
|
||||
when op = 'lte' then '<='
|
||||
when op = 'gt' then '>'
|
||||
when op = 'gte' then '>='
|
||||
when op = 'in' then '= any'
|
||||
else 'UNKNOWN OP'
|
||||
end
|
||||
);
|
||||
res boolean;
|
||||
begin
|
||||
execute format(
|
||||
'select %L::'|| type_::text || ' ' || op_symbol
|
||||
|| ' ( %L::'
|
||||
|| (
|
||||
case
|
||||
when op = 'in' then type_::text || '[]'
|
||||
else type_::text end
|
||||
)
|
||||
|| ')', val_1, val_2) into res;
|
||||
return res;
|
||||
end;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) RETURNS boolean
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $_$
|
||||
/*
|
||||
Should the record be visible (true) or filtered out (false) after *filters* are applied
|
||||
*/
|
||||
select
|
||||
-- Default to allowed when no filters present
|
||||
$2 is null -- no filters. this should not happen because subscriptions has a default
|
||||
or array_length($2, 1) is null -- array length of an empty array is null
|
||||
or bool_and(
|
||||
coalesce(
|
||||
realtime.check_equality_op(
|
||||
op:=f.op,
|
||||
type_:=coalesce(
|
||||
col.type_oid::regtype, -- null when wal2json version <= 2.4
|
||||
col.type_name::regtype
|
||||
),
|
||||
-- cast jsonb to text
|
||||
val_1:=col.value #>> '{}',
|
||||
val_2:=f.value
|
||||
),
|
||||
false -- if null, filter does not match
|
||||
)
|
||||
)
|
||||
from
|
||||
unnest(filters) f
|
||||
join unnest(columns) col
|
||||
on f.column_name = col.name;
|
||||
$_$;
|
||||
|
||||
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'
|
||||
AS $$
|
||||
with pub as (
|
||||
select
|
||||
concat_ws(
|
||||
',',
|
||||
case when bool_or(pubinsert) then 'insert' else null end,
|
||||
case when bool_or(pubupdate) then 'update' else null end,
|
||||
case when bool_or(pubdelete) then 'delete' else null end
|
||||
) as w2j_actions,
|
||||
coalesce(
|
||||
string_agg(
|
||||
realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass),
|
||||
','
|
||||
) filter (where ppt.tablename is not null and ppt.tablename not like '% %'),
|
||||
''
|
||||
) w2j_add_tables
|
||||
from
|
||||
pg_publication pp
|
||||
left join pg_publication_tables ppt
|
||||
on pp.pubname = ppt.pubname
|
||||
where
|
||||
pp.pubname = publication
|
||||
group by
|
||||
pp.pubname
|
||||
limit 1
|
||||
),
|
||||
w2j as (
|
||||
select
|
||||
x.*, pub.w2j_add_tables
|
||||
from
|
||||
pub,
|
||||
pg_logical_slot_get_changes(
|
||||
slot_name, null, max_changes,
|
||||
'include-pk', 'true',
|
||||
'include-transaction', 'false',
|
||||
'include-timestamp', 'true',
|
||||
'include-type-oids', 'true',
|
||||
'format-version', '2',
|
||||
'actions', pub.w2j_actions,
|
||||
'add-tables', pub.w2j_add_tables
|
||||
) x
|
||||
)
|
||||
select
|
||||
xyz.wal,
|
||||
xyz.is_rls_enabled,
|
||||
xyz.subscription_ids,
|
||||
xyz.errors
|
||||
from
|
||||
w2j,
|
||||
realtime.apply_rls(
|
||||
wal := w2j.data::jsonb,
|
||||
max_record_bytes := max_record_bytes
|
||||
) xyz(wal, is_rls_enabled, subscription_ids, errors)
|
||||
where
|
||||
w2j.w2j_add_tables <> ''
|
||||
and xyz.subscription_ids[1] is not null
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime.quote_wal2json(entity regclass) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE STRICT
|
||||
AS $$
|
||||
select
|
||||
(
|
||||
select string_agg('' || ch,'')
|
||||
from unnest(string_to_array(nsp.nspname::text, null)) with ordinality x(ch, idx)
|
||||
where
|
||||
not (x.idx = 1 and x.ch = '"')
|
||||
and not (
|
||||
x.idx = array_length(string_to_array(nsp.nspname::text, null), 1)
|
||||
and x.ch = '"'
|
||||
)
|
||||
)
|
||||
|| '.'
|
||||
|| (
|
||||
select string_agg('' || ch,'')
|
||||
from unnest(string_to_array(pc.relname::text, null)) with ordinality x(ch, idx)
|
||||
where
|
||||
not (x.idx = 1 and x.ch = '"')
|
||||
and not (
|
||||
x.idx = array_length(string_to_array(nsp.nspname::text, null), 1)
|
||||
and x.ch = '"'
|
||||
)
|
||||
)
|
||||
from
|
||||
pg_class pc
|
||||
join pg_namespace nsp
|
||||
on pc.relnamespace = nsp.oid
|
||||
where
|
||||
pc.oid = entity
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
generated_id uuid;
|
||||
final_payload jsonb;
|
||||
BEGIN
|
||||
BEGIN
|
||||
-- Generate a new UUID for the id
|
||||
generated_id := gen_random_uuid();
|
||||
|
||||
-- Check if payload has an 'id' key, if not, add the generated UUID
|
||||
IF payload ? 'id' THEN
|
||||
final_payload := payload;
|
||||
ELSE
|
||||
final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id));
|
||||
END IF;
|
||||
|
||||
-- Set the topic configuration
|
||||
EXECUTE format('SET LOCAL realtime.topic TO %L', topic);
|
||||
|
||||
-- Attempt to insert the message
|
||||
INSERT INTO realtime.messages (id, payload, event, topic, private, extension)
|
||||
VALUES (generated_id, final_payload, event, topic, private, 'broadcast');
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- Capture and notify the error
|
||||
RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM;
|
||||
END;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime.subscription_check_filters() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
/*
|
||||
Validates that the user defined filters for a subscription:
|
||||
- refer to valid columns that the claimed role may access
|
||||
- values are coercable to the correct column type
|
||||
*/
|
||||
declare
|
||||
col_names text[] = coalesce(
|
||||
array_agg(c.column_name order by c.ordinal_position),
|
||||
'{}'::text[]
|
||||
)
|
||||
from
|
||||
information_schema.columns c
|
||||
where
|
||||
format('%I.%I', c.table_schema, c.table_name)::regclass = new.entity
|
||||
and pg_catalog.has_column_privilege(
|
||||
(new.claims ->> 'role'),
|
||||
format('%I.%I', c.table_schema, c.table_name)::regclass,
|
||||
c.column_name,
|
||||
'SELECT'
|
||||
);
|
||||
filter realtime.user_defined_filter;
|
||||
col_type regtype;
|
||||
|
||||
in_val jsonb;
|
||||
begin
|
||||
for filter in select * from unnest(new.filters) loop
|
||||
-- Filtered column is valid
|
||||
if not filter.column_name = any(col_names) then
|
||||
raise exception 'invalid column for filter %', filter.column_name;
|
||||
end if;
|
||||
|
||||
-- Type is sanitized and safe for string interpolation
|
||||
col_type = (
|
||||
select atttypid::regtype
|
||||
from pg_catalog.pg_attribute
|
||||
where attrelid = new.entity
|
||||
and attname = filter.column_name
|
||||
);
|
||||
if col_type is null then
|
||||
raise exception 'failed to lookup type for column %', filter.column_name;
|
||||
end if;
|
||||
|
||||
-- Set maximum number of entries for in filter
|
||||
if filter.op = 'in'::realtime.equality_op then
|
||||
in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype);
|
||||
if coalesce(jsonb_array_length(in_val), 0) > 100 then
|
||||
raise exception 'too many values for `in` filter. Maximum 100';
|
||||
end if;
|
||||
else
|
||||
-- raises an exception if value is not coercable to type
|
||||
perform realtime.cast(filter.value, col_type);
|
||||
end if;
|
||||
|
||||
end loop;
|
||||
|
||||
-- Apply consistent order to filters so the unique constraint on
|
||||
-- (subscription_id, entity, filters) can't be tricked by a different filter order
|
||||
new.filters = coalesce(
|
||||
array_agg(f order by f.column_name, f.op, f.value),
|
||||
'{}'
|
||||
) from unnest(new.filters) f;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION realtime.to_regrole(role_name text) RETURNS regrole
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$ select role_name::regrole $$;
|
||||
|
||||
CREATE FUNCTION realtime.topic() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
select nullif(current_setting('realtime.topic', true), '')::text;
|
||||
$$;
|
||||
@@ -0,0 +1,771 @@
|
||||
-- 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
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO "storage"."objects" ("bucket_id", "name", "owner", "metadata") VALUES (bucketid, name, owner, metadata);
|
||||
-- hack to rollback the successful insert
|
||||
RAISE sqlstate 'PT200' using
|
||||
message = 'ROLLBACK',
|
||||
detail = 'rollback successful insert';
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.enforce_bucket_name_length() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
begin
|
||||
if length(new.name) > 100 then
|
||||
raise exception 'bucket name "%" is too long (% characters). Max is 100.', new.name, length(new.name);
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.extension(name text) RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
_parts text[];
|
||||
_filename text;
|
||||
BEGIN
|
||||
select string_to_array(name, '/') into _parts;
|
||||
select _parts[array_length(_parts,1)] into _filename;
|
||||
-- @todo return the last part instead of 2
|
||||
return reverse(split_part(reverse(_filename), '.', 1));
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.filename(name text) RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
_parts text[];
|
||||
BEGIN
|
||||
select string_to_array(name, '/') into _parts;
|
||||
return _parts[array_length(_parts,1)];
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.foldername(name text) RETURNS text[]
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
_parts text[];
|
||||
BEGIN
|
||||
select string_to_array(name, '/') into _parts;
|
||||
return _parts[1:array_length(_parts,1)-1];
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.get_common_prefix(p_key text, p_prefix text, p_delimiter text) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1)) > 0
|
||||
THEN left(p_key, length(p_prefix) + position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1)))
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.get_size_by_bucket() RETURNS TABLE(size bigint, bucket_id text)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
return query
|
||||
select sum((metadata->>'size')::int) as size, obj.bucket_id
|
||||
from "storage".objects as obj
|
||||
group by obj.bucket_id;
|
||||
END
|
||||
$$;
|
||||
|
||||
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 $_$
|
||||
BEGIN
|
||||
RETURN QUERY EXECUTE
|
||||
'SELECT DISTINCT ON(key COLLATE "C") * from (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN
|
||||
substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1)))
|
||||
ELSE
|
||||
key
|
||||
END AS key, id, created_at
|
||||
FROM
|
||||
storage.s3_multipart_uploads
|
||||
WHERE
|
||||
bucket_id = $5 AND
|
||||
key ILIKE $1 || ''%'' AND
|
||||
CASE
|
||||
WHEN $4 != '''' AND $6 = '''' THEN
|
||||
CASE
|
||||
WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN
|
||||
substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) COLLATE "C" > $4
|
||||
ELSE
|
||||
key COLLATE "C" > $4
|
||||
END
|
||||
ELSE
|
||||
true
|
||||
END AND
|
||||
CASE
|
||||
WHEN $6 != '''' THEN
|
||||
id COLLATE "C" > $6
|
||||
ELSE
|
||||
true
|
||||
END
|
||||
ORDER BY
|
||||
key COLLATE "C" ASC, created_at ASC) as e order by key COLLATE "C" LIMIT $3'
|
||||
USING prefix_param, delimiter_param, max_keys, next_key_token, bucket_id, next_upload_token;
|
||||
END;
|
||||
$_$;
|
||||
|
||||
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 $_$
|
||||
DECLARE
|
||||
v_peek_name TEXT;
|
||||
v_current RECORD;
|
||||
v_common_prefix TEXT;
|
||||
|
||||
-- Configuration
|
||||
v_is_asc BOOLEAN;
|
||||
v_prefix TEXT;
|
||||
v_start TEXT;
|
||||
v_upper_bound TEXT;
|
||||
v_file_batch_size INT;
|
||||
|
||||
-- Seek state
|
||||
v_next_seek TEXT;
|
||||
v_count INT := 0;
|
||||
|
||||
-- Dynamic SQL for batch query only
|
||||
v_batch_query TEXT;
|
||||
|
||||
BEGIN
|
||||
-- ========================================================================
|
||||
-- INITIALIZATION
|
||||
-- ========================================================================
|
||||
v_is_asc := lower(coalesce(sort_order, 'asc')) = 'asc';
|
||||
v_prefix := coalesce(prefix_param, '');
|
||||
v_start := CASE WHEN coalesce(next_token, '') <> '' THEN next_token ELSE coalesce(start_after, '') END;
|
||||
v_file_batch_size := LEAST(GREATEST(max_keys * 2, 100), 1000);
|
||||
|
||||
-- Calculate upper bound for prefix filtering (bytewise, using COLLATE "C")
|
||||
IF v_prefix = '' THEN
|
||||
v_upper_bound := NULL;
|
||||
ELSIF right(v_prefix, 1) = delimiter_param THEN
|
||||
v_upper_bound := left(v_prefix, -1) || chr(ascii(delimiter_param) + 1);
|
||||
ELSE
|
||||
v_upper_bound := left(v_prefix, -1) || chr(ascii(right(v_prefix, 1)) + 1);
|
||||
END IF;
|
||||
|
||||
-- Build batch query (dynamic SQL - called infrequently, amortized over many rows)
|
||||
IF v_is_asc THEN
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' ||
|
||||
'AND o.name COLLATE "C" < $3 ORDER BY o.name COLLATE "C" ASC LIMIT $4';
|
||||
ELSE
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' ||
|
||||
'ORDER BY o.name COLLATE "C" ASC LIMIT $4';
|
||||
END IF;
|
||||
ELSE
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' ||
|
||||
'AND o.name COLLATE "C" >= $3 ORDER BY o.name COLLATE "C" DESC LIMIT $4';
|
||||
ELSE
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' ||
|
||||
'ORDER BY o.name COLLATE "C" DESC LIMIT $4';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- SEEK INITIALIZATION: Determine starting position
|
||||
-- ========================================================================
|
||||
IF v_start = '' THEN
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := v_prefix;
|
||||
ELSE
|
||||
-- DESC without cursor: find the last item in range
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
SELECT o.name INTO v_next_seek FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix AND o.name COLLATE "C" < v_upper_bound
|
||||
ORDER BY o.name COLLATE "C" DESC LIMIT 1;
|
||||
ELSIF v_prefix <> '' THEN
|
||||
SELECT o.name INTO v_next_seek FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix
|
||||
ORDER BY o.name COLLATE "C" DESC LIMIT 1;
|
||||
ELSE
|
||||
SELECT o.name INTO v_next_seek FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id
|
||||
ORDER BY o.name COLLATE "C" DESC LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_next_seek IS NOT NULL THEN
|
||||
v_next_seek := v_next_seek || delimiter_param;
|
||||
ELSE
|
||||
RETURN;
|
||||
END IF;
|
||||
END IF;
|
||||
ELSE
|
||||
-- Cursor provided: determine if it refers to a folder or leaf
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id
|
||||
AND o.name COLLATE "C" LIKE v_start || delimiter_param || '%'
|
||||
LIMIT 1
|
||||
) THEN
|
||||
-- Cursor refers to a folder
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := v_start || chr(ascii(delimiter_param) + 1);
|
||||
ELSE
|
||||
v_next_seek := v_start || delimiter_param;
|
||||
END IF;
|
||||
ELSE
|
||||
-- Cursor refers to a leaf object
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := v_start || delimiter_param;
|
||||
ELSE
|
||||
v_next_seek := v_start;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- MAIN LOOP: Hybrid peek-then-batch algorithm
|
||||
-- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch
|
||||
-- ========================================================================
|
||||
LOOP
|
||||
EXIT WHEN v_count >= max_keys;
|
||||
|
||||
-- STEP 1: PEEK using STATIC SQL (plan cached, very fast)
|
||||
IF v_is_asc THEN
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek AND o.name COLLATE "C" < v_upper_bound
|
||||
ORDER BY o.name COLLATE "C" ASC LIMIT 1;
|
||||
ELSE
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek
|
||||
ORDER BY o.name COLLATE "C" ASC LIMIT 1;
|
||||
END IF;
|
||||
ELSE
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix
|
||||
ORDER BY o.name COLLATE "C" DESC LIMIT 1;
|
||||
ELSIF v_prefix <> '' THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix
|
||||
ORDER BY o.name COLLATE "C" DESC LIMIT 1;
|
||||
ELSE
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek
|
||||
ORDER BY o.name COLLATE "C" DESC LIMIT 1;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
EXIT WHEN v_peek_name IS NULL;
|
||||
|
||||
-- STEP 2: Check if this is a FOLDER or FILE
|
||||
v_common_prefix := storage.get_common_prefix(v_peek_name, v_prefix, delimiter_param);
|
||||
|
||||
IF v_common_prefix IS NOT NULL THEN
|
||||
-- FOLDER: Emit and skip to next folder (no heap access needed)
|
||||
name := rtrim(v_common_prefix, delimiter_param);
|
||||
id := NULL;
|
||||
updated_at := NULL;
|
||||
created_at := NULL;
|
||||
last_accessed_at := NULL;
|
||||
metadata := NULL;
|
||||
RETURN NEXT;
|
||||
v_count := v_count + 1;
|
||||
|
||||
-- Advance seek past the folder range
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := left(v_common_prefix, -1) || chr(ascii(delimiter_param) + 1);
|
||||
ELSE
|
||||
v_next_seek := v_common_prefix;
|
||||
END IF;
|
||||
ELSE
|
||||
-- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows)
|
||||
-- For ASC: upper_bound is the exclusive upper limit (< condition)
|
||||
-- For DESC: prefix is the inclusive lower limit (>= condition)
|
||||
FOR v_current IN EXECUTE v_batch_query USING _bucket_id, v_next_seek,
|
||||
CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix) ELSE v_prefix END, v_file_batch_size
|
||||
LOOP
|
||||
v_common_prefix := storage.get_common_prefix(v_current.name, v_prefix, delimiter_param);
|
||||
|
||||
IF v_common_prefix IS NOT NULL THEN
|
||||
-- Hit a folder: exit batch, let peek handle it
|
||||
v_next_seek := v_current.name;
|
||||
EXIT;
|
||||
END IF;
|
||||
|
||||
-- Emit file
|
||||
name := v_current.name;
|
||||
id := v_current.id;
|
||||
updated_at := v_current.updated_at;
|
||||
created_at := v_current.created_at;
|
||||
last_accessed_at := v_current.last_accessed_at;
|
||||
metadata := v_current.metadata;
|
||||
RETURN NEXT;
|
||||
v_count := v_count + 1;
|
||||
|
||||
-- Advance seek past this file
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := v_current.name || delimiter_param;
|
||||
ELSE
|
||||
v_next_seek := v_current.name;
|
||||
END IF;
|
||||
|
||||
EXIT WHEN v_count >= max_keys;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$_$;
|
||||
|
||||
CREATE FUNCTION storage.operation() RETURNS text
|
||||
LANGUAGE plpgsql STABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN current_setting('storage.operation', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.protect_delete() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Check if storage.allow_delete_query is set to 'true'
|
||||
IF COALESCE(current_setting('storage.allow_delete_query', true), 'false') != 'true' THEN
|
||||
RAISE EXCEPTION 'Direct deletion from storage tables is not allowed. Use the Storage API instead.'
|
||||
USING HINT = 'This prevents accidental data loss from orphaned objects.',
|
||||
ERRCODE = '42501';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
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 $_$
|
||||
DECLARE
|
||||
v_peek_name TEXT;
|
||||
v_current RECORD;
|
||||
v_common_prefix TEXT;
|
||||
v_delimiter CONSTANT TEXT := '/';
|
||||
|
||||
-- Configuration
|
||||
v_limit INT;
|
||||
v_prefix TEXT;
|
||||
v_prefix_lower TEXT;
|
||||
v_is_asc BOOLEAN;
|
||||
v_order_by TEXT;
|
||||
v_sort_order TEXT;
|
||||
v_upper_bound TEXT;
|
||||
v_file_batch_size INT;
|
||||
|
||||
-- Dynamic SQL for batch query only
|
||||
v_batch_query TEXT;
|
||||
|
||||
-- Seek state
|
||||
v_next_seek TEXT;
|
||||
v_count INT := 0;
|
||||
v_skipped INT := 0;
|
||||
BEGIN
|
||||
-- ========================================================================
|
||||
-- INITIALIZATION
|
||||
-- ========================================================================
|
||||
v_limit := LEAST(coalesce(limits, 100), 1500);
|
||||
v_prefix := coalesce(prefix, '') || coalesce(search, '');
|
||||
v_prefix_lower := lower(v_prefix);
|
||||
v_is_asc := lower(coalesce(sortorder, 'asc')) = 'asc';
|
||||
v_file_batch_size := LEAST(GREATEST(v_limit * 2, 100), 1000);
|
||||
|
||||
-- Validate sort column
|
||||
CASE lower(coalesce(sortcolumn, 'name'))
|
||||
WHEN 'name' THEN v_order_by := 'name';
|
||||
WHEN 'updated_at' THEN v_order_by := 'updated_at';
|
||||
WHEN 'created_at' THEN v_order_by := 'created_at';
|
||||
WHEN 'last_accessed_at' THEN v_order_by := 'last_accessed_at';
|
||||
ELSE v_order_by := 'name';
|
||||
END CASE;
|
||||
|
||||
v_sort_order := CASE WHEN v_is_asc THEN 'asc' ELSE 'desc' END;
|
||||
|
||||
-- ========================================================================
|
||||
-- NON-NAME SORTING: Use path_tokens approach (unchanged)
|
||||
-- ========================================================================
|
||||
IF v_order_by != 'name' THEN
|
||||
RETURN QUERY EXECUTE format(
|
||||
$sql$
|
||||
WITH folders AS (
|
||||
SELECT path_tokens[$1] AS folder
|
||||
FROM storage.objects
|
||||
WHERE objects.name ILIKE $2 || '%%'
|
||||
AND bucket_id = $3
|
||||
AND array_length(objects.path_tokens, 1) <> $1
|
||||
GROUP BY folder
|
||||
ORDER BY folder %s
|
||||
)
|
||||
(SELECT folder AS "name",
|
||||
NULL::uuid AS id,
|
||||
NULL::timestamptz AS updated_at,
|
||||
NULL::timestamptz AS created_at,
|
||||
NULL::timestamptz AS last_accessed_at,
|
||||
NULL::jsonb AS metadata FROM folders)
|
||||
UNION ALL
|
||||
(SELECT path_tokens[$1] AS "name",
|
||||
id, updated_at, created_at, last_accessed_at, metadata
|
||||
FROM storage.objects
|
||||
WHERE objects.name ILIKE $2 || '%%'
|
||||
AND bucket_id = $3
|
||||
AND array_length(objects.path_tokens, 1) = $1
|
||||
ORDER BY %I %s)
|
||||
LIMIT $4 OFFSET $5
|
||||
$sql$, v_sort_order, v_order_by, v_sort_order
|
||||
) USING levels, v_prefix, bucketname, v_limit, offsets;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- NAME SORTING: Hybrid skip-scan with batch optimization
|
||||
-- ========================================================================
|
||||
|
||||
-- Calculate upper bound for prefix filtering
|
||||
IF v_prefix_lower = '' THEN
|
||||
v_upper_bound := NULL;
|
||||
ELSIF right(v_prefix_lower, 1) = v_delimiter THEN
|
||||
v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(v_delimiter) + 1);
|
||||
ELSE
|
||||
v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(right(v_prefix_lower, 1)) + 1);
|
||||
END IF;
|
||||
|
||||
-- Build batch query (dynamic SQL - called infrequently, amortized over many rows)
|
||||
IF v_is_asc THEN
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' ||
|
||||
'AND lower(o.name) COLLATE "C" < $3 ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4';
|
||||
ELSE
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' ||
|
||||
'ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4';
|
||||
END IF;
|
||||
ELSE
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' ||
|
||||
'AND lower(o.name) COLLATE "C" >= $3 ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4';
|
||||
ELSE
|
||||
v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
|
||||
'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' ||
|
||||
'ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Initialize seek position
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := v_prefix_lower;
|
||||
ELSE
|
||||
-- DESC: find the last item in range first (static SQL)
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower AND lower(o.name) COLLATE "C" < v_upper_bound
|
||||
ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
|
||||
ELSIF v_prefix_lower <> '' THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower
|
||||
ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
|
||||
ELSE
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname
|
||||
ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_peek_name IS NOT NULL THEN
|
||||
v_next_seek := lower(v_peek_name) || v_delimiter;
|
||||
ELSE
|
||||
RETURN;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ========================================================================
|
||||
-- MAIN LOOP: Hybrid peek-then-batch algorithm
|
||||
-- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch
|
||||
-- ========================================================================
|
||||
LOOP
|
||||
EXIT WHEN v_count >= v_limit;
|
||||
|
||||
-- STEP 1: PEEK using STATIC SQL (plan cached, very fast)
|
||||
IF v_is_asc THEN
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek AND lower(o.name) COLLATE "C" < v_upper_bound
|
||||
ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1;
|
||||
ELSE
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek
|
||||
ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1;
|
||||
END IF;
|
||||
ELSE
|
||||
IF v_upper_bound IS NOT NULL THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower
|
||||
ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
|
||||
ELSIF v_prefix_lower <> '' THEN
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower
|
||||
ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
|
||||
ELSE
|
||||
SELECT o.name INTO v_peek_name FROM storage.objects o
|
||||
WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek
|
||||
ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
EXIT WHEN v_peek_name IS NULL;
|
||||
|
||||
-- STEP 2: Check if this is a FOLDER or FILE
|
||||
v_common_prefix := storage.get_common_prefix(lower(v_peek_name), v_prefix_lower, v_delimiter);
|
||||
|
||||
IF v_common_prefix IS NOT NULL THEN
|
||||
-- FOLDER: Handle offset, emit if needed, skip to next folder
|
||||
IF v_skipped < offsets THEN
|
||||
v_skipped := v_skipped + 1;
|
||||
ELSE
|
||||
name := split_part(rtrim(v_common_prefix, v_delimiter), v_delimiter, levels);
|
||||
id := NULL;
|
||||
updated_at := NULL;
|
||||
created_at := NULL;
|
||||
last_accessed_at := NULL;
|
||||
metadata := NULL;
|
||||
RETURN NEXT;
|
||||
v_count := v_count + 1;
|
||||
END IF;
|
||||
|
||||
-- Advance seek past the folder range
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := lower(left(v_common_prefix, -1)) || chr(ascii(v_delimiter) + 1);
|
||||
ELSE
|
||||
v_next_seek := lower(v_common_prefix);
|
||||
END IF;
|
||||
ELSE
|
||||
-- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows)
|
||||
-- For ASC: upper_bound is the exclusive upper limit (< condition)
|
||||
-- For DESC: prefix_lower is the inclusive lower limit (>= condition)
|
||||
FOR v_current IN EXECUTE v_batch_query
|
||||
USING bucketname, v_next_seek,
|
||||
CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix_lower) ELSE v_prefix_lower END, v_file_batch_size
|
||||
LOOP
|
||||
v_common_prefix := storage.get_common_prefix(lower(v_current.name), v_prefix_lower, v_delimiter);
|
||||
|
||||
IF v_common_prefix IS NOT NULL THEN
|
||||
-- Hit a folder: exit batch, let peek handle it
|
||||
v_next_seek := lower(v_current.name);
|
||||
EXIT;
|
||||
END IF;
|
||||
|
||||
-- Handle offset skipping
|
||||
IF v_skipped < offsets THEN
|
||||
v_skipped := v_skipped + 1;
|
||||
ELSE
|
||||
-- Emit file
|
||||
name := split_part(v_current.name, v_delimiter, levels);
|
||||
id := v_current.id;
|
||||
updated_at := v_current.updated_at;
|
||||
created_at := v_current.created_at;
|
||||
last_accessed_at := v_current.last_accessed_at;
|
||||
metadata := v_current.metadata;
|
||||
RETURN NEXT;
|
||||
v_count := v_count + 1;
|
||||
END IF;
|
||||
|
||||
-- Advance seek past this file
|
||||
IF v_is_asc THEN
|
||||
v_next_seek := lower(v_current.name) || v_delimiter;
|
||||
ELSE
|
||||
v_next_seek := lower(v_current.name);
|
||||
END IF;
|
||||
|
||||
EXIT WHEN v_count >= v_limit;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$_$;
|
||||
|
||||
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 $_$
|
||||
DECLARE
|
||||
v_cursor_op text;
|
||||
v_query text;
|
||||
v_prefix text;
|
||||
BEGIN
|
||||
v_prefix := coalesce(p_prefix, '');
|
||||
|
||||
IF p_sort_order = 'asc' THEN
|
||||
v_cursor_op := '>';
|
||||
ELSE
|
||||
v_cursor_op := '<';
|
||||
END IF;
|
||||
|
||||
v_query := format($sql$
|
||||
WITH raw_objects AS (
|
||||
SELECT
|
||||
o.name AS obj_name,
|
||||
o.id AS obj_id,
|
||||
o.updated_at AS obj_updated_at,
|
||||
o.created_at AS obj_created_at,
|
||||
o.last_accessed_at AS obj_last_accessed_at,
|
||||
o.metadata AS obj_metadata,
|
||||
storage.get_common_prefix(o.name, $1, '/') AS common_prefix
|
||||
FROM storage.objects o
|
||||
WHERE o.bucket_id = $2
|
||||
AND o.name COLLATE "C" LIKE $1 || '%%'
|
||||
),
|
||||
-- Aggregate common prefixes (folders)
|
||||
-- Both created_at and updated_at use MIN(obj_created_at) to match the old prefixes table behavior
|
||||
aggregated_prefixes AS (
|
||||
SELECT
|
||||
rtrim(common_prefix, '/') AS name,
|
||||
NULL::uuid AS id,
|
||||
MIN(obj_created_at) AS updated_at,
|
||||
MIN(obj_created_at) AS created_at,
|
||||
NULL::timestamptz AS last_accessed_at,
|
||||
NULL::jsonb AS metadata,
|
||||
TRUE AS is_prefix
|
||||
FROM raw_objects
|
||||
WHERE common_prefix IS NOT NULL
|
||||
GROUP BY common_prefix
|
||||
),
|
||||
leaf_objects AS (
|
||||
SELECT
|
||||
obj_name AS name,
|
||||
obj_id AS id,
|
||||
obj_updated_at AS updated_at,
|
||||
obj_created_at AS created_at,
|
||||
obj_last_accessed_at AS last_accessed_at,
|
||||
obj_metadata AS metadata,
|
||||
FALSE AS is_prefix
|
||||
FROM raw_objects
|
||||
WHERE common_prefix IS NULL
|
||||
),
|
||||
combined AS (
|
||||
SELECT * FROM aggregated_prefixes
|
||||
UNION ALL
|
||||
SELECT * FROM leaf_objects
|
||||
),
|
||||
filtered AS (
|
||||
SELECT *
|
||||
FROM combined
|
||||
WHERE (
|
||||
$5 = ''
|
||||
OR ROW(
|
||||
date_trunc('milliseconds', %I),
|
||||
name COLLATE "C"
|
||||
) %s ROW(
|
||||
COALESCE(NULLIF($6, '')::timestamptz, 'epoch'::timestamptz),
|
||||
$5
|
||||
)
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
split_part(name, '/', $3) AS key,
|
||||
name,
|
||||
id,
|
||||
updated_at,
|
||||
created_at,
|
||||
last_accessed_at,
|
||||
metadata
|
||||
FROM filtered
|
||||
ORDER BY
|
||||
COALESCE(date_trunc('milliseconds', %I), 'epoch'::timestamptz) %s,
|
||||
name COLLATE "C" %s
|
||||
LIMIT $4
|
||||
$sql$,
|
||||
p_sort_column,
|
||||
v_cursor_op,
|
||||
p_sort_column,
|
||||
p_sort_order,
|
||||
p_sort_order
|
||||
);
|
||||
|
||||
RETURN QUERY EXECUTE v_query
|
||||
USING v_prefix, p_bucket_id, p_level, p_limit, p_start_after, p_sort_column_after;
|
||||
END;
|
||||
$_$;
|
||||
|
||||
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 $$
|
||||
DECLARE
|
||||
v_sort_col text;
|
||||
v_sort_ord text;
|
||||
v_limit int;
|
||||
BEGIN
|
||||
-- Cap limit to maximum of 1500 records
|
||||
v_limit := LEAST(coalesce(limits, 100), 1500);
|
||||
|
||||
-- Validate and normalize sort_order
|
||||
v_sort_ord := lower(coalesce(sort_order, 'asc'));
|
||||
IF v_sort_ord NOT IN ('asc', 'desc') THEN
|
||||
v_sort_ord := 'asc';
|
||||
END IF;
|
||||
|
||||
-- Validate and normalize sort_column
|
||||
v_sort_col := lower(coalesce(sort_column, 'name'));
|
||||
IF v_sort_col NOT IN ('name', 'updated_at', 'created_at') THEN
|
||||
v_sort_col := 'name';
|
||||
END IF;
|
||||
|
||||
-- Route to appropriate implementation
|
||||
IF v_sort_col = 'name' THEN
|
||||
-- Use list_objects_with_delimiter for name sorting (most efficient: O(k * log n))
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
split_part(l.name, '/', levels) AS key,
|
||||
l.name AS name,
|
||||
l.id,
|
||||
l.updated_at,
|
||||
l.created_at,
|
||||
l.last_accessed_at,
|
||||
l.metadata
|
||||
FROM storage.list_objects_with_delimiter(
|
||||
bucket_name,
|
||||
coalesce(prefix, ''),
|
||||
'/',
|
||||
v_limit,
|
||||
start_after,
|
||||
'',
|
||||
v_sort_ord
|
||||
) l;
|
||||
ELSE
|
||||
-- Use aggregation approach for timestamp sorting
|
||||
-- Not efficient for large datasets but supports correct pagination
|
||||
RETURN QUERY SELECT * FROM storage.search_by_timestamp(
|
||||
prefix, bucket_name, v_limit, levels, start_after,
|
||||
v_sort_ord, v_sort_col, sort_column_after
|
||||
);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION storage.update_updated_at_column() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- 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
|
||||
SET search_path TO 'supabase_functions'
|
||||
AS $$
|
||||
DECLARE
|
||||
request_id bigint;
|
||||
payload jsonb;
|
||||
url text := TG_ARGV[0]::text;
|
||||
method text := TG_ARGV[1]::text;
|
||||
headers jsonb DEFAULT '{}'::jsonb;
|
||||
params jsonb DEFAULT '{}'::jsonb;
|
||||
timeout_ms integer DEFAULT 1000;
|
||||
BEGIN
|
||||
IF url IS NULL OR url = 'null' THEN
|
||||
RAISE EXCEPTION 'url argument is missing';
|
||||
END IF;
|
||||
|
||||
IF method IS NULL OR method = 'null' THEN
|
||||
RAISE EXCEPTION 'method argument is missing';
|
||||
END IF;
|
||||
|
||||
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
|
||||
headers = '{"Content-Type": "application/json"}'::jsonb;
|
||||
ELSE
|
||||
headers = TG_ARGV[2]::jsonb;
|
||||
END IF;
|
||||
|
||||
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
|
||||
params = '{}'::jsonb;
|
||||
ELSE
|
||||
params = TG_ARGV[3]::jsonb;
|
||||
END IF;
|
||||
|
||||
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
|
||||
timeout_ms = 1000;
|
||||
ELSE
|
||||
timeout_ms = TG_ARGV[4]::integer;
|
||||
END IF;
|
||||
|
||||
CASE
|
||||
WHEN method = 'GET' THEN
|
||||
SELECT http_get INTO request_id FROM net.http_get(
|
||||
url,
|
||||
params,
|
||||
headers,
|
||||
timeout_ms
|
||||
);
|
||||
WHEN method = 'POST' THEN
|
||||
payload = jsonb_build_object(
|
||||
'old_record', OLD,
|
||||
'record', NEW,
|
||||
'type', TG_OP,
|
||||
'table', TG_TABLE_NAME,
|
||||
'schema', TG_TABLE_SCHEMA
|
||||
);
|
||||
|
||||
SELECT http_post INTO request_id FROM net.http_post(
|
||||
url,
|
||||
payload,
|
||||
params,
|
||||
headers,
|
||||
timeout_ms
|
||||
);
|
||||
ELSE
|
||||
RAISE EXCEPTION 'method argument % is invalid', method;
|
||||
END CASE;
|
||||
|
||||
INSERT INTO supabase_functions.hooks
|
||||
(hook_table_id, hook_name, request_id)
|
||||
VALUES
|
||||
(TG_RELID, TG_NAME, request_id);
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,269 @@
|
||||
-- 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,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid,
|
||||
tipo text NOT NULL,
|
||||
titulo text NOT NULL,
|
||||
data_inicio date NOT NULL,
|
||||
data_fim date,
|
||||
hora_inicio time without time zone,
|
||||
hora_fim time without time zone,
|
||||
recorrente boolean DEFAULT false NOT NULL,
|
||||
dia_semana smallint,
|
||||
observacao text,
|
||||
origem text DEFAULT 'manual'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT agenda_bloqueios_tipo_check CHECK ((tipo = ANY (ARRAY['feriado_nacional'::text, 'feriado_municipal'::text, 'bloqueio'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agenda_configuracoes (
|
||||
owner_id uuid NOT NULL,
|
||||
duracao_padrao_minutos integer DEFAULT 50 NOT NULL,
|
||||
intervalo_padrao_minutos integer DEFAULT 0 NOT NULL,
|
||||
timezone text DEFAULT 'America/Sao_Paulo'::text NOT NULL,
|
||||
usar_horario_admin_custom boolean DEFAULT false NOT NULL,
|
||||
admin_inicio_visualizacao time without time zone,
|
||||
admin_fim_visualizacao time without time zone,
|
||||
admin_slot_visual_minutos integer DEFAULT 30 NOT NULL,
|
||||
online_ativo boolean DEFAULT false NOT NULL,
|
||||
online_min_antecedencia_horas integer DEFAULT 24 NOT NULL,
|
||||
online_max_dias_futuro integer DEFAULT 60 NOT NULL,
|
||||
online_cancelar_ate_horas integer DEFAULT 12 NOT NULL,
|
||||
online_reagendar_ate_horas integer DEFAULT 12 NOT NULL,
|
||||
online_limite_agendamentos_futuros integer DEFAULT 1 NOT NULL,
|
||||
online_modo text DEFAULT 'automatico'::text NOT NULL,
|
||||
online_buffer_antes_min integer DEFAULT 0 NOT NULL,
|
||||
online_buffer_depois_min integer DEFAULT 0 NOT NULL,
|
||||
online_modalidade text DEFAULT 'ambos'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
usar_granularidade_custom boolean DEFAULT false NOT NULL,
|
||||
granularidade_min integer,
|
||||
setup_concluido boolean DEFAULT false NOT NULL,
|
||||
setup_concluido_em timestamp with time zone,
|
||||
agenda_view_mode text DEFAULT 'full_24h'::text NOT NULL,
|
||||
agenda_custom_start time without time zone,
|
||||
agenda_custom_end time without time zone,
|
||||
session_duration_min integer DEFAULT 50 NOT NULL,
|
||||
session_break_min integer DEFAULT 10 NOT NULL,
|
||||
pausas_semanais jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
setup_clinica_concluido boolean DEFAULT false NOT NULL,
|
||||
setup_clinica_concluido_em timestamp with time zone,
|
||||
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])))),
|
||||
CONSTRAINT agenda_configuracoes_intervalo_padrao_minutos_check CHECK (((intervalo_padrao_minutos >= 0) AND (intervalo_padrao_minutos <= 120))),
|
||||
CONSTRAINT agenda_configuracoes_online_buffer_antes_min_check CHECK (((online_buffer_antes_min >= 0) AND (online_buffer_antes_min <= 120))),
|
||||
CONSTRAINT agenda_configuracoes_online_buffer_depois_min_check CHECK (((online_buffer_depois_min >= 0) AND (online_buffer_depois_min <= 120))),
|
||||
CONSTRAINT agenda_configuracoes_online_cancelar_ate_horas_check CHECK (((online_cancelar_ate_horas >= 0) AND (online_cancelar_ate_horas <= 720))),
|
||||
CONSTRAINT agenda_configuracoes_online_limite_agendamentos_futuros_check CHECK (((online_limite_agendamentos_futuros >= 1) AND (online_limite_agendamentos_futuros <= 10))),
|
||||
CONSTRAINT agenda_configuracoes_online_max_dias_futuro_check CHECK (((online_max_dias_futuro >= 1) AND (online_max_dias_futuro <= 365))),
|
||||
CONSTRAINT agenda_configuracoes_online_min_antecedencia_horas_check CHECK (((online_min_antecedencia_horas >= 0) AND (online_min_antecedencia_horas <= 720))),
|
||||
CONSTRAINT agenda_configuracoes_online_modalidade_check CHECK ((online_modalidade = ANY (ARRAY['online'::text, 'presencial'::text, 'ambos'::text]))),
|
||||
CONSTRAINT agenda_configuracoes_online_modo_check CHECK ((online_modo = ANY (ARRAY['automatico'::text, 'aprovacao'::text]))),
|
||||
CONSTRAINT agenda_configuracoes_online_reagendar_ate_horas_check CHECK (((online_reagendar_ate_horas >= 0) AND (online_reagendar_ate_horas <= 720))),
|
||||
CONSTRAINT agenda_configuracoes_slot_mode_chk CHECK ((slot_mode = ANY (ARRAY['fixed'::text, 'dynamic'::text]))),
|
||||
CONSTRAINT session_break_min_chk CHECK (((session_break_min >= 0) AND (session_break_min <= 60))),
|
||||
CONSTRAINT session_duration_min_chk CHECK (((session_duration_min >= 10) AND (session_duration_min <= 240)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agenda_eventos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tipo public.tipo_evento_agenda DEFAULT 'sessao'::public.tipo_evento_agenda NOT NULL,
|
||||
status public.status_evento_agenda DEFAULT 'agendado'::public.status_evento_agenda NOT NULL,
|
||||
titulo text,
|
||||
observacoes text,
|
||||
inicio_em timestamp with time zone NOT NULL,
|
||||
fim_em timestamp with time zone NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
terapeuta_id uuid,
|
||||
tenant_id uuid NOT NULL,
|
||||
visibility_scope text DEFAULT 'public'::text NOT NULL,
|
||||
mirror_of_event_id uuid,
|
||||
mirror_source text,
|
||||
patient_id uuid,
|
||||
determined_commitment_id uuid,
|
||||
link_online text,
|
||||
titulo_custom text,
|
||||
extra_fields jsonb,
|
||||
recurrence_id uuid,
|
||||
recurrence_date date,
|
||||
modalidade text DEFAULT 'presencial'::text,
|
||||
price numeric(10,2),
|
||||
billing_contract_id uuid,
|
||||
billed boolean DEFAULT false NOT NULL,
|
||||
services_customized boolean DEFAULT false NOT NULL,
|
||||
insurance_plan_id uuid,
|
||||
insurance_guide_number text,
|
||||
insurance_value numeric(10,2),
|
||||
insurance_plan_service_id uuid,
|
||||
CONSTRAINT agenda_eventos_check CHECK ((fim_em > inicio_em))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agenda_excecoes (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
data date NOT NULL,
|
||||
hora_inicio time without time zone,
|
||||
hora_fim time without time zone,
|
||||
tipo public.tipo_excecao_agenda NOT NULL,
|
||||
motivo text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
status public.status_excecao_agenda DEFAULT 'ativo'::public.status_excecao_agenda NOT NULL,
|
||||
fonte text DEFAULT 'manual'::text NOT NULL,
|
||||
aplicavel_online boolean DEFAULT true NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
CONSTRAINT agenda_excecoes_check CHECK ((((hora_inicio IS NULL) AND (hora_fim IS NULL)) OR ((hora_inicio IS NOT NULL) AND (hora_fim IS NOT NULL) AND (hora_fim > hora_inicio)))),
|
||||
CONSTRAINT agenda_excecoes_fonte_check CHECK ((fonte = ANY (ARRAY['manual'::text, 'feriado_google'::text, 'sistema'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agenda_online_slots (
|
||||
id bigint NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
weekday integer NOT NULL,
|
||||
"time" time without time zone NOT NULL,
|
||||
enabled 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,
|
||||
tenant_id uuid NOT NULL,
|
||||
CONSTRAINT agenda_online_slots_weekday_check CHECK ((weekday = ANY (ARRAY[0, 1, 2, 3, 4, 5, 6])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agenda_regras_semanais (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
dia_semana smallint NOT NULL,
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
hora_fim time without time zone NOT NULL,
|
||||
modalidade text DEFAULT 'ambos'::text 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,
|
||||
tenant_id uuid NOT NULL,
|
||||
CONSTRAINT agenda_regras_semanais_check CHECK ((hora_fim > hora_inicio)),
|
||||
CONSTRAINT agenda_regras_semanais_dia_semana_check CHECK (((dia_semana >= 0) AND (dia_semana <= 6))),
|
||||
CONSTRAINT agenda_regras_semanais_modalidade_check CHECK (((modalidade = ANY (ARRAY['online'::text, 'presencial'::text, 'ambos'::text])) OR (modalidade IS NULL)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agenda_slots_bloqueados_semanais (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
dia_semana smallint NOT NULL,
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
motivo text,
|
||||
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,
|
||||
tenant_id uuid NOT NULL,
|
||||
CONSTRAINT agenda_slots_bloqueados_semanais_dia_semana_check CHECK (((dia_semana >= 0) AND (dia_semana <= 6)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agenda_slots_regras (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
dia_semana smallint NOT NULL,
|
||||
passo_minutos integer NOT NULL,
|
||||
offset_minutos integer DEFAULT 0 NOT NULL,
|
||||
buffer_antes_min integer DEFAULT 0 NOT NULL,
|
||||
buffer_depois_min integer DEFAULT 0 NOT NULL,
|
||||
min_antecedencia_horas 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,
|
||||
tenant_id uuid NOT NULL,
|
||||
CONSTRAINT agenda_slots_regras_buffer_antes_min_check CHECK (((buffer_antes_min >= 0) AND (buffer_antes_min <= 240))),
|
||||
CONSTRAINT agenda_slots_regras_buffer_depois_min_check CHECK (((buffer_depois_min >= 0) AND (buffer_depois_min <= 240))),
|
||||
CONSTRAINT agenda_slots_regras_dia_semana_check CHECK (((dia_semana >= 0) AND (dia_semana <= 6))),
|
||||
CONSTRAINT agenda_slots_regras_min_antecedencia_horas_check CHECK (((min_antecedencia_horas >= 0) AND (min_antecedencia_horas <= 720))),
|
||||
CONSTRAINT agenda_slots_regras_offset_minutos_check CHECK (((offset_minutos >= 0) AND (offset_minutos <= 55))),
|
||||
CONSTRAINT agenda_slots_regras_passo_minutos_check CHECK (((passo_minutos >= 5) AND (passo_minutos <= 240)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agendador_configuracoes (
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid,
|
||||
ativo boolean DEFAULT false NOT NULL,
|
||||
link_slug text,
|
||||
imagem_fundo_url text,
|
||||
imagem_header_url text,
|
||||
logomarca_url text,
|
||||
cor_primaria text DEFAULT '#4b6bff'::text,
|
||||
nome_exibicao text,
|
||||
endereco text,
|
||||
botao_como_chegar_ativo boolean DEFAULT true NOT NULL,
|
||||
maps_url text,
|
||||
modo_aprovacao text DEFAULT 'aprovacao'::text NOT NULL,
|
||||
modalidade text DEFAULT 'presencial'::text NOT NULL,
|
||||
tipos_habilitados jsonb DEFAULT '["primeira", "retorno"]'::jsonb NOT NULL,
|
||||
duracao_sessao_min integer DEFAULT 50 NOT NULL,
|
||||
antecedencia_minima_horas integer DEFAULT 24 NOT NULL,
|
||||
prazo_resposta_horas integer DEFAULT 2 NOT NULL,
|
||||
reserva_horas integer DEFAULT 2 NOT NULL,
|
||||
pagamento_obrigatorio boolean DEFAULT false NOT NULL,
|
||||
pix_chave text,
|
||||
pix_countdown_minutos integer DEFAULT 20 NOT NULL,
|
||||
triagem_motivo boolean DEFAULT true NOT NULL,
|
||||
triagem_como_conheceu boolean DEFAULT false NOT NULL,
|
||||
verificacao_email boolean DEFAULT false NOT NULL,
|
||||
exigir_aceite_lgpd boolean DEFAULT true NOT NULL,
|
||||
mensagem_boas_vindas text,
|
||||
texto_como_se_preparar text,
|
||||
texto_termos_lgpd text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
pagamento_modo text DEFAULT 'sem_pagamento'::text NOT NULL,
|
||||
pagamento_metodos_visiveis text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
CONSTRAINT agendador_configuracoes_antecedencia_check CHECK (((antecedencia_minima_horas >= 0) AND (antecedencia_minima_horas <= 720))),
|
||||
CONSTRAINT agendador_configuracoes_duracao_check CHECK (((duracao_sessao_min >= 10) AND (duracao_sessao_min <= 240))),
|
||||
CONSTRAINT agendador_configuracoes_modalidade_check CHECK ((modalidade = ANY (ARRAY['presencial'::text, 'online'::text, 'ambos'::text]))),
|
||||
CONSTRAINT agendador_configuracoes_modo_check CHECK ((modo_aprovacao = ANY (ARRAY['automatico'::text, 'aprovacao'::text]))),
|
||||
CONSTRAINT agendador_configuracoes_pix_countdown_check CHECK (((pix_countdown_minutos >= 5) AND (pix_countdown_minutos <= 120))),
|
||||
CONSTRAINT agendador_configuracoes_prazo_check CHECK (((prazo_resposta_horas >= 1) AND (prazo_resposta_horas <= 72))),
|
||||
CONSTRAINT agendador_configuracoes_reserva_check CHECK (((reserva_horas >= 1) AND (reserva_horas <= 48)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.agendador_solicitacoes (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid,
|
||||
paciente_nome text NOT NULL,
|
||||
paciente_sobrenome text,
|
||||
paciente_email text NOT NULL,
|
||||
paciente_celular text,
|
||||
paciente_cpf text,
|
||||
tipo text NOT NULL,
|
||||
modalidade text NOT NULL,
|
||||
data_solicitada date NOT NULL,
|
||||
hora_solicitada time without time zone NOT NULL,
|
||||
reservado_ate timestamp with time zone,
|
||||
motivo text,
|
||||
como_conheceu text,
|
||||
pix_status text DEFAULT 'pendente'::text,
|
||||
pix_pago_em timestamp with time zone,
|
||||
status text DEFAULT 'pendente'::text NOT NULL,
|
||||
recusado_motivo text,
|
||||
autorizado_em timestamp with time zone,
|
||||
autorizado_por uuid,
|
||||
user_id uuid,
|
||||
patient_id uuid,
|
||||
evento_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT agendador_sol_modalidade_check CHECK ((modalidade = ANY (ARRAY['presencial'::text, 'online'::text]))),
|
||||
CONSTRAINT agendador_sol_pix_check CHECK (((pix_status IS NULL) OR (pix_status = ANY (ARRAY['pendente'::text, 'pago'::text, 'expirado'::text])))),
|
||||
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])))
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,284 @@
|
||||
-- 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,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
channel text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
is_active boolean DEFAULT false NOT NULL,
|
||||
display_name text,
|
||||
sender_address text,
|
||||
credentials jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
connection_status text DEFAULT 'disconnected'::text,
|
||||
last_health_check timestamp with time zone,
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
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])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.notification_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
queue_id uuid,
|
||||
agenda_evento_id uuid,
|
||||
patient_id uuid NOT NULL,
|
||||
channel text NOT NULL,
|
||||
template_key text NOT NULL,
|
||||
schedule_key text,
|
||||
recipient_address text NOT NULL,
|
||||
resolved_message text,
|
||||
resolved_vars jsonb,
|
||||
status text NOT NULL,
|
||||
provider text,
|
||||
provider_message_id text,
|
||||
provider_status text,
|
||||
provider_response jsonb,
|
||||
sent_at timestamp with time zone,
|
||||
delivered_at timestamp with time zone,
|
||||
read_at timestamp with time zone,
|
||||
failed_at timestamp with time zone,
|
||||
failure_reason text,
|
||||
estimated_cost_brl numeric(8,4) DEFAULT 0,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT notification_logs_status_check CHECK ((status = ANY (ARRAY['sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'bounced'::text, 'opted_out'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.notification_preferences (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
whatsapp_opt_in boolean DEFAULT true NOT NULL,
|
||||
email_opt_in boolean DEFAULT true NOT NULL,
|
||||
sms_opt_in boolean DEFAULT false NOT NULL,
|
||||
preferred_time_start time without time zone DEFAULT '08:00:00'::time without time zone,
|
||||
preferred_time_end time without time zone DEFAULT '20:00:00'::time without time zone,
|
||||
lgpd_consent_given boolean DEFAULT false NOT NULL,
|
||||
lgpd_consent_date timestamp with time zone,
|
||||
lgpd_consent_version text,
|
||||
lgpd_consent_ip inet,
|
||||
lgpd_opt_out_date timestamp with time zone,
|
||||
lgpd_opt_out_reason 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
|
||||
);
|
||||
|
||||
CREATE TABLE public.notification_queue (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
agenda_evento_id uuid,
|
||||
patient_id uuid NOT NULL,
|
||||
channel text NOT NULL,
|
||||
template_key text NOT NULL,
|
||||
schedule_key text NOT NULL,
|
||||
resolved_vars jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
recipient_address text NOT NULL,
|
||||
status text DEFAULT 'pendente'::text NOT NULL,
|
||||
scheduled_at timestamp with time zone NOT NULL,
|
||||
sent_at timestamp with time zone,
|
||||
next_retry_at timestamp with time zone,
|
||||
attempts integer DEFAULT 0 NOT NULL,
|
||||
max_attempts integer DEFAULT 5 NOT NULL,
|
||||
last_error text,
|
||||
idempotency_key text NOT NULL,
|
||||
provider_message_id text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT notification_queue_channel_check CHECK ((channel = ANY (ARRAY['whatsapp'::text, 'email'::text, 'sms'::text]))),
|
||||
CONSTRAINT notification_queue_status_check CHECK ((status = ANY (ARRAY['pendente'::text, 'processando'::text, 'enviado'::text, 'falhou'::text, 'cancelado'::text, 'ignorado'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.notification_schedules (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
schedule_key text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
trigger_type text NOT NULL,
|
||||
offset_minutes integer DEFAULT 0,
|
||||
whatsapp_enabled boolean DEFAULT true NOT NULL,
|
||||
email_enabled boolean DEFAULT true NOT NULL,
|
||||
sms_enabled boolean DEFAULT false NOT NULL,
|
||||
allowed_time_start time without time zone DEFAULT '08:00:00'::time without time zone,
|
||||
allowed_time_end time without time zone DEFAULT '20:00:00'::time without time zone,
|
||||
skip_weekends boolean DEFAULT false,
|
||||
skip_holidays boolean DEFAULT false,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
sort_order integer DEFAULT 0,
|
||||
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,
|
||||
CONSTRAINT notification_schedules_event_type_check CHECK ((event_type = ANY (ARRAY['lembrete_sessao'::text, 'confirmacao_sessao'::text, 'cancelamento_sessao'::text, 'reagendamento'::text, 'cobranca_pendente'::text, 'boas_vindas_paciente'::text]))),
|
||||
CONSTRAINT notification_schedules_trigger_type_check CHECK ((trigger_type = ANY (ARRAY['before_event'::text, 'after_event'::text, 'immediate'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.notification_templates (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
owner_id uuid,
|
||||
key text NOT NULL,
|
||||
domain text NOT NULL,
|
||||
channel text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
body_text text NOT NULL,
|
||||
meta_template_name text,
|
||||
meta_template_namespace text,
|
||||
meta_components jsonb,
|
||||
meta_status text DEFAULT 'draft'::text,
|
||||
variables jsonb DEFAULT '[]'::jsonb,
|
||||
version integer DEFAULT 1 NOT NULL,
|
||||
is_active boolean DEFAULT true NOT NULL,
|
||||
is_default 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,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT notification_templates_channel_check CHECK ((channel = ANY (ARRAY['whatsapp'::text, 'sms'::text]))),
|
||||
CONSTRAINT notification_templates_domain_check CHECK ((domain = ANY (ARRAY['session'::text, 'intake'::text, 'billing'::text, 'system'::text]))),
|
||||
CONSTRAINT notification_templates_event_type_check CHECK ((event_type = ANY (ARRAY['lembrete_sessao'::text, 'confirmacao_sessao'::text, 'cancelamento_sessao'::text, 'reagendamento'::text, 'cobranca_pendente'::text, 'boas_vindas_paciente'::text, 'intake_recebido'::text, 'intake_aprovado'::text, 'intake_rejeitado'::text]))),
|
||||
CONSTRAINT notification_templates_meta_status_check CHECK ((meta_status = ANY (ARRAY['draft'::text, 'pending_approval'::text, 'approved'::text, 'rejected'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.notifications (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid,
|
||||
type text NOT NULL,
|
||||
ref_id uuid,
|
||||
ref_table text,
|
||||
payload jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
read_at timestamp with time zone,
|
||||
archived boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.twilio_subaccount_usage (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
channel_id uuid NOT NULL,
|
||||
twilio_subaccount_sid text NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
period_end date NOT NULL,
|
||||
messages_sent integer DEFAULT 0 NOT NULL,
|
||||
messages_delivered integer DEFAULT 0 NOT NULL,
|
||||
messages_failed integer DEFAULT 0 NOT NULL,
|
||||
cost_usd numeric(12,6) DEFAULT 0 NOT NULL,
|
||||
cost_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
revenue_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
margin_brl numeric(12,4) GENERATED ALWAYS AS ((revenue_brl - cost_brl)) STORED,
|
||||
usd_brl_rate numeric(8,4) DEFAULT 0,
|
||||
synced_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT twilio_subaccount_usage_period_check CHECK ((period_end >= period_start))
|
||||
);
|
||||
@@ -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])))
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
-- 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,
|
||||
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])))
|
||||
);
|
||||
|
||||
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,
|
||||
user_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
type public.financial_record_type DEFAULT 'receita'::public.financial_record_type NOT NULL,
|
||||
color text DEFAULT '#6366f1'::text,
|
||||
icon text DEFAULT 'pi pi-tag'::text,
|
||||
sort_order integer DEFAULT 0,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.financial_exceptions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid,
|
||||
tenant_id uuid NOT NULL,
|
||||
exception_type text NOT NULL,
|
||||
charge_mode text NOT NULL,
|
||||
charge_value numeric(10,2),
|
||||
charge_pct numeric(5,2),
|
||||
min_hours_notice integer,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT financial_exceptions_charge_chk CHECK ((charge_mode = ANY (ARRAY['none'::text, 'full'::text, 'fixed_fee'::text, 'percentage'::text]))),
|
||||
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,
|
||||
tenant_id uuid,
|
||||
pix_ativo boolean DEFAULT false NOT NULL,
|
||||
pix_tipo text DEFAULT 'cpf'::text NOT NULL,
|
||||
pix_chave text DEFAULT ''::text NOT NULL,
|
||||
pix_nome_titular text DEFAULT ''::text NOT NULL,
|
||||
deposito_ativo boolean DEFAULT false NOT NULL,
|
||||
deposito_banco text DEFAULT ''::text NOT NULL,
|
||||
deposito_agencia text DEFAULT ''::text NOT NULL,
|
||||
deposito_conta text DEFAULT ''::text NOT NULL,
|
||||
deposito_tipo_conta text DEFAULT 'corrente'::text NOT NULL,
|
||||
deposito_titular text DEFAULT ''::text NOT NULL,
|
||||
deposito_cpf_cnpj text DEFAULT ''::text NOT NULL,
|
||||
dinheiro_ativo boolean DEFAULT false NOT NULL,
|
||||
cartao_ativo boolean DEFAULT false NOT NULL,
|
||||
cartao_instrucao text DEFAULT ''::text NOT NULL,
|
||||
convenio_ativo boolean DEFAULT false NOT NULL,
|
||||
convenio_lista text DEFAULT ''::text NOT NULL,
|
||||
observacoes_pagamento text DEFAULT ''::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
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,
|
||||
tenant_id uuid NOT NULL,
|
||||
determined_commitment_id uuid,
|
||||
price numeric(10,2) NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,251 @@
|
||||
-- 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,
|
||||
nome_completo text NOT NULL,
|
||||
email_principal text,
|
||||
telefone text,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
owner_id uuid,
|
||||
avatar_url text,
|
||||
status text DEFAULT 'Ativo'::text,
|
||||
last_attended_at timestamp with time zone,
|
||||
is_native boolean DEFAULT false,
|
||||
naturalidade text,
|
||||
data_nascimento date,
|
||||
rg text,
|
||||
cpf text,
|
||||
identification_color text,
|
||||
genero text,
|
||||
estado_civil text,
|
||||
email_alternativo text,
|
||||
pais text DEFAULT 'Brasil'::text,
|
||||
cep text,
|
||||
cidade text,
|
||||
estado text,
|
||||
endereco text,
|
||||
numero text,
|
||||
bairro text,
|
||||
complemento text,
|
||||
escolaridade text,
|
||||
profissao text,
|
||||
nome_parente text,
|
||||
grau_parentesco text,
|
||||
telefone_alternativo text,
|
||||
onde_nos_conheceu text,
|
||||
encaminhado_por text,
|
||||
nome_responsavel text,
|
||||
telefone_responsavel text,
|
||||
cpf_responsavel text,
|
||||
observacao_responsavel text,
|
||||
cobranca_no_responsavel boolean DEFAULT false,
|
||||
observacoes text,
|
||||
notas_internas text,
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
telefone_parente text,
|
||||
tenant_id uuid NOT NULL,
|
||||
responsible_member_id uuid NOT NULL,
|
||||
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_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])))
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
-- 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,
|
||||
user_id uuid,
|
||||
plan_id uuid NOT NULL,
|
||||
status text DEFAULT 'active'::text NOT NULL,
|
||||
current_period_start timestamp with time zone,
|
||||
current_period_end timestamp with time zone,
|
||||
cancel_at_period_end boolean DEFAULT false NOT NULL,
|
||||
provider text DEFAULT 'manual'::text NOT NULL,
|
||||
provider_customer_id text,
|
||||
provider_subscription_id text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
tenant_id uuid,
|
||||
plan_key text,
|
||||
"interval" text,
|
||||
source text DEFAULT 'manual'::text NOT NULL,
|
||||
started_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
canceled_at timestamp with time zone,
|
||||
activated_at timestamp with time zone,
|
||||
past_due_since timestamp with time zone,
|
||||
suspended_at timestamp with time zone,
|
||||
suspended_reason text,
|
||||
cancelled_at timestamp with time zone,
|
||||
cancel_reason text,
|
||||
expired_at timestamp with time zone,
|
||||
CONSTRAINT subscriptions_interval_check CHECK (("interval" = ANY (ARRAY['month'::text, 'year'::text]))),
|
||||
CONSTRAINT subscriptions_owner_xor CHECK ((((tenant_id IS NOT NULL) AND (user_id IS NULL)) OR ((tenant_id IS NULL) AND (user_id IS NOT NULL)))),
|
||||
CONSTRAINT subscriptions_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'active'::text, 'past_due'::text, 'suspended'::text, 'cancelled'::text, 'expired'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.billing_contracts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
type text NOT NULL,
|
||||
total_sessions integer,
|
||||
sessions_used integer DEFAULT 0,
|
||||
package_price numeric(10,2),
|
||||
amount numeric(10,2),
|
||||
billing_interval text,
|
||||
active_from timestamp with time zone DEFAULT now(),
|
||||
active_to timestamp with time zone,
|
||||
status text DEFAULT 'active'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT billing_contracts_interval_chk CHECK (((billing_interval IS NULL) OR (billing_interval = ANY (ARRAY['monthly'::text, 'weekly'::text])))),
|
||||
CONSTRAINT billing_contracts_sess_used_chk CHECK (((sessions_used IS NULL) OR (sessions_used >= 0))),
|
||||
CONSTRAINT billing_contracts_status_chk CHECK ((status = ANY (ARRAY['active'::text, 'completed'::text, 'cancelled'::text]))),
|
||||
CONSTRAINT billing_contracts_total_sess_chk CHECK (((total_sessions IS NULL) OR (total_sessions > 0))),
|
||||
CONSTRAINT billing_contracts_type_chk CHECK ((type = ANY (ARRAY['per_session'::text, 'package'::text, 'subscription'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.entitlements_invalidation (
|
||||
owner_id uuid NOT NULL,
|
||||
changed_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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE public.modules (
|
||||
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
|
||||
);
|
||||
|
||||
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,
|
||||
module_id uuid NOT NULL,
|
||||
status text DEFAULT 'active'::text NOT NULL,
|
||||
settings jsonb,
|
||||
provider text DEFAULT 'manual'::text NOT NULL,
|
||||
provider_item_id text,
|
||||
installed_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
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])))
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
-- 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,
|
||||
commitment_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 commitment_services_disc_flat_chk CHECK ((discount_flat >= (0)::numeric)),
|
||||
CONSTRAINT commitment_services_disc_pct_chk CHECK (((discount_pct >= (0)::numeric) AND (discount_pct <= (100)::numeric))),
|
||||
CONSTRAINT commitment_services_final_price_chk CHECK ((final_price >= (0)::numeric)),
|
||||
CONSTRAINT commitment_services_quantity_chk CHECK ((quantity > 0))
|
||||
);
|
||||
|
||||
CREATE TABLE public.commitment_time_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
commitment_id uuid NOT NULL,
|
||||
calendar_event_id uuid,
|
||||
source public.commitment_log_source DEFAULT 'manual'::public.commitment_log_source NOT NULL,
|
||||
started_at timestamp with time zone NOT NULL,
|
||||
ended_at timestamp with time zone NOT NULL,
|
||||
minutes integer NOT NULL,
|
||||
created_by uuid,
|
||||
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()
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
-- 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,
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
role text NOT NULL,
|
||||
status text DEFAULT 'active'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.company_profiles (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
nome_fantasia text,
|
||||
razao_social text,
|
||||
tipo_empresa text,
|
||||
cnpj text,
|
||||
ie text,
|
||||
im text,
|
||||
cep text,
|
||||
logradouro text,
|
||||
numero text,
|
||||
complemento text,
|
||||
bairro text,
|
||||
cidade text,
|
||||
estado text,
|
||||
email text,
|
||||
telefone text,
|
||||
site text,
|
||||
logo_url text,
|
||||
redes_sociais jsonb DEFAULT '[]'::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.dev_user_credentials (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
user_id uuid,
|
||||
email text NOT NULL,
|
||||
password_dev text NOT NULL,
|
||||
kind text DEFAULT 'custom'::text NOT NULL,
|
||||
note text,
|
||||
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])))
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user